· 12 min read

Spare cores are a heap budget

Opportunistic ZGC treats idle CPU as something the runtime can spend on earlier collection, trading otherwise-unused compute for a smaller Java heap.

Two long rows of white server racks facing each other in a brightly lit data center aisle.
PiDatacenters, CC BY-SA 4.0

A Java service with spare CPU and a fat heap is sitting on two resources it does not fully need at the same moment. ZGC normally treats those resources separately: CPU is something collection should avoid stealing, while heap headroom is something it keeps so allocation can continue while collection runs. A new paper asks a useful runtime question: what if idle cores could pay down that memory bill before the heap gets urgent?

Opportunistic ZGC, posted as an arXiv preprint on September 14 and listed in the programming-languages feed on September 15, 2026, adds two scheduling rules to HotSpot's ZGC director. The tracing collector is still ZGC. The experiment changes when another cycle is worth starting: only after the heap has grown enough, and only when recent CPU usage suggests otherwise-idle cores can absorb the work.

This is the part I care about: the mechanism is tiny compared with a new collector, but it moves the resource boundary. The scheduler gets permission to exchange one kind of slack for another. Spend compute that would have been idle anyway, and maybe the process never needs to occupy as much memory.

ZGC normally waits for pressure#

Modern ZGC is generational and mostly concurrent. Its asynchronous ZDirector task samples allocation rate, heap occupancy, collection timing, and reclamation statistics at 100 Hz, then asks a sequence of rules whether a young or old cycle should begin. You can read that policy directly in the current OpenJDK source. The common allocation-rate rule waits until the runtime predicts that free memory could run out soon enough that collection needs to start now to avoid an allocation stall.

The conservatism is deliberate. Every extra collection does real work. Concurrent GC threads still consume CPU, caches, and memory bandwidth, and ZGC still has short stop-the-world phases. If memory is plentiful, letting the heap grow can be the right latency trade.

Oracle's current ZGC tuning guide makes that trade visible. -Xmx needs enough space for the live set plus allocations that happen while concurrent collection runs. -XX:SoftMaxHeapSize can ask ZGC to aim lower while preserving a larger hard ceiling for emergencies.

The shipped manual knobsh
java   -XX:+UseZGC   -Xmx32g   -XX:SoftMaxHeapSize=16g   -jar service.jar

A soft maximum is excellent when you know the workload. It is less cute when the live set changes between deployments, tenants, or hours of the day. Too low means extra collection work. Too high means the process can occupy memory that another workload could have used.

OppZGC adds a second reason to collect#

OppZGC keeps the existing ZDirector rules and checks its opportunistic rules only after the normal rules decline to collect. "The heap is about to hurt" therefore remains a reason to run GC. The new reason is closer to "the heap has grown, and CPU is quiet enough that a cycle is affordable right now."

The opportunistic gate, simplifiedcpp
// Simplified from the OppZGC paper.if (heap_growth / last_reclaimed < alpha) {    return false;}idle_cores = processors - cpu_used_over(delta);gc_duration = serial_time + parallel_time / workers;gc_cpu_cost = serial_time + parallel_time;return idle_cores * gc_duration >= gc_cpu_cost;

There are two gates. First, heap occupancy since the previous opportunistic collection must have grown enough relative to what that collection reclaimed. The paper calls the ratio alpha. Around 1.0, another opportunistic cycle becomes eligible after growth replaces roughly the amount the previous one freed. Smaller values push more aggressively against heap growth.

Second, the runtime asks whether recent idle CPU capacity is large enough to cover the expected CPU cost of the collection over its expected wall-clock duration. ZGC already tracks serial and parallel portions of recent cycles, so the policy can use measurements from the workload that is actually running instead of a fixed global cost guess.

The inequality is a little resource market#

The key condition is idle_cores × gc_duration ≥ gc_cpu_cost. The units make the idea much easier to reason about. An idle core multiplied by time is idle core-time. The right side is how much CPU-time the collector expects to consume. If the available core-time during the predicted collection window can cover that work, the scheduler has a bargain it can take.

The paper gives a clean example: three idle cores, a predicted 200 ms collection duration, and 500 ms of total GC CPU work. Three cores over 200 ms offer 600 core-ms, so the condition passes. If application load rises and those idle cores vanish, the condition stops passing.

Notice what the implementation does not do. It does not simply set the GC worker count equal to the number of idle cores. The director still chooses its normal worker count, and the opportunistic rule asks whether enough spare capacity exists to absorb the predicted cost. Scheduling a collection and sizing a collection are separate decisions.

It backs off when the machine gets busy#

The research implementation estimates recent process CPU use with getrusage. That is appropriate for the paper's setup, where the JVM is otherwise alone on the machine, but it is not a complete cloud scheduler. The authors explicitly point toward Linux /proc/schedstat or cgroup cpu.stat as better signals in multi-process or containerized environments.

There is also a warm-up problem: before enough collections have run, the runtime has weak estimates of how long a cycle costs. OppZGC handles that conservatively by requiring at least one idle core during that phase. It also caps opportunistic collections to one per measurement interval, called delta in the paper.

The useful emergent behavior is simple. More application CPU raises measured utilization, which reduces the estimated idle-core budget, which makes the opportunistic condition harder to satisfy. The extra collections then fade out. Normal ZGC pressure rules remain active underneath, so backing off the experiment does not disable ordinary collection.

More GC work can buy much less heap#

The evaluation is where this stops being a nice scheduler thought experiment. On the paper's 16-core Intel machine, the authors sweep several alpha values and CPU measurement windows. They select alpha=1.0 with a 20 ms window as their balanced configuration, and also report alpha=0.9 as a more memory-aggressive point.

Across their DaCapo workloads, the extra collections raise GC CPU time by about 1.7× on average at alpha=1.0 and 2.3× at alpha=0.9. That sounds expensive until you remember the policy is trying to consume CPU that would otherwise be idle. In that setup, measured package power changed by less than one percent, while package energy was slightly lower on average. Those numbers are the authors' measurements; I did not rerun their hardware experiments.

The memory change is large when the configured heap is intentionally roomy. With -Xmx32g, the paper reports average reductions in maximum heap occupancy of 61% for alpha=1.0 and 75% for alpha=0.9 relative to baseline ZGC. At -Xmx160g, those averages rise to 86% and 90%. That does not mean Java objects became smaller. It means the baseline was allowed to defer work and expand much farther before pressure forced collection.

The fourth nine is where the bill shows up#

The latency story is mostly boring until it suddenly is not. The paper reports little effect through p99.9 for the tested DaCapo workloads, but the p99.99 tail exposes regressions. At -Xmx32g, one cassandra-large setup regresses by roughly 30% at that percentile. Some h2 and lusearch configurations also move by double-digit percentages. Restricting the machine to four cores makes the downside sharper; the reported p99.99 regression for one Cassandra case reaches roughly 40–57%.

SPECjbb tells the same story with a different pressure shape. The balanced alpha=1.0 configuration cuts maximum heap occupancy by roughly 19–21% across the evaluated heap sizes while leaving peak and latency-constrained throughput essentially unchanged. The more aggressive alpha=0.9 setting has a worst reported critical-jOPS loss of 6.2% in one 128 GB configuration.

This is why "use idle CPU" is not the same as "free performance." CPU counters can say cores are available while a concurrent collector still perturbs memory bandwidth, cache residency, scheduler timing, or a request that is unlucky enough to meet a GC phase. The policy is opportunistic, not invisible.

SoftMaxHeapSize is still the sharper knife#

The authors also compare OppZGC with workload-specific tuning of SoftMaxHeapSize. When a human (or an offline search) gets to find a good soft maximum separately for each benchmark, that tuned configuration beats the aggressive OppZGC setting on memory in 13 of 22 reported cases. At -Xmx32g, the tuned soft-max setups average about 2.8 GB less maximum heap than alpha=0.9.

That is not a failure of the idea. It tells you what the automation is buying: not the mathematically smallest heap for a known fixed workload, but a way to react to CPU and memory slack without carrying a per-service heap target into production. If your service is stable and you can tune it carefully, the existing knob remains very good. If the deployment shape moves around, a feedback policy has a different kind of value.

You can inspect the shipped director today#

OppZGC itself is not a stock JDK feature. The paper's prototype is based on an OpenJDK 24 development revision, and its alpha and delta controls belong to that research implementation. Do not copy a flag name from the paper into a production command line and assume your JDK has learned this policy.

What is shipped is useful for understanding the baseline. Current OpenJDK keeps the director logic in zDirector.cpp, and unified logging exposes the gc+director tag. A stock ZGC process can log those decisions while you test heap sizing and allocation behavior.

Watch the shipped ZGC directorsh
java   -XX:+UseZGC   -Xlog:gc*,gc+director=debug:file=gc.log:uptimemillis,level,tags   -jar service.jar

That command enables logging; it does not enable OppZGC. The distinction matters because this paper is exactly the kind of result that can get flattened into "Java just added adaptive low-memory GC" after one repost. It did not. A research prototype demonstrated a scheduling policy against current ZGC machinery, with promising memory results and real tail-latency caveats.

The scheduler can exchange slack#

Garbage collection is usually described as a memory-management problem, but its practical job is resource scheduling across time. The runtime chooses when to spend CPU, how much memory to retain, how urgently to reclaim it, and how much interference the application can tolerate. OppZGC makes that accounting unusually explicit.

I think the broader compiler/runtime lesson is useful beyond Java. A system does not always need a new algorithm to exploit a new resource. Sometimes the algorithm is already good enough and the missed opportunity lives in its trigger. If a runtime can measure both the cost of maintenance work and the slack around the application, it can move that work into quieter windows instead of waiting for the resource it protects to become scarce.

The hard part is deciding what "quiet" really means. OppZGC's own results show why that definition cannot stop at average CPU utilization: extreme latency, CPU quotas, neighboring processes, and shared memory systems all push back. But the framing is clean. Spare cores are not just unused compute. For a runtime that can spend them carefully, they are also a temporary heap budget.