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
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<>();Always Clean Up ThreadLocal
threadLocal.set(value); try { doWork(); } finally { threadLocal.remove(); // Critical! }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; } }Minimize Scope Lifetime
// Good - narrow scope ScopedValue.where(VALUE, data) .run(() -> quickOperation()); // Avoid - long-lived scope ScopedValue.where(VALUE, data) .run(() -> longRunningOperation()); // Keep scope shortDocument 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 // ... }