A ledger that processes a billion transactions in under an hour and a trading platform handling six million orders a second on one thread both run on the JVM. Java enables high performance in such systems because HotSpot compiles the code that runs, from profiles of that run, and lets you choose a garbage collector by trade-off. Java 21 to 27 went after its weak spots: startup, warmup and footprint.
How the JVM turns bytecode into fast machine code
javac barely optimizes. The bytecode it writes is a portable description of the program, and the performance work happens later, inside the running JVM, where HotSpot can see which methods are hot, which branches are taken and which classes actually arrive at each call site.
Tiered compilation: interpreter, C1 and C2
Every method starts in the interpreter, which runs at once and counts invocations and loop iterations. Once a method is warm, the C1 compiler turns it into native code quickly, with light optimization and instrumentation that records the types and branches it sees. When the counters show the method is hot, C2 recompiles it with those profiles.
Tiered compilation has been the default since Java 8. A long loop does not have to wait for the next call either: on-stack replacement swaps the compiled version in while the loop is running.
Optimizations that need a runtime profile
C2 compiles for the workload it has observed, which an ahead-of-time compiler cannot see.
Inlining copies a callee into its caller, removing the call and letting every other optimization see across the boundary. HotSpot inlines hot methods of up to 325 bytecodes (FreqInlineSize) and others of up to 35 (MaxInlineSize), and it inlines virtual and interface calls when the profile shows one or two receiver classes at that call site.
Escape analysis proves that an object never leaves the compiled code. C2 then skips the heap allocation and keeps the fields in registers (scalar replacement), so the object costs the garbage collector nothing.
record Point(double x, double y) {}
static double totalLength(double[] xs, double[] ys) {
double sum = 0;
for (int i = 0; i < xs.length; i++) {
Point p = new Point(xs[i], ys[i]); // never escapes this method
sum += Math.sqrt(p.x() * p.x() + p.y() * p.y());
}
return sum; // C2 can scalar-replace Point: no allocation
}
Speculation and deoptimization go together. C2 drops branches that never ran and devirtualizes calls when only one implementation is loaded, and guards each assumption. When one breaks, because a new subclass is loaded or a cold branch is taken, the JVM throws the compiled code away, continues the method in the interpreter and recompiles it later with the new profile.
Intrinsics replace methods such as System.arraycopy, String.equals or AES encryption with hand-written machine code that uses the CPU's vector and crypto instructions.
The same machinery explains the JVM's weak spot. Peak speed arrives only after the profile is collected, so a service is slower for its first seconds or minutes after every start. Project Leyden, covered below, attacks that warmup.
Watching the JIT work
# one line per compilation: tier, method, bytecode size
java -XX:+PrintCompilation -jar app.jar
# inlining decisions, with the reason for each refusal
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar
# compilations and deoptimizations from a JFR recording
jfr print --events jdk.Compilation,jdk.Deoptimization recording.jfr
Benchmarks have to account for all of this, since C2 removes work whose result is never used. That is what JMH is for; the method for measuring and fixing a slow service is in our guide to improving Java performance.
Garbage collectors and what each one trades
Allocation on the JVM is cheap: each thread bumps a pointer in its own thread-local allocation buffer (TLAB), with no lock. The cost moves to collection, and every HotSpot collector balances pause time, throughput (the CPU left to the application) and memory overhead. Most objects die young, so the collectors are generational: they collect the young generation often and cheaply, and the old generation rarely.
| Collector | How it collects | Pauses | What it costs | Fits |
|---|---|---|---|---|
| Serial | One thread, application stopped | Grow with the live set | Lowest overhead, one core | Small heaps and single-CPU containers |
| Parallel | Many threads, application stopped, generational | Long on large heaps | Long pauses, in exchange for the highest throughput | Batch jobs where only total run time counts |
| G1 (default) | Regions; concurrent marking, evacuation in pauses | Aimed at a target, 200 ms by default | Write barriers, some concurrent CPU | Most services; the starting point |
| Generational ZGC | Concurrent marking and relocation, load barriers | Under 1 ms, independent of heap size | More CPU and heap headroom for concurrent work | Tail-latency-sensitive services, heaps up to terabytes |
| Shenandoah | Concurrent evacuation, generational mode since JDK 25 | Short, independent of heap size | Barrier overhead, concurrent CPU | Low-pause services on OpenJDK builds that ship it |
G1 is the right starting point for most services, which is why it is the default. Generational ZGC is the choice when tail latency matters more than a few percent of CPU. Netflix switched its default from G1 to generational ZGC on JDK 21 and reported in March 2024 that, for a given CPU target, ZGC improved both average and P99 latencies with equal or better CPU utilization. Parallel still wins batch jobs, where only total run time counts.
# G1, the default: a pause-time goal (200 ms unless set)
java -XX:MaxGCPauseMillis=50 -Xmx8g -jar app.jar
# Generational ZGC, the only ZGC mode since JDK 24
java -XX:+UseZGC -Xmx32g -jar app.jar
# Generational Shenandoah, a product feature since JDK 25
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar
# Parallel: throughput first, for batch jobs
java -XX:+UseParallelGC -jar batch.jar
# GC log for any of them
java -Xlog:gc*:file=gc.log -jar app.jar
Switching collectors is a configuration change, so it can be tested under production-like load before anyone touches code. Shenandoah ships in OpenJDK builds such as Red Hat's and Amazon Corretto but not in Oracle JDK. Keeping allocation off latency-critical paths, which no collector does for you, is covered in our guide to low-latency Java applications.
The Java memory model and java.util.concurrent
Happens-before: what the memory model guarantees
Since Java 5 (JSR 133), the Java memory model defines which writes a thread is guaranteed to see, through happens-before edges: releasing and then acquiring the same monitor, writing and then reading a volatile field, starting and joining a thread, and the final-field rule for safely constructed objects. Everywhere else the JIT and the CPU may reorder and cache as they like.
That freedom is a large part of why ordinary Java code runs fast on multicore hardware. The explicit edges are why correct concurrent code stays correct on x86 and on ARM, whose memory ordering is weaker. Java 9 added VarHandles (JEP 193), with acquire, release and opaque access modes weaker than volatile, for code that wants to pay for exactly the ordering it needs.
Lock-free structures: from ConcurrentHashMap to the LMAX Disruptor
java.util.concurrent, designed by Doug Lea and in the JDK since Java 5, gives every team compare-and-swap building blocks: ConcurrentHashMap, LongAdder for contended counters, ForkJoinPool, CompletableFuture and StampedLock.
// Many threads incrementing one AtomicLong fight over one cache line.
// LongAdder spreads increments over per-thread cells and adds them up on read.
private final LongAdder processed = new LongAdder();
void onMessage(Message m) {
handle(m);
processed.increment();
}
long processedSoFar() {
return processed.sum();
}
The LMAX Disruptor shows how far the memory model lets a Java team go. Built for LMAX's trading platform, it replaces queues between threads with a pre-allocated ring buffer and sequence counters padded onto their own cache lines, with no locks on the hot path. In the Disruptor technical paper, Disruptor 4 moved 160 million messages a second between one producer and one consumer on an AMD EPYC 9374F with OpenJDK 11, against 21 million for ArrayBlockingQueue.
ArrayBlockingQueue and Disruptor 4, million messages per second, AMD EPYC 9374F, Linux, OpenJDK 11.0.24. Source: LMAX Disruptor technical paper, table 3.The paper's latency test, on a 2011 laptop processor and Java 6, put mean latency per hop through a three-stage pipeline at 52 nanoseconds, against 32,757 for the queue. Its authors traced the queue's latency to locks and condition-variable signalling.
Virtual threads for I/O-bound services
For services that spend most of their time waiting on databases and other services, virtual threads, final in Java 21 (JEP 444), make a blocking thread per request cheap. Java 24 removed their main limitation: a virtual thread that blocks inside synchronized no longer pins its carrier thread (JEP 491). They raise the throughput of waiting, not the speed of computation. Our guide to Java virtual threads covers the patterns and the production pitfalls.
Off-heap memory and SIMD: the FFM and Vector APIs
Off-heap data with the Foreign Function & Memory API
Some data is better kept off the heap: a large cache the collector would otherwise trace on every cycle, a memory-mapped file, a buffer shared with native code. For years the options were ByteBuffer, limited to 2 GB per buffer, and sun.misc.Unsafe, unsupported and unsafe by name.
The Foreign Function & Memory API, final in Java 22 (JEP 454), replaces both. A MemorySegment can be any size, every access is bounds-checked, and an Arena frees the memory deterministically when it closes.
// Java 22+: one million longs off the heap, freed when the arena closes
try (Arena arena = Arena.ofConfined()) {
MemorySegment prices = arena.allocate(ValueLayout.JAVA_LONG, 1_000_000);
for (long i = 0; i < 1_000_000; i++) {
prices.setAtIndex(ValueLayout.JAVA_LONG, i, i * 100);
}
long last = prices.getAtIndex(ValueLayout.JAVA_LONG, 999_999);
} // memory released here, not at the next GC
The same API calls native libraries without JNI glue code. The memory-access methods of sun.misc.Unsafe were deprecated for removal in Java 23 (JEP 471) and print a warning from Java 24 (JEP 498), so libraries that still use them have to move to FFM before the methods go.
Explicit SIMD with the Vector API
C2 already auto-vectorizes simple loops. The Vector API lets you write SIMD code explicitly; C2 compiles it to AVX instructions on x64 and NEON or SVE on ARM, and falls back to scalar code where the hardware lacks them. It has incubated since Java 16 (JEP 338) and is in its twelfth round in Java 27 (JEP 537), waiting for Project Valhalla's value classes before it can be finalized.
// javac and java need --add-modules jdk.incubator.vector
static final VectorSpecies<Float> S = FloatVector.SPECIES_PREFERRED;
static void scale(float[] in, float k, float[] out) {
int i = 0;
for (; i < S.loopBound(in.length); i += S.length()) {
FloatVector.fromArray(S, in, i).mul(k).intoArray(out, i);
}
for (; i < in.length; i++) { // the tail that does not fill a vector
out[i] = in[i] * k;
}
}
What Java 21 to Java 27 changed for performance
Most of the performance work since Java 21 has gone into the JVM's known costs: time to peak speed, memory per object and collector overhead. The timeline places those changes among the releases since Java 8.
Startup and warmup: Project Leyden's AOT cache
Project Leyden moves work from every startup to a training run. With ahead-of-time class loading and linking (JEP 483, Java 24), a training run records which classes the application loads and links, and later runs take them from an AOT cache already parsed and linked. The JEP reports Spring PetClinic 3.2.0, which loads about 21,000 classes, starting in 2.604 seconds instead of 4.486 on JDK 23: 42% faster, with no change to the code.
Java 25 made the cache a one-step command (JEP 514) and added AOT method profiling (JEP 515): the cache now carries method profiles from the training run, so C2 can compile hot methods right after startup instead of waiting for new profiles. On the JEP's small Stream-heavy example, run time fell from 90 to 73 milliseconds, 19%. Java 26 made the cache work with every collector, ZGC included (JEP 516).
# Java 25+: a training run that writes the AOT cache, in one step
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App
# production runs start from the cache
java -XX:AOTCache=app.aot -cp app.jar com.example.App
The training run has to exercise the paths production uses, and the cache only works with the JDK build and class path it was created with.
Compact object headers, the default from Java 27
Every Java object starts with a header of 96 bits in the default 64-bit layout. Compact object headers cut it to 64 bits: experimental in Java 24 (JEP 450), a product feature in Java 25 (JEP 519, enabled with -XX:+UseCompactObjectHeaders) and the default in Java 27 (JEP 534).
JEP 519 cites SPECjbb2015 using 22% less heap and 8% less CPU time in one setting and 15% fewer collections in another, and a highly parallel JSON parser running in 10% less time. Amazon runs hundreds of production services with the feature, most on backports to JDK 17 and 21. The saving grows with the number of small objects, so in-memory data structures with millions of entries gain the most.
Garbage collection: generational ZGC and Shenandoah, a faster G1
Generational ZGC arrived in Java 21 (JEP 439), became ZGC's default in Java 23 (JEP 474) and its only mode in Java 24 (JEP 490). Its JEP reports an Apache Cassandra benchmark that needed a quarter of the heap and reached four times the throughput of non-generational ZGC, with pauses still under one millisecond. Shenandoah gained a generational mode, experimental in Java 24 (JEP 404) and a product feature in Java 25 (JEP 521).
G1 kept improving for everyone who never changes the default. Java 26 cut the synchronization between application and GC threads (JEP 522): the JEP measured 5 to 15% higher throughput in applications that modify many object references, and x64 write barriers shrank from about 50 instructions to 12. Java 27 makes G1 the default everywhere, including the small machines and containers where the JVM used to pick Serial (JEP 523).
Profiling in production with JDK Flight Recorder
JDK Flight Recorder (JFR) has been part of OpenJDK since Java 11 (JEP 328) and is built to stay on in production. Java 25 added CPU-time profiling on Linux (JEP 509, experimental), which samples threads by the CPU time they consume rather than at wall-clock intervals, and cooperative sampling (JEP 518), which walks stacks only at safepoints for a more stable sampler. Method timing and tracing (JEP 520) counts and times chosen methods exactly, through bytecode instrumentation, with no code change.
# exact invocation counts and average times for all static initializers
java '-XX:StartFlightRecording:method-timing=::<clinit>,filename=clinit.jfr' -jar app.jar
jfr view method-timing clinit.jfr
# CPU-time sampling on Linux (experimental in Java 25)
java -XX:StartFlightRecording:jdk.CPUTimeSample#enabled=true,filename=cpu.jfr -jar app.jar
Java 27 redacts command-line arguments, environment variables and system properties in recordings before they leave the process (JEP 536), which helps when a recording from a payments system has to be shared outside production. The other tools, async-profiler included, are compared in our guide to Java profilers.
Why exchanges, banks and data platforms run on the JVM
Trading firms have built low-latency systems in Java for more than fifteen years. LMAX's platform centers on a single-threaded, in-memory business logic processor that, as Martin Fowler described it in 2011, handles 6 million orders a second on one thread. Goldman Sachs began its own Java collections framework in 2004 and open-sourced it in 2012 as GS Collections, now Eclipse Collections.
The data infrastructure banks and telecom operators run at scale is largely JVM software too: Apache Kafka, Hadoop, Spark, Flink, Cassandra and Elasticsearch. LinkedIn, where Kafka started, reported in 2019 that its Kafka deployments handled more than 7 trillion messages a day on over 4,000 brokers.
Our longest-running example is FastPost, the rule-driven accounting platform we built for Legerity from 2013 and that telcos and insurers use worldwide. Its in-memory Java engine, on a distributed grid of the kind described in our post on what Hazelcast is used for, processes a billion financial transactions in under an hour in the cloud, about 280,000 a second on average, and answers queries over billions of balances in under a second. It competed with Oracle, SAS, SAP and Moody's for global implementations.
Why long-lived banking systems stay on Java
A core banking or payments platform runs for years at a steady, high request rate. That is the workload the JIT and the generational collectors are built for: predictable throughput under sustained load, where startup time hardly matters.
Backward compatibility lets those systems take each improvement above without a rewrite. Bytecode compiled for Java 8 still runs on Java 27, apart from removed APIs and the JDK internals encapsulated in Java 17 (JEP 403), so a bank can move a system written in 2014 to a current JVM and get generational ZGC, compact headers and the AOT cache with it. Our guide to legacy system modernization covers how to do that step by step.
The tooling is mature and free: JFR, JDK Mission Control, async-profiler and the GC logs. The talent pool is large, with 29.4% of respondents to the Stack Overflow Developer Survey 2025 using Java in the past year, which matters for a system that will outlive several teams.
The support horizon is long. Oracle's extended support for Java 25 runs to September 2033, and other vendors publish LTS schedules of their own; the dates for every release are in our Java end-of-life calendar.
Where Java's performance model still costs you
Short-lived processes pay for the JIT without collecting the benefit: a function that runs for 200 milliseconds never reaches C2. The AOT cache narrows the gap; GraalVM Native Image closes it by compiling ahead of time, in exchange for a closed-world build and, without profile-guided optimization, a lower peak.
Memory density is the second cost. Until Project Valhalla's value classes ship (JEP 401 is not in any release as of Java 27), a List<Long> holds a reference to a boxed object per element. Primitive arrays, FFM segments and primitive collections such as Eclipse Collections are the workaround.
Allocation on the critical path still costs latency. Even ZGC spends CPU on concurrent work, and an allocation spike can stall a thread. The lowest-latency Java code, the Disruptor among it, pre-allocates its data so the hot path creates no garbage. The failure modes we meet most often are collected in Java performance problems and how to fix them.
When a Java system misses its latency or throughput targets, the cause can be measured: a collector left at its default, a contended lock, an allocation hot spot, a service restarted too often to warm up. Our Performance Rescue engagement finds that bottleneck with profiles from your own workload and verifies the fix under load, the way we have kept FastPost's engine fast since 2013.