Programação Java

Concorrência em Java: threads, sincronização, executors e virtual threads

Avançado10 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Código concorrente falha de jeitos que nunca aparecem em um teste unitário, então a única defesa é entender as regras. Este guia explica o que é uma thread, por que duas threads podem discordar sobre o valor de uma variável, como synchronized, volatile, locks e atômicos garantem visibilidade e atomicidade, como executors e CompletableFuture estruturam o trabalho e como as virtual threads do Java 21 mudam as contas para serviços com muito I/O. Você vai conseguir escrever código com estado compartilhado que é correto, e não apenas sortudo.

O que você vai aprender

  • Explicar os conceitos do modelo de memória do Java: atomicidade, visibilidade e happens-before
  • Proteger corretamente o estado compartilhado com synchronized, volatile, os locks de java.util.concurrent e os atômicos
  • Executar e coordenar tarefas com ExecutorService, Future e CompletableFuture
  • Escolher coleções concorrentes e filas bloqueantes para designs produtor-consumidor
  • Tratar corretamente interrupção e desligamento, e saber quando as virtual threads fazem sentido

Programação multithread em Java não é difícil porque a API é grande — a maioria dos programas precisa de meia dúzia de classes —, mas porque as falhas são invisíveis: um código errado roda corretamente um milhão de vezes no seu notebook e corrompe o total de um pedido em um servidor de 32 núcleos numa sexta-feira à noite. As regras que decidem o que é correto são as do modelo de memória do Java e, depois que você as conhece, as construções de java.util.concurrent deixam de parecer um cardápio e passam a ser respostas a perguntas específicas. Este guia é voltado ao Java 21, versão que também introduziu as virtual threads.

Threads e o que dá errado

Uma thread é um caminho de execução independente que compartilha o heap do processo com todas as outras threads. Criar uma é trivial:

Thread t = new Thread(() -> System.out.println("running on " + Thread.currentThread().getName()));
t.start();          // schedules the thread; run() would just call the method on the current thread
t.join();           // wait for it to finish

O problema começa com o estado mutável compartilhado. Duas propriedades precisam valer para que dados compartilhados estejam corretos:

  • Atomicidade — do ponto de vista das outras threads, uma operação acontece por inteiro ou não acontece. count++ não é atômico: é uma leitura, uma soma e uma escrita, e duas threads que intercalam esses passos perdem um incremento.
  • Visibilidade — uma escrita feita por uma thread acaba sendo vista por outra. Sem sincronização não existe essa garantia: o JIT pode manter um valor em um registrador ou reordenar instruções, e um núcleo pode servir uma linha de cache desatualizada. Um loop while (!stopped) {} pode, de fato, girar para sempre mesmo depois que outra thread tiver feito stopped = true.

O modelo de memória do Java (capítulo 17 da JLS) define, pela relação happens-before, quando uma escrita tem visibilidade garantida para uma leitura. As arestas que importam: liberar um monitor happens-before toda aquisição posterior desse monitor; uma escrita em um campo volatile happens-before toda leitura posterior desse campo; Thread.start() happens-before tudo o que roda na thread iniciada; tudo o que roda em uma thread happens-before o retorno de join() sobre ela; submeter uma tarefa a um executor happens-before a execução da tarefa. Todo código concorrente correto depende de uma dessas arestas, mesmo que o autor nunca a mencione.

synchronized, volatile e atômicos

synchronized adquire o lock intrínseco (monitor) de um objeto, executa o bloco e o libera. Só uma thread por vez detém um determinado monitor, então o bloco é atômico em relação aos outros blocos sobre o mesmo monitor, e o par adquirir/liberar garante a visibilidade.

public final class Counter {
    private long value;                                   // guarded by this

    public synchronized void increment() { value++; }     // locks `this`
    public synchronized long get() { return value; }      // reads need the lock too, for visibility
}

Repare no synchronized em get(). Um lock só protege os dados quando todo acesso, inclusive as leituras, passa por ele. Sincronizar apenas quem escreve ainda deixa os leitores com valores desatualizados.

Um campo volatile dá visibilidade sem exclusão mútua: leituras e escritas vão direto para a memória principal (conceitualmente) e não são reordenadas com o código ao redor. É a ferramenta certa para uma flag ou uma referência que uma thread publica e outras leem; é a ferramenta errada para count++.

private volatile boolean running = true;

public void stop() { running = false; }                   // visible to the worker immediately

public void run() {
    while (running) { doWork(); }                          // exits promptly after stop()
}

Para contadores e atualizações de uma única variável, java.util.concurrent.atomic oferece classes sem lock baseadas na instrução compare-and-swap da CPU: AtomicInteger, AtomicLong, AtomicReference, AtomicBoolean e, para contagem com alta contenção, LongAdder. incrementAndGet, compareAndSet e updateAndGet(fn) são atômicos e visíveis sem lock.

private final AtomicLong requests = new AtomicLong();
requests.incrementAndGet();

private final AtomicReference<Config> config = new AtomicReference<>(initial);
config.updateAndGet(c -> c.withTimeout(Duration.ofSeconds(5)));   // retried until the CAS succeeds

O outro caminho para a segurança é não ter estado mutável compartilhado: objetos imutáveis (records, List.of) podem circular livremente entre threads, e o design concorrente mais simples costuma ser “cada tarefa é dona dos seus dados; os resultados são combinados no final”.

Locks e condições

synchronized não tem timeout, não pode ser interrompido enquanto espera e sempre libera o lock ao sair do bloco. ReentrantLock oferece tryLock(timeout), lockInterruptibly() e fairness, ao custo de liberar o lock manualmente em um finally:

private final ReentrantLock lock = new ReentrantLock();

public boolean transfer(Account to, long cents) throws InterruptedException {
    if (!lock.tryLock(1, TimeUnit.SECONDS)) return false;    // give up rather than deadlock
    try {
        debit(cents);
        to.credit(cents);
        return true;
    } finally {
        lock.unlock();                                       // always
    }
}

ReentrantReadWriteLock deixa vários leitores avançarem ao mesmo tempo enquanto os escritores têm exclusividade, o que é útil para caches mais lidos do que escritos; StampedLock acrescenta leituras otimistas para o mesmo caso, com um custo maior de complexidade.

Um deadlock acontece quando a thread A segura o lock 1 e espera o lock 2 enquanto a thread B segura o 2 e espera o 1. A solução clássica é uma ordem global de aquisição (sempre travar primeiro a conta com o menor ID); tryLock com timeout é o plano B quando impor uma ordem não é viável. Um thread dump (jcmd <pid> Thread.print) mostra explicitamente as threads em deadlock.

Object.wait()/notifyAll() e Condition.await()/signalAll() permitem que uma thread durma até que algum estado mude. Sempre chame esses métodos dentro de um loop que verifica a condição de novo, porque despertares espúrios (spurious wake-ups) são permitidos e outra thread pode ter consumido o estado. Na prática, você raramente vai escrever isso na mão: BlockingQueue, CountDownLatch, Semaphore e CompletableFuture encapsulam esse mecanismo.

Executors: o jeito certo de executar tarefas

Criar uma thread por tarefa não escala e não dá nenhum ciclo de vida. Um ExecutorService separa o que executar (um Runnable ou um Callable<T>) de como executar (um pool de N threads, uma única thread, uma virtual thread por tarefa):

ExecutorService pool = Executors.newFixedThreadPool(8);
try {
    List<Callable<Long>> jobs = files.stream()
            .map(f -> (Callable<Long>) () -> Files.size(f))
            .toList();

    long total = 0;
    for (Future<Long> f : pool.invokeAll(jobs)) {
        total += f.get();                                  // rethrows the task's exception as ExecutionException
    }
    System.out.println("Total bytes: " + total);
} finally {
    pool.shutdown();                                       // stop accepting; finish queued tasks
    if (!pool.awaitTermination(30, TimeUnit.SECONDS)) pool.shutdownNow();
}

Desde o Java 19, ExecutorService implementa AutoCloseable, e close() executa shutdown() seguido de um awaitTermination() sem limite de tempo, então try-with-resources funciona para executors de vida curta.

Dimensionamento do pool: para trabalho CPU-bound, mais ou menos o número de núcleos (Runtime.getRuntime().availableProcessors()); para I/O bloqueante com threads de plataforma, mais do que isso — ou, melhor ainda, virtual threads. ThreadPoolExecutor expõe o tamanho base, o tamanho máximo, a fila e a política de rejeição; as factories de Executors escondem esses parâmetros, e newCachedThreadPool em particular cria threads sem limite sob carga. Um ScheduledExecutorService executa tarefas depois de um atraso ou em intervalos fixos; ele substitui java.util.Timer, cuja única thread morre na primeira exceção não tratada.

Exceções nas tarefas não chegam aos seus logs a não ser que você vá atrás delas: um Runnable submetido com execute vai para o handler de exceções não capturadas da thread; uma tarefa submetida com submit fica capturada no Future e se perde silenciosamente se você nunca chamar get(). Envolva o corpo das tarefas em um try/catch que registre o erro no log, ou sempre consuma o future.

CompletableFuture: compondo trabalho assíncrono

Future.get() bloqueia. CompletableFuture permite descrever o que acontece quando um resultado chega, e combinar vários resultados, sem bloquear nenhuma thread até o final:

CompletableFuture<Customer> customer = CompletableFuture.supplyAsync(() -> customers.load(id), pool);
CompletableFuture<List<Order>> orders = CompletableFuture.supplyAsync(() -> orderRepo.forCustomer(id), pool);

CompletableFuture<Invoice> invoice = customer
        .thenCombine(orders, (c, os) -> Invoice.of(c, os))          // when both complete
        .thenApply(pricing::apply)                                   // transform
        .exceptionally(ex -> Invoice.failed(id, ex))                 // recover
        .orTimeout(5, TimeUnit.SECONDS);                             // fail if too slow (Java 9)

Invoice result = invoice.join();                                     // block only at the edge

thenApply transforma, thenCompose encadeia uma etapa que também devolve um future (o flatMap dos futures), thenCombine junta dois, allOf/anyOf juntam vários, e handle/whenComplete/exceptionally tratam falhas. Os métodos com sufixo Async executam a continuação em um executor informado, e não na thread que concluiu a etapa anterior; sem executor informado, os métodos *Async e supplyAsync usam o ForkJoinPool comum, que é dimensionado para trabalho de CPU e compartilhado com os parallel streams — nunca bloqueie nele.

Coleções concorrentes e filas

As coleções de java.util.concurrent foram projetadas para compartilhamento, ao contrário dos wrappers Collections.synchronizedX, que serializam cada chamada e ainda assim deixam inseguras a iteração e as sequências check-then-act.

ClasseUso
ConcurrentHashMapMap compartilhado; compute, merge e putIfAbsent são atômicos por chave; a iteração nunca lança exceção
CopyOnWriteArrayListListas de listeners e de configuração: escritas raras, leituras frequentes, iteração sobre um snapshot
ConcurrentLinkedQueue / ConcurrentLinkedDequeFila não bloqueante para repassar itens sem esperar
ArrayBlockingQueue / LinkedBlockingQueueProdutor–consumidor com back-pressure: put bloqueia quando está cheia, take bloqueia quando está vazia
ConcurrentSkipListMap / SetMap/set concorrentes ordenados

ConcurrentHashMap.computeIfAbsent(key, fn) é atômico para a chave, o que transforma um cache de memoização em uma linha — mas fn precisa ser curto e não pode mexer no mesmo map, ou vai causar deadlock ou lançar IllegalStateException.

O padrão produtor–consumidor com uma BlockingQueue limitada é a forma padrão de ligar etapas com velocidades diferentes; é o limite que impede um produtor rápido de esgotar a memória. Um elemento poison pill ou shutdownNow sinaliza aos consumidores que devem parar.

Utilitários de coordenação

  • CountDownLatch — espera até que N eventos aconteçam (N workers iniciados, N resultados recebidos). Uso único.
  • CyclicBarrier — N threads esperam umas pelas outras em um ponto e depois seguem; reutilizável.
  • Semaphore — limita o acesso concorrente a um recurso a K permissões, por exemplo K downloads simultâneos.
  • Phaser — uma barreira mais genérica, com várias fases.
  • ThreadLocal — uma variável por thread, usada para contexto da requisição e para utilitários que não são thread-safe, como SimpleDateFormat; sempre chame remove() no fim da tarefa quando as threads vêm de um pool, senão o valor vaza para a próxima tarefa.

Interrupção e desligamento

O Java não tem um jeito seguro de matar uma thread. A interrupção é um pedido cooperativo: thread.interrupt() liga uma flag; os métodos bloqueantes (sleep, wait, join, BlockingQueue.take, Future.get) respondem lançando InterruptedException e limpando a flag; loops CPU-bound precisam consultar Thread.currentThread().isInterrupted() por conta própria.

A regra quando você captura InterruptedException e não pode propagá-la: restaure a flag e pare assim que possível.

@Override
public void run() {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            Job job = queue.take();                        // throws if interrupted while waiting
            process(job);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();                // preserve the request for callers
    }
}

Engolir a exceção é o que faz um serviço ignorar shutdownNow() e continuar rodando até o sistema operacional matá-lo.

Virtual threads (Java 21)

Uma thread de plataforma encapsula uma thread do sistema operacional: cerca de um megabyte de stack reservado, no máximo alguns milhares por processo e cara de criar. É por isso que existem pools de threads, e é por isso que servidores que bloqueiam uma thread por requisição travam em alguns milhares de requisições simultâneas. Uma virtual thread (JEP 444, final no Java 21) é uma thread leve que a JVM escalona sobre um pequeno pool de carrier threads. Quando ela bloqueia em I/O, sua stack é estacionada no heap e a carrier executa outra virtual thread. Milhões podem existir ao mesmo tempo.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> results = new ArrayList<>();
    for (String url : urls) {
        results.add(executor.submit(() -> httpClient.send(request(url), BodyHandlers.ofString()).body()));
    }
    for (Future<String> f : results) System.out.println(f.get().length());
}   // close() waits for every task

O modelo de programação é o bloqueante de sempre — sem callbacks, sem operadores reativos —, e é justamente essa a ideia. Algumas diretrizes:

  • Não coloque virtual threads em pool. Elas são baratas; crie uma por tarefa. Para limitar a concorrência sobre um recurso escasso, use um Semaphore.
  • Evite synchronized em volta de chamadas bloqueantes. No Java 21, uma virtual thread bloqueada dentro de um bloco synchronized ou de uma chamada nativa fica presa (pinned) à sua carrier, o que anula o benefício. Troque por ReentrantLock, que não prende a thread. (O Java 24 eliminou a maior parte do pinning, mas o 21 é a LTS que você provavelmente está rodando.)
  • Cuidado com ThreadLocal. Um milhão de threads com um objeto em cache cada é muita memória; os scoped values (recurso em preview no Java 21) são o sucessor planejado.
  • Trabalho CPU-bound não ganha nada. Virtual threads reduzem o custo de esperar, não o de computar.

O Spring Boot 3.2+ executa o processamento de requisições do Tomcat em virtual threads quando spring.threads.virtual.enabled=true está definido, que é o jeito mais rápido de experimentá-las em um serviço existente.

Erros comuns

Leituras não sincronizadas. Travar quem escreve mas ler um campo sem o lock, ou sem volatile, permite valores desatualizados indefinidamente.

Check-then-act em uma coleção compartilhada. if (!map.containsKey(k)) map.put(k, v) é uma race condition; use putIfAbsent ou computeIfAbsent.

Bloquear no pool comum. CompletableFuture.supplyAsync(() -> jdbcCall()) sem executor, ou I/O bloqueante em um parallel stream, deixa sem recursos todos os outros usuários de ForkJoinPool.commonPool().

Perder as exceções das tarefas. submit() sem get() esconde a falha. Consuma o future ou registre o erro no log dentro da tarefa.

Filas e pools sem limite. newCachedThreadPool e LinkedBlockingQueue sem capacidade transformam um pico de tráfego em um OutOfMemoryError.

Esquecer o shutdown(). Threads não daemon do pool mantêm a JVM viva depois que main retorna.

Engolir InterruptedException. Quebra o cancelamento para todos os chamadores acima de você.

Double-checked locking sem volatile. O bug clássico do singleton lazy; o padrão da classe holder ou um simples synchronized são mais simples e corretos.

ThreadLocal sem remove(). Threads de pool levam o contexto da requisição anterior para a próxima — uma falha de segurança, além de um vazamento.

Principais pontos

  • A correção depende do modelo de memória: dados mutáveis compartilhados precisam de uma aresta happens-before — um lock, volatile, um atômico ou uma passagem por um executor ou coleção concorrente — tanto para quem escreve quanto para quem lê.
  • synchronized dá atomicidade e visibilidade; volatile, só visibilidade; os atômicos, atualizações sem lock de uma única variável; ReentrantLock acrescenta timeouts e interruptibilidade.
  • Execute tarefas por meio de um ExecutorService com pool limitado e desligamento explícito; componha resultados assíncronos com CompletableFuture e informe seu próprio executor para trabalho bloqueante.
  • Prefira ConcurrentHashMap, CopyOnWriteArrayList e BlockingQueues limitadas aos wrappers sincronizados; suas operações atômicas por chave eliminam a maioria das races check-then-act.
  • A interrupção é cooperativa: trate InterruptedException restaurando a flag e saindo.
  • Virtual threads fazem código bloqueante de uma thread por tarefa escalar em serviços com muito I/O; crie uma por tarefa, evite synchronized em volta de chamadas bloqueantes no Java 21 e não espere ganho para trabalho CPU-bound.

Fontes e leituras complementares

Perguntas frequentes

Qual é a diferença entre synchronized e volatile?
volatile garante visibilidade: uma escrita é vista por qualquer leitura posterior, em qualquer thread. Ele não torna atômicas operações compostas como count++. synchronized garante ao mesmo tempo exclusão mútua e visibilidade para tudo o que acontece dentro do bloco. Use volatile para uma flag escrita por uma thread e lida por outras; use synchronized, um Lock ou uma classe atômica quando uma sequência de leitura-modificação-escrita precisar ser indivisível.
Quando devo usar virtual threads?
Em cargas com muitas tarefas concorrentes que passam a maior parte do tempo bloqueadas em I/O: handlers HTTP, chamadas ao banco de dados, consumidores de mensagens. As virtual threads fazem o modelo de uma thread por requisição escalar para centenas de milhares de tarefas sem pool. Elas não aceleram trabalho CPU-bound, e no Java 21 um código que mantém um bloco synchronized durante uma chamada bloqueante prende (pin) a carrier thread, então revise esses trechos antes de migrar.
Por que não criar threads com new Thread() no código da aplicação?
Criar threads sem limite esgota memória e CPU sob carga, e uma thread crua não oferece fila, nem tratamento de resultado, nem desligamento. Um ExecutorService limita a concorrência, devolve Futures, propaga exceções e desliga de forma limpa. Para virtual threads, use Executors.newVirtualThreadPerTaskExecutor.
Como parar uma thread com segurança?
Peça que ela pare interrompendo-a, e escreva a tarefa para que verifique Thread.interrupted() nos loops e deixe InterruptedException se propagar a partir das chamadas bloqueantes. Desde o Java 20, Thread.stop lança UnsupportedOperationException, porque deixava locks e dados em estados desconhecidos. Os executors expõem esse mecanismo por meio de shutdownNow, que interrompe todas as tarefas em execução.