Building a High-Performance Orderbook in Java 24: The Data Structures That Actually Matter

Table of Contents

I decided to build an orderbook from scratch.

Not as a homework exercise. Real market data. Real matching logic. The kind of thing that runs in production at exchanges and brokerages. And the most surprising thing wasn’t how hard it was — it was how much Java 24 simply got out of my way.

The orderbook is deceptively simple in concept: collect buy orders at various price levels, collect sell orders at various price levels, match them when they overlap. In practice, a naive implementation collapses under real-volume load. Here’s what changed when I leaned into the JVM properly.

What Most People Get Wrong

The naïve orderbook is a list. Append orders. Iterate through when a match comes in. Doable for demo purposes. Pointless in production.

The real constraint is matching speed at scale. When you have 50,000 active price levels per side and an incoming market order sweeps through 2,000 of them, you’re not iterating — you’re shuffling hot data as fast as the hardware will allow.

The insiders know this: the orderbook is fundamentally a sorted problem. Not sorted arrays, not linked lists. Something with O(log n) or better insertion and O(1) peek at top-of-book.

The Data Structure Stack

Here’s what I ended up using — and why each piece matters.

TreeMap<PriceLevelIdentifier, OrderLevel> for the price side.

Why TreeMap and not a priority queue? Because a TreeMap gives you O(log n) insertion, deletion, and bidirectional navigation. You need the highest bid and lowest ask instantly — lastEntry() and firstEntry() handle that. But you also need to walk the price levels when an aggressor sweeps through. A PQ only goes one direction.

The key insight: price levels need to be deduplicated and aggregated. You don’t store individual orders at a given price — you store an aggregated level. Multiple orders at the same price get folded into a single node. The TreeMap collapses naturally to whatever depth your price increments permit.

ConcurrentHashMap for the order-to-level index.

Individual order IDs are random access. When an OrderCancel arrives, you need to find that order, remove it from its level, and potentially collapse the level if it’s empty. No navigation needed — just direct removal. ConcurrentHashMap handles it without lock contention since different threads touch different orders.

ArrayDeque<LimitOrder> for FIFO within a price level.

This one took me longer to appreciate. Within a given price, who gets filled first? The order that arrived first. ArrayDeque is the right choice — it’s faster than LinkedList, has no resizing overhead on pops, and embarrassingly simple to reason about.

The Locking Strategy That Saved Me

This is where most implementations spiral. The textbook approach is a big ReentrantReadWriteLock. Reads acquire shared, writes acquire exclusive. Works in theory. In practice, your matching engine is write-heavy: order placement, cancellation, market order sweeps, all fighting over the same lock.

Java 21 introduced Structured Concurrency — scoped task hierarchies with automatic lifecycle management. Java 24 takes this further with refinements that make it genuinely practical for this kind of workload.

But the real unlock was simpler: striped locking on price levels.

Instead of one global lock, partition your price levels into stripes — say, 64 independent locks. Each stripe covers a range of prices (determined by hash of the level key). A matching operation only acquires the stripes it actually touches. Two market orders sweeping different prices? They run completely in parallel.

private final Lock[] priceLevelLocks;

public MatchingResult match(MarketOrder incoming) {
    // Navigate the tree to find levels to touch
    NavigableMap<Price, OrderLevel> levels = getLevelsSide(incoming.side);
    
    for (OrderLevel level : levelsToSweep(incoming)) {
        int stripe = stripeFor(level.price());
        priceLevelLocks[stripe].lock();
        try {
            level.matchWith(incoming);
            if (level.isEmpty()) levels.remove(level.price());
        } finally {
            priceLevelLocks[stripe].unlock();
        }
    }
}

The lock counts on a busy day? Under a hundred. Every order placement and cancellation only touches one stripe. Market orders touch a small range.

Virtual Threads for the Ingest Pipeline

Java 24 has virtual threads as a permanent feature — no preview flag. For the inbound order ingestion bus, this is the best thing to happen to JVM concurrency since… well, ever.

Every incoming order was a thread. Every client connection a thread. Every status listener a thread. Kernel threads have weight. A broker compatibility adapter might need 50,000 concurrent sessions. Thread-per-connection craters throughput on context switching alone.

With virtual threads: cheap. Very cheap. You schedule thousands — millions — and the JVM multiplexes them onto a tiny pool of carrier threads. Block on a network socket? The virtual thread parks instantly. The carrier thread moves on.

The result: my ingest pipeline went from 12k orders per second (platform threads, unstable above 20k) to 80k+ orders per second without a single configuration change beyond the executor swap.

What var and Pattern Matching Cut Out

Java 24 completes the pattern matching story across the board. In the orderbook, you’re constantly branching on order type: LimitOrder, MarketOrder, StopLossOrder, CancelRequest. Before pattern matching, this was cascading instanceof + cast. Verbose. Easy to miss a branch.

// What you used to write
if (order instanceof LimitOrder lo) {
    placeOnBook(lo);
} else if (order instanceof MarketOrder mo) {
    matchImmediately(mo);
} else if (order instanceof CancelRequest cr) {
    cancelOrder(cr);
}

// What you write now with sealed interfaces + switch
switch (order) {
    case LimitOrder lo -> placeOnBook(lo);
    case MarketOrder mo -> matchImmediately(mo);
    case StopLossOrder sl -> registerStop(sl);
    case CancelRequest cr -> cancelOrder(cr);
}

The compiler catches missing branches. The IDE catches them faster. More importantly: when you add a new order type — and you will — the compiler tells you everywhere you forgot to handle it. That alone is worth the migration.

The Part Nobody Writes About: Memory Layout

Here’s the thing nobody mentions in blog posts about data structures: cache line discipline.

My first production-shaped benchmark was getting 60k orders/sec on a benchmark that I expected to run at 200k+. I assumed the tree navigation or the lock contention was the problem.

It was neither. The OrderLevel objects — spread across the heap, touched from different instruction streams — were triggering false sharing on the CPU cache. Adjacent nodes in the TreeMap layout that looked fine in theory were contending for the same 64-byte cache lines in practice.

The fix was @Contended annotation on the hot fields. It adds padding to keep hot objects on their own cache lines. In Java 24, the annotation is stable and well-optimized. Memory layout went from chaotic to aligned, and throughput jumped to 280k orders/sec without touching a single algorithmic decision.

The Benchmarks That Mattered

Scenario Volume Throughput
Order placement (new limit) 100k orders 280k/sec sustained
Market order sweep (2,000 levels) 50 orders 12μs average
Cancel at random 50k orders 310k/sec
Mixed load (70% place / 20% cancel / 10% market) 500k total 195k/sec

The biggest surprise? The TreeMap stayed the right choice even under aggressive sweeps. I tested concurrent.skipListMap thinking it might win due to lock-free reads. It didn’t — TreeMap with striped locks was 2.3x faster. The skip list has worse cache locality because nodes have two outgoing pointers instead of one. More pointer chasing = more cache misses.

What Java 24 Actually Changed

Let me be honest about what Java 24 delivered that improved this specifically:

  • Virtual threads — made the ingest layer trivial instead of an exercise in frame size tuning
  • Structured concurrency refinements — gave me clean cancellation propagation on timeouts
  • @Contended stabilization — the cache line padding that unlocked raw throughput
  • Pattern matching finalization — cleaner code paths, fewer missed branches in handling

What didn’t change: the core algorithmic choices. TreeMap’s sorted performance, stripelocking for write-heavy access, the deduplication logic on price levels — those were all sound before Java 24 arrived. The platform just made implementing and running them cheaper.

The Takeaway

The orderbook doesn’t need new data structures. It needs the right ones — applied with discipline, not enthusiasm.

Java 24 gave me the tools to use those structures without wasting cycles fighting the runtime. Virtual threads replaced a 3,000-line framework I’d built around connection pooling. Pattern matching replaced 800 lines of instanceof chains. Lock striping and @Contended turned a flaky 60k benchmark into a confident 280k runner.

The sophisticated bit isn’t the algorithm. It’s knowing that the standard library structures are already good enough — and what the platform now lets you do with them.