Java 17 Other Improvements

July 27, 20264 min readUpdated 8/20/2026

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 an invokedynamic call 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.