PCSalt
YouTube GitHub
Back to Java
Java · 2 min read

Java 21 — Scoped Values — The ThreadLocal Replacement

Understand Java's Scoped Values — why they replace ThreadLocal, how they work with virtual threads, and practical patterns for request-scoped data.


ThreadLocal has been Java’s way to pass context without method parameters since Java 1.2. But with virtual threads (potentially millions of them), ThreadLocal becomes a problem — each virtual thread gets its own copy, consuming memory and creating confusion about lifecycle.

Scoped Values are the replacement. They’re immutable within a scope, automatically cleaned up, and designed for virtual threads.

The problem with ThreadLocal

static final ThreadLocal<User> currentUser = new ThreadLocal<>();

void handleRequest(User user) {
    currentUser.set(user);
    try {
        processRequest();
    } finally {
        currentUser.remove(); // easy to forget
    }
}

void processRequest() {
    User user = currentUser.get(); // works
    // ...
}

Issues:

  1. Mutable — any code can call set() and change the value
  2. Manual cleanup — forget remove() and you have a memory leak
  3. Thread pool reuse — if the thread is reused, the old value leaks to the next request
  4. Virtual thread overhead — millions of virtual threads × ThreadLocal storage = memory pressure

Scoped Values — The fix

import java.lang.ScopedValue;

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

void handleRequest(User user) {
    ScopedValue.runWhere(CURRENT_USER, user, () -> {
        processRequest();
    });
    // After runWhere completes, CURRENT_USER is automatically unbound
}

void processRequest() {
    User user = CURRENT_USER.get(); // works
    // CURRENT_USER.set(newUser); // COMPILE ERROR — no set method
}

Key differences:

  • Immutable within scope — no set() method, value can’t change
  • Automatic cleanup — when runWhere finishes, the binding is removed
  • Inherited by child threads — structured concurrency subtasks see the parent’s scoped values
  • No memory leak — no way to forget cleanup

Basic usage

Binding a value

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

void handleRequest(HttpServletRequest request) {
    String requestId = request.getHeader("X-Request-ID");

    ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
        // REQUEST_ID is bound here
        processRequest();
    });
    // REQUEST_ID is unbound here
}

Reading a value

void processRequest() {
    if (REQUEST_ID.isBound()) {
        String id = REQUEST_ID.get();
        logger.info("Processing request: {}", id);
    }
}

isBound() checks if the value is available in the current scope. get() throws NoSuchElementException if not bound.

With return value

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

String processWithResult(User user) throws Exception {
    return ScopedValue.callWhere(CURRENT_USER, user, () -> {
        return "Processed for " + CURRENT_USER.get().name();
    });
}

callWhere is like runWhere but returns a value.

Multiple bindings

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();

void handleRequest(User user, String tenantId) {
    ScopedValue
        .where(CURRENT_USER, user)
        .where(TENANT_ID, tenantId)
        .run(() -> {
            processRequest();
        });
}

With structured concurrency

Scoped values are inherited by StructuredTaskScope subtasks:

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

void handleRequest(String requestId) throws Exception {
    ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Both subtasks see REQUEST_ID
            var user = scope.fork(() -> {
                logger.info("Fetching user for request: {}", REQUEST_ID.get());
                return fetchUser();
            });
            var orders = scope.fork(() -> {
                logger.info("Fetching orders for request: {}", REQUEST_ID.get());
                return fetchOrders();
            });

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

Both forked tasks inherit the REQUEST_ID binding from the parent. No explicit passing needed.

Practical: Request context

public record RequestContext(
    String requestId,
    String userId,
    String tenantId,
    Instant startTime
) {}

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

// In a servlet filter or Spring interceptor
@Component
public class RequestContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        HttpServletRequest httpRequest = (HttpServletRequest) request;

        RequestContext context = new RequestContext(
            UUID.randomUUID().toString(),
            httpRequest.getHeader("X-User-ID"),
            httpRequest.getHeader("X-Tenant-ID"),
            Instant.now()
        );

        ScopedValue.runWhere(RequestScope.CONTEXT, context, () -> {
            chain.doFilter(request, response);
        });
    }
}

// Anywhere in the request processing
void processOrder() {
    RequestContext ctx = RequestScope.CONTEXT.get();
    logger.info("[{}] Processing order for tenant {}", ctx.requestId(), ctx.tenantId());
}

Practical: Audit logging

public class AuditScope {
    public static final ScopedValue<String> ACTOR = ScopedValue.newInstance();
}

void handleAdminAction(String adminId) {
    ScopedValue.runWhere(AuditScope.ACTOR, adminId, () -> {
        deleteUser("user-123");      // audit log knows who did this
        updatePermissions("user-456"); // and this
    });
}

void deleteUser(String userId) {
    userRepository.delete(userId);
    auditLog.record(
        "DELETE_USER",
        userId,
        AuditScope.ACTOR.get() // the admin who triggered this
    );
}

ScopedValue vs ThreadLocal

FeatureThreadLocalScopedValue
MutabilityMutable (set())Immutable within scope
CleanupManual (remove())Automatic (scope exit)
InheritanceInheritableThreadLocalBuilt-in with StructuredTaskScope
Virtual threadsExpensive (per-thread copy)Efficient (shared reference)
Memory leaksCommon (forgotten remove())Not possible
RebindingAnywhereOnly via nested runWhere

Nested scopes

Scoped values can be rebound in nested scopes:

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

void handleRequest() {
    ScopedValue.runWhere(ROLE, "user", () -> {
        System.out.println(ROLE.get()); // "user"

        // Temporarily elevate privileges
        ScopedValue.runWhere(ROLE, "admin", () -> {
            System.out.println(ROLE.get()); // "admin"
            performAdminAction();
        });

        System.out.println(ROLE.get()); // "user" again
    });
}

The outer binding is restored when the inner scope exits.

Migration from ThreadLocal

// Before
static final ThreadLocal<User> currentUser = new ThreadLocal<>();

void handle(User user) {
    currentUser.set(user);
    try {
        process();
    } finally {
        currentUser.remove();
    }
}

// After
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

void handle(User user) {
    ScopedValue.runWhere(CURRENT_USER, user, this::process);
}

The migration is straightforward: replace ThreadLocal with ScopedValue, replace set/remove with runWhere.

Summary

Scoped Values:

  • Immutable — no accidental overwrites
  • Auto-cleanup — no memory leaks from forgotten remove()
  • Virtual-thread friendly — efficient with millions of threads
  • Inherited — structured concurrency subtasks see parent values
  • Nestable — rebind in inner scopes, outer value restored

Use them for request context, user identity, tenant ID, audit logging, and anywhere you’d previously use ThreadLocal. They’re safer, more efficient, and designed for modern Java concurrency.