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.
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
switchand 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
-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
| Aspect | Choose G1 when | Choose generational ZGC when |
|---|---|---|
| Pause target | Tens of milliseconds is acceptable | Pauses must stay under a millisecond |
| Heap size | Anything from a few hundred MB upward | Large heaps — tens of GB and beyond |
| Throughput | Slightly higher | A few percent lower in exchange for latency |
| Tuning | -XX:MaxGCPauseMillis and little else | Mostly self-tuning |
| Typical fit | The default for most services | Latency-critical services, large caches |
Pause target
Choose G1 whenTens of milliseconds is acceptableChoose generational ZGC whenPauses must stay under a millisecondHeap size
Choose G1 whenAnything from a few hundred MB upwardChoose generational ZGC whenLarge heaps — tens of GB and beyondThroughput
Choose G1 whenSlightly higherChoose generational ZGC whenA few percent lower in exchange for latencyTuning
Choose G1 when-XX:MaxGCPauseMillis and little elseChoose generational ZGC whenMostly self-tuningTypical fit
Choose G1 whenThe default for most servicesChoose 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.
# 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
// 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
// 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
// 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
// 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
// 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-internalsover 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.activationare 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
| Aspect | Gains with no code change | Gains that need code change |
|---|---|---|
| Memory | Compact strings — typically a noticeably smaller heap | Records reduce boilerplate, not footprint |
| Latency | G1 and ZGC pause behaviour is far better than CMS | Virtual threads for I/O-bound throughput |
| Startup | Class-data sharing, AOT improvements | jlink for a smaller runtime image |
| Debugging | Helpful NullPointerException messages | Exhaustive switches catch missing cases at compile time |
| Security | Years of patches and modern TLS defaults | Modern crypto APIs |
Memory
Gains with no code changeCompact strings — typically a noticeably smaller heapGains that need code changeRecords reduce boilerplate, not footprintLatency
Gains with no code changeG1 and ZGC pause behaviour is far better than CMSGains that need code changeVirtual threads for I/O-bound throughputStartup
Gains with no code changeClass-data sharing, AOT improvementsGains that need code changejlink for a smaller runtime imageDebugging
Gains with no code changeHelpful NullPointerException messagesGains that need code changeExhaustive switches catch missing cases at compile timeSecurity
Gains with no code changeYears of patches and modern TLS defaultsGains 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-previewat 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, runjdeps --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.