DEV Community

Dhellano Castro
Dhellano Castro

Posted on

Virtual Threads no Java 21: O Fim da Era da Escassez (e as Armadilhas que Podem Lhe Derrubar)

Série: Java em Produção de Verdade — Este é o primeiro de dois artigos. Aqui cobrimos os fundamentos, o modelo mental correto e as duas armadilhas que derrubam aplicações silenciosamente. No segundo, descemos para Docker, Kubernetes e observabilidade com JFR.


Imagine um restaurante sofisticado. Cada mesa — uma requisição HTTP — precisa de um garçom dedicado. O garçom anota o pedido, vai até a cozinha... e fica parado lá, esperando o chef terminar o prato. Enquanto isso, novas mesas chegam. Mas não tem garçom disponível. O maître começa a recusar clientes na porta.

O restaurante está cheio de garçons parados na cozinha sem fazer nada — e o salão, vazio de atendimento.

Esse é o modelo clássico de Platform Threads em Java. Cada thread consome cerca de 1MB de stack no sistema operacional. Num servidor com 4GB dedicados a threads, você tem no máximo ~4.000 garçons. Parece muito? Para uma aplicação moderna com alto volume de I/O — chamadas a banco, HTTP externo, mensageria — não é.

O Project Loom, introduzido como preview no Java 19 e estável a partir do Java 21, mudou as regras do jogo. A ideia central é elegante: e se o garçom pudesse largar a mesa na cozinha, voltar ao salão para atender outras mesas, e retornar quando o prato ficasse pronto?

Isso são as Virtual Threads. Milhões delas. Com custo de memória na casa dos kilobytes. O restaurante agora pode ter 1.000 garçons reais atendendo 1.000.000 de mesas simultâneas.

Mas — e sempre tem um "mas" — um restaurante com 1 milhão de garçons e uma única cozinha com 4 fogões ainda vai entupir. É aqui que a história começa a ficar interessante.


O Motor por Baixo do Capô

Antes de sair criando Virtual Threads por aí, vale entender o que está acontecendo embaixo dos panos. A JVM gerencia três conceitos distintos que vivem juntos nesse ecossistema.

Platform Threads são o modelo antigo e honesto: uma thread Java mapeada 1:1 para uma thread do sistema operacional. O SO agenda, o SO bloqueia, o SO paga a conta de memória. São caras, poderosas e limitadas em número.

Virtual Threads são threads gerenciadas pela própria JVM, não pelo SO. São leves, baratas e podem existir em quantidades absurdas. Quando uma Virtual Thread precisa esperar por I/O, ela é desmontada (unmounted) da thread do SO e o seu contexto fica salvo na heap — como objetos Java comuns, sujeitos ao GC.

Carrier Threads são o elo perdido que a maioria dos artigos ignora. São Platform Threads do SO que o ForkJoinPool interno da JVM usa para executar as Virtual Threads. Pense nelas como os trilhos de um metrô: os vagões (Virtual Threads) rodam em cima dos trilhos (Carrier Threads). Você pode ter 1.000 vagões, mas se tiver apenas 4 trilhos, só 4 vagões andam ao mesmo tempo.

┌─────────────────────────────────────────────────────┐
│                      JVM                            │
│                                                     │
│   Virtual Thread 1  ──┐                             │
│   Virtual Thread 2  ──┤                             │
│   Virtual Thread 3  ──┼──► Carrier Thread 1 ──► OS  │
│   Virtual Thread 4  ──┤                             │
│   Virtual Thread ...──┘                             │
│                        ──► Carrier Thread 2 ──► OS  │
│                        ──► Carrier Thread N ──► OS  │
│                                                     │
│   (N = número de CPUs disponíveis, por padrão)      │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

O número padrão de Carrier Threads é igual ao número de CPUs disponíveis. Em produção, dentro de um container Docker com --cpus=2, você tem 2 trilhos para potencialmente milhões de vagões. Isso vai importar — muito — no segundo artigo desta série.


Armadilha 1 — Thread Pinning: O Parafuso no Chão

Lembra do garçom que podia largar a mesa na cozinha e ir atender outras? Pois bem. Existe uma situação onde ele não consegue largar. Alguém parafusou a cadeira dele no chão da cozinha. Esse parafuso se chama synchronized.

Quando uma Virtual Thread entra em um bloco ou método synchronized e encontra um ponto de bloqueio — I/O, por exemplo — ela não consegue ser desmontada da Carrier Thread. Ela pina. A Carrier Thread fica presa junto com ela, esperando. Se todas as Carrier Threads ficarem pinadas, sua aplicação congela. Completamente.

⚠️ Importante: synchronized não é vilão por natureza. É perfeitamente seguro usá-lo para proteger operações rápidas em memória, como manipulação de uma HashMap compartilhada. O problema surge quando dentro do bloco synchronized existe uma operação de I/O demorada — consulta ao banco, chamada HTTP, leitura de arquivo.

Veja a diferença na prática:

// ❌ PROBLEMÁTICO: synchronized + I/O = Thread Pinning garantido
// A Carrier Thread fica presa enquanto o banco responde
public synchronized User findById(Long id) {
    return jdbcTemplate.queryForObject(
        "SELECT * FROM users WHERE id = ?",
        userRowMapper,
        id
    );
}
Enter fullscreen mode Exit fullscreen mode
// ✅ CORRETO: ReentrantLock é "Virtual Thread aware"
// A Virtual Thread pode ser desmontada enquanto aguarda o banco
// A Carrier Thread fica livre para executar outras Virtual Threads
private final ReentrantLock lock = new ReentrantLock();

public User findById(Long id) throws InterruptedException {
    lock.lock();
    try {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?",
            userRowMapper,
            id
        );
    } finally {
        lock.unlock();
    }
}
Enter fullscreen mode Exit fullscreen mode

Por que o ReentrantLock resolve? Porque ele não usa monitores de objeto nativos do SO. Quando a Virtual Thread precisa aguardar dentro de um ReentrantLock, a JVM consegue desmontá-la da Carrier Thread normalmente. O garçom finalmente consegue sair da cadeira.

Para identificar pinning em produção, ative o diagnóstico com a JVM flag:

-Djdk.tracePinnedThreads=full
Enter fullscreen mode Exit fullscreen mode

💡 Nota para quem usa frameworks: Drivers JDBC antigos e algumas implementações de DataSource ainda usam synchronized internamente. Verifique suas versões. O driver do PostgreSQL removeu os synchronized problemáticos a partir da versão 42.6.


Armadilha 2 — O Efeito Manada

Você resolveu o pinning. Sua aplicação está rodando com Virtual Threads lisas como manteiga. Requisições entrando, threads respondendo. Aí você olha para o banco de dados e vê isso:

ERROR: FATAL: remaining connection slots are reserved
       for replication superuser connections
Max connections: 100. Active: 100. Waiting: 4.847.
Enter fullscreen mode Exit fullscreen mode

Bem-vindo ao Efeito Manada.

O problema é sutil e cruel: com Platform Threads, o pool de threads era o limitador natural de conexões com o banco. Se você tinha 200 threads no pool, no máximo 200 conexões simultâneas chegavam ao banco. Era uma contenção acidental, mas funcionava como um freio de mão.

Com Virtual Threads, esse freio sumiu. A JVM pode criar ilimitadas Virtual Threads. Cada uma, ao encontrar um ponto de I/O, fica "parada" aguardando a resposta — mas continua existindo e segurando uma conexão aberta com o banco. Uma enxurrada de 50.000 requisições simultâneas pode se transformar em 50.000 conexões tentando abrir no banco ao mesmo tempo.

O banco colapsa. Não foi a Virtual Thread que foi lenta — foi a ausência de governança sobre o recurso compartilhado.

🎯 A mudança de paradigma central do Project Loom: Com Virtual Threads, o controle sai da thread e vai para o recurso. Você não limita mais threads. Você limita acesso a recursos escassos.


Mitigação — O Freio de Mão Inteligente

Semaphore: O Porteiro do Banco

A solução mais direta é usar um Semaphore como controlador de acesso. Pense nele como um porteiro na porta do banco de dados: independente de quantos clientes cheguem, só N entram ao mesmo tempo.

@Repository
public class ProductRepository {

    // Porteiro: máximo 80 conexões simultâneas ao banco
    private final Semaphore dbGatekeeper = new Semaphore(80);

    public List<Product> findAllByCategory(String category) {
        try {
            dbGatekeeper.acquire(); // Aguarda permissão do porteiro
            try {
                return jdbcTemplate.query(
                    "SELECT * FROM products WHERE category = ?",
                    productRowMapper,
                    category
                );
            } finally {
                dbGatekeeper.release(); // Libera a vaga ao sair
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new DatabaseAccessException("Interrupted while waiting for DB slot", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A beleza aqui: Semaphore.acquire() é um ponto de bloqueio virtual-thread-friendly. A Virtual Thread que aguarda a vaga do porteiro é desmontada da Carrier Thread, que fica livre para executar outras Virtual Threads. Zero desperdício de CPU.

Resilience4j: O Controle de Missão

Para produção real, o Semaphore puro é o mínimo. O Resilience4j oferece um conjunto completo de primitivas para resiliência, todas compatíveis com Virtual Threads.

O BulkheadConfig é essencialmente um Semaphore com superpoderes: métricas, fallbacks, timeouts e integração nativa com Micrometer e Prometheus.

// Configuração do Bulkhead
@Bean
public BulkheadRegistry bulkheadRegistry() {
    BulkheadConfig config = BulkheadConfig.custom()
        .maxConcurrentCalls(80)                 // Máximo de chamadas simultâneas
        .maxWaitDuration(Duration.ofSeconds(2)) // Timeout na fila de espera
        .build();

    return BulkheadRegistry.of(config);
}

// Uso no serviço
@Service
public class ProductService {

    private final Bulkhead dbBulkhead;
    private final ProductRepository repository;

    public ProductService(BulkheadRegistry registry, ProductRepository repository) {
        this.dbBulkhead = registry.bulkhead("database-bulkhead");
        this.repository = repository;
    }

    public List<Product> getProductsByCategory(String category) {
        return Bulkhead.decorateSupplier(
            dbBulkhead,
            () -> repository.findAllByCategory(category)
        ).get();
    }
}
Enter fullscreen mode Exit fullscreen mode

Combine isso com um CircuitBreaker para que, se o banco começar a rejeitar conexões, o circuito abra automaticamente — dando tempo para o banco se recuperar antes de a situação escalar.

@Bean
public CircuitBreakerConfig circuitBreakerConfig() {
    return CircuitBreakerConfig.custom()
        .failureRateThreshold(50)                       // Abre se 50% das calls falharem
        .waitDurationInOpenState(Duration.ofSeconds(30))// Espera 30s para tentar de novo
        .slidingWindowSize(20)                          // Avalia as últimas 20 chamadas
        .build();
}
Enter fullscreen mode Exit fullscreen mode

Quer Ver os Números na Prática?

Há um demo completo e autocontido disponível no repositório — Java 21, zero dependências — que mostra os dois cenários rodando e imprimindo os resultados. O output é brutal:

CENÁRIO 1 — SEM controle:
✅ Sucesso:    80 requisições
❌ Rejeitadas: 420 requisições  ← 84% das requisições perdidas

CENÁRIO 2 — COM Semaphore:
✅ Sucesso:    500 requisições
❌ Rejeitadas: 0 requisições
📈 Pico:       80 conexões (nunca ultrapassou o limite)
Enter fullscreen mode Exit fullscreen mode

🔗 [https://github.com/DheCastro/java-virtual-threads-pitfalls]


O Que Vem no Próximo Artigo

Agora que o modelo mental está correto, vamos descer para onde a maioria das aplicações Java realmente vive: containers em produção.

No próximo artigo desta série, vamos cobrir:

  • O custo do Stack em Docker: por que o -Xmx que era suficiente antes pode não ser mais — e como calcular a margem correta para evitar OOM Kill
  • CPU Throttling no Kubernetes: como limites de CPU afetam as Carrier Threads e causam latência alta com CPU aparentemente baixa nos dashboards
  • Observabilidade com JFR: os eventos exatos para monitorar Thread Pinning e saturação em produção
  • Checklist completo do desenvolvedor moderno para uma migração segura

Continue lendo: Parte 2 — Virtual Threads em Produção de Verdade: Docker, Kubernetes e o que os Dashboards não te Contam

Se este artigo foi útil, deixa uma reação — ajuda muito a saber se vale continuar a série. 🙌


Referências

  • JEP 444 — Virtual Threads (Java 21)
    Especificação oficial do Project Loom. Documenta o modelo de mount/unmount, o comportamento do synchronized e o papel das Carrier Threads.
    https://openjdk.org/jeps/444

  • JEP 491 — Synchronize Virtual Threads without Pinning (Java 24)
    A evolução direta da armadilha do Thread Pinning discutida neste artigo. A partir do Java 24, o synchronized com I/O deixa de causar pinning na maioria dos casos — relevante se você já está ou planeja estar no Java 24+.
    https://openjdk.org/jeps/491

  • Spring Boot 3.2 Release Notes — Virtual Threads
    Documentação oficial da propriedade spring.threads.virtual.enabled e o que ela configura automaticamente (Tomcat, Jetty, @Async, executores).
    https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes

  • Resilience4j — Documentação oficial do Bulkhead
    Referência para o SemaphoreBulkhead e BulkheadConfig usados na seção de mitigação.
    https://resilience4j.readme.io/docs/bulkhead


Código-fonte

Todos os exemplos deste artigo — e mais — estão disponíveis no repositório abaixo.
Cada classe é autocontida e roda com um único comando (java NomeClasse.java).
Nenhuma dependência externa, apenas Java 21.

🔗 github.com/DheCastro/java-virtual-threads-pitfalls

Top comments (1)

Collapse
 
mensonones profile image
Emerson Vieira

Ótimo post!!! Esse nível de detalhe faz muita diferença ao se fazer uso desses recursos que parecem simples mas ao mesmo tempo são poderosos 👏🏾👏🏾