Introdução ao Multithreading em Java
O que são threads, por que usá-las em Java e as compensações da programação concorrente.
Todo programa Java que você escreveu até agora teve uma única thread de execução — um cursor percorrendo o bytecode, uma variável na pilha, uma chamada de método por vez. Essa é a thread "main" que a JVM inicia para você. Multithreading é a JVM executando vários desses cursores ao mesmo tempo, compartilhando o mesmo heap. Duas threads podem estar dentro de dois métodos diferentes em dois objetos diferentes no mesmo instante — e isso é tanto o poder quanto o perigo.
CPUs modernas têm muitos núcleos. Um programa de thread única deixa todos eles ociosos, exceto um. Um servidor web que trata uma requisição por vez não consegue usar uma máquina de 16 núcleos mais do que conseguiria usar uma de 1 núcleo. A razão de existir o multithreading é exatamente colocar esses núcleos para trabalhar e manter o programa responsivo quando uma parte dele está esperando (pelo disco, pela rede, pelo usuário).
O que é uma thread na prática
Uma thread Java é duas coisas unidas:
- Uma thread no nível do SO que o sistema operacional agenda em um núcleo de CPU. Ela tem um contador de programa, um conjunto de registradores e uma pilha nativa. O SO fatia seu tempo com todas as outras threads executáveis na máquina.
- Um objeto Java do tipo
java.lang.Thread. Ele carrega um nome, uma prioridade, um sinalizador de daemon e — o mais importante — uma referência aoRunnablecujo métodorun()ele executará.
Quando você chama thread.start(), a JVM pede ao SO para criar uma nova thread nativa que, ao ser agendada pela primeira vez, chamará seu método run(). A thread original continua imediatamente; as duas agora executam de forma concorrente.
public static void main(String[] args) {
System.out.println("main: hello from " + Thread.currentThread().getName());
Thread t = new Thread(() -> {
System.out.println("worker: hello from " + Thread.currentThread().getName());
}, "worker-1");
t.start(); // worker runs concurrently with main
System.out.println("main: continuing");
}A intercalação da saída não é determinística — o SO decide qual thread executa primeiro, e essa decisão muda entre as execuções. Esse não-determinismo é o fato central da programação concorrente.
Por que usar threads
Duas motivações distintas, frequentemente confundidas:
- Throughput. Você tem trabalho vinculado à CPU — redimensionamento de imagens, análise, compressão. Uma thread usa um núcleo; oito threads usam oito núcleos e terminam aproximadamente oito vezes mais rápido. Isso é paralelismo.
- Responsividade. Você tem uma thread que ficaria bloqueada — esperando por uma resposta de rede, uma resposta de banco de dados, um clique do usuário. Colocar esse trabalho em uma thread separada permite que o restante do programa continue fazendo coisas úteis enquanto espera. Isso é concorrência.
A maioria dos programas reais precisa de ambos. Um servidor web usa muitas threads para que uma requisição lenta não bloqueie as outras (concorrência) e para que muitas requisições rápidas possam ser tratadas em paralelo nos núcleos (paralelismo).
Por que threads são difíceis
Threads compartilham memória. O mesmo HashMap, ArrayList ou int counter++ pode ser acessado por duas threads ao mesmo instante — e a JVM, os caches da CPU e o compilador têm permissão para reordenar operações de maneiras que surpreendem você. Os três problemas que o código multithread continua enfrentando:
- Condições de corrida. Duas threads leem-modificam-escrevem a mesma variável; uma das atualizações é perdida.
counter++não é atômico — é ler counter, somar um, escrever counter. Duas threads podem ambas ler o mesmo valor e ambas escrever de voltavalor + 1, e você perde uma contagem. A correção é a sincronização — forçar a leitura-modificação-escrita a acontecer como uma etapa indivisível. - Visibilidade. Uma thread escreve um campo; outra thread o lê e vê o valor antigo, porque cada thread tem seu próprio cache de CPU e não há regra que force a propagação da escrita sem uma barreira de memória. É por isso que
volatile,synchronizedejava.util.concurrentexistem. - Deadlock. A thread A mantém o lock X e espera pelo lock Y; a thread B mantém o lock Y e espera pelo lock X. Nenhuma delas avança. O programa trava sem exceção e sem linha de log. O capítulo sobre deadlock mostra como detectar e evitá-lo.
O restante desta parte do livro trata, em grande parte, de prevenir essas três falhas enquanto mantém o ganho de throughput.
O vocabulário que você vai encontrar
Alguns termos que aparecem em todo lugar e que os demais capítulos assumem:
| Termo | O que significa |
|---|---|
| Concorrência | Múltiplas tarefas fazendo progresso ao longo do mesmo período de tempo. Elas podem ou não executar literalmente ao mesmo instante. |
| Paralelismo | Múltiplas tarefas executando literalmente ao mesmo instante em núcleos diferentes. Um subconjunto de concorrência. |
| Exclusão mútua | Apenas uma thread tem permissão de estar em uma seção crítica por vez. Locks e synchronized a fornecem. |
| Modelo de memória | As regras que dizem quando uma thread tem a garantia de ver a escrita de outra thread. Definido pelo JLS, refinado pelo JSR-133. |
| Atômico | Uma operação que não pode ser observada pela metade. Ou aconteceu ou não aconteceu — nenhum estado intermediário visível para outras threads. |
| Thread-safe | Uma classe cujo API público pode ser chamado de múltiplas threads sem sincronização externa e ainda se comportar corretamente. |
Threads daemon e a regra de saída da JVM
Uma coisa que surpreende os iniciantes: a JVM sai quando a última thread não-daemon termina. A thread main é não-daemon. Threads que você cria com new Thread(...) são não-daemon por padrão — portanto, criar uma thread worker mantém a JVM ativa até que o worker retorne.
Você pode marcar uma thread como daemon com t.setDaemon(true) antes de start(). Threads daemon não mantêm a JVM ativa; quando todas as threads não-daemon terminam, a JVM as elimina à força. Use daemons para trabalho em segundo plano que deve morrer com o programa (um timer que faz polling, um flusher de métricas) — nunca para trabalho cuja conclusão você realmente precisa (escritas em arquivo, commits de transação).
Threads vs. virtual threads
O Java 21 introduziu as virtual threads, que parecem idênticas no nível da API, mas são agendadas pela JVM em cima de um pequeno pool de threads do SO. O modelo mental deste capítulo — uma Thread Java igual a uma thread do SO — descreve "platform threads", que é o que você obtém com o construtor simples new Thread(...). Platform threads são caras: cada uma ocupa cerca de 1 MB de pilha nativa e o SO limita quantas um processo pode ter, então você as cria com cuidado. Virtual threads são baratas — milhões são tranquilas — e tornam o I/O bloqueante gratuito novamente. Nós as abordamos em Java Virtual Threads; até lá, "thread" significa "platform thread".
Um exemplo prático: serial vs. paralelo
O programa abaixo soma um bloco de trabalho da CPU de duas maneiras — uma vez sequencialmente na thread main, outra dividida entre quatro threads — e imprime o tempo de relógio de parede para cada uma. Os números variam por máquina, mas a forma é a mesma em todo lugar: mais threads, menos tempo de parede, até você ficar sem núcleos.
O que tirar da execução:
- A execução serial usou um núcleo; a execução paralela usou quatro. O speedup é sub-linear (mais próximo de 3x do que 4x) porque o SO, o GC e outras threads da JVM também querem tempo de CPU. A lei de Amdahl em ação — uma pequena fração serial (o loop final de soma de parciais, a inicialização do loop) limita o speedup.
- Cada worker escreveu em seu próprio slot em
partials[]. Nenhuma das duas threads tocou o mesmo índice, então nenhuma sincronização foi necessária. Essa é a forma mais fácil de paralelismo — particionar os dados e deixar cada thread ser dona de sua partição. t.join()é comomainespera queworker-3termine. Sem os joins, o loop leriapartialsantes de os workers terem escrito, eparallelSumestaria errado.joiné a única peça de coordenação de thread que este programa usa; os próximos capítulos introduzirão muito mais.- A thread daemon no final não manteve a JVM ativa. Ela estava prestes a dormir por 60 segundos, mas
mainretornou e a JVM saiu, eliminando o daemon durante o sono sem executar sua instrução de print. Esse é o contrato do daemon. Thread.currentThread().getName()e o nome explícito passado ao construtorThreadsão como você distingue threads em logs, em profilers e em thread dumps. Sempre nomeie suas threads —Thread-3é inútil quando você está tentando descobrir qual está travada.
O que vem a seguir
O próximo capítulo, Java Thread Class, aprofunda o objeto Thread em si — seus construtores, a diferença entre estender Thread e passar um Runnable, e a API para nomeação, prioridade, status de daemon e interrupção.