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.

Você vai aprender
  • O que é uma condição de corrida, com um contador que perde vendas (saídas reais).
  • synchronized em métodos e em blocos: o que é o "cadeado" de um objeto.
  • AtomicInteger: contadores seguros sem cadeado.
  • ReentrantLock: um cadeado explícito, com lock/unlock e finally.
  • ConcurrentHashMap no lugar de HashMap entre 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:

  1. Lê o número no quadro: 41.
  2. Soma 1 de cabeça: 42.
  3. 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.

No editor do guia, o erro não aparece

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 private garante que nenhum código de fora pega o mesmo cadeado por engano.
synchronized (trava) {
"Pegue o cadeado de trava." Também dá para escrever synchronized (this), que é o mesmo cadeado de um método synchronized.
quantidade = quantidade + n;
Só esta linha fica protegida. O println acima 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étodoEquivale 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 unlock nã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
HashMapConcurrentHashMap
ArrayList muito lida e pouco alteradaCopyOnWriteArrayList
Fila entre quem produz e quem consomeLinkedBlockingQueue, ArrayBlockingQueue
Contador intAtomicInteger

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).

Este código pode travar para sempre. Ele está aqui só para ler.
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çãoFerramenta
Um contador ou um número compartilhadoAtomicInteger / AtomicLong
Um mapa compartilhadoConcurrentHashMap
Vários campos que precisam mudar juntos (saldo e extrato)synchronized (método ou bloco)
Precisa de tryLock, tempo limite ou fila justaReentrantLock
Dados que ninguém altera depois de criadosNada! Objetos imutáveis (como records e String) são seguros entre threads
Cada thread com os próprios dados, juntando no fimNada 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ódigoO 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:

void incrementar() { valor++; }

2. Complete com o método atômico que soma 1 e devolve o novo valor:

vendas.;

3. Complete o lugar onde o unlock deve ficar:

trava.lock();
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.
  • AtomicInteger resolve contadores sem cadeado; ReentrantLock é o cadeado explícito (unlock no finally, tryLock).
  • Entre threads, use coleções concorrentes: ConcurrentHashMap em vez de HashMap.
  • 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.