8.3 Thread-Local Variables and Scoped Values

Thread-local variables behave differently with virtual threads due to their massive scale. Scoped values provide a modern alternative designed specifically for virtual threads.

Thread-Local Variables

Traditional Usage:

public class RequestContext {
    private static final ThreadLocal<String> userId = new ThreadLocal<>();

    public static void setUserId(String id) {
        userId.set(id);
    }

    public static String getUserId() {
        return userId.get();
    }

    public static void clear() {
        userId.remove();  // Important: prevent leaks
    }
}

// Usage in platform thread world
public void handleRequest(Request request) {
    RequestContext.setUserId(request.userId());
    try {
        processRequest(request);
    } finally {
        RequestContext.clear();  // Must clean up
    }
}

Problems with ThreadLocal and Virtual Threads

Problem 1: Memory Overhead

// Platform threads: 200 threads = 200 ThreadLocal instances
ExecutorService platform = Executors.newFixedThreadPool(200);

// Virtual threads: 1,000,000 threads = 1,000,000 ThreadLocal instances!
try (var virtual = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        virtual.submit(() -> {
            // Each virtual thread gets its own ThreadLocal copy
            RequestContext.setUserId("user-" + i);
            // Memory usage multiplies by number of threads!
        });
    }
}

Problem 2: Inheritance Overhead

private static final ThreadLocal<Map<String, String>> context = 
    new ThreadLocal<>() {
        @Override
        protected Map<String, String> initialValue() {
            return new HashMap<>();  // Created for EACH thread
        }
    };

// With 1 million virtual threads:
// 1 million HashMap instances created!

Problem 3: Cleanup Complexity

public class ServiceWithThreadLocal {
    private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();

    public void processRequest() {
        Connection conn = connectionPool.getConnection();
        connectionHolder.set(conn);

        try {
            // Process request
            doWork();
        } finally {
            // Easy to forget cleanup
            connectionHolder.remove();
            conn.close();
        }
    }
}

When ThreadLocal is Still Acceptable

Short-Lived Context:

// OK: ThreadLocal for duration of single request
public class RequestProcessor {
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    public void handleRequest(Request request) {
        context.set(new RequestContext(request));
        try {
            process();
        } finally {
            context.remove();  // Clean up immediately
        }
    }
}

Caching Expensive Objects:

// OK: Reusing expensive objects like formatters
private static final ThreadLocal<DateTimeFormatter> formatter = 
    ThreadLocal.withInitial(() -> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

public String formatDate(Instant instant) {
    return formatter.get().format(instant.atZone(ZoneId.systemDefault()));
}

Scoped Values (Preview - Java 21+)

Introduction: Scoped values are designed for virtual threads, providing:

  • Immutable per-thread data
  • Automatic cleanup (no memory leaks)
  • Better performance
  • Bounded lifetime

Basic Usage:

import jdk.incubator.concurrent.ScopedValue;

public class RequestProcessor {
    // Define scoped value
    private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

    public void handleRequest(Request request) {
        // Bind value for scope of lambda
        ScopedValue.where(USER_ID, request.userId())
            .run(() -> {
                // Value available in this scope
                processRequest();
            });
        // Value automatically cleared after lambda
    }

    private void processRequest() {
        String userId = USER_ID.get();  // Access value
        System.out.println("Processing for user: " + userId);

        // Call other methods - value still available
        validateUser(userId);
        fetchUserData();
    }

    private void fetchUserData() {
        // Value available in nested calls
        String userId = USER_ID.get();
        System.out.println("Fetching data for: " + userId);
    }
}

Multiple Values:

public class MultiValueContext {
    private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    public void handleRequest(Request request) {
        // Bind multiple values
        ScopedValue.where(USER_ID, request.userId())
            .where(REQUEST_ID, request.requestId())
            .where(TRACE_ID, generateTraceId())
            .run(() -> {
                // All three values available
                processRequest();
            });
    }

    private void processRequest() {
        String userId = USER_ID.get();
        String requestId = REQUEST_ID.get();
        String traceId = TRACE_ID.get();

        System.out.printf("User: %s, Request: %s, Trace: %s%n", 
            userId, requestId, traceId);
    }
}

Returning Values:

public class ScopedValueWithReturn {
    private static final ScopedValue<DatabaseConnection> DB = ScopedValue.newInstance();

    public User fetchUser(String userId) {
        DatabaseConnection conn = connectionPool.getConnection();

        // Use call() to return a value
        return ScopedValue.where(DB, conn)
            .call(() -> {
                // Query using scoped connection
                return DB.get().query("SELECT * FROM users WHERE id = ?", userId);
            });
        // Connection automatically out of scope
    }
}

Nested Scopes:

public class NestedScopes {
    private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();

    public void outerOperation() {
        ScopedValue.where(CONTEXT, "outer")
            .run(() -> {
                System.out.println(CONTEXT.get());  // "outer"

                innerOperation();

                System.out.println(CONTEXT.get());  // Still "outer"
            });
    }

    private void innerOperation() {
        // Can rebind in nested scope
        ScopedValue.where(CONTEXT, "inner")
            .run(() -> {
                System.out.println(CONTEXT.get());  // "inner"
            });
        // Reverts to "outer" after inner scope
    }
}

ThreadLocal vs Scoped Values Comparison

Feature ThreadLocal ScopedValue
Mutability Mutable Immutable
Cleanup Manual (remove()) Automatic
Inheritance Complex Simple
Memory High with many threads Lower
Performance Slower lookup Faster lookup
Scope Unbounded Bounded
Safety Error-prone Safe

Migrating from ThreadLocal to ScopedValue

Before (ThreadLocal):

public class OldRequestProcessor {
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    public void handleRequest(Request request) {
        RequestContext ctx = new RequestContext(
            request.userId(),
            request.requestId(),
            Instant.now()
        );

        context.set(ctx);
        try {
            processRequest();
        } finally {
            context.remove();  // Easy to forget!
        }
    }

    private void processRequest() {
        RequestContext ctx = context.get();
        logger.info("Processing request {} for user {}", 
            ctx.requestId(), ctx.userId());
    }
}

After (ScopedValue):

public class NewRequestProcessor {
    private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

    public void handleRequest(Request request) {
        RequestContext ctx = new RequestContext(
            request.userId(),
            request.requestId(),
            Instant.now()
        );

        ScopedValue.where(CONTEXT, ctx)
            .run(() -> {
                processRequest();
            });
        // Automatic cleanup - no finally needed!
    }

    private void processRequest() {
        RequestContext ctx = CONTEXT.get();
        logger.info("Processing request {} for user {}", 
            ctx.requestId(), ctx.userId());
    }
}

Real-World Example: Web Request Processing

import jdk.incubator.concurrent.ScopedValue;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;

public class WebRequestProcessor {
    // Define scoped values for request context
    private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
    private static final ScopedValue<Instant> REQUEST_TIME = ScopedValue.newInstance();

    private final DatabaseService database;
    private final CacheService cache;
    private final EmailService email;

    public WebRequestProcessor(DatabaseService database, CacheService cache, EmailService email) {
        this.database = database;
        this.cache = cache;
        this.email = email;
    }

    // Main request handler
    public Response handleRequest(HttpRequest request) {
        // Set up request context
        String userId = extractUserId(request);
        String requestId = UUID.randomUUID().toString();
        String traceId = generateTraceId();

        return ScopedValue.where(USER_ID, userId)
            .where(REQUEST_ID, requestId)
            .where(TRACE_ID, traceId)
            .where(REQUEST_TIME, Instant.now())
            .call(() -> {
                // All context available throughout processing
                logRequestStart();

                try {
                    // Process the request
                    Response response = processRequest(request);

                    logRequestSuccess();
                    return response;

                } catch (Exception e) {
                    logRequestFailure(e);
                    return Response.error(e.getMessage());
                }
            });
        // All scoped values automatically cleaned up
    }

    private Response processRequest(HttpRequest request) {
        // Context available without passing parameters
        String action = request.getParameter("action");

        return switch (action) {
            case "getProfile" -> getProfile();
            case "updateProfile" -> updateProfile(request);
            case "sendEmail" -> sendEmail(request);
            default -> Response.error("Unknown action");
        };
    }

    private Response getProfile() {
        // Check cache first
        User user = cache.get("user:" + USER_ID.get());

        if (user == null) {
            // Fetch from database
            user = database.fetchUser(USER_ID.get());
            cache.put("user:" + USER_ID.get(), user);
        }

        return Response.ok(user);
    }

    private Response updateProfile(HttpRequest request) {
        String newName = request.getParameter("name");

        // Update database
        database.updateUser(USER_ID.get(), newName);

        // Invalidate cache
        cache.remove("user:" + USER_ID.get());

        // Send notification
        notifyProfileUpdate(newName);

        return Response.ok("Profile updated");
    }

    private Response sendEmail(HttpRequest request) {
        String message = request.getParameter("message");

        // Email service has access to context
        email.send(USER_ID.get(), message);

        return Response.ok("Email sent");
    }

    private void notifyProfileUpdate(String newName) {
        // Can spawn subtasks - context inherited
        Thread.startVirtualThread(() -> {
            // Scoped values available in child virtual thread
            String userId = USER_ID.get();
            String traceId = TRACE_ID.get();

            System.out.printf("[%s] Notifying user %s of profile update: %s%n",
                traceId, userId, newName);

            email.send(userId, "Your profile has been updated");
        });
    }

    // Logging methods - context available
    private void logRequestStart() {
        System.out.printf("[%s] Request %s started for user %s at %s%n",
            TRACE_ID.get(), REQUEST_ID.get(), USER_ID.get(), REQUEST_TIME.get());
    }

    private void logRequestSuccess() {
        Duration duration = Duration.between(REQUEST_TIME.get(), Instant.now());
        System.out.printf("[%s] Request %s succeeded in %dms%n",
            TRACE_ID.get(), REQUEST_ID.get(), duration.toMillis());
    }

    private void logRequestFailure(Exception e) {
        Duration duration = Duration.between(REQUEST_TIME.get(), Instant.now());
        System.out.printf("[%s] Request %s failed after %dms: %s%n",
            TRACE_ID.get(), REQUEST_ID.get(), duration.toMillis(), e.getMessage());
    }

    private String extractUserId(HttpRequest request) {
        return request.getHeader("Authorization").replace("Bearer ", "");
    }

    private String generateTraceId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }

    // Supporting classes
    static class HttpRequest {
        private final Map<String, String> parameters = new HashMap<>();
        private final Map<String, String> headers = new HashMap<>();

        public String getParameter(String name) {
            return parameters.get(name);
        }

        public String getHeader(String name) {
            return headers.get(name);
        }

        public void setParameter(String name, String value) {
            parameters.put(name, value);
        }

        public void setHeader(String name, String value) {
            headers.put(name, value);
        }
    }

    record Response(boolean success, Object data) {
        static Response ok(Object data) {
            return new Response(true, data);
        }

        static Response error(String message) {
            return new Response(false, message);
        }
    }

    record User(String id, String name, String email) {}

    // Service interfaces
    interface DatabaseService {
        User fetchUser(String userId);
        void updateUser(String userId, String newName);
    }

    interface CacheService {
        User get(String key);
        void put(String key, User user);
        void remove(String key);
    }

    interface EmailService {
        void send(String userId, String message);
    }
}

Scoped Values with Structured Concurrency

import jdk.incubator.concurrent.ScopedValue;
import jdk.incubator.concurrent.StructuredTaskScope;

public class StructuredConcurrencyWithScopedValues {
    private static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();

    public Result processWithMultipleServices(String correlationId) throws Exception {
        return ScopedValue.where(CORRELATION_ID, correlationId)
            .call(() -> {
                try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                    // All subtasks inherit scoped values
                    var userTask = scope.fork(() -> fetchUser());
                    var ordersTask = scope.fork(() -> fetchOrders());
                    var preferencesTask = scope.fork(() -> fetchPreferences());

                    scope.join();
                    scope.throwIfFailed();

                    return new Result(
                        userTask.get(),
                        ordersTask.get(),
                        preferencesTask.get()
                    );
                }
            });
    }

    private User fetchUser() {
        // Correlation ID available in subtask
        String corrId = CORRELATION_ID.get();
        System.out.println("[" + corrId + "] Fetching user");
        return new User("user-1", "John");
    }

    private List<Order> fetchOrders() {
        String corrId = CORRELATION_ID.get();
        System.out.println("[" + corrId + "] Fetching orders");
        return List.of(new Order("order-1"));
    }

    private Preferences fetchPreferences() {
        String corrId = CORRELATION_ID.get();
        System.out.println("[" + corrId + "] Fetching preferences");
        return new Preferences("theme", "dark");
    }

    record Result(User user, List<Order> orders, Preferences preferences) {}
    record User(String id, String name) {}
    record Order(String id) {}
    record Preferences(String key, String value) {}
}

Performance Comparison

import java.util.concurrent.*;

public class PerformanceComparison {
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();
    private static final ScopedValue<String> scopedValue = ScopedValue.newInstance();

    public static void main(String[] args) throws Exception {
        int numThreads = 100_000;

        System.out.println("ThreadLocal benchmark:");
        benchmarkThreadLocal(numThreads);

        System.out.println("\nScopedValue benchmark:");
        benchmarkScopedValue(numThreads);
    }

    private static void benchmarkThreadLocal(int numThreads) throws Exception {
        long start = System.currentTimeMillis();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();

            for (int i = 0; i < numThreads; i++) {
                final String value = "value-" + i;
                futures.add(executor.submit(() -> {
                    threadLocal.set(value);
                    try {
                        doWork(threadLocal.get());
                    } finally {
                        threadLocal.remove();
                    }
                }));
            }

            for (Future<?> future : futures) {
                future.get();
            }
        }

        long duration = System.currentTimeMillis() - start;
        System.out.println("Time: " + duration + "ms");
    }

    private static void benchmarkScopedValue(int numThreads) throws Exception {
        long start = System.currentTimeMillis();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();

            for (int i = 0; i < numThreads; i++) {
                final String value = "value-" + i;
                futures.add(executor.submit(() -> {
                    ScopedValue.where(scopedValue, value)
                        .run(() -> doWork(scopedValue.get()));
                }));
            }

            for (Future<?> future : futures) {
                future.get();
            }
        }

        long duration = System.currentTimeMillis() - start;
        System.out.println("Time: " + duration + "ms");
    }

    private static void doWork(String value) {
        // Simulate work
        value.hashCode();
    }
}

// Typical results:
// ThreadLocal: ~2000ms, higher memory
// ScopedValue: ~1500ms, lower memory

Best Practices

  1. Prefer ScopedValue for New Code

    // New code
    private static final ScopedValue<Context> CONTEXT = ScopedValue.newInstance();
    
    // Legacy code - gradually migrate
    private static final ThreadLocal<Context> context = new ThreadLocal<>();
    
  2. Always Clean Up ThreadLocal

    threadLocal.set(value);
    try {
        doWork();
    } finally {
        threadLocal.remove();  // Critical!
    }
    
  3. Use Immutable Values with ScopedValue

    // Good - immutable record
    record Context(String userId, String requestId) {}
    private static final ScopedValue<Context> CONTEXT = ScopedValue.newInstance();
    
    // Avoid - mutable objects
    class MutableContext {
        private String userId;  // Can be changed
        public void setUserId(String id) { this.userId = id; }
    }
    
  4. Minimize Scope Lifetime

    // Good - narrow scope
    ScopedValue.where(VALUE, data)
        .run(() -> quickOperation());
    
    // Avoid - long-lived scope
    ScopedValue.where(VALUE, data)
        .run(() -> longRunningOperation());  // Keep scope short
    
  5. Document Scoped Value Dependencies

    /**
     * Requires USER_ID scoped value to be bound.
     * @throws NoSuchElementException if USER_ID not bound
     */
    public void processUserRequest() {
        String userId = USER_ID.get();  // Document dependency
        // ...
    }