Você atualizou o projeto para o JDK 25, rodou o build e tomou um tapa na cara: o seu StructuredTaskScope não compila mais. A classe ShutdownOnFailure sumiu, o throwIfFailed() virou fumaça e a IDE pinta tudo de vermelho. Antes de xingar o Brian Goetz, respira. A notícia boa é que a API nova ficou melhor, mais enxuta e mais difícil de usar errado. A notícia chata é que ninguém colou um aviso gigante no release notes dizendo "ei, isso aqui mudou de verdade".
Esse artigo é o mapa da migração. Vamos ver o que saiu, o que entrou, como traduzir cada padrão antigo para o novo, como tratar erro e timeout no jeito novo e por que esse assunto virou pergunta de entrevista sênior em 2026. Tudo com código que compila no JDK 25.
O que é structured concurrency, em uma frase honesta
Structured concurrency trata um grupo de tarefas concorrentes como uma única unidade de trabalho. Se você já entendeu o try-with-resources, já entendeu metade: você abre um escopo, dispara as subtarefas dentro dele, e quando o bloco fecha nada fica rodando solto. Ou tudo termina junto, ou tudo é cancelado junto. Sem thread órfã, sem CompletableFuture perdido vazando conexão no pool.
A ideia parece simples, mas resolve uma das maiores dores da concorrência clássica em Java: a falta de uma relação clara entre quem disparou a tarefa e quem é responsável por ela. Com ExecutorService solto, uma tarefa filha podia sobreviver à requisição que a criou. Com structured concurrency, o tempo de vida da subtarefa fica preso ao escopo léxico do bloco.
O recurso nasceu dentro do Project Loom, passou por vários previews entre o JDK 21 e o 24, e ganhou no JDK 25 a forma descrita na JEP 505. O detalhe que pega todo mundo é que essa forma não é compatível com o que você escreveu nos previews anteriores.
O problema que a API antiga tinha
A API de preview girava em torno de duas subclasses prontas: ShutdownOnFailure e ShutdownOnSuccess. Você instanciava uma delas com new, decidindo no construtor qual era a política de encerramento. Funcionava, mas tinha dois incômodos.
O primeiro: as políticas eram fixas. Se você quisesse algo entre "falhou um, cancela tudo" e "deu certo um, cancela tudo", precisava herdar de StructuredTaskScope e reimplementar o método de handle na mão. Pouca gente fazia isso direito, e o código que fazia costumava ter bug de concorrência escondido.
O segundo: o fluxo tinha um passo a mais fácil de esquecer. No ShutdownOnFailure você chamava join() e depois throwIfFailed(). Esquecer o segundo significava seguir a vida com um resultado pela metade e nenhum erro na sua cara.
Veja o padrão clássico de fan-out (buscar duas coisas em paralelo) na API antiga:
Response handle(long id) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // espera as duas
scope.throwIfFailed(); // se uma falhou, estoura aqui
return new Response(user.get(), order.get());
}
}
Repare no throwIfFailed() solitário. Ele é o tipo de linha que some num refactor e ninguém percebe até a produção devolver dado incompleto.
O que mudou no JDK 25
A redesenhada da JEP 505 fez três trocas grandes.
Primeiro, acabaram os construtores. Você não faz mais new StructuredTaskScope.ShutdownOnFailure(). Agora existe o factory method estático StructuredTaskScope.open(). As subclasses ShutdownOnFailure e ShutdownOnSuccess foram removidas de vez. Outra mudança estrutural: StructuredTaskScope deixou de ser uma classe e virou uma interface selada. Em vez de herança, você compõe comportamento passando um Joiner.
Segundo, entrou a interface Joiner. Em vez de escolher a política via subclasse, você passa um Joiner que descreve como as subtarefas se juntam. Os mais comuns vêm de factory methods prontos:
Joiner.allSuccessfulOrThrow(): espera todas terminarem com sucesso e devolve um stream das subtarefas. Se uma falha, cancela as outras e estoura.Joiner.anySuccessfulResultOrThrow(): devolve o primeiro resultado bem-sucedido e cancela o resto. É o substituto direto do antigo ShutdownOnSuccess.Joiner.awaitAllSuccessfulOrThrow(): espera todas com sucesso, sem devolver stream.Joiner.awaitAll(): espera todas terminarem, com sucesso ou não, sem cancelamento automático.
Terceiro, o join() deixou de ser um método que só bloqueia e passou a devolver o resultado do Joiner. O próprio join() já te entrega o que interessa.
O mesmo fan-out de antes, agora no JDK 25, fica assim:
Response handle(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // se uma falhar, join() cancela o resto e lança
return new Response(user.get(), order.get());
}
}
O open() sem argumento já vem com a política de "todas com sucesso ou estoura". O throwIfFailed() não existe mais porque o join() faz esse trabalho. Menos linha, menos pegadinha.
De CompletableFuture para StructuredTaskScope
Antes de entrar na migração das versões de preview, vale olhar para quem nunca usou structured concurrency e ainda resolve fan-out com CompletableFuture. Esse é o caso mais comum em código de produção hoje. O padrão típico fica assim:
Response handle(long id) {
var exec = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<User> userF =
CompletableFuture.supplyAsync(() -> findUser(id), exec);
CompletableFuture<Order> orderF =
CompletableFuture.supplyAsync(() -> fetchOrder(id), exec);
CompletableFuture.allOf(userF, orderF).join();
return new Response(userF.join(), orderF.join());
}
O problema mora no detalhe. Se findUser estourar logo no começo, fetchOrder continua rodando até o fim, queimando uma conexão de banco e um pouco de CPU para um resultado que ninguém vai usar. Não existe cancelamento em cascata. As exceções vêm embrulhadas em CompletionException, e você precisa desembrulhar para descobrir o que aconteceu de verdade.
A versão com structured concurrency resolve os três pontos de uma vez: cancelamento automático, fechamento garantido pelo try-with-resources e exceção sem camada de embrulho. Se você cuida de microsserviços que fazem várias chamadas por requisição, esse é o ganho que paga a migração sozinho.
Migração padrão a padrão
Aqui está a tabela mental que você vai querer colar no monitor durante a migração.
O caso "espera todas, falhou uma cancela tudo" era ShutdownOnFailure. Agora é StructuredTaskScope.open() puro, e você apaga o throwIfFailed().
O caso "corre quem chegar primeiro" era ShutdownOnSuccess. Agora é StructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow()), e o resultado vem do próprio join().
Veja a corrida entre réplicas, padrão "primeiro que responder ganha", na forma nova:
String queryFastest() throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> queryReplica("us-east"));
scope.fork(() -> queryReplica("eu-west"));
return scope.join(); // o join() já devolve o primeiro sucesso
}
}
Coletando vários resultados de uma vez
Quando o Joiner é o allSuccessfulOrThrow(), o join() devolve um Stream<Subtask<T>>. Isso é ótimo para quando você dispara N tarefas iguais e quer juntar tudo numa lista:
List<Integer> precos(List<Callable<Integer>> consultas) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
consultas.forEach(scope::fork);
return scope.join()
.map(Subtask::get)
.toList();
}
}
Nada de lista mutável compartilhada entre threads, nada de synchronized na coleta. O stream sai pronto e na ordem em que você bifurcou.
Timeout e nome do escopo
Na API antiga, o timeout vivia num método à parte, o joinUntil(Instant). No JDK 25 a configuração foi para um segundo parâmetro do open(), recebido como uma função de configuração. Dá para nomear o escopo (ótimo para thread dumps) e definir o timeout no mesmo lugar:
Checkout finalizar(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow(),
cf -> cf.withName("checkout")
.withTimeout(Duration.ofMillis(800)))) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Cart> cart = scope.fork(() -> loadCart(id));
scope.join(); // estoura TimeoutException se passar de 800 ms
return new Checkout(user.get(), cart.get());
}
}
Estourou o tempo? O join() cancela as subtarefas pendentes e lança. Você não fica com meia consulta rodando à toa enquanto devolve erro para o cliente. O nome do escopo, definido com withName, aparece no thread dump estruturado e facilita demais o diagnóstico em produção.
Tratamento de erro e inspeção de subtarefas
Com o Joiner.allSuccessfulOrThrow(), qualquer falha faz o join() lançar StructuredTaskScope.FailedException, e a causa real fica disponível pelo getCause(). O padrão de tratamento fica limpo:
Response handleSafe(long id) {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join();
return new Response(user.get(), order.get());
} catch (StructuredTaskScope.FailedException e) {
throw new ServiceException("falha ao montar a resposta", e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("interrompido", e);
}
}
Quando você precisa de controle fino, dá para inspecionar cada subtarefa individualmente em vez de deixar o join() estourar. Cada Subtask carrega um estado e, se falhou, a própria exceção:
try (var scope = StructuredTaskScope.open(Joiner.<Integer>awaitAll())) {
var subtasks = consultas.stream().map(scope::fork).toList();
scope.join();
for (var st : subtasks) {
if (st.state() == Subtask.State.SUCCESS) {
processa(st.get());
} else if (st.state() == Subtask.State.FAILED) {
log.warn("subtarefa falhou", st.exception());
}
}
}
O awaitAll() é o Joiner certo quando você quer dar uma chance a cada tarefa e tratar sucesso e falha caso a caso, em vez de abortar tudo no primeiro erro. É o padrão para coletar resultados parciais de forma resiliente.
Cada fork roda numa virtual thread
Vale lembrar de onde isso tudo ganha força: por padrão, cada fork roda em uma virtual thread. É por isso que structured concurrency e o Project Loom andam de mãos dadas. Disparar cem subtarefas não significa cem threads de plataforma segurando memória, e sim cem virtual threads baratas. Se você ainda está apanhando do efeito colateral disso, vale revisar como detectar thread pinning em virtual threads no Java 25, porque um synchronized mal colocado dentro de um fork ainda consegue estragar o ganho. A trilha completa do tema está na seção de artigos de Java do Meu Universo Nerd.
O par perfeito: Scoped Values
Tem um detalhe que quase ninguém conecta. Quando você bifurca tarefas, muitas vezes precisa passar contexto para elas (o usuário autenticado, o id da requisição, o locale). A reação automática é usar ThreadLocal, e aí o tiro sai pela culatra: ThreadLocal é mutável, herda caro e vaza fácil quando você tem milhares de virtual threads.
O JDK 25 também finalizou os Scoped Values, descritos na JEP 506, que resolvem exatamente isso com um modelo imutável e de escopo definido. O combo é direto: o StructuredTaskScope organiza as tarefas e o ScopedValue carrega o contexto entre elas. A documentação oficial reúne os dois temas no guia de structured concurrency da Oracle.
Por que isso cai em entrevista sênior
Em 2026, perguntar "como você coordena várias chamadas paralelas em Java?" virou um divisor de águas em entrevista. A resposta de pleno é "uso CompletableFuture.allOf e dou um join". A resposta de sênior é "depende do padrão: se preciso de todas, abro um StructuredTaskScope com allSuccessfulOrThrow; se é o primeiro que responder, uso anySuccessfulResultOrThrow; e sei que o cancelamento e o timeout vêm de graça com o escopo".
O entrevistador não está testando se você decorou o nome do método. Está testando se você entende que tarefas concorrentes precisam de um dono, um ciclo de vida e uma política de cancelamento. Structured concurrency dá nome e estrutura para isso. Quem sabe explicar a diferença entre as políticas de Joiner e por que o throwIfFailed() sumiu mostra que acompanhou a evolução da linguagem.
Checklist de migração
- Troque todo
new StructuredTaskScope.ShutdownOnFailure()porStructuredTaskScope.open()e apague othrowIfFailed()correspondente. - Troque todo
new StructuredTaskScope.ShutdownOnSuccess<T>()porStructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow())e faça ojoin()devolver o resultado direto, removendo oresult(). - Substitua
joinUntil(Instant)pela configuraçãocf -> cf.withTimeout(Duration)no segundo argumento doopen(). - Reveja os catch: passe a tratar StructuredTaskScope.FailedException e use
getCause()para chegar na exceção real. - Onde você coletava resultados num laço, troque pelo
join()que devolve Stream<Subtask<T>> com oallSuccessfulOrThrow(). - Rode os testes de concorrência. Se não tem nenhum, esse é um ótimo momento para escrever o primeiro.
Armadilhas comuns na migração
O primeiro tropeço é continuar chamando throwIfFailed() ou result(). Eles não existem mais. O compilador avisa, mas em código gerado ou em macros de IDE o erro pode passar batido por um tempo.
O segundo é esquecer o parâmetro de tipo no Joiner quando o compilador não consegue inferir. Em casos como anySuccessfulResultOrThrow, às vezes você precisa escrever Joiner.<String>anySuccessfulResultOrThrow() para o tipo do join() sair certo.
O terceiro é misturar o velho joinUntil(Instant) com o novo withTimeout(Duration). O método antigo saiu. O timeout agora mora na configuração do open().
Perguntas frequentes
Structured concurrency já é estável no JDK 25? Ela segue como recurso em preview na linha do JDK 25, então é preciso compilar e rodar com --enable-preview. A API descrita aqui é a forma redesenhada da JEP 505, que é a base do que vai estabilizar nas próximas versões.
Preciso usar virtual threads para aproveitar? Por padrão cada fork já roda em virtual thread, então o ganho vem de fábrica.
Posso aninhar escopos? Sim, e é justamente o ponto. Um escopo dentro de outro forma a árvore de tarefas, e o cancelamento se propaga de cima para baixo de forma previsível.
E o desempenho comparado ao CompletableFuture? O ganho principal não é micro-benchmark, é evitar trabalho desperdiçado: cancelamento em cascata corta tarefas que ficariam rodando à toa, o que em produção significa menos conexão presa e menos latência de cauda.
Conclusão
A redesenhada de structured concurrency no JDK 25 é daquelas mudanças que assustam no primeiro build quebrado e agradecem no primeiro code review. A API ficou menor, o cancelamento e o timeout viraram cidadãos de primeira classe e as políticas de junção ganharam nomes claros via Joiner. Migrar é mais questão de traduzir padrões do que de reescrever lógica.
Se o seu projeto está subindo para o JDK 25, faça a migração com calma, troque os ShutdownOnFailure e ShutdownOnSuccess pelos Joiner equivalentes e aproveite para revisar onde você ainda usa CompletableFuture na unha. E fique de olho no próximo artigo da série sobre Scoped Values aqui no Meu Universo Nerd. Concorrência moderna em Java parou de ser bicho de sete cabeças, virou bloco de código com começo, meio e fim.