DEV Community

Cover image for An 8,000 trade settlement simulator shows where virtual threads win big and where CPU-bound work exposes the limit of Loom
Douglas Carmo
Douglas Carmo

Posted on

An 8,000 trade settlement simulator shows where virtual threads win big and where CPU-bound work exposes the limit of Loom

Java's Project Loom promised a "free lunch": swap Executors.newFixedThreadPool() for Executors.newVirtualThreadPerTaskExecutor() and watch your throughput fly. It's a great pitch and it's also only half the story. The other half only shows up once you stop simulating I/O-bound work and start hitting the CPU.

I wanted a benchmark that felt closer to something I'd actually build, so instead of a toy "sleep and print" example, I wrote a T+1 stock settlement simulator: 8,000 trades that need to clear, plus a Monte Carlo Value-at-Risk (VaR) calculation running 20,000 simulations per trade. One workload is I/O-bound (waiting on a settlement clearinghouse call), the other is pure CPU. Running both side by side on the same machine made the boundary between "virtual threads help" and "virtual threads don't help" impossible to miss.

Part 1 — I/O-Bound Work: Settling 8,000 Trades

The setup: each trade settlement blocks on a simulated I/O call (think: a REST call to a custodian or a clearinghouse). I ran it three ways.

Strategy Tasks Time (ms) Throughput (ops/s) Peak OS Threads Memory
Platform threads (pool=200) 8,000 9,788 817.3 207 46 MB
Virtual threads 8,000 651 12,288.8 24 30 MB
Virtual threads (with pinning) 8,000 539 14,842.3 24 105 MB

Virtual threads came out ~15x faster than a platform thread pool capped at 200 threads, while using roughly a tenth of the OS threads. This is the result everyone quotes when they talk about Loom, and it holds up: when a task spends most of its life waiting, a virtual thread can unmount from its carrier thread during that wait and let another virtual thread run in its place. You get massive concurrency without paying for an OS thread per task.

The "with pinning" row is the interesting one, though. It ran faster here, but faster isn't always better — pinning is a trap, not a feature. Pinning happens when a virtual thread can't unmount from its carrier during a blocking operation, most commonly inside a synchronized block or, historically, during certain native calls. When that happens, the carrier thread is stuck too, and you silently lose the whole point of using virtual threads: your effective concurrency collapses back toward the size of the carrier pool. It didn't hurt in this run because the pinned regions were short and the carrier pool (by default, one per CPU core) could still absorb the load — but at higher contention, this is exactly the kind of thing that turns into a production incident that's brutal to diagnose, because it doesn't throw, it just quietly gets slower.

If you suspect pinning, don't guess — run with:

-Djdk.tracePinnedThreads=full
Enter fullscreen mode Exit fullscreen mode

This prints the exact stack trace of where and why a virtual thread got pinned, which is the fastest way to find synchronized blocks worth converting to ReentrantLock.

The code behind it

The settlement pipeline models something close to a real T+1 workflow: fetch the trade, validate the counterparty against a clearinghouse, check buying power, confirm share availability for sells, then write the final status back — five blocking round-trips per trade, each backed by JDBC or a simulated network call:

public void settle(long tradeId) {
    try {
        Trade trade = fetchTrade(tradeId);          // JDBC - blocking
        if (trade == null) return;

        Object localLock = new Object();
        boolean ok;

        synchronized (localLock) {                    // <-- pinning trigger
            ok = runValidationPipeline(trade);
        }

        updateStatus(tradeId, ok ? "SETTLED" : "FAILED");
    } catch (SQLException e) {
        updateStatusSilently(tradeId, "FAILED");
    }
}

private boolean runValidationPipeline(Trade trade) {
    boolean counterpartyOk = clearinghouse.validateCounterparty(trade);
    boolean buyingPowerOk = clearinghouse.checkBuyingPower(trade);
    boolean sharesOk = "SELL".equals(trade.side())
            ? clearinghouse.confirmShareAvailability(trade)
            : true;
    return counterpartyOk && buyingPowerOk && sharesOk;
}
Enter fullscreen mode Exit fullscreen mode

That synchronized (localLock) block is doing exactly one job in this benchmark: it's the pinning trigger for the third scenario. It doesn't need to guard shared state, a synchronized block wrapping any blocking call is enough to pin the virtual thread to its carrier for the duration, on JDKs prior to JEP 491 (JDK 24), which finally removes this restriction. If you're on an older JDK and you see virtual threads not scaling the way the marketing promised, this is one of the first places to look, anything from a legacy synchronized DAO method to a third-party library you don't control.

The clearinghouse itself is a stand-in for the network calls a real settlement service would make to DTCC/NSCC, a broker's margin engine, and a custodian, each one a Thread.sleep standing in for HTTP/gRPC latency:

public boolean validateCounterparty(Trade trade) {
    sleepRandom(60, 160);
    return true;
}

public boolean checkBuyingPower(Trade trade) {
    sleepRandom(40, 120);
    return true;
}

public boolean confirmShareAvailability(Trade trade) {
    sleepRandom(50, 140);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Three calls, 40–160ms each, per trade, times 8,000 trades — that's the "waiting" that virtual threads turn from a resource cost into a rounding error.

Part 2 — CPU-Bound Work: Monte Carlo VaR on 8,000 Trades

This is where the "just use virtual threads everywhere" advice falls apart. Same 8,000 trades, but now each one runs 20,000 Monte Carlo simulations to estimate Value-at-Risk — no I/O, no waiting, just math. Machine had 16 available cores.

Strategy Tasks Time (ms) Throughput (ops/s) Peak OS Threads Memory
Sequential (1 thread) 8,000 13,141 608.8 1 94 MB
Virtual threads (misused for CPU work) 8,000 15,339 521.5 24 96 MB
Fixed pool = 16 cores 8,000 16,750 477.6 24 54 MB
Parallel Stream (ForkJoinPool, 15 workers) 8,000 16,271 491.7 15 110 MB

Notice what didn't happen: virtual threads didn't win. They didn't even reliably beat a plain sequential loop — they came in barely behind it, and every "smarter" concurrency strategy landed in roughly the same narrow band, within a few percentage points of each other. That's the tell.

The reason is structural, not incidental. Virtual threads solve scheduling problems: when you have thousands of tasks and most of them are idle most of the time, Loom lets the JVM interleave them cheaply on a small number of carrier threads. But when a task never yields — because it's pure CPU computation with nothing to wait on — there's no idle time to reclaim. A virtual thread doing CPU work behaves exactly like a platform thread doing CPU work, except now you've added the bookkeeping overhead of the virtual thread scheduler on top, for zero benefit. Once every strategy is CPU-bound, the ceiling is the same for all of them: Runtime.availableProcessors(). You cannot schedule your way past the number of physical cores you have.

The code behind it

The VaR engine simulates a day of price movement via geometric Brownian motion, 20,000 times per trade, then reads the loss off the 5th percentile of the resulting P&L distribution — no network, no disk, no sleeps, just floating-point math and a sort:

public double calculateVaR(Trade trade, int simulations) {
    double[] pnl = new double[simulations];
    double price = trade.price().doubleValue();
    double notional = trade.notional().doubleValue();
    ThreadLocalRandom rnd = ThreadLocalRandom.current();

    for (int i = 0; i < simulations; i++) {
        double z = rnd.nextGaussian();
        double simulatedPrice = price * Math.exp(
                (DRIFT - 0.5 * DAILY_VOLATILITY * DAILY_VOLATILITY) + DAILY_VOLATILITY * z
        );
        double priceChangePct = (simulatedPrice - price) / price;
        pnl[i] = notional * priceChangePct;
    }

    java.util.Arrays.sort(pnl);
    int varIndex = (int) ((1 - CONFIDENCE) * simulations);
    return -pnl[varIndex]; // loss = negative of the 5th percentile
}
Enter fullscreen mode Exit fullscreen mode

Compare this to ClearinghouseClient from Part 1: same shape of "task," radically different runtime behavior. One spends its time parked, waiting on something external; the other spends its time saturating a core. Running the second one inside a virtual thread doesn't change what the CPU has to do — it just adds a scheduler in the middle that has nothing useful to schedule around.

The Rule of Thumb

  • I/O-bound (waiting on a DB, a REST call, a queue, a file): virtual threads. Executors.newVirtualThreadPerTaskExecutor() and let thousands of tasks block "for free."
  • CPU-bound (hashing, simulation, serialization, encryption): a fixed-size pool sized to your core count, or a parallelStream() / ForkJoinPool. Virtual threads add nothing here except the risk that someone downstream assumes I/O-scale concurrency is safe and floods you with work the CPU can't actually absorb any faster.
  • Watch out for pinning any time virtual threads touch legacy synchronized code — it's the one way I/O-bound workloads can quietly regress toward platform-thread performance.

The mental model that held up for me: virtual threads don't make your CPU faster, they make waiting cheaper. If your bottleneck is a clock cycle, not a network socket, Loom has nothing to offer you — and the benchmark numbers make that a lot more concrete than the general advice usually does.


Setup: JDK 21+ (pinning behavior discussed here is fixed in JDK 24 via JEP 491), benchmark simulates T+1 equity settlement (8,000 trades) for the I/O-bound scenario and Monte Carlo VaR (20,000 simulations/trade) for the CPU-bound scenario. Full harness available on GitHub — link in comments / profile.

Top comments (0)