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.

image.png

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.

image.png

image.png

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: