Java9 min de leitura

ScopedValues sucessor do ThreadLocal

ScopedValue: O Sucessor Moderno do ThreadLocal no Java 21+

ScopedValues

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:

  1. Mutabilidade Descontrolada: Qualquer componente com acesso à variável pode chamar set(), tornando o rastreio de alterações e o debug extremamente complexos.
  2. 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.
  3. Herança Cara: Quando uma thread pai cria threads filhas, todas as variáveis ThreadLocal sã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 via where(), 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), ThreadLocal ainda faz sentido.
  • Integração com código legado: Frameworks como Spring e Hibernate usam ThreadLocal internamente. 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), ScopedValue nã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

← Voltar aos Artigos