Java Concurrency from the Trenches: Lessons Learned in the Wild
#Backend

Java Concurrency from the Trenches: Lessons Learned in the Wild

Serverless Reporter
5 min read

A Netflix engineer shares hard-won lessons from scaling a high-throughput IO-bound system, exploring the evolution from sequential code through parallel streams and complex executor configurations to the simplicity of virtual threads.

Featured image

Concurrency in production is rarely a textbook exercise. It's a series of compromises, experiments, and often, painful lessons learned when systems buckle under real-world load. Hugo Marques, a software engineer at Netflix, shared his journey navigating Java concurrency at scale, moving beyond simple frameworks to solve high-throughput IO challenges. Drawing from a year-long project, his talk reveals how seemingly small decisions can cascade into memory errors, service degradation, and operational nightmares.

The core problem was a batch job processing a massive volume of data: 10 regions, 10,000 orders per region, and 5,000 products per order, translating to roughly 270,000 requests per second (RPS) to a downstream gRPC service. The initial approach was straightforward: sequential processing. It was correct, memory-safe, and gentle on dependencies, but it was excruciatingly slow and underutilized the eight-core machines it ran on.

The Parallel Stream Trap

The first attempt to accelerate the job involved parallel streams. This seemed ideal for the initial CPU-bound data parsing and validation phases. However, this led to the first of several pitfalls:

  1. Nested Parallelism: The temptation to parallelize everything resulted in nested parallel streams. While seemingly faster for small datasets, this approach caused significant context switching and memory pressure at scale, leading to unpredictable performance and high latency variance. The solution was to parallelize only at the outermost layer where the most impact could be achieved.

  2. The flatMap Surprise: A subsequent attempt to use parallel streams within a flatMap operation failed silently. The OpenJDK implementation converts streams passed to flatMap to sequential, nullifying any parallelism. This required restructuring the code to apply parallelism at a higher level.

  3. Thread Context Loss: In environments like Netflix, where request context (authentication, tracing) is critical, parallel streams proved problematic. The ForkJoinPool threads used by parallel streams do not inherit the parent thread's context, leaving operations like logging or authentication broken.

The Asynchronous Maze: Executors and CompletableFutures

With the problem being fundamentally IO-bound, the focus shifted to non-blocking execution using ExecutorService and CompletableFuture. The initial naive implementation of firing off requests and processing responses asynchronously quickly led to an Out of Memory (OOM) error. The issue was a classic backpressure failure: the rate of response object creation vastly outpaced the rate of processing, causing the task queue to balloon and consume all available heap space.

This led to a series of increasingly complex solutions:

  • Splitting Thread Pools: Separate executors for requests and responses were introduced to prevent the response queue from starving request processing.
  • Rate Limiting and Semaphores: To avoid overwhelming the downstream gRPC service (effectively DDoSing it), a rate limiter was added to cap requests at 3,000 RPS. However, this created a new problem: the upstream task queue began to overflow again. A semaphore was then added upstream to limit the number of in-flight tasks, creating a multi-layered control system.

This configuration worked, but it was operationally complex. It required managing multiple thread pools, semaphores, and rate limiters, creating a fragile system that was difficult to reason about and debug.

The Promise and Peril of Virtual Threads

Java 21's virtual threads offered a path to simplify this architecture. By enabling them in Spring (spring.threads.virtual.enabled=true), the code could theoretically be simplified by removing the manual management of thread pools. However, this introduced new challenges:

  1. The Pinning Problem: Early testing on Java 21 revealed that virtual threads could become "pinned" to their carrier threads during synchronized block operations, causing application stalls. This was a known issue in Java 21, fixed in later versions like JDK 24.

  2. The Greediness Problem: Just as with parallel streams, a naive implementation of virtual threads—creating them for both order processing and individual gRPC calls—led to another OOM. The sheer volume of lightweight threads (tens of thousands) still created significant memory pressure.

The final, elegant solution emerged from understanding the underlying constraints. By analyzing metrics, it became clear that the downstream service could only handle about 50 concurrent requests. The solution was to simplify the architecture dramatically:

  • Remove nested virtual threads. Instead of creating a virtual thread for every gRPC call, the code was structured to run the entire order processing logic sequentially within a single virtual thread.
  • Use a single semaphore. A simple semaphore with a permit of 50 was placed at the order processing level.

This final architecture achieved the same performance as the complex bounded-executor setup (around 12 minutes execution time) but with vastly simpler code. It provided smooth, predictable traffic to the downstream service (averaging ~5K RPS) and was far easier to maintain.

Java Concurrency from the Trenches: Lessons Learned in the Wild - InfoQ

Key Takeaways

  1. Measure, Don't Guess: Concurrency is not a place for intuition. Every change must be benchmarked and monitored. Simple side-by-side comparisons of metrics can reveal more than complex load testing frameworks.

  2. Work Backwards from the Constraint: The most effective strategy is to identify the most fragile resource—be it a downstream service, memory, or CPU—and design your system to protect it. In this case, the downstream service's capacity defined the entire system's behavior.

  3. Simplicity is the Ultimate Sophistication: The journey from a simple sequential script to a complex asynchronous web and back to a simple, controlled design highlights a crucial lesson. While concurrency tools like virtual threads are powerful, they don't eliminate the need for fundamental backpressure mechanisms. The goal is to use them to build simpler, more robust systems, not just more complex ones.

For those interested in the technical deep dive, the full presentation and transcript are available on InfoQ.

Comments

Loading comments...