back to blog
    ·18 min read

    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:

  1. Before: 15–20 ns (three method calls, stack frames, argument passing)
  2. After: 2–3 ns (direct field access, zero call overhead)
  3. Improvement: 7–10x faster
  4. 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:

  5. Cold start: Interpreted/C1 code
  6. Fully warmed: C2 optimized code
  7. Improvement: 10–20x faster
  8. 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:

  9. Three heap allocations per message (byte array, buffer, order)
  10. Garbage collector tracks all three objects (overhead on every allocation)
  11. Eventually triggers GC when young generation fills
  12. At scale:

  13. Processing 100,000 messages/second
  14. 3 allocations × 100K = 300,000 heap objects/second
  15. Average object size: ~40 bytes
  16. Total garbage created: 12 MB/second
  17. 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:

  18. Zero heap allocations during processing (buffer allocated once at startup)
  19. Direct memory access (read primitives straight from network buffer)
  20. Zero‑copy design (no intermediate byte arrays or objects)
  21. GC never sees this memory (completely off its radar)
  22. 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:

  23. Each thread owns its data — no other thread can access it
  24. No locks needed — exclusive ownership eliminates synchronization
  25. Perfect cache locality — user data always in the owning core's L1 cache
  26. Zero context switching — dedicated thread per shard, pinned to CPU core
  27. Linear scaling — 4 cores = 4x throughput (not sub‑linear)
  28. 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

  29. Measure First: Use -Xlog:gc*:file=gc.log and async‑profiler to find your true bottlenecks
  30. Warm Up: Never benchmark or go live with a "cold" JVM; run synthetic load for 60 seconds to trigger C2 compilation
  31. Isolate: Use isolcpus and thread affinity to keep your L1/L2 caches hot
  32. JVM Tuning: Start with a fixed heap and pre‑touched memory: java -Xms16g -Xmx16g -XX:+AlwaysPreTouch -XX:+UseLargePages YourApp
  33. Four 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.