Todos os artigos

// Knowledge.log — 技術記事

Scoped Values no Java 25: substituindo ThreadLocal sem vazar estado entre threads reaproveitadas

JDK 25 finaliza Scoped Values: reproduza o vazamento de ThreadLocal em pools, veja a API real (sem bind) e a integração com StructuredTaskScope em preview.

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-preview na 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 forkCTX, 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: pairinner/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.

  1. Confirme que o uso é de leitura: nada no meio da pilha faz set para "avisar" o chamador. Esse padrão de duas mãos não tem equivalente em ScopedValue — se você depende dele, o ThreadLocal fica.
  2. Troque static final ThreadLocal<String> TL = new ThreadLocal<>(); por static final ScopedValue<String> TENANT = ScopedValue.newInstance();.
  3. Troque o set() na entrada da requisição por um where(key, value).run(...) (ou .call(...)) envolvendo todo o trabalho. Apague o remove() — a reversão é automática ao sair do bloco.
  4. Troque leituras de get() por get(), isBound(), orElse(default) ou orElseThrow(...). Nunca orElse(null) — lança NullPointerException.
  5. Se o contexto precisa atravessar fan-out, troque ExecutorService.submit solto por StructuredTaskScope.open() + fork, dentro do where (flag de preview: veja Pré-requisitos).
  6. 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ça assert de isBound() na entrada de cada subtask — é o jeito mais barato de flagrar um Thread.start() ou ExecutorService solto que não herdou o contexto.
  7. Guardar objeto mutável dentro de um ScopedValue exige 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.

javajdk-25concurrency

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos