Java Threads Virtuais

As threads que você viu até aqui são threads de plataforma: cada uma é uma thread de verdade do sistema operacional, e elas são caras. Um programa aguenta algumas centenas, talvez uns poucos milhares. As threads virtuais Java 21+ são threads leves, administradas pela própria JVM: dá para ter milhares ou milhões delas. Elas brilham num caso muito comum: muitas tarefas que passam a maior parte do tempo esperando.

Você vai aprender
  • A diferença entre thread de plataforma e thread virtual.
  • Criar threads virtuais com Thread.ofVirtual().start(…) e Thread.startVirtualThread(…).
  • Usar Executors.newVirtualThreadPerTaskExecutor(): uma thread virtual por tarefa.
  • Uma medição real: 10 000 tarefas que esperam, com 200 threads de plataforma e com threads virtuais.
  • Quando elas não ajudam (contas pesadas) e os cuidados (são daemon, não se faz pool delas).

Leia antes Threads e ExecutorService: esta aula usa start, join, submit e o executor em try-with-resources. Os exemplos rodaram no JDK 25 da máquina da Ana, na pasta ~/java.


A ideia antes do código

Pense num restaurante. Cada garçom é uma thread de plataforma: custa caro (salário, uniforme) e o restaurante só pode ter alguns. Agora repare no que um garçom faz a maior parte do tempo: espera. Espera o cliente escolher, espera a cozinha, espera a conta. Se cada garçom ficasse grudado numa mesa só, esperando junto, o restaurante precisaria de um garçom por mesa.

A thread virtual é a comanda da mesa. Há uma comanda para cada mesa (são baratas, é só papel). Poucos garçons atendem todas: quando a mesa 7 está esperando a cozinha, o garçom larga a comanda 7 no balcão e vai atender a mesa 12. Quando o prato da 7 fica pronto, qualquer garçom livre pega a comanda e continua de onde parou.

  • Os garçons são as poucas threads de plataforma que a JVM usa por baixo (chamadas carregadoras, carrier threads).
  • As comandas são as threads virtuais: uma por tarefa, quantas forem precisas.
  • "Largar a comanda no balcão" acontece automaticamente quando a tarefa espera: Thread.sleep, ler da rede, consultar um banco de dados, ler um arquivo.

Mas repare: se a mesa pede para o garçom fazer contas sem parar, comanda nenhuma ajuda. O garçom continua ocupado. Guarde isso para a seção 4.


1. Criando uma thread virtual

Desde o Java 21, a classe Thread tem construtores "fluentes": Thread.ofVirtual() para threads virtuais e Thread.ofPlatform() para as de plataforma.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread v = Thread.ofVirtual().name("entrega-1").start(() -> {
            Thread eu = Thread.currentThread();
            System.out.println("Sou " + eu.getName() + "; virtual? " + eu.isVirtual());
            System.out.println(eu);
        });
        v.join();

        Thread p = Thread.ofPlatform().name("caixa").start(() -> {
            Thread eu = Thread.currentThread();
            System.out.println("Sou " + eu.getName() + "; virtual? " + eu.isVirtual());
        });
        p.join();

        Thread outra = Thread.startVirtualThread(() -> System.out.println("Atalho também funciona"));
        outra.join();
    }
}
$ javac Main.java && java Main
Sou entrega-1; virtual? true
VirtualThread[#35,entrega-1]/runnable@ForkJoinPool-1-worker-1
Sou caixa; virtual? false
Atalho também funciona
Thread.ofVirtual()
"Uma thread, do tipo virtual." Devolve um construtor de threads.
.name("entrega-1")
Dá um nome (opcional; sem ele, a thread virtual fica sem nome).
.start(() -> { … })
Cria a thread com a tarefa e já inicia. Devolve o objeto Thread, que você usa como sempre: join(), getName()…

A segunda linha da saída mostra a thread por dentro: VirtualThread[#35,entrega-1] é a comanda (número 35, nome entrega-1), e @ForkJoinPool-1-worker-1 é o garçom que a estava carregando naquele instante. isVirtual() ("é virtual?") confirma o tipo. Thread.startVirtualThread(tarefa) é um atalho para criar e iniciar sem nome.

Missão: troque a thread de plataforma por uma virtual, usando Thread.ofVirtual().start(…). Mantenha o join() e confira que aparecem Pedido 1 entregue e Fim do main.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread entrega = new Thread(() -> System.out.println("Pedido 1 entregue"));
        entrega.start();
        entrega.join();
        System.out.println("Fim do main");
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread entrega = Thread.ofVirtual().start(() -> System.out.println("Pedido 1 entregue"));
        entrega.join();
        System.out.println("Fim do main");
    }
}

2. Uma thread virtual por tarefa: o executor

No dia a dia, você não cria threads virtuais uma a uma: usa o executor Executors.newVirtualThreadPerTaskExecutor() ("uma thread virtual por tarefa"). Ele funciona como qualquer ExecutorService (submit, invokeAll, Future), mas, em vez de um time fixo, cria uma thread virtual nova para cada tarefa enviada.

try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        ex.submit(() -> consultar());
    }
}   // aqui o close() espera todas as 10 000 tarefas terminarem

O 10_000 é só o número 10000 escrito com um separador para facilitar a leitura (veja inteiros).

3. Por que: 10 000 tarefas esperando

Este é o caso para o qual as threads virtuais foram criadas. Um servidor recebe 10 000 pedidos; cada um precisa consultar um sistema lento e espera 100 milissegundos pela resposta (aqui, a espera é simulada com Thread.sleep(100)). A Ana mediu o tempo total com um time de 200 threads de plataforma e com threads virtuais:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    static void consultar() {
        try {
            Thread.sleep(100); // espera uma resposta lenta (rede, banco de dados)
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    static long medir(ExecutorService executor) {
        long inicio = System.currentTimeMillis();
        try (executor) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(Main::consultar);
            }
        }
        return System.currentTimeMillis() - inicio;
    }

    public static void main(String[] args) {
        long plataforma = medir(Executors.newFixedThreadPool(200));
        System.out.println("200 threads de plataforma: " + plataforma + " ms");

        long virtuais = medir(Executors.newVirtualThreadPerTaskExecutor());
        System.out.println("Threads virtuais:          " + virtuais + " ms");
    }
}
$ javac Main.java
$ java Main
200 threads de plataforma: 5052 ms
Threads virtuais:          165 ms
$ java Main
200 threads de plataforma: 5047 ms
Threads virtuais:          181 ms
TrechoO que faz
try (executor) { … }Usa um executor que já existe num try-with-resources (Java 9+ permite pôr uma variável ali). Ao sair, o close() espera todas as tarefas.
executor.submit(Main::consultar)Envia a tarefa, escrita como referência de método.
catch (InterruptedException e) { Thread.currentThread().interrupt(); }O jeito educado de tratar a interrupção numa tarefa: "anota" de novo o pedido de interrupção para quem vier depois.

As contas batem com a comparação do restaurante:

  • 200 threads de plataforma: 10 000 tarefas ÷ 200 threads = 50 rodadas de 100 ms = 5 segundos. As threads passaram quase todo esse tempo paradas, esperando.
  • Threads virtuais: 10 000 comandas, todas esperando ao mesmo tempo. Quase tudo acaba depois dos mesmos 100 ms, mais o trabalho de criar e organizar as threads: menos de 0,2 segundo. Cerca de 30 vezes mais rápido.

Por que não criar 10 000 threads de plataforma, então? Porque cada uma reserva memória para a pilha (da ordem de 1 MB) e é administrada pelo sistema operacional. Com dezenas de milhares, a máquina sofre. Uma thread virtual ocupa bem pouca memória: a pilha dela fica guardada no heap e cresce só o necessário.

No editor do guia

O simulador roda as tarefas uma depois da outra, numa thread só, e o Thread.sleep não espera de verdade. Os tempos desta página só aparecem no JDK real. Se quiser medir na sua máquina, mantenha números pequenos como estes.


4. Quando não ajudam: contas pesadas

Agora as tarefas não esperam nada: cada uma conta os números primos abaixo de 200 000, ocupando o processador o tempo todo. São 100 tarefas, com um time de threads de plataforma do tamanho do número de núcleos do processador, e depois com threads virtuais:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    static void contarPrimos() {
        int total = 0;
        for (int n = 2; n < 200_000; n++) {
            boolean primo = true;
            for (int d = 2; d * d <= n; d++) {
                if (n % d == 0) { primo = false; break; }
            }
            if (primo) total++;
        }
        if (total != 17984) System.out.println("conta errada!");
    }

    static long medir(ExecutorService executor) {
        long inicio = System.currentTimeMillis();
        try (executor) {
            for (int i = 0; i < 100; i++) {
                executor.submit(Main::contarPrimos);
            }
        }
        return System.currentTimeMillis() - inicio;
    }

    public static void main(String[] args) {
        int nucleos = Runtime.getRuntime().availableProcessors();
        medir(Executors.newFixedThreadPool(nucleos)); // aquecimento: a JVM otimiza o código na primeira rodada

        long plataforma = medir(Executors.newFixedThreadPool(nucleos));
        System.out.println("Uma thread de plataforma por núcleo: " + plataforma + " ms");

        long virtuais = medir(Executors.newVirtualThreadPerTaskExecutor());
        System.out.println("Uma thread virtual por tarefa:       " + virtuais + " ms");
    }
}
$ javac Main.java
$ java Main
Uma thread de plataforma por núcleo: 327 ms
Uma thread virtual por tarefa:       335 ms
$ java Main
Uma thread de plataforma por núcleo: 337 ms
Uma thread virtual por tarefa:       341 ms

Empate. O trabalho de verdade é feito pelos núcleos do processador, e eles são os mesmos nos dois casos: as 100 threads virtuais acabam carregadas pelos mesmos poucos "garçons". Threads virtuais não tornam o processador mais rápido; elas só evitam que threads caras fiquem paradas esperando. (A rodada de "aquecimento" existe porque a JVM compila e otimiza o código enquanto ele roda; sem ela, a primeira medição sairia injustamente mais lenta.)


Qual usar?

Suas tarefas…UsePor quê
Esperam muito (rede, banco, arquivos, sleep) e são muitasnewVirtualThreadPerTaskExecutor()Milhares esperando ao mesmo tempo, quase de graça.
Fazem contas pesadasnewFixedThreadPool(núcleos)Mais threads que núcleos não acelera; virtuais não ajudam.
São poucas e longas, e precisam de controle fino (prioridade, nome fixo)Threads de plataforma (Thread.ofPlatform())Comportamento clássico.
Precisam de um limite, como "no máximo 10 consultas ao banco ao mesmo tempo"Threads virtuais + um Semaphore (semáforo)Limite o recurso, não o número de threads.

Cuidados

  • Não faça pool de threads virtuais. Elas são baratas: crie uma por tarefa e deixe-a terminar. Um "time fixo de 200 virtuais" joga fora a vantagem.
  • São daemon ("de fundo"): a JVM não espera por elas ao terminar. Sem join (ou sem o executor em try), o trabalho pode simplesmente não acontecer (veja "Quando dá errado").
  • Até o Java 23, uma thread virtual que esperava dentro de um bloco synchronized ficava "presa" ao garçom (pinning). Desde o Java 24 isso foi resolvido; em versões 21 a 23, prefira ReentrantLock nesses casos (veja synchronized e Locks).
  • Cuidado com ThreadLocal guardando objetos grandes: com um milhão de threads, são um milhão de cópias. O substituto moderno é o ScopedValue (veja Novidades).

Quando dá errado

O trabalho às vezes some: threads virtuais são daemon

Este código tem um erro de propósito.
public class Main {
    public static void main(String[] args) {
        Thread.ofVirtual().start(() -> System.out.println("Pedido entregue"));
        System.out.println("Fim do main");
    }
}
$ javac Main.java
$ java Main
Fim do main
Pedido entregue
$ java Main
Fim do main
$ java Main
Fim do main

O que aconteceu: três execuções, e só na primeira o pedido foi entregue. Na aula de Threads, uma thread de plataforma sem join ainda terminava o trabalho, porque a JVM esperava por ela. Uma thread virtual é daemon: quando a main acaba, a JVM encerra o programa sem esperar. Se a thread não tiver terminado até ali, o trabalho se perde, sem erro nenhum. Conserto: join() em cada thread, ou o executor num try-with-resources, que espera todas.

"Troquei por virtuais e não ficou mais rápido"

Se as tarefas fazem contas (seção 4), é o esperado. Threads virtuais aceleram esperas, não cálculos. Meça antes de trocar.

cannot find symbol em ofVirtual

Num JDK anterior ao 21, Thread.ofVirtual() e newVirtualThreadPerTaskExecutor() não existem. Confira com java -version (veja Novidades do Java 17 ao 25).


Referência

CódigoO que faz
Thread.ofVirtual().start(tarefa)Cria e inicia uma thread virtual
Thread.ofVirtual().name("nome").start(tarefa)…com nome
Thread.ofVirtual().unstarted(tarefa)Cria sem iniciar (chame start() depois)
Thread.startVirtualThread(tarefa)Atalho: cria e inicia
Executors.newVirtualThreadPerTaskExecutor()Executor com uma thread virtual por tarefa
t.isVirtual()É virtual?
Thread.ofPlatform()…O mesmo construtor, para threads de plataforma

Exercícios

1. Complete para criar e iniciar uma thread virtual:

Thread t = Thread..start(() -> System.out.println("oi"));

2. Complete o executor que cria uma thread virtual por tarefa:

ExecutorService ex = Executors.;

3. Para qual destes programas as threads virtuais mais ajudam?

4. Por que Thread.ofVirtual().start(…) sem join às vezes não mostra nada?

5. 10 000 tarefas de 100 ms de espera, num time de 200 threads de plataforma, levaram cerca de…

Missão final: cinco lojas respondem, cada uma depois de uma espera, quantos itens têm (a loja 1 tem 1 item, a loja 2 tem 2…). Troque o executor pelo de threads virtuais e some as respostas para mostrar Total de itens: 15.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<Future<Integer>> respostas = new ArrayList<>();
        try (ExecutorService ex = Executors.newFixedThreadPool(2)) {
            for (int loja = 1; loja <= 5; loja++) {
                int numero = loja;
                respostas.add(ex.submit(() -> {
                    Thread.sleep(200);
                    return numero;
                }));
            }
        }
        int total = 0;
        // some as respostas com get()

        System.out.println("Total de itens: " + total);
    }
}
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<Future<Integer>> respostas = new ArrayList<>();
        try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int loja = 1; loja <= 5; loja++) {
                int numero = loja;
                respostas.add(ex.submit(() -> {
                    Thread.sleep(200);
                    return numero;
                }));
            }
        }
        int total = 0;
        for (Future<Integer> r : respostas) {
            total = total + r.get();
        }
        System.out.println("Total de itens: " + total);
    }
}

Resumo

  • Threads virtuais Java 21+ são leves e administradas pela JVM; poucas threads de plataforma (as carregadoras) as executam, trocando de uma para outra quando uma espera.
  • Crie com Thread.ofVirtual().start(…) ou, melhor, com Executors.newVirtualThreadPerTaskExecutor() num try-with-resources.
  • Ganham quando há muitas tarefas esperando: 10 000 esperas de 100 ms levaram ~5 000 ms com 200 threads de plataforma e menos de 200 ms com virtuais.
  • Não ajudam em contas pesadas: o limite é o número de núcleos.
  • São daemon (use join ou o executor) e não devem ficar num pool.

Próxima aula: synchronized e Locks, para quando threads mexem nos mesmos dados.