Performance Optimization: Latency, Throughput & Amdahl's Law
Latency and throughput are different metrics that often trade off against each other, and adding threads doesn't scale a program forever. this topic covers when to optimize for which metric, the hard ceiling Amdahl's Law puts on parallel speedup, and how to measure performance without being fooled by the JIT.
Learning objectives
- Distinguish latency and throughput and pick the right optimization strategy for each
- Apply the fan-out/fan-in pattern to reduce the latency of one request
- Configure a ThreadPoolExecutor's core size, max size, queue, and rejection policy to bound throughput
- Apply Amdahl's Law to predict the ceiling on parallel speedup for a given workload
- Explain why cold, un-warmed-up benchmarks give misleading results
"Is it fast?" is really two different questions, and optimizing for one can actively hurt the other. Latency asks: how long does one task take, start to finish? A trading platform cares about latency in microseconds - a slow order fill loses money regardless of how many other orders the system processed that second. Throughput asks: how many tasks get completed per unit time? A batch ETL pipeline cares about throughput in gigabytes per hour - nobody cares how long any single record took, only about the aggregate rate.
The tension between them is concrete, not theoretical. Using more threads to reduce the latency of one request - splitting it into independent subtasks that run in parallel - can reduce overall throughput if the thread creation and context-switching overhead outweighs the benefit, especially under heavy concurrent load. Conversely, funneling many requests through a shared, bounded thread pool to maximize throughput means individual requests may sit in a queue behind others, which increases their latency. You can't optimize blindly; you have to decide which metric actually matters for your use case before you start tuning anything.
A useful way to sort use cases: real-time trading and payments care almost entirely about latency, and the strategy is to parallelize a single request's independent subtasks and avoid queuing. UI responsiveness cares about perceived latency - under roughly 100ms feels instant - and the strategy is to keep a dedicated UI thread free by pushing heavy work to a background thread. A typical web server cares about throughput - requests per second - and the strategy is a well-sized thread pool (or virtual threads) plus async I/O. Video encoding genuinely needs both: encode each frame fast, and use every available core doing it, which is why it's built as a parallel pipeline of stages rather than one giant sequential loop.
Consider an API handler that needs data from both a database and a third-party service before it can respond, where neither call depends on the other's result. Call them one after another and the response waits for the sum of both durations - a 150ms database call plus a 200ms API call is 350ms of latency. But since the two calls are genuinely independent, there's no reason to wait for one before starting the other.
This is the fan-out/fan-in pattern, also called scatter-gather: dispatch every independent slow operation to its own worker thread at the same time, then wait for all of them to finish. Because the I/O waits overlap in wall-clock time, total latency approaches the slowest of the durations rather than their sum. The prerequisite is that the subtasks are genuinely independent - no shared mutable state, and neither needs the other's output as input. If they did, they'd have to run sequentially no matter how many threads are available, which is a small preview of the hard limit Amdahl's Law describes later in this topic.
The same idea applies to CPU-bound work in a different shape. Image processing is a textbook case of data-parallelism: every pixel's transformation is completely independent of every other pixel's, so an array can be split into contiguous, non-overlapping chunks - one per CPU core - with zero coordination between chunks. On an 8-core machine, that's close to an 8x speedup over one thread working through the whole image serially. The sharp difference from the I/O case: for CPU-bound work, adding threads beyond the core count doesn't help - it just adds context-switch overhead, since the bottleneck is compute, not waiting. For I/O-bound work, running far more threads than cores can be productive, because those threads spend most of their time blocked rather than consuming CPU.
💻 Code example
package concurrency.performance; import java.util.concurrent.*; public class FanOutFanInLatencyDemo { public static void main(String[] args) throws Exception { // Pool sized to 2 so both independent subtasks run truly concurrently // instead of queueing behind each other on a single worker thread. ExecutorService executor = Executors.newFixedThreadPool(2); long start = System.currentTimeMillis(); // submit() returns immediately with a Future; the lambda body starts // executing on a pool thread right away, in parallel with the next call. Future<String> dbResult = executor.submit(() -> { Thread.sleep(150); return "db-row"; }); Future<String> apiResult = executor.submit(() -> { Thread.sleep(200); return "api-payload"; }); // get() blocks only until that specific Future completes. Because both // tasks were already running concurrently, the combined wait is close // to max(150, 200)ms, not the sum of both. String merged = dbResult.get() + " + " + apiResult.get(); long elapsed = System.currentTimeMillis() - start; System.out.println("Merged: " + merged); System.out.println("Latency: " + elapsed + "ms (sequential would be ~350ms)"); executor.shutdown(); } }
A naive server design fails under load in two opposite ways. Spawn an unbounded thread per incoming request, and memory runs out eventually - each platform thread reserves a stack of roughly 512KB to 1MB - while excessive context-switching degrades throughput instead of improving it. Cap the thread count but queue incoming requests without any limit instead, and the server keeps accepting work it can never keep up with: memory and latency both grow silently until the process runs out of memory or is effectively dead. This is the classic "queue explosion" failure mode.
Where latency optimization is about one request, throughput optimization is about many concurrent clients, and the fix is bounding every stage of the pipeline: the pool size (both a core size and a maximum), the backlog queue, and what happens once both are full. ThreadPoolExecutor exposes exactly these knobs directly, rather than hiding them behind a convenience factory.
corePoolSize is the minimum number of worker threads kept alive even when idle, ready to pick up work with no startup latency. maximumPoolSize is the cap the pool may grow to - creating extra threads beyond the core size, once the queue is completely full - to absorb bursts. keepAliveTime controls how long those extra threads may sit idle before being reclaimed, so the pool shrinks back down after a burst subsides. The workQueue is the fixed-capacity backlog: too small and the rejection policy fires too eagerly; too large and backpressure only kicks in after memory and latency damage is already done. The rejection handler only fires once both the pool and the queue are saturated; CallerRunsPolicy runs the rejected task on the calling thread itself, which slows down whoever is submitting work and naturally throttles new requests without failing them outright.
The convenience factories most people reach for by default - Executors.newFixedThreadPool(), newCachedThreadPool() - use an unbounded queue internally. That silently reintroduces the exact queue-explosion risk a hand-configured ThreadPoolExecutor exists to prevent: with an unbounded queue, maximumPoolSize becomes unreachable in practice, because the pool never grows past its core size while the queue can always absorb more work.
💻 Code example
package concurrency.performance; import java.util.concurrent.*; public class BoundedThreadPoolWebServer { public static void main(String[] args) { // core=10 threads always ready; grows to max=50 under burst load; idle // extra threads beyond core are reclaimed after 60s; a bounded queue of // 100 caps the backlog so memory/latency can't grow without limit; // CallerRunsPolicy self-throttles once pool and queue are both full. ExecutorService webServerExecutor = new ThreadPoolExecutor( 10, 50, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy() ); System.out.println("Handling incoming requests..."); for (int i = 0; i < 15; i++) { final int requestId = i; webServerExecutor.submit(() -> System.out.println("Handling request " + requestId + " on " + Thread.currentThread().getName()) ); } // Orderly shutdown: no new tasks accepted, but already-queued tasks // are still allowed to finish before pool threads terminate. webServerExecutor.shutdown(); } }
Adding more threads doesn't make a program proportionally faster forever. Some part of almost every program has to run one step at a time - reading input, merging results at the end, a single lock somewhere - and no amount of extra cores can speed up that part. Amdahl's Law makes this intuition precise:
Speedup(N) = 1 / (S + (1 - S) / N)
where S is the fraction of the work that must run sequentially and N is the number of processors. As N grows large, the (1-S)/N term shrinks toward zero, but S itself never does. Speedup asymptotically approaches a hard ceiling of 1 / S, no matter how many cores are thrown at the problem. If 25% of a program is sequential, speedup is capped at 4x, full stop - adding a thousand cores past that point buys nothing.
The practical implication is that performance work should start by finding and shrinking the sequential fraction - a lock held too broadly, an I/O step that can't be split, a single-threaded merge step - before adding threads. At S = 90%, going from 8 to 64 cores buys almost nothing (roughly 2.91x to 3.83x), because the 10% sequential cost dominates everything else. Capacity-planning decisions - "how many cores should we buy" - should be grounded in this ceiling, not in a naive assumption of linear scaling with core count.
💻 Code example
package concurrency.performance; public class AmdahlsLawCalculator { // s = sequential fraction (0.0 to 1.0), n = number of processors. static double speedup(double s, int n) { return 1.0 / (s + (1.0 - s) / n); } public static void main(String[] args) { int[] processors = {1, 2, 4, 8, 16, 32, 64}; double[] sequentialFractions = {0.0, 0.1, 0.25, 0.5, 0.9}; System.out.printf("%-8s", "Cores"); for (double s : sequentialFractions) System.out.printf(" S=%.0f%% ", s * 100); System.out.println(); for (int n : processors) { System.out.printf("%-8d", n); for (double s : sequentialFractions) System.out.printf(" %5.2fx ", speedup(s, n)); System.out.println(); } // At S=90%, even 64 cores only reaches ~1.10x speedup - the // sequential 10% dominates everything else. } }
Suppose you want to know whether a lock-free stack is actually faster than a synchronized one under contention. Wrapping a loop in System.currentTimeMillis(), running it once, and comparing the two numbers produces a result that's close to meaningless - and worse, it can point to the wrong conclusion entirely.
The JVM doesn't run bytecode at full speed from the first call. It starts by interpreting bytecode, and only after a method has been invoked "enough" times does the JIT compiler kick in and replace it with optimized native machine code, through tiered compilation, inlining, dead-code elimination, and escape analysis. Measure the very first execution of a loop, and the result mixes interpreted bytecode with JIT compilation overhead running concurrently on background compiler threads - not the steady-state performance production code will actually see under sustained load. This distorts concurrency comparisons directly: an un-warmed-up benchmark can make one implementation look artificially slow or fast, leading to shipping the "faster" one for entirely the wrong reasons.
The fix is a warm-up phase: run the hot method enough times to let the JIT profile and compile it, discard that timing, and only measure a second pass. The real JMH (Java Microbenchmark Harness) library formalizes this: it forks a fresh JVM per benchmark to avoid cross-contamination between runs, uses "blackholes" to stop the JIT from eliminating unused results as dead code, and reports statistically sound numbers (mean, standard deviation, percentiles) instead of one noisy sample. The mental model is the same either way - never trust a cold, un-warmed-up measurement.
At a larger scale, load-testing tools like JMeter measure the same idea across a whole server: throughput in requests per second, response-time percentiles (p50, p90, p99), and error rate under increasing numbers of simulated concurrent users. A throughput graph that flattens as load increases is showing exactly where a thread pool or connection pool saturates - that plateau is the system's concurrency limit. For live diagnosis of a running process, jstack <PID> gives a point-in-time thread dump showing every thread's state; many threads stuck BLOCKED on the same lock usually means that lock's critical section is too broad.
💻 Code example
package concurrency.performance; public class WarmupSensitiveBenchmark { public static void main(String[] args) { long start = System.nanoTime(); // Warm-up loop: runs the hot method enough times to give the JIT a // chance to profile and optimize it. This timing is NOT trustworthy - // it mixes interpreter overhead with compilation overhead. for (int i = 0; i < 1_000_000; i++) compute(i); long mid = System.nanoTime(); // Measurement loop: by now compute() should be JIT-compiled to native // code, so this timing better reflects steady-state throughput. for (int i = 0; i < 1_000_000; i++) compute(i); long end = System.nanoTime(); System.out.println("Warm-up time: " + (mid - start) / 1_000_000.0 + " ms"); System.out.println("Measurement time: " + (end - mid) / 1_000_000.0 + " ms"); } private static int compute(int val) { return val * 123; } }
▲ Common mistake
Adding threads without profiling first. If the actual bottleneck is I/O wait or garbage collection, more threads don't help at all - they just add scheduling overhead on top of a problem that was never about CPU parallelism. Profile with a tool like VisualVM or Java Flight Recorder to find the real bottleneck before touching thread counts.
▲ Common mistake
Ignoring Amdahl's Law when sizing a thread pool. If 80% of a workload is sequential, the maximum possible speedup is 1.25x no matter how many cores are available - throwing 64 threads at it wastes 63 of them. Reduce the sequential fraction first; parallelism can only ever speed up the part of the work that's actually parallel.
▲ Common mistake
Creating threads inside a hot path. Spinning up a new Thread costs on the order of a millisecond. Done once, that's negligible; done inside a loop handling thousands of requests, it's catastrophic overhead. Use a pre-warmed ExecutorService (or a virtual-thread-per-task executor) instead of constructing threads by hand.
▲ Edge case
Lock contention can hide behind seemingly unrelated code. Many threads queuing behind one synchronized method is easy to miss in a profiler that only samples CPU time, since blocked threads aren't consuming CPU - they show up as low utilization, which can look like the opposite of a bottleneck. Striped locking, ConcurrentHashMap, or lock-free atomics are the usual fixes once contention is confirmed.
▲ Edge case
Parallel streams share one JVM-wide pool. Stream.parallel() uses ForkJoinPool.commonPool() by default. If any parallel stream operation blocks on I/O, its thread occupies a slot in that shared pool - starving every other parallel stream running anywhere else in the same JVM, even in unrelated code. Use a dedicated ExecutorService (or CompletableFuture) for I/O-bound work instead of parallel streams.
Real-world Java frameworks make Amdahl's Law and the latency/throughput distinction concrete: Spring Boot's default Tomcat thread pool caps concurrent request handling at a fixed size (throughput-oriented, bounded), while ForkJoinPool (used by parallel streams) is tuned for CPU-bound divide-and-conquer work matched to core count, and virtual threads target exactly the I/O-bound, high-concurrency case where thousands of blocking calls need to overlap without a thread-per-request memory cost.
Latency vs throughput : Latency is one task's completion time - reduce it by parallelizing that one task's independent subtasks (fan-out/fan-in). Throughput is tasks completed per second - increase it by handling more tasks at once with a properly bounded thread pool. Decide which one matters before optimizing either.
What does Amdahl's Law say? : Speedup(N) = 1 / (S + (1-S)/N), where S is the fraction of work that must run sequentially. At S=50%, speedup never exceeds 2x regardless of core count. Always reduce the sequential fraction first - it's the bottleneck no amount of parallelism can fix.
Why can't I just trust a quick System.currentTimeMillis() benchmark?
: The JVM interprets bytecode before the JIT compiler kicks in and optimizes it. An un-warmed-up measurement mixes interpreter overhead with compilation overhead, and can make one implementation look faster or slower than it really is under steady state. Use a warm-up phase, or better, JMH.
What's the danger in Executors.newFixedThreadPool() for a busy server?
: It uses an unbounded internal queue, so maximumPoolSize is effectively unreachable and the queue can grow without limit under sustained overload - the exact "queue explosion" a hand-tuned ThreadPoolExecutor with a bounded queue and rejection policy is built to prevent.
Why do CPU-bound and I/O-bound workloads want different thread counts? : CPU-bound work has no benefit past the core count - extra threads just add context-switch overhead since the bottleneck is compute. I/O-bound work can productively use far more threads than cores, because those threads spend most of their time blocked rather than consuming CPU.
Want a visual for this concept?
Generate a diagram tailored to “Performance Optimization: Latency, Throughput & Amdahl's Law” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →