Why architecture matters here

Collector choice is workload-dependent. A batch job with 30 GB heap and no pause requirement prefers G1 or Parallel. A low-latency service with 10 ms SLO and 100 GB heap wants ZGC. A microservice at 512 MB with tight memory prefers Serial or G1 depending on cores.

The architecture matters because misconfigured GC dominates the tail. Long pauses cause request timeouts, connection resets, and cascading retries. Wrong heap sizing causes constant GC or OOMKilled. Ignoring container awareness leads to JVMs using twice the memory the pod is allowed.

Knowing the internals lets you pick and tune with confidence, not folklore.

Advertisement

The architecture: every piece explained

The top strip is shared JVM machinery. Application threads allocate objects and read/write references. Heap regions — fixed-size chunks in modern collectors — divide the heap so collection can be incremental. TLABs (thread-local allocation buffers) let threads bump-pointer allocate without contention. Write barriers record cross-region references so old-generation collection can find roots without scanning the whole heap.

The middle row is the collectors. G1 uses region-based mixed collection: young collection is stop-the-world but bounded; mixed collection includes some old regions each cycle. ZGC uses colored pointers (metadata in address bits) and concurrent everything — marking, relocation, remap — with sub-millisecond pauses even on multi-TB heaps. Shenandoah uses Brooks forwarding pointers for concurrent relocation and per-region collection. Metadata + code cache reminds you that heap is not the only memory — Metaspace and JIT code cache also grow.

The bottom rows are practice. Tuning picks heap size (Xms=Xmx for predictability), pause target (G1's -XX:MaxGCPauseMillis), and collector flags. Observability means unified GC log (-Xlog:gc*), JFR flight recordings, and cause analysis. Ops adds container awareness (JVM sees cgroup limits), native memory tracking, and off-heap monitoring.

JVM garbage collectors — G1, ZGC, Shenandoah — trading throughput, pause, and heap sizeconcurrent collection at the pause SLO you wantApplication threadsallocate + read + writeHeap regionsyoung + old, region-basedTLABsper-thread allocation bufferWrite barriersrecord cross-region refsG1region + mixed collectionZGCcolored pointers, concurrentShenandoahconcurrent + Brooks pointerMetadata + code cachecompressed class + jit codeTuningheap sizing + pause targetObservabilityGC log unified + JFROps — SLO-based selection of collector, container awareness, native memory trackingallocateassignfast pathtracktuneobservelogoperatesize
JVM collectors and the shared machinery around them.
Advertisement

The tri-colour abstraction and why concurrent marking needs a barrier

Every tracing collector is doing the same thing underneath: partitioning the heap into objects it has proved reachable and objects it has not. The standard vocabulary for that proof is tri-colour. White objects have not been reached. Grey objects have been reached but their outgoing references have not been scanned yet. Black objects have been reached and fully scanned. Marking greys the roots, then repeatedly takes a grey object, blackens it, and greys everything it points to. When no grey objects remain the wavefront has swept the entire reachable graph, and everything still white is garbage - reclaimable without ever being touched. That is why a tracing collector's cost is proportional to the live data, not to the garbage.

The argument is airtight only if the graph holds still. A concurrent collector marks while the application keeps rewiring references, and the application can destroy the proof. The lost-object problem needs exactly two things to happen together: the mutator stores a reference to a white object into a black object - already scanned, so the collector will never look at it again - and the mutator then destroys every remaining path to that white object from anything grey. Nothing grey leads to it and nothing will rescan the black holder, so a live object gets swept. This is not an exotic race; an ordinary "publish into a cache, then clear the local field" sequence produces exactly that shape.

The fix is an invariant maintained by a barrier: code the JIT injects around heap accesses. The strong tri-colour invariant forbids the first condition outright - no black object may ever point at a white one. The weak invariant permits such an edge, provided every white object on the far end of one stays reachable from some grey object. Which invariant you pick determines which barrier you need, and that is the deepest fork in the whole GC design space.

Two write-barrier families - SATB versus incremental update

Incremental-update barriers enforce the strong invariant by reacting to the store that would create the black-to-white edge. Dijkstra's variant shades the newly stored target grey; Steele's variant re-greys the black object doing the storing so it will be rescanned. Either way the collector's view is repaired as the mutation happens and the wavefront tracks the graph as it currently is. The cost is that marking chases a moving target: newly published objects keep landing on the work list, and proving termination means establishing that no mutator has pending work, which in practice implies rescanning roots and a longer final pause.

Snapshot-at-the-beginning (SATB) barriers take the opposite position: conceptually freeze the heap at the instant marking started, and reclaim only what was already dead then. The barrier fires before a reference field is overwritten and records the old value, so an object about to be unlinked stays reachable through the collector's queues. Marking then terminates against a fixed target and the final remark only has to drain those queues. The price is floating garbage: anything that dies after the snapshot survives this cycle and is collected by the next one. In a high-churn service that overhead is real, but it is bounded and predictable - which is why G1 and Shenandoah both mark with SATB. Its role in G1 is covered in G1 GC, and in Shenandoah in Shenandoah GC.

The cost profiles are very different. A card-marking write barrier for a generational collector is unconditional but trivial: one byte store into a card table, no branch. An SATB pre-write barrier has to load the old field value and conditionally enqueue it, so it is a load plus a branch on every reference store, and it is normally only armed while a marking cycle is in flight. A load barrier is a different tax entirely: it runs on reference reads, which vastly outnumber reference writes in typical code, so its aggregate throughput cost is higher. What it buys is the one thing a write barrier can never provide - concurrent relocation. That is the trade ZGC and Shenandoah accept deliberately.

The generational hypothesis and what it costs when it fails

Nearly every production collector bets on one empirical claim: the overwhelming majority of objects become unreachable very soon after allocation, and references from old objects to young ones are comparatively rare. If that holds, an enormous shortcut opens up. Collect only the young area, and collect it often. Because a copying collection's cost is proportional to the bytes that survive rather than the bytes reclaimed, a young space that is 97% garbage is nearly free to collect: copy the 3%, then declare the whole space empty in constant time. Reclaiming a gigabyte can cost less than reclaiming a megabyte, if the gigabyte is dead.

The rare old-to-young references are precisely why remembered sets and card tables exist. Without them, collecting only the young space would still require scanning the entire old generation for inbound pointers, which destroys the shortcut. The write barrier's job in a generational collector is to record those cross-generational stores so that a young collection's root set is thread stacks, globals, and a small recorded set - not the whole heap.

Workloads break the hypothesis in recognisable ways: an in-memory cache with a long TTL, an object pool, large per-session state, a batch job accumulating results into a growing structure. The signature is always a high survival rate out of the young space. Survivors overflow the survivor space and get promoted regardless of the tenuring threshold - premature promotion - and the old generation fills with objects that were going to die shortly anyway. You are now paying old-generation collection cost for young-generation garbage, which is the single most expensive mistake in GC behaviour. The remedy is almost never a collector swap; it is a larger young space, a different survivor sizing, or fixing the code that holds the references. Note too that "generational" is a strategy rather than a collector identity: the same collector may or may not be generational depending on release and flags, and a non-generational concurrent collector simply pays full-heap marking cost every cycle in exchange for uniform behaviour.

Region-based heaps versus contiguous generations

In a contiguous design, eden, survivor and old are address ranges. Sizing is fixed up front and changes only by resizing a whole space. A collection touches an entire space, so pause length is a consequence of how much survived rather than something you can budget. The bookkeeping is minimal - a card table and a few pointers - which is exactly why the simplest collectors still win on raw throughput.

A region-based heap slices the whole heap into equal fixed-size chunks and makes generation membership a per-region attribute instead of an address range. Two things follow. First, the young/old boundary can move without relocating a single object, because you only relabel regions; generation sizes then adapt continuously to the workload. Second and more importantly, the collector may choose a subset of regions to collect, which turns pause time into a budget the collector spends rather than an outcome it reports afterwards. Every pause-target mechanism in every incremental collector depends on that one property.

The costs are real. Cross-region references become a many-to-many relation, so remembered-set metadata grows with the number of region pairs that actually reference each other, and a densely cross-linked heap can spend meaningful memory and barrier CPU maintaining it. Per-region headers and free lists add fixed overhead. And large objects do not fit the model at all.

Large objects break the region model

An object bigger than a region cannot go through the ordinary allocation path. Collectors handle it with a special class of region: a run of contiguous regions dedicated to the single object, often with coarser or more restricted reclamation than normal regions get. The practical consequences are the same everywhere. Large-object allocation is slow because it needs contiguous space. It can fail even when total free memory is ample, if the free regions are scattered. And a churn of large arrays can fragment the region space badly enough to force a full compaction. If your service allocates multi-megabyte byte arrays per request - a very common shape in serialization, image handling, and buffer-per-request designs - that allocation path, not the marking algorithm, is probably your actual GC problem. Reuse the buffers, or size them below the threshold.

Moving objects while the mutator runs

Compaction is what stops a long-running heap from fragmenting, and a copying collector gets it for free as a side effect of evacuation. The hard part is that the instant you copy an object, every existing reference still points at the old address, and a mutator may dereference one at any moment. Structurally there are only three answers.

Stop the world and fix up. Copy the live objects, then walk roots and copied objects rewriting every reference before anyone resumes. Simple, fast, correct, and the pause is proportional to live bytes copied. This is what Serial and Parallel do, and what every G1 evacuation pause does.

Forwarding word plus an access barrier. Give every object a header slot that initially points at itself and is set to the new address when the object is copied. A barrier on reference load follows that word, so any thread touching a moved object is transparently redirected. Correctness is maintained per access instead of per pause. This is Shenandoah's shape.

Metadata in the pointer plus lazy healing. Store collector state in unused bits of the reference itself; a load barrier checks those bits and consults a forwarding table only when they are stale, then updates the reference in place so the next load is fast. This is ZGC's shape.

What all three share is a single idea: an interception point where a stale reference is detected and repaired. The design choice is only about where you pay - in a pause, in a per-load check, or in memory for forwarding state. There is also a fourth option, which is not to move at all: a mark-sweep collector with free lists sidesteps the entire problem and pays in fragmentation, a slower allocation path, and an eventual compacting pause that arrives at the worst possible moment. That last failure mode is most of the reason the industry converged on concurrent moving collectors.

The trilemma is the real decision axis

Throughput, pause time, footprint. You optimise two.

Throughput is the fraction of CPU your application keeps. Every mechanism that shortens pauses spends it: barriers add instructions to the mutator's hottest paths, and concurrent GC threads consume cores that would otherwise run your code. Moving work out of a pause does not reduce the work - it relocates the work to somewhere it competes with the application. On a container with two cores and a saturated CPU quota, a concurrent collector can make latency worse, because GC threads and request threads fight for the same runnable slots.

Footprint is the price of concurrency. A stop-the-world collector can run the heap nearly full: it stops everything, reclaims, resumes. A concurrent collector is racing the mutator and must finish reclaiming before the application exhausts free memory, so the heap has to hold the live set, plus everything allocated during one collection cycle - allocation rate multiplied by cycle duration - plus slack for the variance. Under-provision that headroom and the collector loses the race, producing each design's characteristic failure: an evacuation failure and full GC, an allocation stall, or a degenerate cycle. The symptom reads as "GC got slow"; the cause is that the heap was sized for the live set instead of for the allocation rate.

Pause time is the one thing you can actually bound, and only once you have paid the other two. That is the entire content of collector selection: work out which of the three resources is scarce for this workload, and spend the other two.

Allocation is nearly free until it is not

The allocation fast path is not a call into a memory manager. Each thread owns a thread-local allocation buffer - a private slice of the young space - and allocating means bumping a pointer: compare the current position plus the object size against the buffer's end, store the new position, done. The JIT inlines it, so an allocation compiles to a handful of instructions with no lock, no atomic, and no contention with other threads. Zeroing the object's fields frequently costs more than obtaining the memory.

The slow paths are where cost actually lives. When a buffer is exhausted the thread atomically claims a fresh one from the shared young space - a genuine synchronisation point, but amortised over many allocations. The runtime sizes each thread's buffer adaptively from its recent allocation behaviour, so a hot allocating thread gets large buffers and a mostly-idle one gets small ones. The tension is that a large buffer wastes its unused remainder when retired, while a small one refills too often, and the runtime targets a small waste percentage to balance the two. Objects too large for a buffer skip it entirely and take the synchronised shared path every single time.

The consequence worth internalising is that allocation cost is almost never the problem. Allocation rate is, because it sets how often you collect, and survival rate is, because it sets how much each collection costs. A profile that appears to show most of the time in allocation is nearly always showing GC-induced work rather than the bump-pointer itself. Two escape hatches remove the allocation rather than speeding it up: the JIT can scalar-replace objects it proves do not escape (see escape analysis), and flattened value types change object layout so aggregates need not be separately heap-allocated at all (see Project Valhalla).

The numbers that matter in a GC log

Log to a file with timestamps, then derive four quantities. Almost everything else is detail.

# capture pauses, heap sizes and safepoint cost, with wall + uptime stamps
-Xlog:gc*,gc+heap=info,safepoint:file=gc.log:time,uptime,level,tags

derive from the log, not from a dashboard average:

  live set        heap occupancy immediately AFTER a full or concurrent cycle
                  -> the floor; every headroom calculation starts here

  allocation rate sum over young events of (used_before[i] - used_after[i-1])
                  divided by wall time -- i.e. bytes the mutators added
                  BETWEEN collections, not bytes reclaimed by one
                  -> sets collection FREQUENCY and whether a concurrent
                     collector can stay ahead of you

  promotion rate  increase in old-gen occupancy across young events / wall time
                  -> the best early warning for old-generation trouble

  GC CPU overhead GC thread CPU seconds / total process CPU seconds
                  -> what a throughput workload actually pays; invisible
                     in pause statistics

  pause profile   max and p99.9 of pause duration -- never the mean

Report pause as a distribution, always. The mean pause is the most misleading number in GC tuning, because the failure you are guarding against is a rare multi-second event that a mean over thousands of two-millisecond young collections hides completely. Look at the maximum, look at p99.9, and look at what caused the outliers. Promotion rate deserves the same attention: a service whose promotion rate is climbing will eventually hit a long pause no matter how good its current pause numbers look, because the old generation is filling with objects the young collector should have reclaimed.

Then learn the failure signatures rather than memorising a flag list. Evacuation failure or to-space exhaustion means the collector had nowhere free to copy survivors. An allocation stall means a thread blocked waiting for the concurrent collector to free memory. A degenerate or full collection means the concurrent path gave up and fell back to stop-the-world. Concurrent cycles running back-to-back with no idle gap mean the collector is saturated. Every one of these says the same thing in a different dialect: the collector is losing the race against your allocation rate. And when a pause is long but the collector reports little work done, the time went into reaching the stop-the-world rendezvous rather than into collecting - that is a safepoint problem, not a GC problem, and a bigger heap will not touch it.

Choosing a collector from the workload's shape

Answer five questions before touching a flag. What is the live set? What is the allocation rate? What fraction of allocations survive the young space? How many cores can you afford to hand the collector? And what is the pause budget, at which percentile, and what concretely happens when it is exceeded?

Those answers map onto the design space directly. A batch or analytics job that is CPU-bound with no interactive SLO should take the throughput-oriented collector: cheapest barriers, least concurrent overhead, and its long pauses cost nothing when nobody is waiting. A short-lived process or a small single-core container is best served by the simplest serial collector, because concurrent machinery needs cores it does not have and the process may well exit before a concurrent cycle would even complete. A general server application with a moderate heap and a soft latency target is exactly what a region-based generational collector with a pause target was designed for, and it is the sensible starting point. A service with a hard tail-latency SLO, or one whose live set is large enough that a pause proportional to live data is simply unacceptable, wants a concurrent-relocating collector - and wants you to budget both memory headroom and GC cores for it, or it will fail in the ways described above. A benchmark or a process with a known bounded allocation budget can use the no-op collector to take GC out of the measurement entirely.

The recurring mistakes are worth naming. An aggressive pause target is a wish, not a configuration: a goal the collector cannot meet makes it shrink the young generation and collect more often, losing throughput without achieving the goal. Copying a flag list from a blog post configures somebody else's workload. Leaving -Xms well below -Xmx lets the heap resize under load, adding pauses at the exact moment you least want them. Sizing the heap to the live set with no headroom guarantees a concurrent collector will stall. And running a low-pause collector inside a CPU-limited container starves the very threads that make it low-pause.

Finally, do not assume which collector you are actually running. Defaults are ergonomic: they depend on the release, on the detected core count and available memory, and inside a container on the cgroup limits the JVM reads - and they have changed across releases. Print the flags in effect at startup and read the collector's name out of the GC log rather than out of memory. Tuning the collector you merely believe you have is the most reliable way to spend a week changing nothing.

End-to-end flow

End-to-end: a payments service runs on Java 21 with a 32 GB heap and 20 ms pause SLO. Team picks ZGC. Application threads allocate via TLABs. ZGC concurrently marks live objects using colored pointers; the barrier fixes up stale references on access. Concurrent relocation moves live objects to fresh regions; freed regions become available. A GC cycle completes with two ~200 microsecond pauses. GC log records total pause well under SLO. Under a memory pressure test, allocation rate spikes; ZGC increases cycles per second; heap headroom stays adequate. If headroom drops below threshold, an alarm fires and the service auto-scales. All decisions logged and reviewed weekly.

Every JVM collector is assembled from the same parts: a tri-colour marking wavefront, a barrier that keeps that wavefront honest while the application rewires the graph, a bet on the generational hypothesis, and a strategy for relocating objects without the mutator noticing. What separates them is only which of throughput, pause time, and footprint they choose to spend. Pick by measuring your live set, allocation rate, and promotion rate - and by knowing which of those three resources is scarce - rather than by collector reputation.