Apache Cassandra 6.0 Part 9: JDK 21 and Generational ZGC

JDK 21 and Generational ZGC

A Cassandra JVM upgrade is not a package refresh. It changes the garbage collector, JIT compiler, memory layout, module-access behaviour, security-manager handling, logging implementation, native libraries, JMX attachment path, and compatibility surface for every agent running inside or alongside the process. Cassandra 6.0 makes JDK 21 a supported runtime to evaluate, but it should be introduced with the same discipline as a storage-engine or topology change.

The Cassandra 6.0 build declares Java 11, 17, and 21 as supported versions, while retaining Java 11 as the default build JDK. The build file describes non-default JDK builds as experimental and intended for development and testing. Support means the path is maintained and tested. It does not mean a particular collector, vendor distribution, heap size, or JVM flag set will be right for every production cluster.

Cassandra 6.0 is pre-release software. This post reflects 6.0-alpha3 and must be checked again against the release notes and upgrade documentation for the exact version being deployed.

AreaCassandra 5.0 baselineCassandra 6.0 work to validate
Runtime versionsJava 11 and 17 are the established supported options for most Cassandra 5.0 deployments.The build accepts Java 21 and supplies a Java-21-specific server options file.
Default collector profileExisting deployments commonly run an established collector and a locally tuned heap.The JDK 21 file enables ZGC with its generational mode and provides an alternative G1 configuration as comments.
Memory-layout settingsHeap, direct memory, compressed references, and host-page configuration are usually tuned locally.Cassandra carries a temporary -XX:-UseCompressedOops workaround for the JDK 21 ZGC and Jamm interaction.
GC loggingGC logs can introduce file-I/O work on the JVM path.Async log mode is enabled in the JDK 17 and 21 option files to reduce log-file I/O stalls, with an explicit loss mode if its buffer fills.

What Cassandra configures for JDK 21

The jvm21-server.options file is selected for Java 21 and newer. Its active collector settings are the following.

-XX:+UseZGC
-XX:+ZGenerational
-XX:-UseCompressedOops
-Xlog:async

The first two settings select Generational ZGC. The third is a temporary Cassandra configuration workaround for Jamm’s incorrect default assumption about compressed ordinary object pointers under this setup. It must not be removed casually because it affects the memory-layout calculations used by the measurement library. The fourth setting changes JVM log handling, not garbage-collection policy. Log records are written through an in-memory circular buffer so GC log-file I/O is less likely to block a JVM thread.

The same file contains commented alternatives and tuning options rather than active recommendations. These include G1 settings, explicit large-page support, SoftMaxHeapSize, disabling ZGC uncommit, and a delay before uncommitting unused memory. The presence of an option in the file is not a configuration baseline. Each has a memory, CPU, latency, or host-configuration consequence.

SettingBehaviourValidation required
-XX:+UseZGCEnables ZGC, a concurrent low-latency collector.Compare end-to-end latency and application throughput with the current collector on the same workload.
-XX:+ZGenerationalSeparates young and old collection work so short-lived allocation can usually be reclaimed without scanning the older live set.Check allocation rate, concurrent GC CPU, generation behaviour, heap headroom, and latency under compaction and repair.
-XX:-UseCompressedOopsAvoids the Jamm compressed-reference measurement issue documented in Cassandra’s JDK 21 options.Calculate heap and process memory requirements again; pointer size affects the retained-heap footprint.
-Xlog:asyncBuffers JVM log records before they are drained to the configured output.Confirm the log pipeline, free space, rotation, and that the asynchronous buffer does not lose records under the expected logging volume.
-XX:+AlwaysPreTouch (commented)Faults heap pages at startup instead of allowing later page faults during normal traffic.Measure restart time, required memory availability, and whether the latency profile improves after startup.

G1 remains the default collector for Cassandra on JDK 11 and 17. Generational ZGC becomes the default only with the JDK 21 options. Cassandra does not recommend non-generational ZGC, so -XX:+UseZGC should not be used without -XX:+ZGenerational on JDK 21. On newer JDKs, the generational flag is deprecated in JDK 23 and non-generational ZGC is removed in JDK 24, which is another reason to test the exact runtime rather than extrapolating from JDK 21.

How Generational ZGC works

Generational ZGC keeps the concurrent compacting design of ZGC. It does not stop Cassandra for a heap-wide pointer update when an object moves. Instead, ZGC changes the state that references are expected to carry, then barriers on application reference loads and stores do the remaining work while the application runs.

That distinction is important. A compacting collector must eventually move live objects out of sparse regions, or a long-running heap becomes fragmented. Moving an object makes its former address stale, yet a Cassandra thread can load that reference while relocation is taking place. ZGC keeps the reference usable by encoding GC state in the reference and checking that state every time Java code loads another object reference.

Coloured references and barriers

A ZGC reference contains an object address and a small amount of GC state. One mark or remap state is the current state for a collection phase. The load barrier compares the state in the loaded reference with that current state. It is a small register operation when the reference is already current.

When the states do not match, the load barrier takes its slow path. During concurrent marking, that path can mark the referenced object. During relocation, it consults the forwarding table to find the object’s new address. It returns a current reference and can use a compare-and-swap to heal the field that held the stale one. A later load of that field can then take the fast path.

Generational ZGC adds a store barrier because a young collection cannot ignore references from old objects into young space. When an old cache object, schema object, or other retained object is updated to point at a new young object, the barrier records that field in the remembered set. The next young collection treats those entries as additional roots, alongside thread stacks, and therefore does not need to trace the complete old generation to find young objects that are still live.

The store barrier also supports snapshot-at-the-beginning marking. If application code overwrites a reference while marking is in progress, the old reference is retained for the marker before the write completes. Without that record, the collector could lose the only path to an object that was live when marking started.

A young collection

A young collection begins with a short pause that changes shared GC state, including the current mark state and remembered-set snapshot. The Java heap is not walked during that pause. Concurrent marking then traces the live young graph from thread stacks and remembered references while Cassandra threads continue to execute.

Once marking has identified the live objects, ZGC selects sparse young regions for relocation and installs forwarding tables. It copies live objects into new regions, returns the old regions to the allocator, and lets load barriers forward and repair references as they are used. Surviving objects can be relocated into old space according to ZGC’s adaptive tenuring policy. Old-generation collections use the same broad marking and relocation model on a less frequent schedule.

Conceptual Generational ZGC young-collection memory map showing thread-stack roots, the remembered set, live young objects, relocation, reclamation, and promotion.

The diagram covers Java-heap references and objects. With Cassandra’s default heap_buffers allocation mode, memtable data is on the ZGC-managed heap and participates in collection like other live heap data. Direct buffers, SSTables, and the operating-system page cache remain outside the Java heap. Memtables are not a ZGC generation in their own right, and offheap_* memtable modes move part of their storage outside the heap.

Cassandra performance impact

Generational ZGC does not make an SSTable read, a compaction, a replica response, or a network queue intrinsically faster. It changes how Java allocation and Java-heap collection affect the work Cassandra is already doing. That distinction is important when comparing results. A node limited by storage latency will not gain lower read latency simply because its collector changed.

Cassandra creates short-lived Java objects while decoding native-protocol requests, executing CQL, building results, serialising responses, handling internode messages, and running maintenance work. It also retains objects for caches, schema metadata, prepared statements, executors, metrics, and the live data structures behind an active node. The generational design lets frequent young collections focus on the first group without repeatedly tracing the second group.

The result that is most plausible is a reduction in GC-caused tail disturbance when allocation pressure is high. Short stop-the-world phases remove one source of broad latency spikes, while young collection is less likely to fall behind because it does not retrace the complete retained old set every cycle. This does not mean every request completes faster. A load barrier can take a slow path while marking or relocation is active, and an allocation stall can still delay a request if the collector cannot reclaim memory fast enough.

Inside Java’s Generational ZGC explainer, published by Oracle’s Java team, includes an Apache Cassandra benchmark that shows the useful failure mode. Single-generation ZGC began to suffer allocation stalls above 75 concurrent clients in that test, while Generational ZGC maintained its pause profile through 275 clients. That is evidence that the generational design can protect the far tail of an allocation-heavy Cassandra workload. It is not a sizing rule for a production cluster, because heap size, data model, object allocation rate, CPU allocation, driver behaviour, and storage latency will all change the crossover point.

Cassandra measureWhat Generational ZGC can changeWhat still requires measurement
Coordinator and replica p95, p99, and p99.9 latencyShort GC pauses can remove a node-wide pause contribution. Lower allocation-stall risk can prevent isolated requests from waiting for space to be reclaimed.Disk reads, page-cache misses, queueing, replica load, network delay, speculative execution, and timeouts can dominate the same percentiles. Capture client and server-side latency together.
Read and write throughputYoung collections can spend less time tracing retained old objects, leaving more CPU time for useful work when allocation pressure was previously high.Java reference load and store barriers have a CPU cost. The store barrier is triggered by Java reference writes, not by a CQL write as such. Measure sustained operations per second with the same data, consistency level, and concurrency.
Node CPULess collection work per short-lived allocation can reduce GC pressure. Avoiding allocation stalls can also avoid a collapse in useful request work at high concurrency.Concurrent GC workers run beside Cassandra executors, compaction, repair, compression, encryption, and network processing. A lower pause graph can coincide with higher total CPU use. Track process CPU and GC-worker CPU, not just pause duration.
Heap capacity and process RSSGenerational ZGC adapts generation sizes and tenuring thresholds to the allocation rate and live set. A well-sized heap gives concurrent collection room to run while requests continue.Cassandra’s JDK 21 options disable compressed ordinary object pointers for the Jamm workaround. Equal -Xmx values can therefore produce a different retained-heap and resident-memory footprint. Recalculate host and container headroom.
Allocation stallsFrequent young collection reduces the chance that short-lived allocations force work against the full retained heap.A node can still stall when allocation exceeds reclamation capacity. Treat any allocation-stall event as an application-latency investigation, even when stop-the-world pauses remain small.
Compaction, repair, and streamingA collector with a steadier latency profile can reduce Java-heap interference while maintenance runs alongside traffic.These operations remain constrained by disk bandwidth, page-cache residency, network throughput, compaction strategy, and the CPU they share with the collector. GC cannot compensate for an overloaded storage path.

For a read-heavy node whose p99 is driven by page-cache misses and device queueing, the collector may have almost no visible effect. For a coordinator with high request concurrency and a large allocation rate, the same change may remove the pauses or allocation stalls that were responsible for its worst requests. Those are different bottlenecks, and Cassandra tuning needs to identify which one is present before assigning credit to the JVM.

The comparison should include a deliberately busy period rather than only an idle benchmark. Run normal client traffic through compaction and flush activity, then repeat during repair, streaming, bootstrap, or replacement where those workflows are relevant. A JVM change is useful when it improves latency and throughput without creating an unacceptable CPU, RSS, or page-cache trade-off for the rest of the node.

Heap, direct memory, and the host

The Java heap is only part of a Cassandra process. Cassandra uses direct buffers and native memory, while the operating system needs memory for page cache, file-system metadata, thread stacks, JIT code, shared libraries, and the kernel. A test that gives a Java process the same -Xmx under JDK 17 and JDK 21 without checking the complete resident memory footprint is incomplete.

The JDK 21 options file makes this concrete. Its SoftMaxHeapSize and uncommit controls are commented because their right value depends on the host. ZGC can uncommit unused memory, which can reduce process footprint, although recommitting and faulting pages later can affect latency. Setting -Xms equal to -Xmx with AlwaysPreTouch can avoid later heap page faults at the cost of reserving and touching the memory during startup. Neither approach is universally better.

Large pages are another host decision. The options file leaves UseLargePages disabled. Explicit huge pages may help throughput and latency on a correctly configured host, while transparent huge pages can introduce latency spikes on a latency-sensitive workload. Treat page configuration as part of the experiment and record it with the result; otherwise two JVM tests may be using different memory behaviour without making that obvious.

Memory areaEvidence to captureCommon mistake
Java heapUsed heap, committed heap, allocation rate, live-set estimate, GC cycles, pauses, and concurrent-GC CPU.Concluding that low used heap means the node has spare memory.
Direct and off-heap memoryDirect-buffer use, native memory tracking where available, allocator metrics, and process RSS.Keeping the old direct-memory limit after changing heap or collector settings.
Page cacheMajor faults, cache residency, device reads, and query latency through compaction.Giving nearly all RAM to the JVM and starving the cache used for SSTable reads.
Container or cgroup limitLimit, current use, OOM events, memory pressure, and host-level eviction evidence.Treating -Xmx as the complete process memory budget.
Startup memory policyStartup duration, resident memory after start, page faults under first traffic, and recovery time.Enabling pre-touch without reserving enough capacity for rolling restart overlap.

Async GC logging

CASSANDRA-21372 enables -Xlog:async for JDK versions that support it. Writing GC logs to a file can block on I/O, and a GC-related JVM thread waiting on that I/O can create an avoidable latency disruption. Asynchronous logging places records into a circular memory buffer and drains them separately.

CASSANDRA-20980 also separates GCInspector thresholds for concurrent GC events. Monitor concurrent GC CPU and allocation stalls alongside pauses, because a low pause time does not show all collector work.

It does not make the garbage collector faster, reduce allocation rate, or guarantee that every record is preserved. Cassandra’s option file documents the failure mode clearly. If the asynchronous buffer fills before it can be drained, log records are silently dropped. The buffer size can be adjusted, but the correct response is not automatically to make it huge. Investigate why the logging destination is unable to keep up and confirm that disk capacity, rotation, log shipping, and incident retention still work.

Compare async logging under the same GC log configuration and traffic profile. Capture GC log volume, dropped-record evidence, storage latency for the log destination, pauses, allocation stalls, and application latency. It is reasonable to keep the change when it removes an I/O-related disturbance without losing required diagnostic data. It is not a substitute for tracking GC through JVM metrics and profiler evidence.

Cassandra 5.0 and 6.0 validation

Run each collector and JDK test with the same Cassandra version under test, schema, data volume, replication, compaction settings, client driver workload, and host class. Change one dimension at a time. A JDK change combined with a new heap size, new storage class, changed page settings, and changed application traffic will not produce a result that can be trusted.

TestBaselineJDK 21 comparisonMeasurements
Steady client trafficCurrent supported JDK and production collector configuration.JDK 21 with Cassandra’s supplied options, then a controlled heap change only if needed.Throughput, p50/p95/p99 coordinator and replica latency, retries, timeouts, allocation rate, GC pauses, concurrent-GC CPU, and RSS.
Compaction and flushSame active read and write workload while maintenance runs.Repeat with JDK 21, preserving compaction and flush concurrency.Read and write tail latency, page faults, device queueing, compaction throughput, heap and direct memory, and GC activity.
Repair and streamingExisting repair workload with realistic data size and network use.Repeat on JDK 21 during repair, bootstrap, replacement, or streaming.Session completion, latency, CPU, off-heap memory, GC logs, network throughput, and disk headroom.
Restart and recoveryCurrent JVM with the existing startup policy.JDK 21 with and without any deliberate pre-touch or uncommit choice.Startup duration, resident memory, first-request latency, page faults, time to become healthy, and impact on other nodes.
Observability and agentsExisting JMX, metrics, tracing, backup, profiler, and log collection path.Repeat every operational workflow against JDK 21.Successful attachment, metric continuity, agent errors, log format and retention, backup/restore behaviour, and alert compatibility.

Use an identical JDK vendor and patch level across each group of tests. A major JDK number is not enough detail; vendor builds and patch levels can affect bug fixes, TLS, native libraries, GC behaviour, and operational tooling. Keep the version, JVM flags, cassandra-env settings, cgroup limits, kernel, page configuration, and agent versions in the test record.

AxonOps can correlate the JVM and GC evidence with Cassandra request metrics, compaction, repair, disk I/O, configuration history, logs, and events. The useful conclusion is not that one collector produced an attractive chart. It is whether the cluster behaved better through its normal workload and whether the team retained the evidence required to investigate the next incident.

Contributors

Achilles Benetopoulos proposed the JDK 21 support work, with Josh McKenzie assigned to it. Dmitry Konstantinov proposed and implemented asynchronous GC logging for the JDK 17 and 21 options. The JDK support path also relies on people who keep Cassandra building and testing on multiple JDKs, investigate compatibility breakage in dependencies and agents, review JVM flags, maintain packaging, and document operational implications.

I am grateful for that work. JVM support is easy to overlook when it succeeds, but it is fundamental to every Cassandra node that starts, recovers, handles traffic, compacts SSTables, and is investigated during an incident.

Series

Sources