Scoped Values: Um Substituto Moderno para o ThreadLocal
Objective
Por vinte anos, a única forma de entregar um valor para código lá no fundo de uma call stack sem passá-lo por toda assinatura de método foi ThreadLocal — um slot mutável por thread que qualquer código que tenha a chave pode sobrescrever com set(), e que vaza a menos que todo caminho que o escreve também chame remove() em um finally. ScopedValue (java.lang.ScopedValue), finalizado no Java 25 pela JEP 506, substitui esse slot por um binding imutável cujo tempo de vida é a extensão dinâmica de uma lambda: ScopedValue.where(KEY, value).run(() -> ...) faz KEY.get() retornar value para todo frame chamado a partir dessa lambda — incluindo threads disparadas dentro dela — e desfaz o binding no instante em que run() retorna. Não existe set(), então nada rio abaixo consegue mutar o contexto por baixo do seu chamador, e não existe remove() para esquecer, então não há nada para vazar. Esta é uma API estável, pronta para produção, não uma de preview.
Use Cases
- Carregar contexto por requisição — request ID, usuário autenticado, tenant, identificadores de trace/span, locale — de um handler de entrada até um repositório ou logger muitos frames adiante, sem adicionar um parâmetro
RequestContext ctxa cada método pelo caminho. - Rodar esse mesmo padrão em um servidor que reutiliza platform threads de um pool, onde um
ThreadLocaldeixado para trás por uma requisição fica visível para a próxima requisição servida pela mesma thread — um bug de correção em cima do vazamento de memória. - Rodar em virtual threads na escala para a qual elas foram construídas: um milhão de requisições em voo significa um milhão de threads, e uma cópia de
ThreadLocalpor thread é um milhão de cópias desse contexto; um binding deScopedValueé desenhado exatamente para esse fan-out. - Propagar contexto para subtasks disparadas de um
StructuredTaskScopecom zero plumbing — o filho enxerga o binding do pai automaticamente, semInheritableThreadLocale sem cópia manual no ponto do fork. - Reforçar que um pedaço de contexto só é legível onde faz sentido: fora do binding,
get()lança exceção em vez de silenciosamente retornar um valor obsoleto de uma requisição anterior.
Deep Dive
Declarando um scoped value: é uma chave, não um container
javapublic class Server {
// static final — one shared key for the whole program
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
}newInstance() não cria um slot que guarda algo. O objeto ScopedValue é uma chave — o valor para o qual ela mapeia depende inteiramente de qual binding está ativo na thread atual no momento em que get() roda. É por isso que a declaração idiomática é static final e em nível de pacote/classe: o mesmo objeto chave precisa estar visível tanto para o código que faz o binding quanto para o código lá embaixo que o lê. Torná-lo um campo de instância, ou criar um novo por requisição, quebra todo o mecanismo — quem lê precisaria de uma referência para aquela instância, que é exatamente o problema de passar parâmetro que a classe existe para resolver.
Fazendo binding com run() e call()
java// run() — no result
ScopedValue.where(CURRENT_USER, "alice").run(() -> {
System.out.println(CURRENT_USER.get()); // "alice"
processRequest(); // any depth below also sees "alice"
});
System.out.println(CURRENT_USER.isBound()); // false — binding is gonewhere(key, value) retorna um ScopedValue.Carrier: uma descrição imutável de "esta chave vinculada a este valor", que ainda não teve efeito. Nada é vinculado até você invocar uma operação sobre o carrier. run(Runnable) executa a lambda com o binding em vigor e retorna void; call(...) faz o mesmo mas retorna o resultado da lambda e pode propagar exceções checadas:
javaString greeting = ScopedValue.where(CURRENT_USER, "bob").call(() -> {
return "Hello, " + CURRENT_USER.get(); // "Hello, bob"
});O tempo de vida do binding é exatamente a execução dessa lambda — sua extensão dinâmica, significando todo método que ela chama, e todo método que esses chamam, nessa thread. Quando a lambda retorna (normalmente ou lançando exceção), o binding é desfeito pelo runtime. Não existe caminho de código que consiga pular essa desmontagem.
Rebinding aninhado sombreia, não muta
Não existe CURRENT_USER.set(...). A única forma de mudar para o que uma chave resolve é abrir um novo escopo que a vincula de novo; esse binding interno sombreia o externo pela sua própria extensão, e o valor externo volta automaticamente:
javaScopedValue.where(CURRENT_USER, "alice").run(() -> {
System.out.println(CURRENT_USER.get()); // before: "alice"
ScopedValue.where(CURRENT_USER, "admin").run(() -> {
System.out.println(CURRENT_USER.get()); // during: "admin"
escalatedOperation(); // sees "admin"
});
System.out.println(CURRENT_USER.get()); // after: "alice" — restored
});Essa é a diferença estrutural em relação a ThreadLocal. Um rebinding só é visível para baixo, na lambda aninhada; ele nunca pode ser visível para cima, para o código que abriu o escopo externo. Um callee não consegue corromper a visão de contexto do seu chamador, porque o rebinding é delimitado pela construção da linguagem, não por disciplina.
Acesso seguro: isBound(), orElse(), orElseThrow()
Chamar get() sem nenhum binding ativo é uma falha dura:
javastatic final ScopedValue<String> TENANT = ScopedValue.newInstance();
TENANT.get(); // NoSuchElementException — nothing is bound on this threadPara código que pode legitimamente rodar tanto dentro quanto fora de um binding (um logger, um interceptor de métricas, um utilitário chamado tanto de um caminho de requisição quanto de um caminho de startup), use um dos acessores seguros em vez disso:
javaif (TENANT.isBound()) {
log("tenant=" + TENANT.get());
}
String tenant = TENANT.orElse("default");
String tenant2 = TENANT.orElseThrow(() -> new IllegalStateException("no tenant bound"));orElse fornece um fallback; orElseThrow troca o NoSuchElementException genérico por um específico do domínio, na fronteira onde a falha de fato tem significado. Note o contraste com ThreadLocal.get(), que retorna null (ou o initialValue()) quando não definido — um binding faltando ali é silenciosamente indistinguível de um binding cujo valor por acaso é null.
Vinculando várias chaves em um carrier
Carrier.where(...) retorna um novo carrier com o binding extra adicionado, então bindings encadeiam:
javastatic final ScopedValue<String> USER = ScopedValue.newInstance();
static final ScopedValue<String> LOCALE = ScopedValue.newInstance();
ScopedValue.where(USER, "alice")
.where(LOCALE, "pt-PT")
.run(() -> {
System.out.println(USER.get()); // "alice"
System.out.println(LOCALE.get()); // "pt-PT"
});Ambos os bindings compartilham um escopo: entram em vigor juntos quando run() começa e são desfeitos juntos quando ele retorna. Essa é a forma normal de estabelecer um contexto de requisição inteiro em um só lugar, em vez de aninhar um run() por chave.
Herança por threads filhas e StructuredTaskScope
Um binding de scoped value é visível para threads criadas dentro de sua extensão, sem nenhum equivalente a InheritableThreadLocal e sem copiar o valor por thread:
javastatic final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(REQUEST_ID.get())); // sees "req-42"
var order = scope.fork(() -> fetchOrder(REQUEST_ID.get())); // sees "req-42"
scope.join();
render(user.get(), order.get());
}
});É aqui que as duas features de concorrência do Java 25 se encaixam: StructuredTaskScope garante que as subtasks disparadas terminem antes do bloco try sair, o que significa que elas têm garantia de terminar dentro da extensão do scoped value — então o binding que elas leem não pode ser desfeito enquanto ainda estão rodando. Um Thread.start() simples dentro da extensão também herda o binding, mas nada força essa thread a terminar antes que o escopo termine; um binding que sobrevive à sua estrutura é exatamente o que StructureViolationException existe para reportar. O conceito irmão structured-concurrency cobre o lado do escopo dessa combinação; note que StructuredTaskScope ainda é uma API de preview no Java 25 enquanto ScopedValue é final.
Como isso se relaciona com o modelo de memória
O conceito complementar visibility-and-safe-publication cobre o que é necessário para compartilhar estado mutável entre threads corretamente — uma aresta happens-before via volatile, um lock, ou um campo final. ScopedValue não resolve esse problema; ele o evita. Um binding é estabelecido antes da subtask começar e nunca é escrito de novo, então não há escrita concorrente para um leitor perder e nenhum reordenamento a se preocupar. Duas subtasks lendo REQUEST_ID.get() não conseguem observar valores diferentes, não conseguem observar um valor rasgado, e não precisam de sincronização — porque não existe uma segunda escrita em lugar nenhum do quadro. Compartilhar um objeto mutável através de um scoped value te coloca de volta direto no território do modelo de memória, porém: o binding é imutável, o objeto para o qual ele aponta é o que você fez dele.
Trade-offs
O valor só é legível dentro da extensão dinâmica que o vinculou. Você não pode fazer o binding uma vez no startup e ler em qualquer lugar depois, da forma como alguns frameworks baseados em
ThreadLocalsão configurados; toda leitura precisa estar sob umrun()/call()na mesma thread, e código que acaba fora de um falha ruidosamente em vez de degradar.javaScopedValue.where(CURRENT_USER, "alice").run(() -> doWork()); CURRENT_USER.get(); // NoSuchElementException — extent already ended"Chave, não container" é a mudança de modelo mental em que as pessoas tropeçam. Desenvolvedores vindos de
ThreadLocalesperam que o próprio objeto guarde algo e procuram por um setter; não existe um, e código escrito sobre a suposição errada tende a criar umScopedValuenovo por requisição, que nada rio abaixo consegue ler.Imutável e limpo automaticamente vs. mutável e limpo manualmente. O idiom de
ThreadLocalempurra o fardo de correção para cada call site; a forma do scoped value torna ambas as propriedades estruturais.java// ThreadLocal — any code can set(), and forgetting remove() leaks on a pooled thread static final ThreadLocal<String> TL = new ThreadLocal<>(); TL.set("alice"); try { handle(); // callees may call TL.set(...) and change it for the caller } finally { TL.remove(); // must not forget this } // ScopedValue — no set() exists, teardown is not optional static final ScopedValue<String> SV = ScopedValue.newInstance(); ScopedValue.where(SV, "alice").run(() -> handle());Rebinding custa um escopo, não uma atribuição. Onde
ThreadLocaldeixa você trocar um valor com uma instrução, mudar um scoped value significa abrir umrun()/call()aninhado e mover o código afetado para dentro dele — mais limpo de raciocinar, mas um custo real de reestruturação quando atinge código que muta contexto em vários lugares.Retrofitar uma stack existente baseada em
ThreadLocalnão é um rename mecânico. O ponto de binding precisa ser elevado até um ponto que englobe toda leitura — tipicamente um servlet filter ou wrapper de handler — e qualquer biblioteca no caminho que lê oThreadLocalantigo continua precisando dele, então setups mistos são comuns durante a migração.Interop com frameworks e thread pools que você não controla. Uma tarefa entregue a um executor que você não criou roda em uma thread fora da sua extensão, então o binding não a acompanha; o valor precisa ser capturado explicitamente e re-vinculado dentro da tarefa se precisar cruzar essa fronteira.