Garbage Collection and Memory Sizing (Java 25) deprecated

The Java 25 upgrade switches the HTTP request workers to a virtual-thread pool by default (com.openexchange.http.grizzly.virtualThreadsEnabled=true). This keeps far more requests in flight at once and raises the rate of short-lived, request-scoped allocations — a pattern that G1's stop-the-world pauses handle less gracefully under high concurrency. That is what brought Generational ZGC into the picture: its sub-millisecond, concurrent, heap-size-independent pauses suit the virtual-thread workload, so it is now offered as an opt-in alternative collector. (See Why ZGC fits the virtual-thread worker pool below for the reasoning.)

So the middleware can run on either of two garbage collectors: G1 (the unchanged default) or Generational ZGC (opt-in). This article guides you to the right choice for your deployment and tells you exactly what to set.

  • Start with Quick decision to find your case.
  • What to configure gives copy-paste Helm values per goal, plus a settings reference.
  • The Background sections explain why (sizing rationale, load-test evidence, trade-offs).

If the middleware is deployed as a subchart, all javaOpts.* / resources.* keys below live under the core-mw: block of your values.

Quick decision: G1 or ZGC?

If you do nothing, you run G1. The Java 25 upgrade requires no GC or memory changes by itself.

Your situation Use What to do
Default; unsure; tight or throughput-/batch-bound pods; very small heaps; CPU-constrained nodes G1 (default) Nothing
Latency-sensitive; can afford native-memory + CPU headroom ZGC Opt in to ZGC (below)
Usage-billed hosting (pay per actual RSS); want idle memory returned to the OS ZGC + uncommit ZGC on usage-billed hosting (below)

ZGC gives lower and more stable latency and higher throughput (see Load-test results), at the cost of more committed memory and CPU. G1 is the safe, zero-effort default.

What to configure

Staying on G1 (default)

Nothing to set. G1 remains the default collector and your existing memory sizing is unaffected by the Java 25 upgrade.

Opting in to ZGC (latency-sensitive)

Set in your Helm values:

javaOpts:
  zgc: true                 # -> -XX:+UseZGC
  mallocArenaMax: "2"       # chart default — keep it (bounds native memory, see below)
  memory:
    maxHeapSize: 4G         # the load-validated heap point
resources:
  requests:
    memory: 6G
    cpu: "1"                # ZGC's concurrent GC needs CPU — do not run it CPU-starved
  limits:
    memory: 6G              # 4G heap fits a 6G limit *with* MALLOC_ARENA_MAX=2 (else needs 8G)

Rules of thumb

  • Keep mallocArenaMax: "2" — without it the same workload needs an 8 G limit instead of 6 G.
  • Heap ≈ ⅔ of the limit at this size (4 G heap / 6 G limit). Give the pod real CPU headroom.
  • Do not tighten below 4 G / 6 G without re-testing under your own load — the validation used a small heap live set and under-exercised mail/attachment-heavy paths, so treat the measured peak as a floor.
  • Under-sizing does not show up as an OOM — it appears as failures to open outbound IMAP/SMTP connections (mail-backend timeouts) under load. If you see that, raise the limit.

ZGC on usage-billed hosting (return idle memory)

If you are billed on actual memory used (RSS), have ZGC hand idle heap back to the OS. Use the ZGC values above plus:

javaOpts:
  zgc: true
  zgcUncommitDelay: "60"    # -> -XX:ZUncommitDelay=60 (uncommit unused heap after 60s idle; default 300s)
  • Measured: ~2.2 G returned to the OS within ~4 min of load dropping, error-free under load.
  • Returns heap only; native/malloc memory is bounded separately by mallocArenaMax.
  • Only helps if billed on RSS. If billed on the pod reservation (request/limit), this does not change the bill — right-size the request instead.
  • Do not set -XX:SoftMaxHeapSize to force a smaller footprint — under load it starves ZGC of allocation headroom and caused a severe tail regression (request timeouts, max 37.5 s). The idle give-back does not need it. The knob is intentionally not offered by the chart.

Settings reference

Helm key Effect Default Set it when
javaOpts.zgc -XX:+UseZGC (else G1) false latency-sensitive deployment, sized for headroom
javaOpts.memory.maxHeapSize -XX:MaxHeapSize 2048M per-pod sizing; 4G for the validated ZGC point
resources.requests/limits.memory cgroup memory limit 6G for a 4 G ZGC heap (8 G without the arena cap)
javaOpts.mallocArenaMax MALLOC_ARENA_MAX env "2" keep the default; bounds glibc malloc arenas (~1.2 G)
javaOpts.zgcUncommitDelay -XX:ZUncommitDelay (seconds) "" (= 300 s) usage-billed (RSS) hosting; return idle heap faster
javaOpts.other extra JVM flags (appended) -XX:+UseCompactObjectHeaders extra/diagnostic flags
(not offered) SoftMaxHeapSize never — load-tested, regressed the tail

Background and rationale

Load-test results — performance and sizing

A full-stack load test (real Dovecot/Postfix/DB/Redis, in-cluster Gatling AppSuiteSimulation, 50 concurrent users; JDK 25, Generational ZGC, 4 G heap) measured ZGC against G1 at an identical 4 G heap / 6 G limit with MALLOC_ARENA_MAX=2, both error-free:

Metric (50 users, 4G heap, 6G limit) ZGC G1
mean response time 38 ms 56 ms
95th percentile 106 ms 130 ms
99th percentile 213 ms 796 ms
max response time 1 466 ms 6 118 ms
throughput (same window) 1.03 M req 0.70 M req
cgroup peak memory ~5.3 G ~1.3 G
  • ZGC is faster, not slower — and +47 % throughput. ZGC's occasional allocation stalls cost far less than G1's multi-second stop-the-world pauses on this allocation-heavy, virtual-thread workload.
  • Memory: the ZGC-vs-G1 gap is not GC overhead — with MALLOC_ARENA_MAX=2 the non-heap native is ~1.25 G for both. It is ZGC eager-committing the 4 G heap (file-backed) while G1 right-sized to ~370 M for this small test live set; G1 could not be forced to hold 4 G (-Xms4G/AlwaysPreTouch spiked then uncommitted). Under a production-sized live set the two converge.
  • Conclusion: with MALLOC_ARENA_MAX=2 the memory premium of ZGC shrinks to its eager heap commit (small in practice) while the latency/throughput win is clear — hence ZGC is recommended for latency-sensitive, virtual-thread deployments; G1 remains the safe default for tight or throughput-bound pods.

Why the native-memory headroom is large, and how it shrinks

Native-memory tracking under load overturns the original assumption that direct buffers dominate: direct/off-heap memory (NMT "Other") peaks at only ~0.3–0.5 G and ZGC's own structures at ~80 MB. The real driver of the excess is glibc malloc-arena retention (~1.2 G) — the many-threaded virtual-thread middleware spawns many per-thread malloc arenas that hoard memory. Setting MALLOC_ARENA_MAX=2 (the chart default) caps this and cuts the cgroup peak from ~6.5 G to ~5.3 G at a 4 G heap, letting ZGC fit a 6 G limit. This brings ZGC's non-heap native memory down to the same level as G1 (~1.25 G in both); the only remaining difference is that ZGC eager-commits its heap while G1 right-sizes, and that gap converges under a realistic heap working set. -XX:MaxDirectMemorySize is not a useful sizing lever here (direct memory is small).

Why opt-in, not default

Defaulting ZGC on would force a mandatory pod re-sizing onto every installation at upgrade time; an operator that did not re-size would silently hit the native-memory failure mode above (IMAP/SMTP connection failures, not an obvious OOM) — a poor default for large/cloud deployments. G1 has zero such sizing impact. The opt-in default is therefore retained for conservatism, even though the benchmark favours ZGC for virtual-thread, latency-sensitive deployments; a future release may reconsider it as the default.

Why ZGC fits the virtual-thread worker pool

The middleware request path runs on a virtual-thread worker pool, which raises in-flight concurrency and the rate of short-lived, request-scoped allocations. G1's stop-the-world young/mixed collections scale with heap/live-set and pause all carrier threads at once, causing latency spikes across many virtual threads. Generational ZGC collects concurrently with sub-millisecond, heap-size-independent pauses, and its young generation suits exactly this short-lived-allocation pattern, giving stable tail latency under high concurrency. Compact Object Headers (also default) reduces live heap and further eases GC pressure.

Concerns / when to stay on G1

  • Native-memory headroom (see above) — largely mitigated by the MALLOC_ARENA_MAX=2 default; still budget the headroom and validate under load for mail/attachment-heavy workloads.
  • CPU: ZGC trades some throughput/CPU for low pauses. On CPU-starved pods its concurrent GC threads compete with the virtual-thread carriers and can raise latency. Ensure CPU headroom.
  • Allocation stalls: if the allocation rate outpaces concurrent collection (heap or CPU too small), ZGC stalls threads until memory is freed — watch for "Allocation Stall" GC-log lines under load spikes.
  • Stay on G1 for very tight containers, throughput-/batch-bound workloads, or very small heaps where G1 is more CPU-efficient.
  • Virtual-thread pinning is orthogonal — ZGC does not pin virtual threads.

About the virtual-thread worker pool

Virtual threads (JEP 444, stable since JDK 21) are lightweight JVM-managed threads multiplexed onto a small pool of OS "carrier" threads. A virtual thread blocked on I/O (IMAP/SMTP/DB) unmounts its carrier instead of holding an OS thread, so the server keeps far more requests in flight at roughly a stack's cost each rather than a full platform thread.

  • Benefit: HTTP throughput under high concurrency is no longer capped by a bounded worker pool, while blocking I/O stays simple (no async rewrite).
  • Behavioral changes: concurrency is no longer throttled by pool exhaustion; more in-flight requests mean higher peak heap/native use (hence the ZGC sizing above applies when ZGC is enabled); thread dumps show many short-lived virtual threads instead of a fixed named pool.
  • Fallback: com.openexchange.http.grizzly.virtualThreadsEnabled=false reverts to the platform-thread worker pool.
  • Virtual-thread pinning (a VT stuck to its carrier during synchronized/native sections) was audited — middleware code shows none; ZGC does not pin either.