In my work, our team has been gradually upgrading our Java version — from JDK 8 to JDK 11, and eventually to JDK 21. With each version, Java has introduced many new features, but the upgrade process also comes with things worth paying attention to.
In this blog, I’d like to share some of the key improvements, as well as the considerations we encountered during the upgrade journey.
Module
The module system, introduced in JDK 9, allows developers to define which modules (previously JARs) are included in the Java Runtime Environment. If you look into the JDK after version 9, you’ll notice that instead of bundling everything into a single monolithic JAR structure like in JDK 8, the JDK is now split into multiple modular files. This modularity makes it easier to create lightweight Java runtimes — especially useful in environments with limited storage, such as IoT devices.
ZGC
ZGC (Z Garbage Collector) was first introduced in JDK 11 and has been actively improved in subsequent Java releases. It’s designed for low-latency applications and built on a 64-bit colored pointer model, which is why it doesn’t support 32-bit architectures. ZGC achieves extremely low stop-the-world (STW) pause times — often less than 1 millisecond — and supports very large heap sizes, up to several terabytes.
Compared to collectors like G1 or CMS, ZGC can concurrently relocate objects to new physical addresses, which helps minimize pause times and improves throughput.
A common misconception is that ZGC doesn’t support generational garbage collection. While it’s true that earlier versions (e.g., in JDK 11) didn’t include generational support due to implementation complexity, generational ZGC was officially introduced in JDK 21, and the legacy non-generational mode is expected to be removed in JDK 24.
I came across a benchmark comparing major garbage collectors: both Parallel GC and G1 often show GC pauses exceeding 16 ms, while ZGC consistently keeps pause times below 1 ms. However, this performance comes at a cost — ZGC tends to use more off-heap memory, reflecting the common trade-off between time and space.
Virtual Thread
However, there’s an important caveat to be aware of — as noted in the related JEP, before JDK 24, using
synchronized blocks inside a virtual thread can cause pinning, where the virtual thread is permanently attached to an OS thread. This can lead to performance bottlenecks or even deadlocks.Here’s an example: I created two types of tasks doing the same job. The only difference is that one uses
synchronized, and the other doesn’t. The lock I used is fair, meaning the first thread which does not use synchronized, will get it once it's available.When running the version with
synchronized, I observed that all available OS threads were pinned, so the unpinned virtual threads couldn't acquire the lock — even though the pinned ones were idle. As a result, the program deadlocked.To avoid this problem, you can:
- Avoid using
synchronizedin virtual threads
- Or, make sure the number of pinned threads stays below
Runtime.getRuntime().availableProcessors()
public static void main(String[] args) { boolean shouldPin = true; // With fairness to ensure that the unpinned thread is next in line ReentrantLock lock = new ReentrantLock(true); lock.lock(); Runnable takeLock = () -> { try { System.out.println(Thread.currentThread() + " waiting for lock"); lock.lock(); System.out.println(Thread.currentThread() + " took lock"); } finally { lock.unlock(); System.out.println(Thread.currentThread() + " released lock"); } }; // start thread Thread unpinnedThread = Thread.ofVirtual().name("unpinned").start(takeLock); List<Thread> pinnedThreads = IntStream.range(0, Runtime.getRuntime().availableProcessors()) .mapToObj(i -> Thread.ofVirtual().name("pinning-" + i).start(() -> { if (shouldPin) { synchronized (new Object()) { takeLock.run(); } } else { takeLock.run(); } })).toList(); lock.unlock(); // deadlock detection Stream.concat(Stream.of(unpinnedThread), pinnedThreads.stream()).forEach(thread -> { try { if (!thread.join(Duration.ofSeconds(3))) { throw new RuntimeException("Deadlock detected"); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); }
switch
JDK 14 introduced a new form of the
switch statement using case -> labels, which significantly improves code readability and reduces boilerplate.With this new syntax, we no longer need to write a
break in every branch. Additionally, it supports returning values directly from each case.public static String from(int type) { return switch (type) { case 0 -> "cool"; case 1 -> "good"; case 2 -> "best"; default -> "cool"; }; }
instanceOf
Pattern Matching for
instanceof, introduced in JDK 16, allows us to avoid explicit casting when performing type checks. If the instanceof condition is true, the matched variable is automatically cast and scoped within the conditional block — making the code both safer and more concise.if (obj instanceof String) { String s = (String) obj; // grr... ... } upgrade to: if (obj instanceof String s) { // Let pattern matching do the work! ... }
@Test public void test() { test("123"); } private String s = "s"; public void test(Object a) { if (a instanceof String s) { System.out.println(s); } System.out.println(s); }
Record & Record Patterns
Records, introduced in JDK 14, provide a concise way to declare immutable data classes.
With records, you no longer need to manually implement boilerplate methods like
equals(), hashCode(), toString(), or even getters — they are automatically generated by the compiler.You can think of it as a built-in immutable alternative to Lombok’s
@Data or @Value annotations.public record Person(String a, String b) { // 在初始化后执行 public Person { System.out.println(a); } }
Java also supports record patterns in
instanceof checks@Test public void test() { test(new Person("123", "456", new Job("code"))); } public void test(Object a) { if (a instanceof Person(String aa, String bb, Job(String cc))) { System.out.println(aa); System.out.println(bb); System.out.println(cc); } }
String Templates
String Templates were introduced as a preview feature in JDK 21, aiming to bring Java closer to the string interpolation capabilities of modern languages like JavaScript, Kotlin, or Python. This feature allows us to embed expressions directly into strings, making dynamic string construction more concise and readable at runtime. However, this feature is expected to be removed in JDK 23, possibly for redesign.
Personally, I believe trying out new language features is a great way to modernize and improve our codebase. That said, we should carefully evaluate preview features before adopting them in production, especially when they may be short-lived or subject to change.
C# $"{x} plus {y} equals {x + y}" Visual Basic $"{x} plus {y} equals {x + y}" Python f"{x} plus {y} equals {x + y}" Scala s"$x plus $y equals ${x + y}" Groovy "$x plus $y equals ${x + y}" Kotlin "$x plus $y equals ${x + y}" JavaScript `${x} plus ${y} equals ${x + y}` Ruby "#{x} plus #{y} equals #{x + y}" Swift "\(x) plus \(y) equals \(x + y)"
As you can see, two of the long-standing criticisms of Java — being verbose and not always the best in performance — are actively being addressed by the JDK development team through continuous improvements.
In my opinion, Java will remain one of the most widely used languages in the future, especially in banking and large-scale enterprise systems, where stability and a strong baseline (lower limit) are highly valued.
If you want to keep up with Java's evolution, I strongly recommend reading JEPs (JDK Enhancement Proposals) — they are one of the best ways to understand what’s coming in each version and how new features are designed. That said, be cautious when adopting preview or experimental features. Make sure they are suitable for your use case and won't compromise long-term stability.