achieving microsecond latencies with Java: practical techniques for building ultra‑fast systems
How we stopped writing "good Java" and started writing hardware‑aware Java — complete code examples from production trading systems that maintain sub‑microsecond latency under massive load.
Four years ago, Stefan Angelov — codebefore's founder and an Engineering Manager at Tradu — joined a crypto trading company. First week, he's sitting in a meeting and someone casually mentions they need to process "north of 10 gigabytes per second of market data." He's nodding along like this is normal. It's not normal.
Our Java application was a disaster. Memory usage looked like a hockey stick. Latency? All over the map — 2ms one second, 50ms the next when the GC decided to wake up. In crypto trading, 50ms might as well be 50 years. By the time your order hits the exchange, someone else already took the price.
Then management drops the bomb: "Let's build our own exchange."
That conversation broke my brain. Everything I thought I knew about writing Java — immutable objects, dependency injection, clean abstractions — suddenly didn't matter. What mattered was keeping the garbage collector from wrecking your latency at the worst possible moment.
There's no magic to solving this. We had to realize that the JIT compiler is essentially a black box that makes you look like a genius if you feed it right. But it's fickle — one tiny change in your method size can push you over the inlining threshold and suddenly your 2ns hot path is now 20ns. You learn to work with it, not against it.
This isn't theoretical. The open‑source exchange‑core project proves every technique in this article works in production, maintaining sub‑microsecond latency for the critical path even under massive load. The codebase is available on GitHub for you to study, benchmark, and learn from.
Here's what worked for us: complete code examples from production trading systems based on actual benchmarks, not theoretical projections. We had to stop writing "Good Java" and start writing "Hardware‑Aware Java."
How the JIT Compiler Enables Microsecond Latencies
Most developers think Java is interpreted bytecode. Maybe in 1995. Not anymore.
The JIT compiler is constantly watching your code run. It profiles everything. After about 10,000 invocations of a hot method — which happens in milliseconds for our trading loops — it compiles that method with C2, the aggressive optimizing compiler. At that point, you're running native machine code that's been optimized for your exact workload.
Here's what the JIT knows that you don't: which branches you actually take. Not the ones you could theoretically take. The ones you do take, 99.9% of the time. It optimizes for that. It also knows which objects never escape a method scope. If it can prove that Order temp = new Order() never leaves the method, it doesn't allocate. Just eliminates the whole thing. Scalar replacement. Suddenly your code that looks like it's creating objects is just shuffling primitives on the stack.
But the JIT is fickle. I spent two days debugging why our order validation suddenly got 10x slower after I extracted a helper method. Turns out I crossed the MaxInlineSize threshold. The JIT won't inline methods bigger than 35 bytes of bytecode by default. My "clean" refactoring added 8 bytes of bytecode to the caller, pushed it over 35, killed inlining. 10x slower. I reverted the refactoring.
The Optimization Tiers
Code Execution Journey:
0-1,500 calls: Interpreter (collecting profiling data)
1,500-10,000: C1 Compiler (fast compilation, basic optimizations)
10,000+: C2 Compiler (aggressive optimization based on profiles)
Result: After 10,000 invocations (milliseconds!), code runs at
full native speed with all optimizations applied.In production systems, hot paths hit 10,000 invocations within seconds of startup. After that, they're fully optimized for the lifetime of the process. This is why HFT systems always warm up before live trading.
Method Inlining: The Mother of All Optimizations
The JIT's most impactful optimization is method inlining — literally copying method bodies into callers.
Before C2 Optimization
public boolean validateOrder(Order order) {
return validatePrice(order) && // Method call
validateQuantity(order) && // Method call
validateSymbol(order); // Method call
}
private boolean validatePrice(Order order) {
return order.price > 0 && order.price < MAX_PRICE;
}
private boolean validateQuantity(Order order) {
return order.quantity > 0 && order.quantity < MAX_QUANTITY;
}
private boolean validateSymbol(Order order) {
return order.symbol != null && order.symbol.length() > 0;
}After C2 Optimization (what actually executes)
public boolean validateOrder(Order order) {
// All method calls inlined - direct field access!
return (order.price > 0 && order.price < MAX_PRICE) &&
(order.quantity > 0 && order.quantity < MAX_QUANTITY) &&
(order.symbol != null && order.symbol.length() > 0);
}Performance:
The JIT knows the actual call sites from profiling data and can inline aggressively. It doesn't need to be conservative about virtual methods or separate compilation units — it has complete runtime visibility.
Escape Analysis: Eliminating Allocations
The JIT's escape analysis can eliminate allocations entirely based on runtime behavior:
// Looks like allocation, but JIT eliminates it!
public void processOrder(long orderId, double price, int quantity) {
Order temp = new Order(); // Allocation? NOPE!
temp.setOrderId(orderId);
temp.setPrice(price);
temp.setQuantity(quantity);
double totalValue = calculateValue(temp.price, temp.quantity);
sendToRiskEngine(totalValue);
// temp never escapes - JIT optimizes it away!
}After JIT Optimization
public void processOrder(long orderId, double price, int quantity) {
// Object replaced with scalar values!
double totalValue = price * quantity; // Inlined, zero allocation
sendToRiskEngine(totalValue);
}Impact: Object creation, method calls, and field accesses become a simple multiplication. Zero allocations, zero GC pressure.
The Warm‑Up Requirement
In high‑frequency trading (HFT) systems, the warm‑up phase is a mandatory production requirement rather than an optional optimization, as it ensures the JIT compiler has fully optimized the critical path before the first live order is ever processed.
// Startup warm-up phase (60 seconds)
for (int i = 0; i < 1_000_000; i++) {
processSyntheticOrder(...); // Trigger JIT optimization
}
// Now processing live orders with fully optimized code
while (true) {
processLiveOrder(...); // Running at peak performance
}Performance Difference:
Production Practice: exchange‑core runs a 60‑second warm‑up phase before processing live market data. Without this, the first few seconds would have 10–20x worse latency.
Method inlining is the big one. When the JIT inlines your methods, it eliminates the call overhead entirely — just copies the method body directly into the caller. I've seen 7–10x speedups from inlining alone. But you have to keep your methods small. Under 35 bytes of bytecode for hot paths, or the JIT won't inline them.
Then there's escape analysis. If an object never leaves the method — doesn't get stored in a field, doesn't get passed to another method that might store it, doesn't get returned — the JIT can optimize it away completely. What looks like new Order() in your source code becomes three local variables in the actual execution. Zero allocation. Zero GC pressure.
But none of this happens instantly. You need to warm up.
In high‑frequency trading, a comprehensive warm‑up phase is mandatory to ensure the JIT compiler triggers C2 compilation across all hot paths — such as order validation and matching — before the system goes live. This process ensures that by the time real orders arrive, the code is already fully optimized with the necessary inlining and escape analysis applied.
Neglecting this can lead to misleading performance data; for instance, a cold‑start benchmark might show a 200‑microsecond latency, while the same system warmed up drops to 0.8 microseconds. The trap is spending days profiling what is essentially just the overhead of the interpreter. To get an accurate picture of a system's true potential, you must benchmark the optimized machine code, not the transitional state.
Off‑Heap Memory: Bypassing the Garbage Collector
When I first started working on low‑latency Java, I followed the standard advice: let the JVM manage memory for you. Trust the garbage collector. Don't worry about allocations because "the JVM is really good at this."
That advice is great for web apps serving 100 requests per second. For microsecond‑latency systems? It's a death sentence.
At 100,000 messages per second, we were creating 12 MB of garbage every second. The young generation would fill up, the GC would wake up, and our latency would spike from 10 microseconds to 50 milliseconds. In crypto trading, 50ms means someone else already took the arbitrage opportunity. Your order is stale. You lost money.
The On‑Heap Problem
Let me show you the code that was killing our latency targets when I first joined. We were processing market data like this:
// Traditional on-heap approach - multiple allocations!
public void processMarketData(Socket socket) throws IOException {
byte[] networkBytes = new byte[256]; // Heap allocation #1
int bytesRead = socket.getInputStream().read(networkBytes);
ByteBuffer buffer = ByteBuffer.wrap(networkBytes); // Heap allocation #2
// Extract fields
long orderId = buffer.getLong();
double price = buffer.getDouble();
int quantity = buffer.getInt();
// Create domain object
Order order = new Order(orderId, price, quantity); // Heap allocation #3
processOrder(order);
}What's happening under the hood:
At scale:
Result: Young generation (typically 100–200MB) fills in 10–15 seconds, triggering a GC pause. GC pause duration: 10–50ms. In a system with a 10 microsecond latency budget, a 10ms GC pause is 1,000x your entire budget. Unacceptable.
The Off‑Heap Solution: Zero GC, Zero Pauses
Think of the garbage collector as a helicopter parent. It wants to know where every single byte is at all times. It tracks every object, checks if you're still using it, moves things around when you're not looking. Very helpful for normal applications. Absolutely devastating when you need consistent microsecond latency.
By moving our data off‑heap, we basically emancipated our memory. If the GC doesn't know the memory exists, it can't try to "help" you by pausing your threads for 50ms while it cleans up. It's a bit more dangerous — you have to manage the lifecycle yourself, no safety net — but the silence from the GC logs is worth the risk.
Here's how we rewrote that market data processing with Agrona's off‑heap buffers:
public class MarketDataProcessor {
// Allocate off-heap buffer ONCE at startup
private final UnsafeBuffer buffer;
public MarketDataProcessor() {
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
this.buffer = new UnsafeBuffer(directBuffer);
}
// Zero-copy message processing
public void processMarketData(UnsafeBuffer networkBuffer, int offset) {
// Read primitives directly from off-heap memory
long orderId = networkBuffer.getLong(offset);
offset += 8;
double price = networkBuffer.getDouble(offset);
offset += 8;
int quantity = networkBuffer.getInt(offset);
offset += 4;
// Process inline - no object allocation!
updateOrderBook(orderId, price, quantity);
// Zero heap allocations = zero garbage = zero GC pressure
}
private void updateOrderBook(long orderId, double price, int quantity) {
// Your business logic here
}
}What changed:
Moving to an off‑heap approach transforms performance from a game of chance into a predictable science, shifting message processing latency from 200 nanoseconds down to a mere 20 nanoseconds. While the 10x speedup in average processing time is significant, the true victory lies in the elimination of tail latency; by removing the three heap allocations per message that previously generated 12 MB of garbage every second, the system bypasses the 50‑millisecond spikes caused by the garbage collector entirely.
This transition ensures that latency remains a flat line even during periods of extreme market volatility, though achieving this requires a total commitment to the architecture. Mixing on‑heap and off‑heap logic in the same hot path is a recipe for failure, combining the overhead of manual memory management with the very GC pauses you set out to avoid. The fundamental reality is that every byte left on the heap represents a future pause, and only by committing to a zero‑allocation, off‑heap design can a system maintain microsecond‑level consistency under load.
Why Single‑Threaded Shards Beat Multi‑Threading
For years, I wrote Java code the "right" way: use multiple threads to maximize CPU utilization. Use ConcurrentHashMap for thread‑safe collections. Synchronize when you need to coordinate. Then I benchmarked it properly. Adding threads made our account service 800x slower. Not 2x, not 10x — 800 times slower. It sounds impossible, but that's the reality of lock contention in Java.
The Multi‑Threading Trap
Let's start with the "obvious" solution for concurrent account updates:
// Traditional multi-threaded approach
public class MultiThreadedAccountService {
private final Map<Long, Account> accounts = new ConcurrentHashMap<>();
public void updateBalance(long userId, long amount) {
Account account = accounts.get(userId);
synchronized(account) { // ALL THREADS WAIT HERE!
account.balance += amount;
}
}
}Benchmarking the impact of lock contention reveals a startling reality: while a single thread can process an update in a mere 0.5 nanoseconds, adding even a second thread with synchronization can balloon that time to 150 nanoseconds. As more threads are introduced, the overhead of coordinating access via synchronized blocks and concurrent collections destroys cache coherence, leading to performance that can be 800 to 2,400 times slower than the single‑threaded baseline.
This exponential degradation proves that the CPU is spending its cycles managing lock contention and context switching rather than performing actual work, highlighting the hardest lesson in concurrent programming: locks don't scale, they suffocate.
The Single‑Threaded Solution: Sharding
Instead of multiple threads sharing data with locks, use multiple single‑threaded shards, each owning exclusive data:
public class ShardedAccountService {
private final int shardId;
private final long shardMask;
// This thread OWNS these accounts - NO sharing!
private final LongObjectHashMap<Account> myAccounts;
public ShardedAccountService(int shardId, int numShards) {
this.shardId = shardId;
this.shardMask = numShards - 1;
this.myAccounts = new LongObjectHashMap<>();
}
// Determine if this user belongs to MY shard
private boolean isMyUser(long userId) {
return (userId & shardMask) == shardId;
}
public void updateBalance(long userId, long amount) {
if (!isMyUser(userId)) {
return; // Different shard handles it
}
// MY USER! I own this account exclusively
// NO LOCKS NEEDED!
Account account = myAccounts.get(userId);
account.balance += amount; // Direct access, ~0.5 ns!
}
}Why this works:
The shift from traditional locking to sharded, isolated threads reveals a massive performance gap: while adding locks and multiple threads can degrade latency by 800x and push p99s from 2 to 5,000 nanoseconds, switching to single‑threaded shards restores the 0.5‑nanosecond baseline with perfect linear scaling. By assigning each thread exclusive ownership of its data, CPU utilization jumps from 60% spent on coordination to 95% spent on actual work.
However, even with locks removed, the Linux scheduler can cause random 50‑microsecond spikes by migrating threads between cores and flushing the L1/L2 caches. Eliminating these blips requires core isolation via the isolcpus boot parameter and pinning threads to specific cores using libraries like OpenHFT's Java‑Thread‑Affinity:
import net.openhft.affinity.Affinity;
// Pin shard 0 to CPU core 1 (isolated)
Affinity.setAffinity(1);
// Now this thread is locked to core 1
// Linux scheduler can't migrate it anymore
processOrdersForShard0();The scheduler can't touch our threads anymore. Cache stays hot. p99 spikes vanished.
Object Pooling: Zero Allocations, Zero GC Pauses
Allocation is cheap in Java. That's what they teach you. And it's true — allocating one object takes maybe 10–20 nanoseconds. Basically free, right?
Wrong. At 100,000 allocations per second, you're creating 240 MB of garbage every minute. And garbage means GC pauses. Those "cheap" allocations add up to expensive pauses.
The solution feels wrong if you learned Java the traditional way: pre‑allocate everything at startup and reuse it forever. You're essentially writing C‑style memory management in Java. But it works.
Pre‑Allocate Everything, Reuse Forever
Instead of allocating on‑demand, pre‑allocate at startup and reuse circularly:
public class RingBufferObjectPool<T> {
private final T[] pool;
private final AtomicLong cursor = new AtomicLong(0);
private final int mask;
@SuppressWarnings("unchecked")
public RingBufferObjectPool(Supplier<T> factory, int size) {
// Size must be power of 2 for fast modulo
if ((size & (size - 1)) != 0) {
throw new IllegalArgumentException("Size must be power of 2");
}
this.pool = (T[]) new Object[size];
this.mask = size - 1;
// Pre-allocate ALL objects at startup (one-time cost)
for (int i = 0; i < size; i++) {
pool[i] = factory.get();
}
}
public T acquire() {
// Fast modulo using bitwise AND instead of expensive %
long index = cursor.getAndIncrement();
return pool[(int)(index & mask)];
}
// No release method needed!
// Objects are reused circularly: 0 → 1 → 2 → ... → 1023 → 0
}Transitioning to object pooling can reduce p99 latency by over 20x — dropping from 250 microseconds to just 12 — by replacing millions of per‑order allocations with a small set of objects pre‑allocated at startup. This approach completely eliminates mid‑trading GC pauses, though it requires sizing pools for 3–4x peak throughput to handle sudden market volatility and accepting a longer initial startup time to pre‑load the necessary domain objects. By trading memory and startup speed for runtime consistency, the system moves from 240 MB of garbage per minute to zero, ensuring that performance remains rock‑solid when it matters most.
Conclusion: The "Zero" Philosophy
The real‑world success of systems like exchange‑core proves that these techniques are not merely theoretical. By combining off‑heap memory, single‑threaded sharding, and object pooling, you move beyond "optimizing" and toward "eliminating" overhead entirely. Zero allocations means zero GC pauses; zero locks means zero contention; zero thread migration means zero cache misses.
When you are chasing microseconds, the pattern is consistent: you must stop fighting the JVM and start feeding it exactly what it needs to optimize for the underlying hardware. Understanding the JIT, bypassing the heap, and isolating your CPU cores aren't just tricks — they are the requirements for building ultra‑fast systems.
Getting Started Checklist
-Xlog:gc*:file=gc.log and async‑profiler to find your true bottlenecksisolcpus and thread affinity to keep your L1/L2 caches hotjava -Xms16g -Xmx16g -XX:+AlwaysPreTouch -XX:+UseLargePages YourAppFour years ago, if you'd told me Java could achieve sub‑microsecond median latency, I would have laughed. Today, I know the language isn't the limitation — our understanding of the platform is. Study the code, run the benchmarks, and start writing hardware‑aware Java.
related reading
How we hosted 7 WordPress sites on one $5 VPS with CloudPanel
A codebefore case study: replacing a sprawling Docker-compose setup with a single Contabo VPS running CloudPanel — cheaper, calmer, and 80% idle.
why my basis trading bot hasn't blown up my account yet
Nine months. Five hundred tests. One 3 AM panic attack. Here's what I actually learned building a delta-neutral crypto bot — and most of it has nothing to do with trading.
building a data mart without ETL: how we replaced Spark and Airflow with pure ClickHouse
How a single Go binary and a handful of materialized views replaced what would traditionally require Kafka, Spark, Airflow, and a team of data engineers.