ScopedValue: O Sucessor Moderno do ThreadLocal no Java 21+
Com a chegada das Virtual Threads no Java 21, a escalabilidade atingiu um novo patamar, permitindo que aplicações gerenciem milhões de threads simultaneamente. No entanto, essa nova densidade expôs fraquezas em ferramentas veteranas, como o ThreadLocal. Para resolver esses problemas, o Java introduziu o ScopedValue (finalizado no Java 25 via JEP 506), uma funcionalidade fundamental para a concorrência moderna.
Neste artigo, vamos explorar por que o ThreadLocal se tornou um gargalo, como o ScopedValue oferece uma alternativa mais segura e eficiente, e como ele se integra ao ecossistema de Virtual Threads e Structured Concurrency.
📦 Repositório de referência: github.com/guigomes91/virtual-threads
O Problema: As Deficiências do ThreadLocal
O ThreadLocal permite que dados sejam armazenados localmente em uma thread, mas possui três falhas críticas de design que se agravam com Virtual Threads:
- Mutabilidade Descontrolada: Qualquer componente com acesso à variável pode chamar
set(), tornando o rastreio de alterações e o debug extremamente complexos. - Vazamentos de Memória (Memory Leaks): Os dados persistem enquanto a thread estiver viva, a menos que
remove()seja chamado explicitamente. Em ambientes de longa duração, esquecer de limpar essas variáveis causa vazamentos graves. - Herança Cara: Quando uma thread pai cria threads filhas, todas as variáveis
ThreadLocalsão copiadas para as novas threads. Com milhões de Virtual Threads, essa duplicação de dados esgota rapidamente o heap da JVM.
Exemplo do problema com ThreadLocal
// ⚠️ Padrão problemático com ThreadLocal
public class RequestContext {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
public void handleRequest(String userId) {
USER_ID.set(userId);
try {
processRequest();
} finally {
USER_ID.remove(); // esqueceu isso? -> memory leak
}
}
private void processRequest() {
// Qualquer código pode chamar set() e corromper o estado
USER_ID.set("hacker"); // ⚠️ mutação silenciosa e perigosa
System.out.println("Processando para: " + USER_ID.get());
}
}
A Solução: ScopedValue
O ScopedValue surge como uma alternativa de "via de mão única" para compartilhar dados imutáveis entre componentes e suas sub-tarefas de forma segura.
Os Pilares do ScopedValue
| Característica | ThreadLocal | ScopedValue |
|---|---|---|
| Mutabilidade | set() livre |
Imutável após where() |
| Tempo de vida | Lifetime da thread | Escopo da tarefa |
| Herança | Cópia completa (cara) | Referência compartilhada (grátis) |
| Compatibilidade | Pool de threads | Virtual Threads nativas |
| Risco de leak | Alto | Nenhum |
- Imutabilidade: Não existe o método
set(). Uma vez vinculado viawhere(), o valor permanece constante durante todo o escopo de execução. - Tempo de Vida Delimitado: O valor é vinculado apenas à execução de um método específico. Assim que a tarefa termina, o valor é invalidado automaticamente, eliminando o risco de memory leaks.
- Eficiência de Memória: Threads filhas (especialmente dentro de um
StructuredTaskScope) compartilham a mesma referência do valor da thread pai, sem cópias custosas.
Como Implementar na Prática
1. Declaração
Geralmente declarado como campo estático e final, com acesso restrito para garantir encapsulamento.
public class SecurityContext {
// private: apenas esta classe pode fazer bind e leitura
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
// public: expõe acesso de leitura para outros componentes de forma controlada
public static String currentUserId() {
return USER_ID.get();
}
}
2. Vinculação e Execução (Binding)
A vinculação define onde e por quanto tempo o valor estará disponível. É possível encadear múltiplos valores.
// Vinculando um único valor
ScopedValue.where(USER_ID, "getcaramelo")
.run(() -> processarRequisicao());
// Encadeando múltiplos valores
ScopedValue.where(USER_ID, "getcaramelo")
.where(REQUEST_ID, UUID.randomUUID().toString())
.where(TENANT_ID, "acme-corp")
.run(() -> processarRequisicao());
3. Recuperação Segura
// Verificação antes do acesso
if (USER_ID.isBound()) {
System.out.println("Usuário atual: " + USER_ID.get());
}
// Valor padrão caso não esteja vinculado (Java 25: orElse não aceita null)
String userId = USER_ID.orElse("anonimo");
// Lança exceção se não estiver vinculado
try {
String id = USER_ID.get();
} catch (NoSuchElementException e) {
// ScopedValue não foi vinculado neste escopo
}
4. Retornando valores com call()
Além de run() (que retorna void), use call() quando precisar de um resultado:
// run() -> void
ScopedValue.where(USER_ID, "getcaramelo")
.run(() -> salvarAuditoria());
// call() -> retorna valor
String resultado = ScopedValue.where(USER_ID, "getcaramelo")
.call(() -> buscarPerfil());
ScopedValue em Fluxos Reais
Exemplo 1: TravelApp com Structured Concurrency
No cenário de um sistema de viagens, o ScopedValue organiza o fluxo de dados entre microserviços concorrentes de forma limpa, sem passar parâmetros por toda a pilha de chamadas.
public class TravelService {
private static final ScopedValue<String> LOC = ScopedValue.newInstance();
private static final ScopedValue<String> DEST = ScopedValue.newInstance();
public TravelOffer fetchTravelOffers(String origem, String destino) throws Exception {
return ScopedValue.where(LOC, origem)
.where(DEST, destino)
.call(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// As sub-tarefas herdam LOC e DEST automaticamente
// sem nenhuma cópia de memória
var ridesharing = scope.fork(this::fetchRidesharing);
var publicTransport = scope.fork(this::fetchPublicTransport);
var taxi = scope.fork(this::fetchTaxi);
scope.join().throwIfFailed();
return buildOffer(
ridesharing.get(),
publicTransport.get(),
taxi.get()
);
}
});
}
private RidesharingQuote fetchRidesharing() {
// LOC e DEST disponíveis sem parâmetros!
String from = LOC.get();
String to = DEST.get();
return ridesharingApi.quote(from, to);
}
private TransportQuote fetchPublicTransport() {
return transitApi.routes(LOC.get(), DEST.get());
}
}
Exemplo 2: Segurança em APIs Web (contexto de autenticação)
Padrão comum em frameworks web para propagar o usuário autenticado sem injeção de dependência manual.
public class SecurityFilter {
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
public void doFilter(HttpRequest request, FilterChain chain) {
Principal principal = authenticate(request);
ScopedValue.where(PRINCIPAL, principal)
.run(() -> chain.doFilter(request));
// Após o run(), PRINCIPAL é automaticamente invalidado
}
}
public class OrderService {
public Order createOrder(OrderRequest req) {
// Acessa o principal sem receber por parâmetro
Principal user = SecurityFilter.PRINCIPAL.get();
if (!user.hasRole("CUSTOMER")) {
throw new AccessDeniedException("Acesso negado para: " + user.getName());
}
return orderRepository.save(new Order(req, user.getName()));
}
}
Exemplo 3: Rastreamento distribuído (Tracing)
Propagação de traceId para logs estruturados em sistemas distribuídos.
public class TracingContext {
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
private static final ScopedValue<String> SPAN_ID = ScopedValue.newInstance();
public static void withNewTrace(Runnable task) {
ScopedValue.where(TRACE_ID, UUID.randomUUID().toString())
.where(SPAN_ID, UUID.randomUUID().toString())
.run(task);
}
public static String currentTraceId() {
return TRACE_ID.orElse("sem-trace");
}
public static String currentSpanId() {
return SPAN_ID.orElse("sem-span");
}
}
// Uso em qualquer camada da aplicação
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public void processPayment(Payment payment) {
// Log automaticamente enriquecido com o trace
log.info("[traceId={}] [spanId={}] Processando pagamento de {}",
TracingContext.currentTraceId(),
TracingContext.currentSpanId(),
payment.amount());
}
}
Exemplo 4: Rebinding - Sobrescrita Controlada de Escopo
Em alguns cenários, uma chamada aninhada precisa de um valor diferente no mesmo ScopedValue. O rebinding cria um novo escopo sem afetar o escopo externo.
static final ScopedValue<String> ROLE = ScopedValue.newInstance();
public void demonstrate() {
ScopedValue.where(ROLE, "USER").run(() -> {
System.out.println("Escopo externo: " + ROLE.get()); // "USER"
// Rebinding: cria um escopo interno com valor diferente
ScopedValue.where(ROLE, "ADMIN").run(() -> {
System.out.println("Escopo interno: " + ROLE.get()); // "ADMIN"
});
// Escopo externo é restaurado automaticamente
System.out.println("Escopo externo restaurado: " + ROLE.get()); // "USER"
});
}
Conexão com Virtual Threads: Por Que Isso Importa
O repositório guigomes91/virtual-threads demonstra como evitar thread pinning usando ReentrantLock em vez de synchronized. O ScopedValue é o par natural dessa estratégia: enquanto o ReentrantLock libera a carrier thread durante bloqueios, o ScopedValue garante que o contexto seja propagado corretamente para os milhões de Virtual Threads sem explosão de memória.
// ✅ Padrão completo: Virtual Thread + ReentrantLock + ScopedValue
public class ModernRequestHandler {
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
private final Lock lock = new ReentrantLock();
public void handle(HttpRequest request) {
var context = RequestContext.from(request);
// 1. Propaga contexto imutável via ScopedValue
ScopedValue.where(CTX, context).run(() -> {
lock.lock(); // 2. ReentrantLock evita pinning da Virtual Thread
try {
processWithContext(); // carrier thread é liberada durante I/O
} finally {
lock.unlock();
}
});
// 3. Contexto invalidado automaticamente após o run()
}
private void processWithContext() {
RequestContext ctx = CTX.get();
// ... lógica de negócio usando o contexto
}
}
Histórico de Evolução e Status no Java 25
| Java | JEP | Status |
|---|---|---|
| 20 | 429 | Incubator |
| 21 | 446 | 1º Preview |
| 22 | 464 | 2º Preview |
| 23 | 481 | 3º Preview |
| 24 | 487 | 4º Preview |
| 25 (LTS) | 506 | ✅ Finalizado |
A única mudança na finalização: ScopedValue.orElse() não aceita mais null como argumento. A API chegou estável, sem flags de preview.
Quando NÃO usar ScopedValue
ScopedValue não é um substituto direto para todo caso de ThreadLocal. Há cenários em que ThreadLocal ainda é apropriado:
- Estado mutável por design: Se você precisa acumular dados ao longo de uma thread (ex.: contadores, listas que crescem),
ThreadLocalainda faz sentido. - Integração com código legado: Frameworks como Spring e Hibernate usam
ThreadLocalinternamente. A migração deve ser gradual. - Escopos verdadeiramente abertos: Se o valor precisa sobreviver além do escopo da chamada de método (ex.: dados mantidos entre requisições na mesma thread de um pool),
ScopedValuenão é a ferramenta certa.
Conclusão
O ScopedValue não é apenas uma melhoria incremental, é um componente fundamental para a arquitetura de sistemas modernos em Java. Ao forçar a imutabilidade e restringir o tempo de vida dos dados ao escopo da tarefa, ele oferece a segurança e a leveza necessárias para que as Virtual Threads alcancem seu potencial máximo de vazão sem comprometer a estabilidade da memória.
Com o Java 25 (LTS) finalizando a API, não há mais razão para usar --enable-preview. É hora de considerar o ScopedValue como o padrão para propagação de contexto em qualquer código novo.
Referências
- JEP 506: Scoped Values (Final)
- JEP 444: Virtual Threads
- JEP 505: Structured Concurrency (Final)
- Repositório: guigomes91/virtual-threads
- Java Coding Problems, Second Edition — Anghel Leonard
Caramelo