The Release Cadence
In the early years of Java, there was a new release every couple of years, and two of them (1.2 and 5) had significant advances of the API and/or language.
Then came a long dry spell. It took five years for Java 7 (meh) and three years each for Java 8 (default methods and streams, nice) and 9 (modules, ugly).
In order to put things on a more predictable footing, Oracle switched to a new release model. Rain or shine, there is a release every 6 months. Releases every three years get long term support, starting with Java 11. That makes Java 17, released in September, the second LTS release. Followed by 23, 29, 35, 41, 47, 53, and so on. Like clockwork.
Except this press release seems to indicate that the next LTS release may be Java 21. Which would actually be fine. A two year LTS cycle is pretty common elsewhere.
So, now that there is a new LTS release, you are probably asking yourself if you should switch to it.
Rah Rah Rah
I’ve been amazed—although I shouldn’t be—at the amount of hype from the usual suspects. Check out this gushing article. Top three features:
- Sealed classes
- Context-specific deserialization filters
- New macOS rendering pipeline
Really? I am supposed to update my apps for that???
Infoworld does no better. Their headline features:
- Always-strict floating point semantics
- A foreign function and memory API
- A uniform API for pseudo random number generators
Sharat Chander has his heart in the right place, starting out with bug fixes and quality improvements. But then he too gets lost in the weeds of minor Java 17 features:
- Sealed classes
- Restore Always-Strict Floating-Point Semantics
- Enhanced Pseudo-Random Number Generator
Clearly, there is a need for some unbiased advice. I am going to tell you what has actually changed from the last LTS release (Java 11), rating the features as nice, meh, or ugly. And then I’ll tell you why you should update.
I am not rating “preview features” at all. They give a glimpse of what may come, but the details are subject to change. Interesting for enthusiasts, but not something you would want to touch in production.
I am not giving brownie points for future promise. Sure, switch
expressions, instanceof
patterns, records, and sealed classes lay the ground work for robust pattern matching in the future. And robust pattern matching will be great once it arrives. In the future. I only rate features for the utility they have today.
Here is an executive summary of the highlights:
Feature | Rating | Why care? |
---|---|---|
Records | Nice | Constructor, accessors, toString, hashCode automatically provided |
Switch expressions without fallthrough | Nice | More compact than if /else , safer than classic switch |
Two other switch constructs |
Ugly | I never asked for four kinds of switch |
Pattern matching for instanceof | Nice | No need to cast |
Sealed types | Nice | Can accurately model closed hierarchies |
Text blocks | Meh | Nice for multiline strings, but must still escape \ and some " |
Minor API changes | Meh | Few exciting API improvements in this cycle |
Packaging tool | Meh | Need to run separately on each platform |
Alpine port, NUMA-Aware Memory Allocation for G1, JFR Event Streaming, many more | YMMV | Depends on how badly you need the particular feature |
Tens of thousands of bug fixes | Count me in! | Do you really want to gamble that none of them affect you, or that they are all backported? |
What is in the Box?
Let’s break this into three parts: the Java Enhancement Proposals (JEP) that specify new language features or APIs, smaller API changes, and bug fixes.
JEPs
Here are the JEPs for JDK 12 through 17, sorted by category. I dropped previews and incubations that were superseded by later versions.
- Language
-
361: Switch Expressions (Standard) 378: Text Blocks 394: Pattern Matching for instanceof 395: Records 409: Sealed Classes
- API
-
334: JVM Constants API 339: Edwards-Curve Digital Signature Algorithm (EdDSA) 352: Non-Volatile Mapped Byte Buffers 356: Enhanced Pseudo-Random Number Generators 380: Unix-Domain Socket Channels
- Tools
-
392: Packaging Tool
- Warnings and Errors
-
358: Helpful NullPointerExceptions 390: Warnings for Value-Based Classes 396: Strongly Encapsulate JDK Internals by Default 403: Strongly Encapsulate JDK Internals 415: Context-Specific Deserialization Filters
- Plumbing
-
189: Shenandoah: A Low-Pause-Time Garbage Collector (Experimental) 230: Microbenchmark Suite 340: One AArch64 Port, Not Two 341: Default CDS Archives 344: Abortable Mixed Collections for G1 345: NUMA-Aware Memory Allocation for G1 346: Promptly Return Unused Committed Memory from G1 347: Enable C++14 Language Features 349: JFR Event Streaming 350: Dynamic CDS Archives 351: ZGC: Uncommit Unused Memory 353: Reimplement the Legacy Socket API 357: Migrate from Mercurial to Git 364: ZGC on macOS 365: ZGC on Windows 369: Migrate to GitHub 371: Hidden Classes 373: Reimplement the Legacy DatagramSocket API 376: ZGC: Concurrent Thread-Stack Processing 377: ZGC: A Scalable Low-Latency Garbage Collector 379: Shenandoah: A Low-Pause-Time Garbage Collector 382: New macOS Rendering Pipeline 386: Alpine Linux Port 387: Elastic Metaspace 388: Windows/AArch64 Port 391: macOS/AArch64 Port
- Deprecation and Removal
-
306: Restore Always-Strict Floating-Point Semantics 362: Deprecate the Solaris and SPARC Ports 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector 366: Deprecate the ParallelScavenge + SerialOld GC Combination 367: Remove the Pack200 Tools and API 372: Remove the Nashorn JavaScript Engine 374: Disable and Deprecate Biased Locking 381: Remove the Solaris and SPARC Ports 385: Deprecate RMI Activation for Removal 398: Deprecate the Applet API for Removal 407: Remove RMI Activation 410: Remove the Experimental AOT and JIT Compiler 411: Deprecate the Security Manager for Removal
- Incubation
-
383: Foreign-Memory Access API (Second Incubator) 412: Foreign Function & Memory API (Incubator) 414: Vector API (Second Incubator)
Of course, that categorization is a bit subjective. What I call “plumbing” may be boring to me, but exciting and important for you. And anyway, boring isn’t bad. Without a continuous investment in plumbing, the platform crumbles.
API
There are quite a few API changes that aren’t part of a JEP. You can see all changes at Marc Hoffmann’s nifty API Diff site https://javaalmanac.io/jdk/17/apidiff/11/. However, most of them are quite mundane or specialized. My favorite:
var result = Files.lines(Path.of("/usr/share/dict/words")).filter(w -> w.length() > 20).toList()
Yes, that’s toList
and not collect(Collectors.toList())
.
Bug Fixes
Finally, bug fixes. If you run this query, you get over 32,000 entries. Each one is an entry in the bug database that was addressed in JDK 12—17. Now that’s impressive.
Not all bug reports are bugs. The bug tracker is also used for API changes, Unicode versions, and so on.
Sure, many of those bugs are obscure. But obscure bugs can be incredibly annoying. I recently ran into Bug 8247546. It took me many hours to realize that it wasn’t my code that was at fault. Had I used Java 17, I wouldn’t have had to go through that. Maybe not many people were affected by this particular obscure issue, but every fixed bug affected someone. In my mind, I multiply my pain by 32,000, and it’s clear that bug fixes are a big deal.
Language Features
There are five significant new language features in JDK 17. Let’s look at them one at a time.
Records
Records are plainly nice. When you have an immutable class with getters for every field, it looks neater as a record. List the fields, toss in some methods, and you are done:
record Point(double x, double y) { public double distance(Point q) { return Math.hypot(x - q.x, y - q.y); } public Point translate(double dy, double dy) { return new Point(x + dx, y + dy); } }
You get accessors x
and y
as well as toString
, equals
, and hashCode
for free. More info here.
Note that the class must be immutable. And the accessors don’t follow the JavaBeans convention—they are not getX
and getY
. So you won’t be converting your JPA entity classes into records.
How important is this feature in practice? The generally accepted wisdom is that it’s of the same order of utility as enum
. Nice, but by itself probably not a reason for switching to a new JDK.
Sealed Classes
What about sealed classes? If you have an inheritance hierarchy that is inherently bounded, then sealed classes are nice. I like the example of modeling JSON:
A JSONValue
is either a JSONObject
, JSONArray
, or JSONPrimitive
. It can never be something else. And there are exactly four kinds of JSONPrimitive
:
public sealed class JSONValue permits JSONObject, JSONArray, JSONPrimitive { . . . } public sealed class JSONPrimitive extends JSONValue permits JSONString, JSONNumber, JSONBoolean, JSONNull { . . . }
For now, the compiler checks that nobody declares spurious subclasses. One day, pattern matching over a sealed class will be able to tell that you don’t need a default
class. Sure, it’s nice. But how many hierarchies like that do you have in your code?
Pattern Match for instanceof
Nobody likes to write:
if (obj instanceof Woozle) { Woozle woozle = (Woozle) obj; woozle.aWoozleMethod(); }
That’s now shorter:
if (obj instanceof Woozle woozle) { woozle.aWoozleMethod(); }
Nice. But really, how often do you do this? I really, really hope that your code isn’t brimming with instanceof
and casts.
Text Blocks
I regularly have SQL or HTML or regex or some other foreign strings in my Java code. And I’d love to be able to paste like this:
String myForeignString = some delimiter paste my foreign content here some delimiter;
I can now do this. The delimiter is """
. What if """
occurs in myForeignContent? I am actually not super concerned about that. If you are, there is a simple fix. Use a \"
escape.
But that has a really unfortunate side effect. What if you just want a
\
? You have to escape it as \\
. I regularly have foreign strings with \
and I wish I didn’t have to double-escape them. There are known ways to avoid this hassle, and it irks me that none was adopted.
I am also mildly peeved at another feature—stripping initial white space. Consider this:
String myNameInABox = """ +---+ |Cay| +---+"""
Java takes it upon itself to strip the common whitespace prefix, so that this is the same as
String myNameInABox = """ +---+ |Cay| +---+"""
There is some accidental complexity, like what about tabs? It doesn’t do any harm because you can always opt not to indent. Still, I wish the complexity budget had instead been expended to give me copy and paste without worrying about backslashes. Hence my overall “meh” rating.
Switch
Do you use switch
? I never do any more, because I’ve more than once been bitten by fall-through due to a forgotten break
. But maybe I should. The switch
statement has one advantage. It is more efficient than a long sequence of if
/else
when it can be compiled into a jump table. How long does the sequence have to be? Not that long—after ten or so if
/else
, a jump table is often a performance win.
Of course, many people use switch
because it signals switchiness—branching solely on a value. And they live with the break
after each branch.
This has now gotten better:
int numConsonants = switch (season) { case "Fall" -> 3; case "Summer", "Winter" -> 4; case "Spring" -> 5; default -> -1; }
Note the arrows. They indicate that the branches are disjoint. Note that the switch
is an expression. It’s like a ? :
with multiple branches. How do you know it’s an expression? The syntax doesn’t tell you. You have to observe that switch
is in an expression position, being on the right hand side of an =
. I don’t love that part, but otherwise, these switch
expressions are nice. Probably many of the switch
statements in your code can be rewritten into this form. If in fact you commonly use switch
.
However, along the way, two more forms of switch
were introduced: a switch
statement without fallthrough, and a switch
expression with fallthrough. That’s a lot of accidental complexity, and on more than one occasion, I’ve been amazed that Java luminaries were entirely unaware of that—see this survey. My advice: The new expression no-fallthrough switch
is nice (and would have been even nicer if it hadn’t been called switch
). But stay away from the ugly siblings.
Should You Move to the Latest JDK?
If you are currently using JDK 11, I don’t think you’ll be bowled over by the new language and API features. Sure, some of them are nice, but they are not game changers. But I think that’s where so many of the rah-rah blogs are off track. And Sharat Chander is right. The first considerations should be bug fixes and plumbing improvements. It is is a huge engineering effort to keep the JDK alive and well. The default mode should be to partake in the benefits of that effort, and move on from JDK 11 to JDK 17.
I didn’t list the changes from JDK 8, where many projects still are, but you can go through the same exercise of enumerating JEPs and API changes. There are quite a few pleasant API changes between JDK 8 and 11, but only two significant language changes: local type inference (var
) and modules.
And modules are the elephant in the update room. As necessary as they are for the long-term evolution of the Java platform, they have caused pain to which Java developers, blessed with a truly remarkable level of backwards compatibility, are unaccustomed.
Moving on from Java 8 can be some amount of work. But it’s not going to get any easier. Standing still is not a long-term option. Java is alive and well, and exciting improvements lie ahead. And the grass isn’t greener on the other side. Better get moving.