Você tem um ThreadLocal<String> guardando o tenantId da requisição. Funciona há anos. Um dia alguém troca o executor por um pool reaproveitado — ou só aumenta a concorrência — e a requisição B começa a responder com dado da requisição A. Ninguém mudou a lógica de negócio. Mudou a vida útil da thread.
Este artigo reproduz esse vazamento de propósito, mostra a API que o JDK 25 finalizou para resolver o problema (ScopedValue, JEP 506) e cobre a parte que costuma sair errada: gente colando o snippet de fan-out de um tutorial de 2023 que não compila mais.
Pré-requisitos
- JDK 25 LTS. Testado aqui com Temurin 25.0.4.
ScopedValueé API final desde o Java 25 — nenhuma flag de preview necessária para ele.- A seção de fan-out usa
StructuredTaskScope(JEP 505), que ainda é preview no 25 e continua preview no 26 (JEP 525). Isso exige--enable-previewna compilação, nos testes e na execução — só para essa seção, não para o resto do artigo.
O vazamento, reproduzido
Um ThreadLocal guarda valor por thread, não por tarefa. Num pool, a thread sobrevive à tarefa. Se ninguém chama remove(), o próximo submit() na mesma thread encontra o lixo do anterior:
import java.util.concurrent.Executors;
static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
try (var pool = Executors.newSingleThreadExecutor()) {
pool.submit(() -> { TL.set("tenant-A"); return TL.get(); }).get();
String leaked = pool.submit(TL::get).get();
System.out.println("LEAK_CONFIRMED=" + leaked);
}
}
Rodando local: LEAK_CONFIRMED=tenant-A. A segunda tarefa nunca chamou set, mas herdou o tenant da primeira porque as duas caíram na mesma thread do pool. É o tipo de bug que passa despercebido em teste (uma tarefa por execução, thread nova) e aparece só sob carga.
Vale registrar a exceção honesta: virtual threads geralmente escapam dessa variante específica do bug, porque não são reaproveitadas — cada uma nasce e morre com a tarefa. Mas isso não é uma defesa do ThreadLocal: set sem remove() ainda mantém o valor vivo até a thread morrer, e InheritableThreadLocal ainda copia o valor para threads filhas criadas com Thread.start(). Trocar a implementação de executor não é a correção; trocar o mecanismo é.
A API real do ScopedValue (não tem bind)
Se você já viu um preview antigo do JEP 506, esqueça bind. Nunca existiu como método público, e não existe agora. A vinculação é feita por where(...) seguido de run ou call:
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
String id = ScopedValue.where(TENANT, "acme")
.call(() -> service()); // dentro de service(), TENANT.get() == "acme"
ScopedValue.where(TENANT, "acme").run(() -> service());
where devolve um Carrier imutável — dá para encadear where(A, a).where(B, b). call aceita um Callable-like que pode lançar checked exception; run aceita Runnable. Fora do run/call, o valor não existe: get() lança NoSuchElementException, não devolve null.
Rebind é aninhado e reverte sozinho ao sair do escopo:
ScopedValue.where(TENANT, "hello").run(() -> {
// TENANT.get() -> "hello"
ScopedValue.where(TENANT, "goodbye").run(() -> {
// TENANT.get() -> "goodbye"
});
// de volta aqui, TENANT.get() -> "hello"
});
O ganho sobre ThreadLocal não é sintático, é estrutural: não existe remove() porque não existe estado para esquecer de limpar. O valor só existe dentro da extensão dinâmica de run/call. Verificamos isso no mesmo pool de thread única do exemplo anterior — depois que o call retorna, uma tarefa seguinte que não passou por where chega com isBound() == false, resultado local SCOPED_NO_LEAK laterBound=false. Sem remove, sem sobra.
A API completa que você vai usar: newInstance(), where(key, value), get(), isBound(), orElse(other) e orElseThrow(supplier). Uma pegadinha documentada: orElse(null) lança NullPointerException — o parâmetro precisa ser não nulo. Se você quer tratar "não vinculado" como null, use isBound() antes, não orElse(null).
Fan-out com StructuredTaskScope (preview)
ScopedValue sozinho resolve o caso sequencial. A parte interessante é o que acontece quando a tarefa se divide em subtarefas concorrentes — é aí que ThreadLocal nunca teve uma resposta decente e ScopedValue também não resolve sozinho: quem propaga o contexto para as subtarefas é o StructuredTaskScope.
Atenção: o próprio texto do JEP 506 mostra um snippet com new StructuredTaskScope.ShutdownOnFailure(). Isso não compila no 25 nem no 26 — essa classe some. A API atual (JEP 505, quinto preview) é StructuredTaskScope.open(...) mais um Joiner:
// javac --release 25 --enable-preview
// java --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.List;
record Ctx(String tenant, String trace) {}
static final ScopedValue<Ctx> CTX = ScopedValue.newInstance();
List<String> fanOut(String tenant) throws InterruptedException {
return ScopedValue.where(CTX, new Ctx(tenant, "t-1")).call(() -> {
try (var scope = StructuredTaskScope.open(
Joiner.<String>allSuccessfulOrThrow())) {
scope.fork(() -> "user:" + CTX.get().tenant());
scope.fork(() -> "order:" + CTX.get().tenant());
return scope.join().map(StructuredTaskScope.Subtask::get).toList();
}
});
}
open() sem argumento já dá um comportamento padrão (falha se qualquer subtarefa falhar, mas join() não devolve os resultados); use um Joiner explícito — aqui allSuccessfulOrThrow() — quando você precisa da lista de resultados. Cada fork roda por padrão numa virtual thread nova.
A vinculação é herdada porque é capturada na abertura do escopo, não copiada de thread para thread. É por isso que fork vê CTX, mas um Thread.start() solto ou um ExecutorService comum não veriam — não há garantia estrutural de que a thread filha morre antes do escopo sair do bloco, então o JEP simplesmente não propaga nesse caso. Confirmamos localmente: Thread.ofVirtual().unstarted(...).start() dentro de um where produz uma thread filha com isBound() == false — saída local: UNSTRUCTURED_VT_INHERITS=false.
Dentro do escopo, um filho pode rebindar sem vazar para o irmão nem para o pai — o rebind é local àquele fork:
var pair = ScopedValue.where(TENANT, "outer").call(() -> {
try (var scope = StructuredTaskScope.open()) {
var inner = scope.fork(() ->
ScopedValue.where(TENANT, "inner").call(TENANT::get));
var sibling = scope.fork(TENANT::get);
scope.join();
return inner.get() + "/" + sibling.get();
}
});
Local: pair dá inner/outer (saída local: STS_INNER_SIBLING=inner/outer) — o fork que rebindou vê "inner", o irmão continua vendo "outer", o pai nunca muda.
Prova automatizada de isolamento
Reproduzir o vazamento manualmente convence uma vez; o teste evita que ele volte. Dois testes cobrem o que importa: isolamento entre tarefas concorrentes no mesmo pool, e o comportamento de fork/irmão do StructuredTaskScope.
@Test
void concurrentScopesDoNotLeak() throws Exception {
var barrier = new java.util.concurrent.CyclicBarrier(2);
try (var pool = Executors.newFixedThreadPool(2)) {
var a = pool.submit(() -> ScopedValue.where(TENANT, "A").call(() -> {
barrier.await();
return TENANT.get();
}));
var b = pool.submit(() -> ScopedValue.where(TENANT, "B").call(() -> {
barrier.await();
return TENANT.get();
}));
assertEquals("A", a.get());
assertEquals("B", b.get());
}
}
@Test
void forkInheritsAndSiblingDoesNotSeeRebound() throws Exception {
var pair = ScopedValue.where(TENANT, "outer").call(() -> {
try (var scope = StructuredTaskScope.open()) {
var inner = scope.fork(() ->
ScopedValue.where(TENANT, "inner").call(TENANT::get));
var sibling = scope.fork(TENANT::get);
scope.join();
return inner.get() + "/" + sibling.get();
}
});
assertEquals("inner/outer", pair);
}
@Test
void unboundGetThrows() {
assertFalse(TENANT.isBound());
assertThrows(NoSuchElementException.class, TENANT::get);
}
O CyclicBarrier força as duas tarefas a rodarem sobrepostas de propósito — sem ele, um pool de duas threads poderia terminar a tarefa A inteira antes de começar B, e o teste passaria mesmo com um bug de propagação. Lembrete: forkInheritsAndSiblingDoesNotSeeRebound usa StructuredTaskScope, então o runtime de teste também precisa de --enable-preview (veja Pré-requisitos).
Checklist de migração e pontos de atenção
Nem todo ThreadLocal do seu código é candidato. ScopedValue resolve contexto imutável e de mão única (tenant, trace id, principal autenticado); ThreadLocal continua sendo a ferramenta certa para cache mutável por thread — o JEP 506 não o depreca.
- Confirme que o uso é de leitura: nada no meio da pilha faz
setpara "avisar" o chamador. Esse padrão de duas mãos não tem equivalente emScopedValue— se você depende dele, oThreadLocalfica. - Troque
static final ThreadLocal<String> TL = new ThreadLocal<>();porstatic final ScopedValue<String> TENANT = ScopedValue.newInstance();. - Troque o
set()na entrada da requisição por umwhere(key, value).run(...)(ou.call(...)) envolvendo todo o trabalho. Apague oremove()— a reversão é automática ao sair do bloco. - Troque leituras de
get()porget(),isBound(),orElse(default)ouorElseThrow(...). NuncaorElse(null)— lançaNullPointerException. - Se o contexto precisa atravessar fan-out, troque
ExecutorService.submitsolto porStructuredTaskScope.open()+fork, dentro dowhere(flag de preview: veja Pré-requisitos). - Observabilidade: logue sempre
isBound()na entrada do handler. Logue o valor vinculado só quando for um id não sensível (tenant, trace); para principal ou token, logue um hash ou id estável, nunca o valor cru. No fan-out, logue ou façaassertdeisBound()na entrada de cada subtask — é o jeito mais barato de flagrar umThread.start()ouExecutorServicesolto que não herdou o contexto. - Guardar objeto mutável dentro de um
ScopedValueexige sincronização externa — a API não copia nem protege o conteúdo.
Recomendação
Se o seu ThreadLocal só carrega contexto de leitura por requisição (tenant, trace, principal) e você já está no JDK 25, a migração para ScopedValue vale a pena agora — a API é final, o ganho estrutural (nada para esquecer de limpar) é real, e o custo de portar é baixo. Se o fan-out concorrente com StructuredTaskScope for parte do plano, trate essa parte como preview de verdade: isole a flag --enable-preview no módulo que precisa dela e não a espalhe pelo build inteiro, porque a API de Joiner ainda muda de uma versão para outra (o próprio JDK 26 já renomeia métodos do Joiner). Fora isso — cache mutável por thread, padrão de "set de dentro para avisar quem chamou" — deixe o ThreadLocal onde está.
Próximo passo
Rode os três testes deste artigo com --enable-preview no seu pom.xml/build.gradle (compiler + surefire/test) e confirme as duas saídas que mais importam no seu ambiente: SCOPED_NO_LEAK (nada sobra entre tarefas) e STS_INNER_SIBLING=inner/outer (fork isola do irmão). Se você já tem fan-out em produção com StructuredTaskScope, é o ponto natural para plugar ScopedValue no lugar de qualquer ThreadLocal que hoje atravessa esse fork na marra.