PrepZone Logo
PrepZone

Java 21 and What Comes Next

Generational ZGC, the preview features worth watching, and a practical 8 to 21 migration path.

Why this matters

  • Knowing which features are final and which are previews is the difference between a confident upgrade and an unpleasant surprise two releases later.
  • Garbage collection improved more between 8 and 21 than in the decade before, and the right collector choice is usually a configuration change rather than a code change.
  • A migration plan that sequences the work correctly takes days; one that does everything at once takes months.
Java 8Lambdas, streams
Java 11var, HTTP client
Java 17Records, sealed, switch
Java 21Virtual threads, patterns
Each long-term support release is supported for years, which is why most teams sit on one of these four rather than the six-month interim releases.

What is final in Java 21

Production-ready, no flags required

  • Virtual threads — thread-per-request at a scale that previously needed reactive code.
  • Pattern matching for switch and record patterns — exhaustive, destructuring dispatch over sealed hierarchies.
  • Sequenced collections — one vocabulary for first, last and reversed.
  • Generational ZGC — sub-millisecond pauses with much better throughput than non-generational ZGC.
  • A key encapsulation mechanism API for modern cryptography.
  • Plus everything final in 17: records, sealed classes, text blocks, switch expressions, helpful NPE messages.

Garbage collection in 21

Java
-XX:+UseG1GC                      # the default since Java 9 — balanced, tunable, good enough
-XX:+UseZGC -XX:+ZGenerational    # sub-millisecond pauses, generational since 21
-XX:+UseShenandoahGC              # low pause, an alternative to ZGC
-XX:+UseSerialGC                  # single-threaded — genuinely best for small containers
-XX:+UseParallelGC                # throughput-first, when pause length does not matter
AspectChoose G1 whenChoose generational ZGC when
Pause targetTens of milliseconds is acceptablePauses must stay under a millisecond
Heap sizeAnything from a few hundred MB upwardLarge heaps — tens of GB and beyond
ThroughputSlightly higherA few percent lower in exchange for latency
Tuning-XX:MaxGCPauseMillis and little elseMostly self-tuning
Typical fitThe default for most servicesLatency-critical services, large caches
  • Pause target

    Choose G1 whenTens of milliseconds is acceptable
    Choose generational ZGC whenPauses must stay under a millisecond
  • Heap size

    Choose G1 whenAnything from a few hundred MB upward
    Choose generational ZGC whenLarge heaps — tens of GB and beyond
  • Throughput

    Choose G1 whenSlightly higher
    Choose generational ZGC whenA few percent lower in exchange for latency
  • Tuning

    Choose G1 when-XX:MaxGCPauseMillis and little else
    Choose generational ZGC whenMostly self-tuning
  • Typical fit

    Choose G1 whenThe default for most services
    Choose generational ZGC whenLatency-critical services, large caches

Generational ZGC in 21 removed the main reason not to use ZGC — it now handles high allocation rates well.

Java
# Measure before you tune — unified logging makes this easy
-Xlog:gc*:file=gc.log:time,uptime,level,tags

# Containers: let the JVM see the cgroup limits (default since Java 10)
-XX:MaxRAMPercentage=75

The previews worth understanding

Structured concurrency

Java
// Preview in 21 and 22, changed in 23 — the shape is still settling
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<User> user = scope.fork(() -> loadUser(id));
    Supplier<Stats> stats = scope.fork(() -> loadStats(id));
    scope.join();
    scope.throwIfFailed();
    return new Dashboard(user.get(), stats.get());
}

The idea is that concurrent subtasks get a scope with a defined lifetime: if one fails the others are cancelled, and the scope cannot exit until all are finished. That turns concurrency into something a stack trace can describe. Use Executors.newVirtualThreadPerTaskExecutor() in production until this is final.

Scoped values

Java
// Preview — the intended replacement for ThreadLocal with virtual threads
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, id).run(() -> {
    handle();                       // anything called here can read REQUEST_ID
});

ThreadLocal is mutable, unbounded in lifetime, and inherited by child threads — all of which scale badly when there are a million threads. A scoped value is immutable and bounded to a dynamic scope, so it is cheap to propagate.

String templates

Java
// Preview in 21 and 22, then withdrawn for redesign — do not build on this
// String message = STR."Hello \{name}, you have \{count} messages";

Foreign Function and Memory API

Java
// Preview in 21, final in 22 — the replacement for JNI
try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment = arena.allocate(1024);
    MethodHandle strlen = Linker.nativeLinker()
            .downcallHandle(SymbolLookup.loaderLookup().find("strlen").orElseThrow(),
                            FunctionDescriptor.of(JAVA_LONG, ADDRESS));
}

Calling native code and managing off-heap memory without writing C glue or using Unsafe. It became final in Java 22, so on a 21 baseline it needs --enable-preview.

Also in flight

Java
// Project Valhalla — value classes, to remove the object header for small immutable types
// Project Babylon — code reflection, aimed at GPU and ML workloads
// Vector API — still an incubator module after many rounds, pending Valhalla

A migration path from 8 to 21

Six steps, in this order

  • Compile on 21 targeting your current level with --release 8. This surfaces removed APIs without changing any runtime behaviour, and it is reversible.
  • Run jdeps --jdk-internals over every jar to find illegal access into JDK internals before it fails at runtime.
  • Upgrade build tools, test frameworks and bytecode libraries. This is the bulk of the real work; 8-era versions of these generally do not run on 21.
  • Add back the removed JDK modules as ordinary dependencies — JAXB and java.activation are the usual two.
  • Switch the runtime to 21 on the classpath. Do not modularise at the same time; keep one variable at a time.
  • Adopt features last, and incrementally: records for DTOs, switch expressions, text blocks for embedded SQL, then virtual threads once you have audited for pinning.

What to expect from the upgrade

AspectGains with no code changeGains that need code change
MemoryCompact strings — typically a noticeably smaller heapRecords reduce boilerplate, not footprint
LatencyG1 and ZGC pause behaviour is far better than CMSVirtual threads for I/O-bound throughput
StartupClass-data sharing, AOT improvementsjlink for a smaller runtime image
DebuggingHelpful NullPointerException messagesExhaustive switches catch missing cases at compile time
SecurityYears of patches and modern TLS defaultsModern crypto APIs
  • Memory

    Gains with no code changeCompact strings — typically a noticeably smaller heap
    Gains that need code changeRecords reduce boilerplate, not footprint
  • Latency

    Gains with no code changeG1 and ZGC pause behaviour is far better than CMS
    Gains that need code changeVirtual threads for I/O-bound throughput
  • Startup

    Gains with no code changeClass-data sharing, AOT improvements
    Gains that need code changejlink for a smaller runtime image
  • Debugging

    Gains with no code changeHelpful NullPointerException messages
    Gains that need code changeExhaustive switches catch missing cases at compile time
  • Security

    Gains with no code changeYears of patches and modern TLS defaults
    Gains that need code changeModern crypto APIs

The left column alone usually justifies the upgrade before you write a single new line of Java.

Staying current after 21

A habit that keeps this knowledge fresh

  • Read the JEP list for each release. Every feature has a JEP with a rationale, and it is short.
  • Watch what moves from preview to final, and what gets withdrawn — string templates are the cautionary example.
  • Target the next LTS, which is Java 25, and treat the interim releases as a place to try previews.
  • Re-check your GC choice when you change heap size, container limits or latency targets, not on a schedule.
  • Prefer final APIs in production, always. Understanding a preview is valuable; depending on one is not.

Common misreadings

  • "Everything in Java 21 is production-ready." Structured concurrency, scoped values and string templates were previews.
  • "ZGC is always better than G1." It trades throughput for pause time. Stay on G1 until you measure a pause problem.
  • "Preview features are stable enough." String templates were withdrawn entirely after two rounds.
  • "You can run preview-compiled classes on a newer JDK." They are pinned to the exact version that compiled them.
  • "Migrating means modularising." The classpath still works, and most applications stay on it.
  • "Virtual threads replace CompletableFuture." They replace much of its use, not fan-out-and-combine composition.
  • "The upgrade needs a code rewrite." Most of the benefit arrives with no code change at all.

Quick recall

Everything you need if you only revisit this box.

  • Final in 21: virtual threads, pattern matching for switch, record patterns, sequenced collections, generational ZGC.
  • Preview in 21: structured concurrency, scoped values, string templates (later withdrawn), the Foreign Function API (final in 22).
  • Preview code needs --enable-preview at compile and run time, and is pinned to that exact JDK version.
  • G1 is the default and the right default. Generational ZGC is the answer for sub-millisecond pauses on large heaps.
  • Migration order: compile on 21 with --release 8, run jdeps --jdk-internals, upgrade libraries, add back JAXB, switch the runtime on the classpath, adopt features last.
  • Free wins: compact strings, modern GC, class-data sharing, helpful NPE messages, years of security patches.
  • The next LTS is Java 25; use interim releases to try previews, not to run services.

Test yourself

Answer these before moving on — recall is what makes it stick.