Garbage collection and the JIT, for low latency

Java is fast once it's running hot, but two JVM mechanisms decide its worst moments: the garbage collector (pauses) and the just-in-time compiler (warm-up and deoptimisation). Trading systems care about the worst moments, so this page explains both, how to keep them off the order path, and how to measure latency without fooling yourself.

1. The problem: the average lies

A matching engine that answers in 20 µs on average but stalls for 50 ms once a minute is a problem: those stalls land on real orders, often during the busiest moments, when allocation is highest. That's why trading systems quote percentiles (p99, p99.9, max) rather than averages, and why the JVM's pause behaviour matters more than its raw speed.

2. A business example: the restaurant's cleaning crew and the new chef

3. Allocation and garbage collection

4. Choosing a collector

CollectorFlagPausesGood for
G1default since Java 9Typically a few to tens of ms, aiming at a target (-XX:MaxGCPauseMillis, default 200)General services; a good default
ZGC-XX:+UseZGC (generational mode in Java 21 with -XX:+ZGenerational; the default mode from Java 23)Sub-millisecond, nearly independent of heap sizeLatency-sensitive services with large heaps
Shenandoah-XX:+UseShenandoahGC (in many OpenJDK builds)Low, concurrent compactionSimilar goals to ZGC
Parallel-XX:+UseParallelGCLonger, stop-the-worldBatch jobs where throughput is all that matters
Epsilon-XX:+UseEpsilonGC (experimental)None: it never collects, and the JVM dies when the heap is fullProving a code path is garbage-free, and benchmarks

Concurrent collectors aren't free: they take CPU from your threads and add small costs (barriers) to reads and writes of references. Low pause is a trade, not a gift.

5. Garbage-free hot paths

The most reliable way to avoid GC pauses is to give the collector nothing to do on the critical path. Common techniques:

TechniqueExample
Pre-allocate and reuseThe Disruptor's ring of CommandSlots, made once at start-up
Flyweights over buffersSBE encoders and decoders (see flyweights)
Primitives, not boxeslong not Long; primitive-keyed maps (Agrona, fastutil) instead of HashMap<Long, ...>
Object poolsReuse order objects from a free list instead of new
Watch hidden allocationString concatenation, varargs, autoboxing, capturing lambdas, iterators that escape, logging with formatted messages
Garbage-free code is harder to read and easy to get wrong (a reused object that someone still holds). Real systems apply it only where a profiler or a latency histogram shows allocation hurts.

6. The JIT and warm-up

  1. Interpreter. Bytecode runs instruction by instruction, while the JVM counts calls and branches.
  2. C1 (tiers 1 to 3). After some hundreds to thousands of calls, a quick compiler produces decent machine code with profiling built in.
  3. C2 (tier 4). After roughly ten thousand calls, the optimising compiler uses the profile: it inlines hot callees, removes unneeded checks, unrolls loops and applies escape analysis.
  4. Deoptimisation. C2 bets on what it saw ("this call always goes to NewOrder"). When a bet fails, the code is thrown away and the method runs slower until it's recompiled.

Consequences for a trading system:

7. Safepoints

Some JVM operations (most GC phases, deoptimisation, taking thread dumps) need every Java thread stopped at a known point, a safepoint. Threads only check for a pending safepoint at certain places (method returns, loop back-edges), so the pause includes "time to safepoint": waiting for the slowest thread to arrive. Logging with -Xlog:safepoint shows both parts. A long counted loop without a check can delay everyone.

8. Measuring latency honestly

9. Try it: tails and pauses

A service handles requests at a steady rate, each in about 20 µs. Every so often the JVM pauses; requests arriving during the pause wait until it ends. Compare the honest percentiles with those of a load tester that suffers from coordinated omission.

10. In Prayog

TodayWhy, and what's next
Commands and events are Java records, allocated per orderClear and correct. S9 measures the cost before anything gets more complex (ADR 0006 trade-offs).
Ring slots, journal buffers and SBE flyweights are allocated onceThe infrastructure is already garbage-free; only the domain objects allocate.
Default collector (G1)Try ZGC once there's a latency histogram to compare against.
Sealed Command and ExchangeEvent with switchExhaustive and JIT-friendly (see modern Java).
exchange-bench module (JMH), HdrHistogramS9: book operations and end-to-end latency, recorded in docs/benchmarks.md with method and machine.

11. Questions

Why not just make the heap huge so GC never runs?

Young collections still happen as the young generation fills; a bigger heap mostly makes the rare full collection longer. Some firms do size the heap so that no old-generation collection happens during a trading day and restart nightly, combined with very low allocation. That's a deliberate design, not a default.

Is C++ still better for this?

C++ has no GC or JIT warm-up, so its worst case is easier to control. Java teams get close with garbage-free code, careful warm-up and modern collectors, and gain safety and productivity. Several well-known exchanges and trading firms run their matching engines on the JVM.

What does "mechanical sympathy" mean?

Writing software that suits how the hardware works: sequential memory access, few cache misses, little contention, no needless allocation. The ring buffer, SBE and the single-writer engine are all examples.