Six years and three LTS releases separate Java 11 from Java 17. The headline features get their own posts; this one collects the smaller changes that arrived in between and are easy to miss entirely — several of which you have probably already benefited from without noticing.
Helpful NullPointerExceptions
The most immediately useful change in the whole range, and it needs no code at all. Since Java 14,
a NullPointerException names what was null:
// Java 8
Exception in thread "main" java.lang.NullPointerException
at com.example.OrderService.validate(OrderService.java:42)
// Java 14+
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.length()" because "customer.name" is null
at com.example.OrderService.validate(OrderService.java:42)
On a line like a.getB().getC().getD(), the old message told you a null happened
somewhere on line 42. The new one names which link in the chain. It is on by default from Java 15 and
is, on its own, a decent argument for upgrading — see Debugging
for how to read the rest of the trace.
Stream.toList()
class Demo {
void run() {
List<String> names = List.of("Ana", "Bo");
List<String> old = names.stream().collect(Collectors.toList());
List<String> modern = names.stream().toList(); // Java 16
System.out.println(old.equals(modern)); // true
}
}
Shorter, and it returns an unmodifiable list where
Collectors.toList() returns a mutable ArrayList. That difference is the one
thing to watch when replacing the old form: code that collected and then added will start throwing
UnsupportedOperationException. It also permits null elements, unlike
List.of.
Stream additions
class Demo {
void run() {
List<Integer> numbers = List.of(1, 2, 3, 10, 4);
// Stop at the first failure, rather than filtering the whole stream
System.out.println(numbers.stream().takeWhile(n -> n < 5).toList()); // [1, 2, 3]
System.out.println(numbers.stream().dropWhile(n -> n < 5).toList()); // [10, 4]
// A three-argument iterate, with a condition instead of a limit
System.out.println(Stream.iterate(1, n -> n < 20, n -> n * 2).toList());
// [1, 2, 4, 8, 16]
// mapMulti — one element in, any number out, without allocating a stream each time
System.out.println(Stream.of("a,b", "c").<String>mapMulti((s, consumer) -> {
for (String part : s.split(",")) consumer.accept(part);
}).toList()); // [a, b, c]
}
}
takeWhile and dropWhile are the ones you will actually use. Note they
stop at the first element failing the test, which is different from filter — on
[1, 2, 3, 10, 4], takeWhile(n < 5) gives three elements while
filter(n < 5) gives four.
Compact number formatting
class Demo {
void run() {
NumberFormat short_ = NumberFormat.getCompactNumberInstance(
Locale.US, NumberFormat.Style.SHORT);
System.out.println(short_.format(1_000)); // 1K
System.out.println(short_.format(1_500_000)); // 2M
NumberFormat long_ = NumberFormat.getCompactNumberInstance(
Locale.US, NumberFormat.Style.LONG);
System.out.println(long_.format(1_000)); // 1 thousand
}
}
Added in Java 12, and it does the locale-aware thing rather than the naive division everyone writes by hand for view counts and file sizes.
Files.mismatch and other small file additions
class Demo {
void run(Path a, Path b) throws IOException {
long index = Files.mismatch(a, b); // -1 if identical
System.out.println(index == -1 ? "same" : "differ at byte " + index);
}
}
Comparing two files byte-for-byte used to mean reading both and looping. This does it in one call and short-circuits at the first difference.
String.formatted and indentation
class Demo {
void run() {
System.out.println("%s is %d".formatted("age", 30)); // instance form of String.format
String block = """
one
two
""";
System.out.println(block.indent(4)); // adds four spaces to every line
System.out.println(block.stripIndent()); // removes common leading whitespace
}
}
These arrived alongside text blocks and are mostly used
with them. formatted reads better at the end of a block than wrapping the whole thing in
String.format(...).
Switch and instanceof, briefly
Two features that arrived in this range have their own posts, but they belong on the list because they are the changes most likely to make Java 17 code look unfamiliar if you last used Java 8: switch expressions became standard in 14, and pattern matching for instanceof in 16.
Together with records and sealed classes, they are why Java 17 is the release people describe as the language finally moving again after a long quiet period.
The jpackage tool
jpackage --name MyApp --input target/ --main-jar myapp.jar --main-class com.example.Main --type dmg
Java 14 added a tool that packages an application together with a trimmed JVM into a native
installer — a .dmg, .msi or .deb. The user does not need Java
installed, which removes the oldest complaint about shipping desktop Java.
It pairs with jlink, which builds the trimmed runtime. For server applications a
container image usually fills the same role, but for anything a person downloads and double-clicks
this is a real improvement over telling them to install a JDK first.
Under the hood
Three changes you write no code for but should know exist:
- New garbage collectors. ZGC and Shenandoah both became production-ready in this range. Both target very short pauses on large heaps — worth knowing about if you have a latency problem, and worth ignoring otherwise, because G1 remains the right default.
- Compact strings and a new String concatenation strategy. Strings that fit in
Latin-1 use one byte per character instead of two, and
+concatenation compiles to aninvokedynamiccall that the runtime optimises. Both are why upgrading often makes code faster with no changes. - Strong encapsulation of JDK internals. From Java 17, reflective access into JDK internals throws rather than warning. This is the change most likely to break an upgrade — see the migration guide.
Next
The Java 17 migration guide is next — what breaks between 11 and 17, and the one flag you should not reach for.