Why ScopedValue Is the Future of Thread-Local Data in Java
This article explores the limitations of ThreadLocal, introduces Java's new ScopedValue feature, provides detailed usage examples, performance benchmarks, migration strategies, and compares both approaches to help developers decide when and how to adopt ScopedValue in modern backend applications.
Introduction
Today we discuss a new feature that will change our programming habits—ScopedValue. It aims to solve the common pitfalls of ThreadLocal such as memory leaks, data contamination, and performance overhead.
1. ThreadLocal Pain Points
Before introducing ScopedValue, we review the typical problems of ThreadLocal.
ThreadLocal memory leak problem
Typical problematic code
/**
* ThreadLocal typical problem demonstration
*/
public class ThreadLocalProblems {
private static final ThreadLocal<UserContext> userContext = new ThreadLocal<>();
private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
/**
* Problem 1: Memory leak – forget to call remove()
*/
public void processRequest(HttpServletRequest request) {
UserContext context = new UserContext(request.getHeader("X-User-Id"));
userContext.set(context);
try {
// business processing
businessService.process();
// Problem: forget to call userContext.remove()
} finally {
// In thread pool, the thread may be reused and retain previous user info
}
}
/**
* Problem 2: Data pollution – thread reuse leads to data mixing
*/
public void processMultipleRequests() {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
final int userId = i;
executor.submit(() -> {
userContext.set(new UserContext("user_" + userId));
try {
Thread.sleep(100);
String currentUser = userContext.get().getUserId();
System.out.println("Processing user: " + currentUser);
} finally {
userContext.remove(); // easy to forget or skip due to exception
}
});
}
executor.shutdown();
}
/**
* Problem 3: Inheritance issue – child thread cannot inherit parent data
*/
public void parentChildThreadProblem() {
userContext.set(new UserContext("parent_user"));
Thread childThread = new Thread(() -> {
UserContext context = userContext.get(); // null
System.out.println("Child thread user: " + context);
});
childThread.start();
}
/**
* Problem 4: Performance – large number of ThreadLocal instances affect performance
*/
public void performanceProblem() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("value_" + i);
String value = tl.get();
tl.remove();
}
long endTime = System.currentTimeMillis();
System.out.println("ThreadLocal operation time: " + (endTime - startTime) + "ms");
}
}Root cause of ThreadLocal issues
Complex lifecycle management : manual set/remove can be missed.
Memory leak risk : thread pool reuse prevents GC.
Poor inheritance : child threads cannot automatically inherit values.
Performance overhead : hash map operations add cost.
Some wonder whether InheritableThreadLocal solves inheritance; it mitigates the problem but introduces new complexity and worse performance.
2. ScopedValue: The Next‑Generation Thread‑Local Variable
ScopedValue was introduced as a preview feature in Java 20 and became a standard feature in Java 21. It is designed to address the pain points of ThreadLocal, offering safer and more efficient thread‑local data transmission.
Core design philosophy
To help understand ScopedValue, we provide an architecture diagram.
Key advantages of ScopedValue:
Basic usage
/**
* ScopedValue basic usage demonstration
*/
public class ScopedValueBasics {
private static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
private static final ScopedValue<Connection> DB_CONNECTION = ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
/**
* Basic usage: use ScopedValue inside a scope
*/
public void basicUsage() {
UserContext user = new UserContext("user_123");
// Bind value inside the scope
ScopedValue.runWhere(USER_CONTEXT, user, () -> {
System.out.println("Current user: " + USER_CONTEXT.get().getUserId());
// Nested scope
ScopedValue.runWhere(REQUEST_ID, "req_456", () -> {
System.out.println("Request ID: " + REQUEST_ID.get());
System.out.println("User: " + USER_CONTEXT.get().getUserId());
});
});
// USER_CONTEXT is out of scope here
}
/**
* ScopedValue with return value
*/
public String scopedValueWithReturn() {
UserContext user = new UserContext("user_789");
String result = ScopedValue.callWhere(USER_CONTEXT, user, () -> {
String userId = USER_CONTEXT.get().getUserId();
return "Processing user: " + userId;
});
return result;
}
/**
* Multiple ScopedValues simultaneously
*/
public void multipleScopedValues() {
UserContext user = new UserContext("user_multi");
Connection conn = createConnection();
ScopedValue.runWhere(
ScopedValue.where(USER_CONTEXT, user)
.where(DB_CONNECTION, conn)
.where(REQUEST_ID, "multi_req"),
() -> {
processBusinessLogic();
}
);
}
/**
* Exception handling example
*/
public void exceptionHandling() {
UserContext user = new UserContext("user_exception");
try {
ScopedValue.runWhere(USER_CONTEXT, user, () -> {
processBusinessLogic();
if (someCondition()) {
throw new RuntimeException("Business exception");
}
});
} catch (RuntimeException e) {
System.out.println("Caught exception: " + e.getMessage());
}
// Value is automatically cleared
try {
USER_CONTEXT.get(); // will throw
} catch (Exception e) {
System.out.println("Value correctly cleared: " + e.getMessage());
}
}
private Connection createConnection() { return null; }
private void processBusinessLogic() {
UserContext user = USER_CONTEXT.get();
System.out.println("Processing business logic, user: " + user.getUserId());
}
private boolean someCondition() { return Math.random() > 0.5; }
}3. ScopedValue vs ThreadLocal: Comprehensive Comparison
We compare the two approaches in detail.
3.1 Memory management comparison
Memory model diagrams illustrate the differences.
3.2 Code comparison
/**
* ThreadLocal vs ScopedValue comparison demo
*/
public class ThreadLocalVsScopedValue {
private static final ThreadLocal<UserContext> TL_USER_CONTEXT = new ThreadLocal<>();
private static final ThreadLocal<Connection> TL_CONNECTION = new ThreadLocal<>();
private static final ScopedValue<UserContext> SV_USER_CONTEXT = ScopedValue.newInstance();
private static final ScopedValue<Connection> SV_CONNECTION = ScopedValue.newInstance();
/** ThreadLocal traditional implementation */
public void processRequestThreadLocal(HttpServletRequest request) {
UserContext userContext = new UserContext(request.getHeader("X-User-Id"));
TL_USER_CONTEXT.set(userContext);
Connection conn = null;
try {
conn = dataSource.getConnection();
TL_CONNECTION.set(conn);
processBusinessLogic();
} finally {
TL_USER_CONTEXT.remove();
TL_CONNECTION.remove();
if (conn != null) {
try { conn.close(); } catch (SQLException ignored) {}
}
}
}
/** ScopedValue modern implementation */
public void processRequestScopedValue(HttpServletRequest request) {
UserContext userContext = new UserContext(request.getHeader("X-User-Id"));
try (Connection conn = dataSource.getConnection()) {
ScopedValue.runWhere(
ScopedValue.where(SV_USER_CONTEXT, userContext)
.where(SV_CONNECTION, conn),
() -> {
processBusinessLogic();
}
);
} catch (SQLException e) {
handleException(e);
}
}
private void processBusinessLogic() {
UserContext tlUser = TL_USER_CONTEXT.get();
if (tlUser == null) throw new IllegalStateException("User context not set");
Connection tlConn = TL_CONNECTION.get();
if (tlConn == null) throw new IllegalStateException("Connection not set");
System.out.println("Processing user: " + tlUser.getUserId());
}
private void processBusinessLogicScoped() {
UserContext svUser = SV_USER_CONTEXT.get(); // never null inside scope
Connection svConn = SV_CONNECTION.get(); // never null inside scope
System.out.println("Processing user: " + svUser.getUserId());
}
private void handleException(SQLException e) {}
}3.3 Performance comparison
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class PerformanceComparison {
private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
private static final ScopedValue<String> SCOPED_VALUE = ScopedValue.newInstance();
private static final int ITERATIONS = 100000;
@Benchmark
public void threadLocalPerformance() {
for (int i = 0; i < ITERATIONS; i++) {
THREAD_LOCAL.set("value_" + i);
String v = THREAD_LOCAL.get();
THREAD_LOCAL.remove();
}
}
@Benchmark
public void scopedValuePerformance() {
for (int i = 0; i < ITERATIONS; i++) {
ScopedValue.runWhere(SCOPED_VALUE, "value_" + i, () -> {
String v = SCOPED_VALUE.get();
});
}
}
}4. ScopedValue Advanced Features
After mastering the basics, developers often want to explore advanced capabilities.
4.1 Structured concurrency support
/**
* ScopedValue with structured concurrency example
*/
public class StructuredConcurrencyExample {
private static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
private static final ScopedValue<RequestInfo> REQUEST_INFO = ScopedValue.newInstance();
public void structuredConcurrencyWithScopedValue() throws Exception {
UserContext user = new UserContext("structured_user");
RequestInfo request = new RequestInfo("req_123", System.currentTimeMillis());
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
ScopedValue.runWhere(
ScopedValue.where(USER_CONTEXT, user).where(REQUEST_INFO, request),
() -> {
Future<String> userTask = scope.fork(this::fetchUserData);
Future<String> orderTask = scope.fork(this::fetchOrderData);
Future<String> paymentTask = scope.fork(this::fetchPaymentData);
scope.join();
scope.throwIfFailed();
System.out.println("Aggregated result: " + userTask.resultNow() + ", " + orderTask.resultNow() + ", " + paymentTask.resultNow());
}
);
}
}
private String fetchUserData() {
UserContext user = USER_CONTEXT.get();
RequestInfo request = REQUEST_INFO.get();
return "User data: " + user.getUserId() + ", request: " + request.getRequestId();
}
private String fetchOrderData() { return "Order data: " + USER_CONTEXT.get().getUserId(); }
private String fetchPaymentData() { return "Payment data: " + USER_CONTEXT.get().getUserId(); }
}4.2 Inheritance and nested scopes
/**
* ScopedValue inheritance and nested scopes
*/
public class ScopedValueInheritance {
private static final ScopedValue<String> PARENT_VALUE = ScopedValue.newInstance();
private static final ScopedValue<String> CHILD_VALUE = ScopedValue.newInstance();
public void nestedScopes() {
ScopedValue.runWhere(PARENT_VALUE, "parent_value", () -> {
System.out.println("Outer scope: " + PARENT_VALUE.get());
ScopedValue.runWhere(CHILD_VALUE, "child_value", () -> {
System.out.println("Inner scope - parent: " + PARENT_VALUE.get());
System.out.println("Inner scope - child: " + CHILD_VALUE.get());
ScopedValue.runWhere(PARENT_VALUE, "shadowed_parent", () -> {
System.out.println("Shadowed scope - parent: " + PARENT_VALUE.get());
System.out.println("Shadowed scope - child: " + CHILD_VALUE.get());
});
System.out.println("Restored scope - parent: " + PARENT_VALUE.get());
});
try {
System.out.println(CHILD_VALUE.get()); // throws
} catch (Exception e) {
System.out.println("Child value out of scope: " + e.getMessage());
}
});
}
}4.3 Error handling and debugging
/**
* ScopedValue error handling and debugging
*/
public class ScopedValueErrorHandling {
private static final ScopedValue<String> MAIN_VALUE = ScopedValue.newInstance();
private static final ScopedValue<Integer> COUNT_VALUE = ScopedValue.newInstance();
public void exceptionHandling() {
try {
ScopedValue.runWhere(MAIN_VALUE, "test_value", this::processWithError);
} catch (RuntimeException e) {
System.out.println("Caught exception: " + e.getMessage());
}
try {
System.out.println(MAIN_VALUE.get()); // should throw
} catch (Exception e) {
System.out.println("Value correctly cleared: " + e.getMessage());
}
}
private void processWithError() {
throw new RuntimeException("Business processing exception");
}
public void debugInformation() {
ScopedValue.runWhere(
ScopedValue.where(MAIN_VALUE, "debug_value").where(COUNT_VALUE, 42),
() -> {
System.out.println("Current bindings:");
System.out.println("MAIN_VALUE: " + MAIN_VALUE.get());
System.out.println("COUNT_VALUE: " + COUNT_VALUE.get());
debugComplexScenario();
}
);
}
private void debugComplexScenario() {
ScopedValue.runWhere(COUNT_VALUE, COUNT_VALUE.get() + 1, () -> {
System.out.println("Nested debug - COUNT_VALUE: " + COUNT_VALUE.get());
});
}
}5. Real‑World Case Study: Web Application User Context Management
We demonstrate ScopedValue in a typical web application.
5.1 Defining ScopedValues for the web layer
/**
* Web application ScopedValue definitions
*/
public class WebScopedValues {
public static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
public static final ScopedValue<RequestInfo> REQUEST_INFO = ScopedValue.newInstance();
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}
class UserContext {
private final String userId;
private final String username;
private final List<String> roles;
private final Map<String, Object> attributes;
private final Locale locale;
// constructors, getters, toString omitted for brevity
}
class RequestInfo {
private final String requestId;
private final String method;
private final String path;
private final String clientIp;
private final Map<String, String> headers;
// constructors, getters omitted for brevity
}5.2 Authentication filter using ScopedValue
@Component
@Slf4j
public class AuthenticationFilter implements Filter {
@Autowired private UserService userService;
@Autowired private JwtTokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestId = generateRequestId();
RequestInfo requestInfo = extractRequestInfo(httpRequest, requestId);
UserContext userContext = authenticateUser(httpRequest);
ScopedValue.runWhere(
ScopedValue.where(WebScopedValues.REQUEST_INFO, requestInfo)
.where(WebScopedValues.USER_CONTEXT, userContext)
.where(WebScopedValues.TRACE_ID, requestId),
() -> {
try {
chain.doFilter(request, response);
} catch (Exception e) {
log.error("Request processing exception", e);
throw new RuntimeException("Filter exception", e);
}
}
);
log.info("Request processing completed: {}", requestId);
}
private String generateRequestId() {
return "req_" + System.currentTimeMillis() + "_" + ThreadLocalRandom.current().nextInt(1000, 9999);
}
private RequestInfo extractRequestInfo(HttpServletRequest request, String requestId) {
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
headers.put(name, request.getHeader(name));
}
return new RequestInfo(requestId, request.getMethod(), request.getRequestURI(), request.getRemoteAddr(), headers);
}
private UserContext authenticateUser(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
return tokenProvider.validateToken(token);
}
return new UserContext("anonymous", "Anonymous User", List.of("GUEST"), Map.of("source", "web"), request.getLocale());
}
}5.3 Service layer using ScopedValue
@Service
@Slf4j
@Transactional
public class UserService {
@Autowired private UserRepository userRepository;
@Autowired private OrderService orderService;
public UserProfile getCurrentUserProfile() {
UserContext ctx = WebScopedValues.USER_CONTEXT.get();
RequestInfo req = WebScopedValues.REQUEST_INFO.get();
String trace = WebScopedValues.TRACE_ID.get();
log.info("[{}] Fetching profile for {}", trace, ctx.getUserId());
User user = userRepository.findById(ctx.getUserId())
.orElseThrow(() -> new UserNotFoundException("User not found: " + ctx.getUserId()));
return UserProfile.builder()
.userId(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.roles(ctx.getRoles())
.locale(ctx.getLocale())
.lastLogin(user.getLastLoginTime())
.build();
}
public void updateUserProfile(UpdateProfileRequest request) {
UserContext ctx = WebScopedValues.USER_CONTEXT.get();
String trace = WebScopedValues.TRACE_ID.get();
log.info("[{}] Updating profile for {}", trace, ctx.getUserId());
if (!ctx.getUserId().equals(request.getUserId())) {
throw new PermissionDeniedException("Cannot update other users");
}
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException("User not found: " + request.getUserId()));
user.setEmail(request.getEmail());
user.setUpdateTime(LocalDateTime.now());
userRepository.save(user);
log.info("[{}] Profile update successful for {}", trace, ctx.getUserId());
}
public List<Order> getUserOrders() {
// No need to pass userId explicitly
return orderService.getUserOrders();
}
}5.4 Controller layer
@RestController
@RequestMapping("/api/users")
@Slf4j
public class UserController {
@Autowired private UserService userService;
@GetMapping("/profile")
public ResponseEntity<UserProfile> getCurrentUserProfile() {
UserProfile profile = userService.getCurrentUserProfile();
return ResponseEntity.ok(profile);
}
@PutMapping("/profile")
public ResponseEntity<Void> updateUserProfile(@RequestBody @Valid UpdateProfileRequest request) {
userService.updateUserProfile(request);
return ResponseEntity.ok().build();
}
@GetMapping("/orders")
public ResponseEntity<List<Order>> getUserOrders() {
List<Order> orders = userService.getUserOrders();
return ResponseEntity.ok(orders);
}
@ExceptionHandler({UserNotFoundException.class, PermissionDeniedException.class})
public ResponseEntity<ErrorResponse> handleUserExceptions(RuntimeException e) {
String trace = WebScopedValues.TRACE_ID.get();
log.error("[{}] User operation exception: {}", trace, e.getMessage());
ErrorResponse error = new ErrorResponse(e.getClass().getSimpleName(), e.getMessage(), trace);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
@Data @AllArgsConstructor
class ErrorResponse {
private String error;
private String message;
private String traceId;
private long timestamp = System.currentTimeMillis();
}6. Migration Guide: From ThreadLocal to ScopedValue
Migration is straightforward. Below are the steps and example code.
6.1 Migration steps
public class MigrationGuide {
// Old ThreadLocal definitions
private static final ThreadLocal<UserContext> TL_USER = new ThreadLocal<>();
private static final ThreadLocal<Connection> TL_CONN = new ThreadLocal<>();
private static final ThreadLocal<String> TL_TRACE = new ThreadLocal<>();
// New ScopedValue definitions
private static final ScopedValue<UserContext> SV_USER = ScopedValue.newInstance();
private static final ScopedValue<Connection> SV_CONN = ScopedValue.newInstance();
private static final ScopedValue<String> SV_TRACE = ScopedValue.newInstance();
// Before migration – manual management
public void beforeMigration() {
TL_USER.set(new UserContext("user_old"));
TL_TRACE.set("trace_old");
Connection conn = null;
try {
conn = createConnection();
TL_CONN.set(conn);
processBusinessOld();
} finally {
TL_USER.remove();
TL_TRACE.remove();
TL_CONN.remove();
if (conn != null) conn.close();
}
}
// After migration – ScopedValue handles lifecycle automatically
public void afterMigration() {
UserContext user = new UserContext("user_new");
String trace = "trace_new";
try (Connection conn = createConnection()) {
ScopedValue.runWhere(
ScopedValue.where(SV_USER, user).where(SV_TRACE, trace).where(SV_CONN, conn),
this::processBusinessNew
);
} catch (Exception e) {
// exception handling
}
}
private void processBusinessOld() {
UserContext user = TL_USER.get();
if (user == null) throw new IllegalStateException("User context not set");
Connection conn = TL_CONN.get();
if (conn == null) throw new IllegalStateException("Connection not set");
String trace = TL_TRACE.get();
System.out.println("Processing user: " + user.getUserId() + ", trace: " + trace);
}
private void processBusinessNew() {
UserContext user = SV_USER.get();
Connection conn = SV_CONN.get();
String trace = SV_TRACE.get();
System.out.println("Processing user: " + user.getUserId() + ", trace: " + trace);
}
private Connection createConnection() { return null; }
private void closeConnection(Connection c) {}
}6.2 Compatibility handling
/**
* Compatibility layer for gradual migration
*/
public class CompatibilityLayer {
// New ScopedValue used by new code
private static final ScopedValue<UserContext> SV_USER = ScopedValue.newInstance();
// Old ThreadLocal still present in legacy code
private static final ThreadLocal<UserContext> TL_USER = new ThreadLocal<>();
/** Bridge pattern – keep both in sync */
public void bridgePattern() {
UserContext user = new UserContext("bridge_user");
ScopedValue.runWhere(SV_USER, user, () -> {
TL_USER.set(user); // legacy code can still read ThreadLocal
try {
processMixedBusiness();
} finally {
TL_USER.remove();
}
});
}
private void processMixedBusiness() {
UserContext svUser = SV_USER.get();
System.out.println("ScopedValue user: " + svUser.getUserId());
UserContext tlUser = TL_USER.get();
System.out.println("ThreadLocal user: " + tlUser.getUserId());
assert svUser == tlUser;
}
}Conclusion
ScopedValue brings memory safety, automatic lifecycle management, better performance, and seamless support for virtual threads and structured concurrency. It simplifies code by removing the need for manual cleanup and reduces the risk of subtle bugs.
Core Advantages
Memory safety : automatic lifecycle prevents leaks.
Ease of use : structured binding without manual removal.
Performance : optimized for virtual threads.
Concurrency friendliness : works naturally with structured concurrency.
Migration Decision Guide
ThreadLocal vs ScopedValue selection mnemonic New projects : use ScopedValue directly. Legacy projects : migrate gradually, start with new modules. Performance‑sensitive : ScopedValue shines with virtual threads. Memory‑sensitive : ScopedValue eliminates leak risk. Team skillset : ThreadLocal is familiar, ScopedValue requires learning.
Technical Comparison
Feature
ThreadLocal
ScopedValue
Memory management
Manual remove
Automatic
Memory leak risk
High
None
Complexity
High (try‑finally)
Low (structured binding)
Performance
Good
Better with virtual threads
Inheritance
Requires InheritableThreadLocal
Automatic
Virtual‑thread support
Problematic
Perfect
Final Recommendations
Learn and master ScopedValue – it is the future of thread‑local data.
Prefer ScopedValue for new projects to avoid legacy pitfalls.
Plan a gradual migration for existing codebases, using bridge patterns if needed.
Watch ecosystem support – many frameworks are adding ScopedValue integration.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Su San Talks Tech
Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
