Um Milhão de Threads — Virtual Threads

Um Milhão de Threads — Virtual Threads

Threads virtuais esperam quase de graça: dez mil requisições de um segundo terminam em pouco mais de um segundo, um milhão cabe em 660 MB. Como a JVM monta e desmonta cada uma nas portadoras, por que não ajudam em cálculo, como limitar o recurso com Semaphore e o mapConcurrent.
Java

• • 22 min de leitura

Como as threads virtuais do Java 21 permitem escrever código simples, que bloqueia à vontade, e ainda assim atender dezenas de milhares de tarefas ao mesmo tempo: o que são, como funcionam por dentro, onde ajudam e onde não fazem diferença nenhuma.

"Encadeando o que Ainda Não Aconteceu" terminou com uma pergunta: todo o esforço do CompletableFuture para não deixar threads paradas esperando existe porque threads de plataforma são caras. E se uma thread parada não custasse quase nada? Essa pergunta ocupou uma equipe da Oracle, liderada por Ron Pressler, durante anos, no chamado Projeto Loom, iniciado em 2017. A resposta chegou em prévia no Java 19, em 2022, e como recurso definitivo no Java 21, em setembro de 2023, pela JEP 444: as threads virtuais. Elas são, provavelmente, a mudança mais importante na forma de escrever programas concorrentes em Java desde o pacote java.util.concurrent, em 2004, e o motivo é que elas não pedem nenhum estilo novo. O código continua sendo o código sequencial de sempre, com sleep, get() e leituras bloqueantes, e é a JVM que muda o que acontece por baixo dele.

O problema: uma thread por requisição

A forma mais simples de escrever um servidor é dar a cada requisição sua própria thread, que lê o pedido, consulta o banco, chama outro serviço e responde, tudo em sequência. Esse estilo, chamado de uma thread por requisição (thread-per-request), é fácil de escrever, de ler e de depurar: a pilha de chamadas de uma exceção conta exatamente a história daquela requisição.

O problema é a escala. Cada thread de plataforma corresponde a uma thread do sistema operacional, com uma pilha reservada de tamanho fixo (1 MB, por padrão, na maioria dos sistemas de 64 bits), e o sistema operacional não lida bem com dezenas de milhares delas. Por isso os servidores usam pools de algumas centenas de threads. E uma requisição típica passa quase todo o tempo esperando, pelo banco, pela rede, pelo disco. Durante essa espera, a thread não faz nada, mas continua ocupada. O resultado é que o servidor fica limitado pelo número de threads muito antes de a CPU chegar perto do limite:

import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

// Uma "requisição" que passa quase todo o tempo esperando (banco, rede...)
static int atender(int n) throws InterruptedException {
    Thread.sleep(Duration.ofSeconds(1));
    return n;
}

static void rodar(String nome, ExecutorService executor) {
    long t0 = System.nanoTime();
    try (executor) {
        IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> atender(i)));
    }
    IO.println(String.format("%-28s %6d ms", nome, (System.nanoTime() - t0) / 1_000_000));
}

void main() {
    rodar("pool fixo de 200 threads", Executors.newFixedThreadPool(200));
    rodar("uma thread virtual por tarefa", Executors.newVirtualThreadPerTaskExecutor());
}
pool fixo de 200 threads       50062 ms
uma thread virtual por tarefa   1100 ms

Dez mil requisições de um segundo cada. Com 200 threads, elas são atendidas em 50 levas de 200, e o total é de 50 segundos, com a CPU praticamente parada o tempo todo. Com threads virtuais, as dez mil esperam ao mesmo tempo, e tudo termina em pouco mais de um segundo. O código da tarefa é idêntico nos dois casos; só o executor mudou. O try (executor) é a forma do try-with-resources do Java 9 que aceita uma variável já declarada, e fecha o executor passado como parâmetro.

A alternativa antes das threads virtuais era a programação assíncrona da aula anterior: nunca bloquear, e encadear callbacks ou futuros para continuar o trabalho quando a espera terminasse. Funciona, e escala, mas ao preço de todo o código virar uma cadeia de lambdas, com as pilhas de chamadas fragmentadas e a depuração difícil que "Encadeando o que Ainda Não Aconteceu" descreveu. As threads virtuais oferecem a escala do estilo assíncrono com a simplicidade do estilo sequencial.

Criando threads virtuais

Uma thread virtual é um objeto Thread como qualquer outro, com a mesma API. Há três formas de criá-la:

// 1. O construtor de threads do Java 21, como em "Fazendo Duas Coisas ao Mesmo Tempo"
Thread t1 = Thread.ofVirtual().name("atendente-1").start(() -> IO.println("olá"));

// 2. O atalho
Thread t2 = Thread.startVirtualThread(() -> IO.println("olá de novo"));

// 3. Um executor que cria uma thread virtual nova para cada tarefa
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> IO.println("olá do executor"));
}

A terceira forma é a mais usada, porque combina as threads virtuais com tudo o que "Não Crie Threads, Entregue Tarefas" ensinou sobre Future, invokeAll e desligamento ordenado. Repare que o nome desse executor não tem a palavra pool: ele não reaproveita threads, cria uma nova para cada tarefa, e isso é intencional, como a seção sobre o que muda vai explicar.

Algumas propriedades distinguem uma thread virtual:

Thread v = Thread.ofVirtual().unstarted(() -> {});
IO.println("virtual? " + v.isVirtual() + ", daemon? " + v.isDaemon() + ", nome: '" + v.getName() + "'");
virtual? true, daemon? true, nome: ''

Threads virtuais são sempre daemon: não impedem a JVM de terminar, e o programa precisa esperar por elas explicitamente, com join() ou fechando o executor. Por padrão, não têm nome, e a prioridade é sempre a normal; setPriority e setDaemon(false) são ignorados ou rejeitados. Fora isso, tudo funciona igual: sleep, join, interrupção, synchronized, volatile, as travas e as coleções concorrentes de "Ferramentas de Precisão".

Como funcionam por dentro: montar e desmontar

O truque das threads virtuais é que elas não correspondem a threads do sistema operacional. A JVM mantém um pequeno grupo de threads de plataforma, as threads portadoras (carrier threads), por padrão uma por núcleo, num ForkJoinPool dedicado. Para executar, uma thread virtual é montada numa portadora. Quando ela faz uma operação que bloquearia, como sleep, uma leitura de socket ou a espera por uma trava, a JVM a desmonta: copia a pilha de chamadas dela para o heap, como um objeto comum, e libera a portadora para executar outra thread virtual. Quando a espera termina, a thread virtual é agendada de novo e montada em qualquer portadora livre, com a pilha restaurada, e continua de onde parou, sem saber que foi interrompida.

A saída do toString() de uma thread virtual mostra isso acontecendo:

import java.time.Duration;
import java.util.*;

void main() throws InterruptedException {
    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofVirtual().name("virtual-" + i).start(() -> {
            IO.println("antes:  " + Thread.currentThread());
            try { Thread.sleep(Duration.ofMillis(100)); } catch (InterruptedException e) { return; }
            IO.println("depois: " + Thread.currentThread());
        }));
    }
    for (Thread t : threads) t.join();
}
antes:  VirtualThread[#23,virtual-0]/runnable@ForkJoinPool-1-worker-1
antes:  VirtualThread[#25,virtual-1]/runnable@ForkJoinPool-1-worker-2
antes:  VirtualThread[#28,virtual-2]/runnable@ForkJoinPool-1-worker-2
antes:  VirtualThread[#29,virtual-3]/runnable@ForkJoinPool-1-worker-2
depois: VirtualThread[#23,virtual-0]/runnable@ForkJoinPool-1-worker-2
depois: VirtualThread[#28,virtual-2]/runnable@ForkJoinPool-1-worker-1
depois: VirtualThread[#25,virtual-1]/runnable@ForkJoinPool-1-worker-1
depois: VirtualThread[#29,virtual-3]/runnable@ForkJoinPool-1-worker-1

O texto depois da @ é a portadora. Quatro threads virtuais revezaram-se em duas portadoras, e a virtual-0 começou na worker-1 e terminou na worker-2. É a mesma ideia com que o sistema operacional multiplexa milhares de processos sobre poucos núcleos, feita agora dentro da JVM, com uma vantagem: a JVM sabe exatamente onde cada thread pode ser pausada, e a pilha de uma thread virtual ocupa só o que ela realmente usa, crescendo e encolhendo no heap, e não um megabyte reservado de antemão.

Para que isso funcionasse, a equipe do Loom reescreveu as partes bloqueantes da biblioteca padrão, como Socket, Thread.sleep, as travas de java.util.concurrent e as filas bloqueantes, para desmontar a thread virtual em vez de bloquear a portadora. É por isso que o código antigo, que usa essas APIs, ganha a escalabilidade sem mudança nenhuma.

Um milhão, de verdade

O título da aula não é exagero. Com threads virtuais, criar um milhão de threads é uma operação comum:

import java.time.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;

void main(String[] args) throws InterruptedException {
    int quantidade = Integer.parseInt(args.length > 0 ? args[0] : "1000000");
    AtomicInteger terminadas = new AtomicInteger();
    List<Thread> threads = new ArrayList<>(quantidade);
    long t0 = System.nanoTime();
    for (int i = 0; i < quantidade; i++) {
        threads.add(Thread.ofVirtual().start(() -> {
            try {
                Thread.sleep(Duration.ofSeconds(2));
                terminadas.incrementAndGet();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }));
    }
    long criadas = (System.nanoTime() - t0) / 1_000_000;
    Runtime rt = Runtime.getRuntime();
    long usadaMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
    IO.println(quantidade + " threads virtuais criadas em " + criadas + " ms; heap em uso ≈ " + usadaMb + " MB");
    for (Thread t : threads) t.join();
    IO.println("terminadas: " + terminadas.get() + " em " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}

Numa máquina de dois núcleos e 8 GB:

100000 threads virtuais criadas em 325 ms; heap em uso ≈ 111 MB
terminadas: 100000 em 2972 ms
1000000 threads virtuais criadas em 1666 ms; heap em uso ≈ 661 MB
terminadas: 1000000 em 14234 ms

Um milhão de threads, todas dormindo ao mesmo tempo, em cerca de 660 MB de heap, o que inclui a lista que guarda os objetos Thread. A mesma quantidade de threads de plataforma exigiria, só de pilhas reservadas, perto de um terabyte de espaço de endereçamento, e o sistema operacional recusaria bem antes: ao tentar esse experimento com threads de plataforma no mesmo contêiner, o programa simplesmente parou de responder e teve de ser encerrado.

O tempo total merece honestidade. Com cem mil threads, o programa termina em 3 segundos, quase só os 2 segundos de espera. Com um milhão, leva 14 segundos. O custo não está em esperar, e sim em acordar: quando o sono termina, cada uma das um milhão de threads precisa ser agendada e montada de novo, e com apenas duas portadoras isso leva uns 12 microssegundos por thread. Threads virtuais são baratas, mas não gratuitas, e esse é um lembrete de "Medir Antes de Otimizar": o número que importa é o do seu programa, na sua máquina. O que muda de escala é o que é possível: com threads de plataforma, a pergunta é "quantas threads cabem?"; com threads virtuais, é "quanto trabalho real há para fazer?".

Onde não fazem diferença: cálculo

Threads virtuais aumentam a quantidade de tarefas que podem esperar ao mesmo tempo, não a velocidade do processador. Numa tarefa que só calcula, elas não ajudam em nada:

static long rodar(ExecutorService executor) throws Exception {
    try (executor) {
        List<Future<Long>> partes = new ArrayList<>();
        for (int p = 0; p < 100; p++) {
            int de = p * 50_000;
            partes.add(executor.submit(() -> contarPrimos(de, de + 50_000)));
        }
        long total = 0;
        for (Future<Long> f : partes) total += f.get();
        return total;
    }
}

Com a contagem de primos de "Não Crie Threads, Entregue Tarefas", abaixo de cinco milhões:

pool de 2 threads: 348513 em 653 ms | virtuais: 348513 em 660 ms
pool de 2 threads: 348513 em 653 ms | virtuais: 348513 em 663 ms

Os tempos são praticamente iguais. As cem threads virtuais rodam sobre duas portadoras, e duas threads de plataforma usam os mesmos dois núcleos. Nenhuma delas espera, então não há nada para ganhar desmontando. Para trabalho de CPU, continuam valendo o pool dimensionado pelo número de núcleos e os streams paralelos. A regra de escolha é direta: muitas tarefas que esperam, threads virtuais; poucas tarefas que calculam, threads de plataforma.

O que muda na forma de pensar

Três hábitos de quem trabalha com threads de plataforma se invertem com as threads virtuais.

Não faça pool de threads virtuais. O pool existe para amortizar o custo de criar threads caras e para limitar quantas existem ao mesmo tempo. Uma thread virtual custa pouco mais que um objeto comum, e um pool delas só acrescenta uma fila e um limite artificial. Crie uma thread virtual nova para cada tarefa e deixe-a morrer ao fim, como faz o newVirtualThreadPerTaskExecutor.

Limite o recurso, não as threads. Com threads de plataforma, o tamanho do pool servia, sem querer, como limite para o banco de dados: com 50 threads, no máximo 50 consultas simultâneas. Com threads virtuais, esse limite desaparece, e dez mil tarefas podem tentar abrir dez mil conexões com um banco que aceita cem. O limite precisa ficar onde está o recurso escasso, e o Semaphore de "Ferramentas de Precisão" é a ferramenta:

import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

static final Semaphore conexoes = new Semaphore(10);   // o banco aceita só 10 conexões
static final AtomicInteger emUso = new AtomicInteger(), pico = new AtomicInteger();

static void consultarBanco() throws InterruptedException {
    conexoes.acquire();
    try {
        pico.accumulateAndGet(emUso.incrementAndGet(), Math::max);
        Thread.sleep(Duration.ofMillis(100));
    } finally {
        emUso.decrementAndGet();
        conexoes.release();
    }
}

void main() {
    long t0 = System.nanoTime();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 1_000; i++) executor.submit(() -> { consultarBanco(); return null; });
    }
    IO.println("1000 consultas em " + (System.nanoTime() - t0) / 1_000_000 + " ms; pico de conexões: " + pico.get());
}
1000 consultas em 10064 ms; pico de conexões: 10

Mil threads virtuais existem ao mesmo tempo, mas só dez usam o banco de cada vez, em cem levas de 100 ms. As outras esperam no acquire(), desmontadas, sem ocupar portadora. Na prática, o pool de conexões do banco de dados, que a Trilha A vai configurar, faz esse papel.

Cuidado com ThreadLocal. Com um pool de 200 threads, um ThreadLocal que guarda um objeto caro cria no máximo 200 cópias. Com uma thread virtual por tarefa, cria uma cópia por tarefa, possivelmente milhões, e o cache vira um desperdício de memória. O antigo hábito de guardar um SimpleDateFormat por thread, por exemplo, deve ser substituído pelo DateTimeFormatter de "Tempo, Dinheiro e Padrões", que é imutável e pode ser compartilhado por todas as threads num campo static final. Para o outro uso do ThreadLocal, carregar um contexto pela requisição, a próxima aula apresenta uma ferramenta feita para as threads virtuais.

Fixação: quando a thread não consegue sair

Há situações em que uma thread virtual bloqueia sem conseguir se desmontar, prendendo a portadora. Isso se chama fixação (pinning). No Java 21, a causa mais comum era o synchronized: uma thread virtual que bloqueasse dentro de um bloco synchronized ficava presa à portadora, porque o mecanismo de travas da JVM estava ligado à thread do sistema operacional. Como boa parte das bibliotecas usa synchronized, isso era um problema sério, e a recomendação da época era trocá-lo por ReentrantLock.

A JEP 491, do Java 24, reescreveu o funcionamento das travas de objeto e eliminou essa causa. No Java 25, dá para verificar com uma única portadora, forçada pela propriedade jdk.virtualThreadScheduler.parallelism:

import java.time.Duration;
import java.util.concurrent.*;

// Cada tarefa segura a sua própria trava enquanto "espera o banco"
static void tarefa(Object trava) {
    synchronized (trava) {
        try { Thread.sleep(Duration.ofMillis(500)); } catch (InterruptedException e) { }
    }
}

void main() {
    long t0 = System.nanoTime();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 8; i++) {
            Object trava = new Object();
            executor.submit(() -> tarefa(trava));
        }
    }
    IO.println("8 tarefas de 500 ms com synchronized: " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}
$ java -Djdk.virtualThreadScheduler.parallelism=1 Travadas.java
8 tarefas de 500 ms com synchronized: 508 ms

Oito tarefas, cada uma dormindo meio segundo dentro de um synchronized, com uma única portadora, e o total é meio segundo: todas esperaram juntas. No Java 21, o mesmo programa levaria 4 segundos, uma tarefa depois da outra, com a portadora presa em cada sleep. O catch vazio aqui é aceitável apenas porque a tarefa termina logo depois; em código real, a interrupção seria restaurada, como sempre.

A fixação ainda acontece em casos mais raros: quando a thread virtual está executando código nativo, como uma chamada da Foreign Function & Memory API de "Ponte para o Nativo" que por sua vez chama de volta código Java, e em alguns pontos da inicialização de classes. Para encontrar esses casos, o JFR de "Medir Antes de Otimizar" registra o evento jdk.VirtualThreadPinned sempre que uma thread virtual fica fixada por tempo demais.

Observando milhões de threads

As ferramentas de diagnóstico também precisaram se adaptar. O comando jcmd <pid> Thread.print, usado em "O Cadeado e a Memória" para encontrar o impasse, mostra apenas as threads de plataforma, porque listar um milhão de threads num terminal não ajudaria ninguém. Para as virtuais, existe um despejo em arquivo, em texto ou JSON:

$ jcmd 29181 Thread.dump_to_file -format=json threads.json

O JSON organiza as threads em contêineres, e as virtuais criadas diretamente com Thread.ofVirtual() aparecem no contêiner raiz, ao lado das threads de plataforma, cada uma com sua pilha de chamadas completa:

<root>                                    11 threads, 3 delas virtuais
    atendente-0  virtual  VirtualThread.parkNanos ← VirtualThread.sleepNanos ← Thread.sleepNanos ...
    atendente-1  virtual  ...
    atendente-2  virtual  ...
ForkJoinPool-1/...SharedThreadContainer    4 threads de plataforma (as portadoras)

A pilha de uma thread virtual é tão legível quanto a de uma de plataforma, e isso é a grande vantagem sobre o estilo assíncrono: quando algo trava, o despejo mostra exatamente em que linha do seu código cada tarefa está esperando. A próxima aula mostra como esses contêineres formam uma árvore quando as tarefas são organizadas em escopos.

Streams com concorrência limitada: Gatherers.mapConcurrent

"Agrupar, Achatar, Reduzir" apresentou os gatherers, finalizados no Java 24, e deixou um deles para esta aula. O Gatherers.mapConcurrent(n, funcao) aplica a função a cada elemento de um stream em threads virtuais, com no máximo n execuções simultâneas, e entrega os resultados na ordem original:

import java.time.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.*;

static final AtomicInteger simultaneas = new AtomicInteger();
static final AtomicInteger pico = new AtomicInteger();

// Consulta "remota" de 200 ms
static String buscarTitulo(int id) {
    int agora = simultaneas.incrementAndGet();
    pico.accumulateAndGet(agora, Math::max);
    try {
        Thread.sleep(Duration.ofMillis(200));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        simultaneas.decrementAndGet();
    }
    return "artigo-" + id;
}

void main() {
    long t0 = System.nanoTime();
    List<String> titulos = IntStream.rangeClosed(1, 20).boxed()
            .gather(Gatherers.mapConcurrent(5, id -> buscarTitulo(id)))  // no máximo 5 ao mesmo tempo
            .toList();
    IO.println(titulos.subList(0, 4) + " ... " + titulos.get(19));
    IO.println("20 consultas de 200 ms em " + (System.nanoTime() - t0) / 1_000_000 + " ms; pico de simultâneas: " + pico.get());
}
[artigo-1, artigo-2, artigo-3, artigo-4] ... artigo-20
20 consultas de 200 ms em 818 ms; pico de simultâneas: 5

Vinte consultas de 200 ms, cinco de cada vez, em quatro levas: cerca de 800 ms, contra 4 segundos em sequência. O limite de concorrência está embutido, e a ordem do stream é preservada. É a diferença essencial para o parallel(): os streams paralelos usam o ForkJoinPool comum, dimensionado para cálculo, e numa máquina de dois núcleos fariam duas consultas de cada vez, uma na única thread do pool e outra na thread que chamou. O mapConcurrent é a ferramenta para "chame este serviço para cada elemento, mas não mais de N ao mesmo tempo", uma das tarefas mais comuns em sistemas que integram outros sistemas.

Com as threads virtuais, criar uma tarefa concorrente passou a ser tão barato quanto chamar um método. Isso cria um problema novo, que já apareceu nos exemplos desta aula e da anterior: quando uma tarefa dispara outras, e uma delas falha, quem cancela as demais? Quem garante que nenhuma continue rodando depois que o resultado já não interessa? Com ExecutorService e CompletableFuture, a resposta é "o programador, com cuidado".

Fontes e leituras recomendadas

Exercícios

Exercício 1

Explique o tempo de 50062 ms do pool fixo de 200 threads e o de 1100 ms das threads virtuais. Quanto levaria o pool fixo com 1000 threads? E com 10000? Por que aumentar o pool não é uma boa solução?

Ver resposta

✓ Resposta: Com 200 threads, só 200 das 10000 tarefas podem esperar ao mesmo tempo; as outras ficam na fila. São 10000 / 200 = 50 levas de 1 segundo cada, daí os 50 segundos. Com threads virtuais, as 10000 esperam juntas, e o tempo é o de uma espera, mais o custo de criar, agendar e acordar dez mil threads, os 100 ms extras. Com 1000 threads de plataforma, seriam 10 levas, uns 10 segundos. Com 10000, uma leva só, perto de 1 segundo, se o sistema aguentar. Mas cada thread de plataforma reserva sua pilha e ocupa uma thread do sistema operacional: dez mil delas consomem muita memória, deixam o escalonador do sistema lento e ficam perto dos limites de processos do usuário. E o número certo muda com a carga: o servidor que hoje recebe dez mil requisições amanhã recebe cinquenta mil. Com threads virtuais, o custo de uma thread esperando é o de um objeto no heap, e não é preciso adivinhar um número.

Exercício 2

Uma equipe trocou o Executors.newFixedThreadPool(8) de um serviço de compressão de imagens, numa máquina de 8 núcleos, por newVirtualThreadPerTaskExecutor(), e não viu melhora nenhuma. Explique por quê.

Ver resposta

✓ Resposta: Comprimir imagens é trabalho de CPU: cada tarefa usa um núcleo o tempo todo, sem esperar por nada. As threads virtuais ganham desempenho desmontando-se durante as esperas para que a portadora execute outra coisa, mas uma tarefa que nunca espera nunca se desmonta. Com oito portadoras, uma por núcleo, só oito threads virtuais executam de cada vez, exatamente como as oito threads do pool, e o trabalho total é o mesmo. É o que o teste dos primos mostrou: 653 ms contra 660 ms. Poderia até piorar um pouco, porque sem o limite do pool milhares de tarefas pesadas podem existir ao mesmo tempo, cada uma com seus dados na memória. A melhora, se existir, virá de outro lugar: algoritmos melhores, vetorização, ou mais núcleos.

Exercício 3

Um desenvolvedor, preocupado com o custo, criou um pool de threads virtuais com Executors.newFixedThreadPool(100, Thread.ofVirtual().factory()) para limitar as chamadas a uma API externa que aceita 100 requisições simultâneas. Esse código funciona? Qual seria a forma recomendada?

Ver resposta

✓ Resposta: Funciona, no sentido de que no máximo 100 tarefas chamarão a API ao mesmo tempo, mas mistura dois conceitos e perde as vantagens das threads virtuais. As tarefas além de 100 ficam numa fila do executor, sem thread, e o código que as enviou não tem como saber se estão esperando ou rodando. Além disso, as threads virtuais são reaproveitadas entre tarefas, o que traz de volta os problemas do ThreadLocal herdado entre tarefas de "Ferramentas de Precisão". E o limite fica no lugar errado: se a mesma tarefa, além da API, também ler arquivos ou consultar o banco, essas operações ficam limitadas a 100 sem necessidade. A forma recomendada é uma thread virtual por tarefa e o limite no recurso:

static final Semaphore limiteDaApi = new Semaphore(100);

static String chamarApi(String pedido) throws InterruptedException {
    limiteDaApi.acquire();
    try {
        return clienteHttp.enviar(pedido);   // só aqui o limite se aplica
    } finally {
        limiteDaApi.release();
    }
}

com as tarefas enviadas a um newVirtualThreadPerTaskExecutor(). Se a lista de pedidos estiver num stream, o Gatherers.mapConcurrent(100, ...) faz o mesmo em uma linha.

Exercício 4

Um sistema antigo guarda um SimpleDateFormat num ThreadLocal para formatar datas em cada requisição, porque essa classe não é segura entre threads. Depois da migração para uma thread virtual por requisição, a memória usada cresceu muito. Explique e corrija.

Ver resposta

✓ Resposta: Com um pool de 200 threads de plataforma, o ThreadLocal criava no máximo 200 instâncias de SimpleDateFormat, reaproveitadas por todas as requisições. Com uma thread virtual nova por requisição, cada requisição encontra o ThreadLocal vazio, cria sua própria instância, usa-a uma vez e a descarta quando a thread morre. Com dezenas de milhares de requisições simultâneas, há dezenas de milhares de formatadores vivos, e o coletor de lixo trabalha muito mais. O cache deixou de ser cache. A correção é usar o DateTimeFormatter do java.time, que é imutável e seguro entre threads, numa constante compartilhada:

static final DateTimeFormatter FORMATO = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
// em qualquer thread:
String texto = FORMATO.format(LocalDateTime.of(2026, 9, 25, 18, 7));   // "25/09/2026 18:07"

Uma única instância serve a todas as threads, virtuais ou não, sem trava e sem ThreadLocal. A lição geral é de "O Cadeado e a Memória": objetos imutáveis dispensam qualquer proteção.

Exercício 5

É preciso buscar os dados de 1000 produtos num serviço externo, que responde em cerca de 100 ms e aceita no máximo 20 chamadas simultâneas, e a lista resultante deve manter a ordem dos identificadores. Compare ids.parallelStream().map(this::buscar).toList() com ids.stream().gather(Gatherers.mapConcurrent(20, this::buscar)).toList() numa máquina de 4 núcleos.

Ver resposta

✓ Resposta: As duas versões preservam a ordem na lista final, porque toList() respeita a ordem de encontro do stream também em streams paralelos. A diferença está na concorrência. O stream paralelo usa o ForkJoinPool comum, com paralelismo igual ao número de núcleos menos um, 3 nesta máquina, mais a própria thread que chamou: cerca de 4 buscas ao mesmo tempo, o que dá 1000 / 4 × 100 ms ≈ 25 segundos, e ainda ocupa o pool comum, deixando lentos outros streams paralelos do programa. O mapConcurrent(20, ...) executa as buscas em threads virtuais, exatamente 20 de cada vez, o limite do serviço: 1000 / 20 × 100 ms ≈ 5 segundos, sem tocar no pool comum. O stream paralelo foi feito para dividir cálculo entre núcleos; o mapConcurrent, para sobrepor esperas com um limite explícito. Para chamadas de rede, o segundo é a escolha certa.

Comentários

Mais em Java

O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets
O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets

Começa o projeto final: o que trafega entre navegador e servidor no HTTP/1.1…

Extensão — Quem É Você? Spring Security e JWT
Extensão — Quem É Você? Spring Security e JWT

Nesta aula a API ganha autenticação e autorização. As senhas são guardadas…

A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive
A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive

Nesta aula o Farol passa a atender cada conexão numa thread virtual própria…