Java synchronized e Locks
Quando duas threads alteram os mesmos dados ao mesmo tempo, o resultado fica errado, e errado de um jeito diferente a cada execução. Esta aula mostra o problema de verdade (a condição de corrida) e as ferramentas do Java para evitá-lo: synchronized, AtomicInteger, ReentrantLock e ConcurrentHashMap.
- O que é uma condição de corrida, com um contador que perde vendas (saídas reais).
synchronizedem métodos e em blocos: o que é o "cadeado" de um objeto.AtomicInteger: contadores seguros sem cadeado.ReentrantLock: um cadeado explícito, comlock/unlockefinally.ConcurrentHashMapno lugar deHashMapentre threads, e a ideia de deadlock (impasse).
Esta é a última aula do grupo Concorrência. Leia antes Threads (a seção 6 já mostrou o problema rapidamente). Os exemplos rodaram no JDK 25 da máquina da Ana, na pasta ~/java.
A ideia antes do código
A loja da Ana tem um quadro de vendas na parede, com o total do dia escrito a giz. Dois caixas atualizam o quadro. Para registrar uma venda, cada um faz três passos:
- Lê o número no quadro: 41.
- Soma 1 de cabeça: 42.
- Apaga e escreve o novo número: 42.
Agora imagine os dois caixas vendendo no mesmo instante. Os dois leem 41. Os dois calculam 42. Os dois escrevem 42. Foram duas vendas, mas o quadro só aumentou uma. Ninguém errou a conta; o problema foi a ordem em que os passos se misturaram. Isso é uma condição de corrida (race condition): o resultado depende de quem "corre" mais rápido.
A solução da Ana: um giz só, pendurado no quadro. Quem quer atualizar pega o giz, faz os três passos e devolve. Quem chegar enquanto o giz está ocupado espera. Em Java, esse giz se chama cadeado (lock, ou monitor).
1. O problema de verdade
class Contador {
private int valor = 0;
void incrementar() {
valor++;
}
int valor() {
return valor;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Contador vendas = new Contador();
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas.incrementar();
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas.valor());
}
}
Dois caixas, 100 000 vendas cada um. O certo seria 200 000. A Ana rodou três vezes:
$ javac Main.java $ java Main Vendas: 103485 $ java Main Vendas: 144417 $ java Main Vendas: 128165
Errado, e errado de forma diferente a cada vez. O valor++ é exatamente o quadro de giz: ler valor, somar 1, gravar. Quando as duas threads se intercalam entre esses passos, vendas se perdem. Pior: num computador mais lento, ou com menos vendas, o erro pode nem aparecer, e o programa passa nos testes. Erros de concorrência são difíceis justamente porque nem sempre acontecem.
O simulador do guia roda tudo numa thread só: o start() executa a tarefa inteira na hora, então os caixas nunca se atropelam e o resultado sai sempre 200 000, com ou sem proteção. O synchronized e os locks são aceitos, mas lá não fazem diferença. Por isso as missões desta aula conferem se você usou a proteção certa, e as saídas erradas acima são do JDK real.
2. synchronized: um cadeado por objeto
Em Java, todo objeto tem um cadeado embutido. A palavra synchronized ("sincronizado") num método quer dizer: "para rodar este método, pegue o cadeado do objeto; ao terminar, devolva". Só uma thread por vez segura o cadeado; as outras esperam na fila.
class Contador {
private int valor = 0;
synchronized void incrementar() {
valor++;
}
synchronized int valor() {
return valor;
}
}
$ javac Main.java $ java Main Vendas: 200000 $ java Main Vendas: 200000 $ java Main Vendas: 200000
- synchronized
- Antes de entrar no método, a thread pega o cadeado deste objeto (
this, o contador). Se outra thread estiver com ele, espera. - void incrementar()
- O método de sempre. Os três passos do
valor++agora acontecem sem interrupção de outra thread que também precise do cadeado.
Por que o valor() também é synchronized, se só lê? Porque o cadeado também garante que uma thread enxerga o que a outra gravou. Sem ele, o Java pode deixar uma thread lendo uma cópia antiga do valor (é o problema de visibilidade). Regra simples: se um dado é protegido por um cadeado, toda leitura e escrita dele passa pelo cadeado.
Missão: proteja o Contador com synchronized nos dois métodos. (Lembre-se: no editor do guia o resultado sai certo de qualquer jeito; a missão confere se você usou a proteção.)
class Contador {
private int valor = 0;
void incrementar() {
valor++;
}
int valor() {
return valor;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Contador vendas = new Contador();
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas.incrementar();
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas.valor());
}
}
class Contador {
private int valor = 0;
synchronized void incrementar() {
valor++;
}
synchronized int valor() {
return valor;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Contador vendas = new Contador();
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas.incrementar();
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas.valor());
}
}
Bloco synchronized: proteger só um pedaço
Às vezes só uma parte do método mexe no dado compartilhado. O bloco synchronized (objeto) { … } pega o cadeado só durante aquele trecho. O resto do método roda livre, em paralelo:
class Estoque {
private int quantidade = 0;
private final Object trava = new Object();
void repor(int n) {
System.out.println(Thread.currentThread().getName() + " vai repor " + n);
synchronized (trava) {
quantidade = quantidade + n;
}
}
int quantidade() {
synchronized (trava) {
return quantidade;
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Estoque estoque = new Estoque();
Runnable repositor = () -> {
for (int i = 0; i < 3; i++) {
estoque.repor(10);
}
};
Thread a = new Thread(repositor, "Ana");
Thread b = new Thread(repositor, "Bruno");
a.start();
b.start();
a.join();
b.join();
System.out.println("Estoque final: " + estoque.quantidade());
}
}
$ javac Main.java && java Main Bruno vai repor 10 Ana vai repor 10 Bruno vai repor 10 Ana vai repor 10 Bruno vai repor 10 Ana vai repor 10 Estoque final: 60
- private final Object trava = new Object();
- Um objeto que existe só para emprestar o cadeado dele. Ser
privategarante que nenhum código de fora pega o mesmo cadeado por engano. - synchronized (trava) {
- "Pegue o cadeado de
trava." Também dá para escreversynchronized (this), que é o mesmo cadeado de um métodosynchronized. - quantidade = quantidade + n;
- Só esta linha fica protegida. O
printlnacima roda sem esperar ninguém. - }
- Fim do bloco: o cadeado é devolvido, mesmo que aconteça uma exceção lá dentro.
3. AtomicInteger: contador seguro sem cadeado
Para o caso mais comum, um número que várias threads aumentam, o pacote java.util.concurrent.atomic tem classes atômicas. "Atômico" vem do grego "indivisível": ler, somar e gravar acontecem como um passo só, que ninguém consegue interromper no meio. O processador tem instruções especiais para isso, então nem é preciso cadeado.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger vendas = new AtomicInteger(0);
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas.incrementAndGet();
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas.get());
}
}
$ javac Main.java && java Main Vendas: 200000
| Método | Equivale a (sem a corrida) |
|---|---|
new AtomicInteger(0) | int x = 0 |
x.get() | ler x |
x.set(5) | x = 5 |
x.incrementAndGet() | ++x (soma e devolve o novo valor) |
x.getAndIncrement() | x++ (devolve o valor antigo e soma) |
x.addAndGet(10) | x += 10 |
x.updateAndGet(v -> v * 2) | x = x * 2 |
Também existem AtomicLong, AtomicBoolean e AtomicReference. E, para contadores muito disputados, LongAdder.
4. ReentrantLock: o cadeado explícito
O pacote java.util.concurrent.locks oferece cadeados como objetos comuns. O mais usado é o ReentrantLock ("cadeado reentrante": a thread que já o segura pode pegá-lo de novo sem travar a si mesma; o synchronized também é assim). Você pega com lock() e devolve com unlock(), sempre num finally:
import java.util.concurrent.locks.ReentrantLock;
class Caixa {
private final ReentrantLock trava = new ReentrantLock();
private double saldo = 0;
void receber(double valor) {
trava.lock();
try {
saldo = saldo + valor;
} finally {
trava.unlock();
}
}
double saldo() {
trava.lock();
try {
return saldo;
} finally {
trava.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Caixa caixa = new Caixa();
Runnable vendedor = () -> {
for (int i = 0; i < 50_000; i++) {
caixa.receber(2.0);
}
};
Thread ana = new Thread(vendedor);
Thread bruno = new Thread(vendedor);
ana.start();
bruno.start();
ana.join();
bruno.join();
System.out.println("Saldo: " + caixa.saldo());
}
}
$ javac Main.java && java Main Saldo: 200000.0
- trava.lock();
- Pega o cadeado (ou espera até conseguir).
- try { … }
- O trecho protegido.
- finally { trava.unlock(); }
- Devolve o cadeado em qualquer caso, mesmo com exceção (veja finally). Se o
unlocknão acontecer, todas as outras threads ficam esperando para sempre.
Por que usar isso no lugar de synchronized, que devolve o cadeado sozinho? Porque o ReentrantLock sabe fazer coisas que o synchronized não sabe: tryLock() ("tente pegar; se estiver ocupado, desista na hora"), tryLock(2, TimeUnit.SECONDS) ("espere no máximo 2 segundos") e filas justas (new ReentrantLock(true): quem chegou primeiro pega primeiro).
5. Coleções entre threads: ConcurrentHashMap
As coleções comuns, como HashMap e ArrayList, não foram feitas para várias threads alterarem ao mesmo tempo. Quatro repositores contam frutas no mesmo mapa, cada um somando 2 500 maçãs (10 000 no total):
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Main {
static void contar(Map<String, Integer> estoque) throws InterruptedException {
String[] frutas = { "maçã", "uva", "kiwi", "pera" };
Runnable repositor = () -> {
for (int i = 0; i < 10_000; i++) {
estoque.merge(frutas[i % 4], 1, Integer::sum);
}
};
Thread[] equipe = new Thread[4];
for (int i = 0; i < 4; i++) {
equipe[i] = new Thread(repositor);
equipe[i].start();
}
for (Thread t : equipe) {
t.join();
}
}
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> comum = new HashMap<>();
contar(comum);
System.out.println("HashMap: " + comum.get("maçã") + " maçãs (esperado: 10000)");
Map<String, Integer> segura = new ConcurrentHashMap<>();
contar(segura);
System.out.println("ConcurrentHashMap: " + segura.get("maçã") + " maçãs (esperado: 10000)");
}
}
$ javac Main.java $ java Main Exception in thread "Thread-3" Exception in thread "Thread-0" java.util.ConcurrentModificationException at java.base/java.util.HashMap.merge(HashMap.java:1394) at Main.lambda$contar$0(Main.java:10) at java.base/java.lang.Thread.run(Thread.java:1474) java.util.ConcurrentModificationException at java.base/java.util.HashMap.merge(HashMap.java:1394) at Main.lambda$contar$0(Main.java:10) at java.base/java.lang.Thread.run(Thread.java:1474) HashMap: 4209 maçãs (esperado: 10000) ConcurrentHashMap: 10000 maçãs (esperado: 10000) $ java Main Exception in thread "Thread-1" java.util.ConcurrentModificationException at java.base/java.util.HashMap.merge(HashMap.java:1394) at Main.lambda$contar$0(Main.java:10) at java.base/java.lang.Thread.run(Thread.java:1474) HashMap: 5932 maçãs (esperado: 10000) ConcurrentHashMap: 10000 maçãs (esperado: 10000)
Com o HashMap, um desastre: algumas threads morreram com ConcurrentModificationException ("exceção de modificação concorrente": o mapa percebeu que estava sendo alterado por duas threads ao mesmo tempo), as duas mensagens de erro até saíram embaralhadas na primeira execução, e a contagem ficou muito abaixo do certo. O ConcurrentHashMap ("mapa de hash concorrente") acertou todas as vezes: ele foi feito para isso, e operações como merge, compute e putIfAbsent nele são atômicas.
| Em vez de… | Entre threads, use |
|---|---|
HashMap | ConcurrentHashMap |
ArrayList muito lida e pouco alterada | CopyOnWriteArrayList |
| Fila entre quem produz e quem consome | LinkedBlockingQueue, ArrayBlockingQueue |
Contador int | AtomicInteger |
6. A ideia de deadlock (impasse)
Cadeados resolvem a corrida, mas criam um risco novo. Imagine que, para fechar o caixa, a Ana precisa da chave do cofre e da chave da gaveta. O Bruno também. A Ana pega a chave do cofre; no mesmo instante, o Bruno pega a da gaveta. Agora a Ana espera a chave da gaveta (que está com o Bruno), e o Bruno espera a do cofre (que está com a Ana). Ninguém solta a que tem. Os dois vão esperar para sempre. Isso é um deadlock ("trava mortal", impasse).
Object cofre = new Object();
Object gaveta = new Object();
Thread ana = new Thread(() -> {
synchronized (cofre) { // Ana pega o cofre…
synchronized (gaveta) { // …e espera a gaveta
System.out.println("Ana fechou o caixa");
}
}
});
Thread bruno = new Thread(() -> {
synchronized (gaveta) { // Bruno pega a gaveta…
synchronized (cofre) { // …e espera o cofre
System.out.println("Bruno fechou o caixa");
}
}
});
Na maior parte das vezes, uma das threads pega as duas chaves antes de a outra começar, e tudo funciona. Mas, se as duas pegarem a primeira chave ao mesmo tempo, o programa congela: não gasta processador nem mostra erro, só fica parado (você teria de encerrá-lo com Ctrl+C). É o tipo de defeito que aparece uma vez por mês, no servidor, de madrugada.
Como evitar:
- Sempre a mesma ordem. Se todo mundo pega primeiro o cofre e depois a gaveta, o impasse é impossível: quem não pegou o cofre nem tenta a gaveta.
- Segure o cadeado o menor tempo possível e evite pegar um segundo cadeado enquanto segura o primeiro.
- Desista depois de um tempo: com
ReentrantLock.tryLock(tempo, unidade), quem não consegue a segunda chave devolve a primeira e tenta de novo depois. - Prefira as ferramentas prontas (
AtomicInteger,ConcurrentHashMap, executores), que já cuidam disso por dentro.
Qual usar?
| Situação | Ferramenta |
|---|---|
| Um contador ou um número compartilhado | AtomicInteger / AtomicLong |
| Um mapa compartilhado | ConcurrentHashMap |
| Vários campos que precisam mudar juntos (saldo e extrato) | synchronized (método ou bloco) |
Precisa de tryLock, tempo limite ou fila justa | ReentrantLock |
| Dados que ninguém altera depois de criados | Nada! Objetos imutáveis (como records e String) são seguros entre threads |
| Cada thread com os próprios dados, juntando no fim | Nada durante o trabalho; junte os resultados com Future (veja ExecutorService) |
Quando dá errado
O número muda a cada execução
Sintoma clássico de condição de corrida: um dado alterado por várias threads sem proteção (seção 1). Conserto: AtomicInteger, synchronized ou um lock, em todos os acessos ao dado.
ConcurrentModificationException com threads
Uma coleção comum foi alterada por várias threads (seção 5). Conserto: troque por ConcurrentHashMap (ou outra coleção do pacote java.util.concurrent). Essa mesma exceção também aparece numa thread só, quando você altera uma lista enquanto a percorre com for-each; veja Iterator.
Protegeu a escrita, esqueceu a leitura
Um método synchronized para alterar e um sem synchronized para ler: a leitura pode ver um valor velho ou "pela metade". Conserto: o mesmo cadeado em todos os acessos.
Cadeados diferentes não se protegem
Duas threads com synchronized (new Object()), ou cada uma com o seu próprio Contador, não esperam uma pela outra: cada uma pega um cadeado diferente. Conserto: as threads precisam disputar o mesmo objeto de cadeado, guardado num campo private final.
O programa congela sem erro
Pode ser um deadlock (seção 6) ou um lock() sem o unlock() correspondente. Conserto: unlock() sempre num finally; pegar cadeados sempre na mesma ordem. Para investigar um programa travado, o comando jstack do JDK mostra o que cada thread está esperando (veja Depurar).
Referência
| Código | O que faz |
|---|---|
synchronized void m() { … } | Método com o cadeado de this |
static synchronized void m() | Método com o cadeado da classe |
synchronized (obj) { … } | Bloco com o cadeado de obj |
new AtomicInteger(0), incrementAndGet(), get() | Contador atômico |
new ReentrantLock(), lock(), unlock() | Cadeado explícito (unlock no finally) |
lock.tryLock() / tryLock(t, unidade) | Tenta pegar sem esperar / com tempo limite |
new ConcurrentHashMap<>() | Mapa seguro entre threads |
mapa.merge(chave, 1, Integer::sum) | Soma atômica num ConcurrentHashMap |
Exercícios
1. Complete para que só uma thread por vez execute o método:
2. Complete com o método atômico que soma 1 e devolve o novo valor:
3. Complete o lugar onde o unlock deve ficar:
try { saldo += valor; } { trava.unlock(); }
4. Por que valor++ perde somas com duas threads?
5. Várias threads atualizam um mapa de estoque. Qual a melhor escolha?
6. Qual regra impede o deadlock do cofre e da gaveta?
Missão final: troque o int do contador por um AtomicInteger: crie-o com new AtomicInteger(0), some com incrementAndGet() e leia com get(). O import já está pronto.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static int vendas = 0;
public static void main(String[] args) throws InterruptedException {
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas++;
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas);
}
}
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static AtomicInteger vendas = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable caixa = () -> {
for (int i = 0; i < 100_000; i++) {
vendas.incrementAndGet();
}
};
Thread caixa1 = new Thread(caixa);
Thread caixa2 = new Thread(caixa);
caixa1.start();
caixa2.start();
caixa1.join();
caixa2.join();
System.out.println("Vendas: " + vendas.get());
}
}
Resumo
- Threads que alteram o mesmo dado sem proteção causam condição de corrida: resultados errados e diferentes a cada execução (103 485, 144 417, 128 165 em vez de 200 000).
synchronized(método ou bloco) usa o cadeado de um objeto: uma thread por vez. Proteja leituras e escritas.AtomicIntegerresolve contadores sem cadeado;ReentrantLocké o cadeado explícito (unlocknofinally,tryLock).- Entre threads, use coleções concorrentes:
ConcurrentHashMapem vez deHashMap. - Deadlock: duas threads esperando, cada uma, o cadeado da outra. Evite pegando cadeados sempre na mesma ordem.
- No editor do guia tudo roda numa thread só: a corrida não aparece lá, só no JDK real.
Próxima aula: Empacotar em JAR, no grupo Ferramentas.