codeWithYoha logo
Code with Yoha
HomeArticlesAboutContact
Java 24

Java 24 Scoped Values & Structured Concurrency: Modern Thread Safety in High-Throughput Systems

CodeWithYoha
CodeWithYoha
17 min read
Java 24 Scoped Values & Structured Concurrency: Modern Thread Safety in High-Throughput Systems

Introduction

Concurrent programming in Java has always been a double-edged sword: powerful for leveraging multi-core processors and handling high-throughput operations, yet notoriously complex and prone to subtle bugs. Traditional mechanisms like ThreadLocal for context propagation and raw ExecutorService for task management often introduce boilerplate, memory leaks, and make reasoning about thread lifetimes and error handling incredibly difficult.

Java 24, building on the groundbreaking work of Project Loom, introduces two pivotal features that aim to revolutionize modern thread safety and concurrency: Scoped Values (JEP 446) and Structured Concurrency (JEP 453). These features, designed to work seamlessly with virtual threads, offer a safer, more performant, and significantly more intuitive way to manage shared data and concurrent tasks in high-throughput systems.

This comprehensive guide will dive deep into Scoped Values and Structured Concurrency, explaining their purpose, demonstrating their usage with practical examples, outlining best practices, and discussing how they collectively address the long-standing challenges of concurrent programming in Java.

Prerequisites

To get the most out of this article and the code examples, you should have:

  • A solid understanding of core Java concepts, including lambdas and basic concurrency (Runnable, Callable, ExecutorService).
  • Familiarity with the concept of threads and thread pools.
  • Java Development Kit (JDK) 24 or a recent early-access build that includes JEP 446 and JEP 453. You can typically download these from the official OpenJDK website.

Understanding the Problem: Traditional Concurrency Challenges

Before we introduce the solutions, let's briefly recap the pain points these new features address:

ThreadLocal's Limitations

ThreadLocal has been the go-to mechanism for propagating context-specific data (like a request ID, security principal, or transaction context) through a call chain without explicitly passing it as method arguments. However, it comes with several significant drawbacks:

  • Memory Leaks: If ThreadLocal values are not explicitly removed (remove()) at the end of a task, especially in thread pool environments, they can lead to memory leaks as the pooled thread might retain stale data.
  • Inheritance Issues: InheritableThreadLocal exists, but it's often misunderstood. It copies values only when a child thread is created, not when tasks are submitted to an ExecutorService where underlying threads are reused.
  • Complexity with Virtual Threads: While ThreadLocal works with virtual threads, its performance characteristics and memory overhead can be problematic at Loom's scale (millions of virtual threads).
  • Mutability: ThreadLocal values are mutable by default, leading to potential accidental modifications and hard-to-debug state issues across different parts of an application.

Unstructured Concurrency

Traditional Java concurrency often involves launching tasks into an ExecutorService and managing their Future objects manually. This "unstructured" approach leads to:

  • Difficult Error Handling: If one of several concurrently running tasks fails, propagating that failure to the main task and canceling others is cumbersome.
  • Complex Cancellation: Canceling a group of related tasks when one fails or when the main task is no longer needed requires explicit, often error-prone, management.
  • Resource Leaks: Ensuring all child threads/tasks complete or are properly shut down before the parent task finishes is challenging, potentially leading to resource leaks or hanging processes.
  • Readability: Reasoning about the lifecycle and dependencies of concurrent tasks becomes difficult, impacting code readability and maintainability.

Introducing Scoped Values (JEP 446)

Scoped Values are a new mechanism for sharing immutable data within a thread and with any child threads or tasks launched from that thread. They are designed as a safer, more performant, and more manageable alternative to ThreadLocal for context propagation.

Key Characteristics:

  • Immutability: Once a Scoped Value is bound using ScopedValue.where(), its value cannot be changed within that scope. This eliminates accidental modifications and makes reasoning about data flow much easier.
  • Scoped Lifetime: A Scoped Value's binding is active only for the duration of a specific code block (the scope defined by where()). Once the block exits, the binding is automatically removed.
  • Automatic Inheritance: Unlike ThreadLocal, Scoped Values are designed to work naturally with structured concurrency and virtual threads. Bindings are automatically available to any tasks (virtual threads or platform threads) spawned within their scope.
  • Performance: Optimized for virtual threads, Scoped Values offer better performance and lower memory overhead compared to ThreadLocal when dealing with a massive number of threads.

Basic Usage:

java
import java.util.concurrent.ScopedValue;

public class ScopedValueExample {

    // Declare a ScopedValue instance. It's typically static final.
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        System.out.println("Main thread - Before binding: " + REQUEST_ID.orElse("N/A"));

        // Bind a value to REQUEST_ID for a specific scope
        ScopedValue.where(REQUEST_ID, "req-12345")
                   .run(() -> {
                       System.out.println("Inside scope - Current REQUEST_ID: " + REQUEST_ID.get());
                       
                       // Any method called within this lambda will see the bound value
                       processRequest();

                       // Even a new virtual thread launched here will inherit the value
                       Thread.ofVirtual().start(() -> {
                           System.out.println("Virtual thread - Inherited REQUEST_ID: " + REQUEST_ID.get());
                       }).join(); // Wait for the virtual thread to complete
                   });

        System.out.println("Main thread - After scope: " + REQUEST_ID.orElse("N/A"));
    }

    public static void processRequest() {
        System.out.println("Processing request - Accessing REQUEST_ID: " + REQUEST_ID.get());
        // Further logic that might call other methods, all of which can access REQUEST_ID
    }
}

In this example, REQUEST_ID is bound to "req-12345" only within the run() lambda. Methods called within this scope, and even new virtual threads launched within it, automatically inherit and can access this value. Once the run() block completes, the binding is automatically undone, preventing leaks.

Scoped Values in Action: A Practical Example

Let's consider a web application where each incoming request needs a unique ID for logging and tracing across various service calls. Traditionally, this would involve ThreadLocal or passing the ID through numerous method signatures.

java
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScopedValue;
import java.util.concurrent.TimeUnit;

public class WebRequestProcessor {

    // ScopedValue to hold the request ID for the current call chain
    private static final ScopedValue<String> CURRENT_REQUEST_ID = ScopedValue.newInstance();

    public static void main(String[] args) throws InterruptedException {
        System.out.println("Starting web server simulation...");

        // Simulate incoming web requests
        for (int i = 0; i < 3; i++) {
            String requestId = "REQ-" + UUID.randomUUID().toString().substring(0, 8);
            
            // Each request is processed in its own scope
            ScopedValue.where(CURRENT_REQUEST_ID, requestId)
                       .run(() -> {
                           System.out.println("\n--- Processing new request: " + CURRENT_REQUEST_ID.get() + " ---");
                           handleIncomingRequest();
                       });
            Thread.sleep(100); // Simulate some delay between requests
        }

        System.out.println("\nServer simulation finished.");
    }

    // Simulates the top-level handler for an incoming request
    private static void handleIncomingRequest() {
        log("Received request.");
        authenticateUser();
        fetchDataFromDatabase();
        callExternalService();
        log("Request processing complete.");
    }

    private static void authenticateUser() {
        log("Authenticating user...");
        // Logic for authentication. No need to pass request ID explicitly.
    }

    private static void fetchDataFromDatabase() {
        log("Fetching data from DB...");
        // Simulate a database call, potentially in a new thread/task
        Thread.ofVirtual().start(() -> {
            log("DB thread: Querying for request " + CURRENT_REQUEST_ID.get());
            try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            log("DB thread: Data fetched.");
        }).join();
    }

    private static void callExternalService() {
        log("Calling external service...");
        // Simulate an external API call, potentially in a new thread/task
        Thread.ofVirtual().start(() -> {
            log("External service thread: Sending request " + CURRENT_REQUEST_ID.get());
            try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            log("External service thread: Response received.");
        }).join();
    }

    // Centralized logging method that automatically includes the request ID
    private static void log(String message) {
        // Use orElse("N/A") for cases where CURRENT_REQUEST_ID might not be bound (e.g., initial setup)
        String requestId = CURRENT_REQUEST_ID.orElse("GLOBAL"); 
        System.out.println(String.format("[%s] [%s] %s", Thread.currentThread().getName(), requestId, message));
    }
}

This example clearly shows how CURRENT_REQUEST_ID is automatically available across different methods and even new virtual threads launched within the where().run() scope, simplifying context propagation significantly.

Benefits of Scoped Values

  • Eliminates Boilerplate: No more passing context objects through every method signature or managing ThreadLocal cleanup.
  • Enhanced Safety: Immutability by design prevents accidental modification of context data, making your application more robust.
  • Automatic Lifecycle Management: The where() method ensures that bindings are automatically established and removed, preventing memory leaks and stale data issues.
  • Virtual Thread Friendly: Designed from the ground up to work efficiently with Project Loom's virtual threads, offering superior performance and scalability compared to ThreadLocal in high-concurrency scenarios.
  • Improved Readability: Code that relies on implicit context is often cleaner and easier to understand, as long as the context's existence is well-documented and predictable.

Introducing Structured Concurrency (JEP 453)

Structured Concurrency is a paradigm that treats a group of related concurrent tasks as a single unit of work. It brings the benefits of structured programming (like if-else, for loops, try-catch) to concurrency, making concurrent code easier to reason about, debug, and cancel.

Key Concepts:

  • StructuredTaskScope: The core API, analogous to try-with-resources. It ensures that all tasks forked within its scope complete or are explicitly canceled before the scope exits.
  • Child Tasks: Tasks launched within a StructuredTaskScope are considered child tasks of that scope. Their lifetimes are bound to the parent scope.
  • Unified Error Handling: If any child task within a StructuredTaskScope fails, the scope can be configured to automatically shut down and propagate the exception to the parent.
  • Unified Cancellation: If the parent task is canceled or fails, all child tasks within its scope are automatically canceled.
  • ShutdownOnFailure / ShutdownOnSuccess: Two concrete implementations of StructuredTaskScope that define its behavior upon task completion or failure.
    • ShutdownOnFailure: If any subtask fails, all other running subtasks are canceled, and the scope's join() or throwIfFailed() will rethrow the first exception.
    • ShutdownOnSuccess: The scope completes as soon as any subtask successfully completes, canceling all other subtasks.

Basic Usage:

java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;

public class StructuredConcurrencyExample {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        // Using ShutdownOnFailure: if any task fails, others are cancelled and exception is propagated
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Fork tasks. These are effectively virtual threads.
            StructuredTaskScope.Subtask<String> task1 = scope.fork(() -> {
                System.out.println("Task 1 starting...");
                Thread.sleep(100); // Simulate work
                if (Math.random() < 0.2) { // Simulate occasional failure
                    throw new RuntimeException("Task 1 failed!");
                }
                System.out.println("Task 1 completed.");
                return "Result A";
            });

            StructuredTaskScope.Subtask<String> task2 = scope.fork(() -> {
                System.out.println("Task 2 starting...");
                Thread.sleep(200); // Simulate more work
                System.out.println("Task 2 completed.");
                return "Result B";
            });

            // Wait for all forked tasks to complete (or for one to fail in ShutdownOnFailure)
            scope.join(); 
            // If any task failed, throwIfFailed() will rethrow the exception
            scope.throwIfFailed(); 

            // Retrieve results only after all tasks have successfully completed
            String result1 = task1.get();
            String result2 = task2.get();

            System.out.println("All tasks completed successfully. Results: " + result1 + ", " + result2);

        } catch (InterruptedException e) {
            System.err.println("Main thread interrupted: " + e.getMessage());
            Thread.currentThread().interrupt();
        } catch (ExecutionException e) {
            System.err.println("One or more tasks failed: " + e.getCause().getMessage());
        }
    }
}

This code demonstrates how StructuredTaskScope manages the lifecycle of task1 and task2. The try-with-resources block ensures the scope is properly closed. scope.join() waits for all tasks to complete, and scope.throwIfFailed() propagates any exceptions from child tasks, turning complex error handling into a simple try-catch block.

Structured Concurrency in Action: Concurrent API Calls

Imagine a scenario where you need to fetch user details and their recent orders from two different microservices concurrently. If either call fails, you want to abort the entire operation and report the error.

java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;

public class UserOrderAggregator {

    record User(String id, String name) {}
    record Order(String orderId, String item) {}
    record UserOrderSummary(User user, java.util.List<Order> orders) {}

    public static void main(String[] args) {
        String userId = "user-abc";
        try {
            UserOrderSummary summary = fetchUserAndOrders(userId);
            System.out.println("\nSuccessfully aggregated data for " + summary.user().name() + ":");
            System.out.println("User: " + summary.user());
            System.out.println("Orders: " + summary.orders());
        } catch (Exception e) {
            System.err.println("\nFailed to aggregate data: " + e.getMessage());
            if (e.getCause() != null) {
                System.err.println("Caused by: " + e.getCause().getMessage());
            }
        }

        // Example with a simulated failure
        System.out.println("\n--- Simulating a failure ---");
        try {
            fetchUserAndOrders("user-fail");
        } catch (Exception e) {
            System.err.println("\nFailed to aggregate data (expected failure): " + e.getMessage());
            if (e.getCause() != null) {
                System.err.println("Caused by: " + e.getCause().getMessage());
            }
        }
    }

    public static UserOrderSummary fetchUserAndOrders(String userId) throws InterruptedException, ExecutionException {
        // Use ShutdownOnFailure: if either task fails, the other is cancelled and the exception is propagated.
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Task to fetch user details
            StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> {
                System.out.println("Fetching user details for " + userId + "...");
                Thread.sleep(150); // Simulate network delay
                if (userId.equals("user-fail")) {
                    throw new RuntimeException("User service unavailable!");
                }
                return new User(userId, "John Doe");
            });

            // Task to fetch user orders
            StructuredTaskScope.Subtask<java.util.List<Order>> ordersTask = scope.fork(() -> {
                System.out.println("Fetching orders for " + userId + "...");
                Thread.sleep(200); // Simulate network delay
                return java.util.List.of(
                    new Order("ORD-001", "Laptop"),
                    new Order("ORD-002", "Mouse")
                );
            });

            // Wait for both tasks to complete successfully or for one to fail.
            scope.join();
            // Propagate any exceptions from failed tasks.
            scope.throwIfFailed();

            // If we reach here, both tasks completed successfully.
            return new UserOrderSummary(userTask.get(), ordersTask.get());

        } 
    }
}

This example elegantly handles concurrent API calls. If userTask fails, ordersTask is automatically canceled, and the exception is propagated to the main method, simplifying error management significantly.

Combining Scoped Values and Structured Concurrency

Scoped Values and Structured Concurrency are designed to be used together. Scoped Values provide a clean way to propagate context through a tree of tasks, while Structured Concurrency provides the framework for managing that tree of tasks.

Let's extend our web request example to include structured concurrency for internal service calls.

java
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScopedValue;
import java.util.concurrent.StructuredTaskScope;

public class CombinedExample {

    private static final ScopedValue<String> CURRENT_REQUEST_ID = ScopedValue.newInstance();

    record UserProfile(String userId, String username, String email) {}
    record UserPreferences(String userId, String theme, boolean notifications) {}

    public static void main(String[] args) throws InterruptedException {
        String requestId = "REQ-" + UUID.randomUUID().toString().substring(0, 8);

        ScopedValue.where(CURRENT_REQUEST_ID, requestId)
                   .run(() -> {
                       log("Starting request processing with ID: " + CURRENT_REQUEST_ID.get());
                       try {
                           UserProfile profile = getUserProfileWithPreferences();
                           log("Successfully fetched profile for " + profile.username());
                       } catch (Exception e) {
                           log("Failed to fetch user profile: " + e.getCause().getMessage());
                       }
                   });

        Thread.sleep(100); // Give threads time to finish

        String anotherRequestId = "REQ-" + UUID.randomUUID().toString().substring(0, 8);
        ScopedValue.where(CURRENT_REQUEST_ID, anotherRequestId)
                   .run(() -> {
                       log("Starting another request with ID: " + CURRENT_REQUEST_ID.get());
                       try {
                           // Simulate a failure in one of the subtasks
                           getUserProfileWithPreferences("user-with-broken-prefs");
                       } catch (Exception e) {
                           log("Failed to fetch user profile (expected failure): " + e.getCause().getMessage());
                       }
                   });
    }

    public static UserProfile getUserProfileWithPreferences() throws InterruptedException, ExecutionException {
        return getUserProfileWithPreferences("user-123"); // Default user
    }

    public static UserProfile getUserProfileWithPreferences(String userId) throws InterruptedException, ExecutionException {
        // Use ShutdownOnFailure to ensure if preferences fail, profile fetch is cancelled
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Task to fetch user profile, which needs CURRENT_REQUEST_ID
            StructuredTaskScope.Subtask<UserProfile> profileTask = scope.fork(() -> {
                log("Fetching user profile for " + userId);
                Thread.sleep(100);
                return new UserProfile(userId, "Alice", "alice@example.com");
            });

            // Task to fetch user preferences, also needs CURRENT_REQUEST_ID
            StructuredTaskScope.Subtask<UserPreferences> preferencesTask = scope.fork(() -> {
                log("Fetching user preferences for " + userId);
                Thread.sleep(150);
                if (userId.equals("user-with-broken-prefs")) {
                    throw new RuntimeException("Failed to load user preferences!");
                }
                return new UserPreferences(userId, "dark", true);
            });

            scope.join(); // Wait for both tasks
            scope.throwIfFailed(); // Propagate any failure

            // Both tasks completed, access their results
            UserProfile profile = profileTask.get();
            UserPreferences preferences = preferencesTask.get();

            log("Combined profile and preferences for " + profile.username() + ": " + preferences.theme());
            return profile; // For simplicity, we just return the profile here
        }
    }

    private static void log(String message) {
        String requestId = CURRENT_REQUEST_ID.orElse("N/A");
        System.out.println(String.format("[%s] [Request:%s] %s", Thread.currentThread().getName(), requestId, message));
    }
}

In this combined example, the CURRENT_REQUEST_ID is bound at the top level using ScopedValue.where(). Any tasks forked within the StructuredTaskScope (e.g., profileTask, preferencesTask) automatically inherit and can access this REQUEST_ID without it being explicitly passed. This makes the code clean, safe, and robust against failures.

Best Practices for Modern Thread Safety

  1. Prefer Scoped Values for Context Propagation: For immutable, request-scoped, or transaction-scoped data that needs to be propagated across a call chain, always choose ScopedValue over ThreadLocal. It's safer, more performant, and designed for virtual threads.
  2. Embrace Structured Concurrency: Whenever you have a group of related concurrent tasks that need to complete as a single logical unit (e.g., all succeed, or all fail together), use StructuredTaskScope. It drastically simplifies error handling, cancellation, and reasoning about task lifetimes.
  3. Design for Immutability: Scoped Values enforce immutability of the bound value. Extend this principle to your application's data models where possible to reduce complexity in concurrent environments.
  4. Understand StructuredTaskScope Implementations: Choose ShutdownOnFailure when all subtasks must succeed, and ShutdownOnSuccess when you only need one subtask to complete (e.g., race for the fastest result).
  5. Leverage Virtual Threads: Combine Scoped Values and Structured Concurrency with virtual threads (implicitly used by StructuredTaskScope.fork()) for high-throughput, I/O-bound operations. Virtual threads reduce the overhead of context switching and thread creation, allowing for millions of concurrent tasks.
  6. Clear Scope Definition: Always define the scope of a ScopedValue as narrowly as possible using where().run() or where().call(). Avoid broad, application-wide bindings unless absolutely necessary.
  7. Error Handling: Always include scope.join() and scope.throwIfFailed() (or scope.result()) within your StructuredTaskScope blocks to ensure proper error propagation and cleanup.

Common Pitfalls and Anti-Patterns

  1. Using ScopedValue for Mutable State: Scoped Values are for immutable data. Attempting to store mutable objects and then modifying them within the scope will lead to unexpected behavior and defeat the purpose of immutability. If you need mutable state, consider other concurrency primitives (e.g., AtomicReference, StampedLock).
  2. Forgetting join() or throwIfFailed(): Neglecting to call scope.join() or scope.throwIfFailed() within a StructuredTaskScope can lead to tasks not completing, exceptions being swallowed, or resource leaks. Always ensure the parent task waits for its children and handles their outcomes.
  3. Over-scoping ScopedValue: Binding a ScopedValue for an unnecessarily long duration can lead to confusion and potential for unintended data access. Keep the where() scope as tight as the logical unit of work that needs the value.
  4. Mixing ThreadLocal and ScopedValue for the same purpose: While they can coexist, using both for context propagation can lead to confusion and make debugging harder. For new code and context propagation, prefer ScopedValue.
  5. Ignoring InterruptedException: While Structured Concurrency simplifies cancellation, you still need to handle InterruptedException in your long-running tasks within the scope if they are interruptible. This allows for graceful shutdown when scope.shutdown() is called implicitly or explicitly.
  6. Blocking StructuredTaskScope: Avoid blocking operations in the parent thread of a StructuredTaskScope that are not part of the join() or throwIfFailed() calls, as this can negate the benefits of concurrency. The goal is to fork tasks and then await their collective completion.

Conclusion

Java 24's Scoped Values and Structured Concurrency represent a significant leap forward in Java's concurrency model. By providing safer, more intuitive, and highly performant mechanisms, they address many of the long-standing complexities associated with shared mutable state and task management in concurrent applications.

Scoped Values offer a robust alternative to ThreadLocal for propagating immutable context data, eliminating common pitfalls like memory leaks and making code cleaner and more reliable. Structured Concurrency, with StructuredTaskScope, brings a much-needed structure to concurrent task execution, simplifying error handling, cancellation, and making concurrent code as readable and maintainable as sequential code.

Together, these features empower developers to build high-throughput, resilient systems with greater confidence and less boilerplate, especially when combined with the scalability benefits of Project Loom's virtual threads. Embrace these modern tools to write more robust, understandable, and efficient concurrent Java applications.

It's time to move beyond the complexities of traditional concurrency and step into a new era of thread safety in Java.

CodewithYoha

Written by

CodewithYoha

Full-Stack Software Engineer with 7+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

Related Articles