O Cadeado e a Memória — synchronized, volatile e o Java Memory Model

O Cadeado e a Memória — synchronized, volatile e o Java Memory Model

Como coordenar threads com synchronized e volatile: a trava que torna o incremento atômico, o laço que nunca vê a alteração de outra thread, a relação acontece-antes do Java Memory Model, um impasse diagnosticado com jcmd e evitado com ordem de travas, e a criação preguiçosa segura com o portador.
Java

• • 19 min de leitura

Como garantir que só uma thread por vez mexa num dado, por que uma thread pode não enxergar o que outra gravou, e as regras do modelo de memória que dizem quando isso é garantido.

"Fazendo Duas Coisas ao Mesmo Tempo" terminou com um contador que perdia incrementos quando duas threads o alteravam ao mesmo tempo, e com a promessa de um problema ainda mais estranho. Esta aula apresenta as duas ferramentas mais antigas do Java para coordenar threads: as travas, com a palavra synchronized, que garantem que só uma thread por vez execute um trecho de código, e a palavra volatile, que garante que uma alteração feita por uma thread seja vista pelas outras. Para entender por que a segunda é necessária, é preciso conhecer o modelo de memória do Java, o contrato que diz o que uma thread tem a garantia de enxergar. No caminho, você vai provocar e diagnosticar um impasse, o travamento clássico da concorrência.

synchronized: uma thread por vez

Todo objeto Java tem uma trava embutida, também chamada de monitor. A palavra synchronized faz uma thread obter a trava de um objeto antes de executar um trecho de código, e liberá-la no fim. Se outra thread já segura a trava, a que chega fica esperando, no estado BLOCKED da aula anterior, até ela ser liberada. O contador, protegido:

static class Contador {
    private int valor = 0;

    public synchronized void incrementar() {
        valor++;
    }

    public synchronized int valor() {
        return valor;
    }
}

Um método synchronized usa a trava do próprio objeto (this). Com as mesmas duas threads e um milhão de incrementos cada:

execução 1: 2000000 em 253 ms
execução 2: 2000000 em 253 ms
execução 3: 2000000 em 33 ms

O resultado agora é sempre correto. As três operações do valor++ (ler, somar, gravar) passam a acontecer sem que outra thread se intrometa, porque só quem segura a trava pode estar dentro de incrementar. Um trecho de código que se comporta como uma unidade indivisível para as outras threads é chamado de atômico, e um trecho que só uma thread por vez pode executar, de seção crítica.

Repare que valor() também é synchronized, mesmo só lendo. A razão está na segunda metade desta aula: sem a trava, uma thread poderia ler um valor desatualizado. Todo acesso a um dado compartilhado e mutável, leitura ou escrita, precisa da mesma trava.

A trava pode ser aplicada a um bloco, em vez do método inteiro, e com qualquer objeto:

private final Object trava = new Object();

public void incrementar() {
    // ... trabalho que não precisa de proteção ...
    synchronized (trava) {
        valor++;
    }
}

Isso permite proteger só o essencial, e manter a trava privada, para que nenhum código de fora possa segurá-la e travar a classe por engano.

As travas do Java são reentrantes: uma thread que já segura uma trava pode obtê-la de novo sem esperar. Um método synchronized pode chamar outro método synchronized do mesmo objeto sem travar a si mesmo.

Proteger tem custo. Com as travas, as threads passam a maior parte do tempo esperando umas pelas outras, e nenhum trabalho acontece de fato em paralelo, a variação de tempo das três execuções mostra que a disputa pela trava é imprevisível. Por isso, seções críticas devem ser curtas: nada de chamadas de rede, leitura de arquivos ou cálculos longos dentro de um synchronized. Em "Ferramentas de Precisão", você vai ver alternativas mais eficientes para um contador compartilhado.

O problema da visibilidade

Agora o problema estranho. Uma thread roda um laço até que outra lhe diga para parar:

public class Visibilidade {
    static boolean parar = false;

    public static void main(String[] args) throws InterruptedException {
        Thread trabalhadora = new Thread(() -> {
            long voltas = 0;
            while (!parar) {
                voltas++;
            }
            IO.println("trabalhadora parou depois de " + voltas + " voltas");
        });
        trabalhadora.setDaemon(true);
        trabalhadora.start();

        Thread.sleep(1000);
        parar = true;
        IO.println("main mandou parar");

        trabalhadora.join(3000);
        IO.println(trabalhadora.isAlive() ? "a trabalhadora continua rodando!" : "a trabalhadora terminou");
    }
}
main mandou parar
a trabalhadora continua rodando!

O main gravou true em parar, esperou três segundos, e a trabalhadora continuou rodando. Não há nenhuma condição de corrida aqui: uma só thread escreve, e a outra só lê. Mesmo assim, a trabalhadora nunca viu a alteração.

O culpado é o JIT de "A Máquina por Trás da Máquina". Ele compilou o laço e percebeu que nada dentro dele altera parar. Como a leitura de um campo comum não tem nenhuma garantia em relação a outras threads, o JIT tem permissão para ler o valor uma vez, antes do laço, e transformar o código em algo equivalente a if (!parar) { while (true) { voltas++; } }. É uma otimização perfeitamente legal para código de uma thread só, e desastrosa aqui. O processador também contribui: cada núcleo tem os seus próprios caches de memória, e uma escrita num núcleo não é imediatamente visível no outro, a não ser que algo force a sincronização.

volatile

A palavra volatile num campo diz à JVM que ele é compartilhado entre threads, e garante que toda leitura veja a escrita mais recente de qualquer thread:

static volatile boolean parar = false;
main mandou parar
trabalhadora parou depois de 2805009518 voltas
a trabalhadora terminou

Com volatile, o JIT não pode mais guardar o valor fora do laço: cada volta lê o campo de verdade, e a alteração feita pelo main é vista imediatamente. É a ferramenta certa para sinais simples entre threads, como um pedido de parada ou um indicador de "pronto".

Mas volatile não torna operações compostas atômicas. O contador com um campo volatile:

static volatile int contador = 0;
// duas threads, um milhão de contador++ cada
volatile: 1461421 (esperado 2000000)

Cada leitura vê o valor mais recente, mas entre a leitura e a gravação do contador++, outra thread ainda pode ler o mesmo valor. volatile resolve a visibilidade, e synchronized resolve a visibilidade e a atomicidade. Para um sinal que uma thread grava e outras leem, volatile basta. Para qualquer operação que lê e depois grava com base no que leu, é preciso uma trava, ou as variáveis atômicas de "Ferramentas de Precisão".

O modelo de memória do Java

O que garante que uma escrita seja vista por outra thread? A resposta está no Java Memory Model (JMM), uma parte da especificação da linguagem, reformulada no Java 5, que define exatamente quando uma thread tem a garantia de ver o que outra fez. A peça central é a relação acontece-antes (happens-before). Se uma ação A acontece-antes de uma ação B, então tudo o que A fez é visível para B. Sem essa relação, não há garantia nenhuma, e o compilador, o JIT e o processador podem reordenar e adiar escritas à vontade, como fizeram com parar.

As regras mais importantes:

  • Ordem do programa: dentro de uma mesma thread, cada ação acontece-antes da seguinte. É a razão de programas de uma thread só nunca verem esses problemas.
  • Travas: liberar uma trava acontece-antes de qualquer obtenção posterior da mesma trava. Tudo o que uma thread fez antes de sair de um synchronized fica visível para a próxima thread que entrar num synchronized do mesmo objeto. É por isso que valor() também precisava da trava: sem ela, não haveria relação entre a escrita de uma thread e a leitura da outra.
  • volatile: uma escrita num campo volatile acontece-antes de toda leitura posterior do mesmo campo. E vale mais do que o próprio campo: tudo o que a thread fez antes de gravar o volatile fica visível para quem lê o volatile depois.
  • start e join: tudo o que uma thread fez antes de chamar start() é visível para a thread iniciada, e tudo o que uma thread fez é visível para quem retorna de um join() nela. É por isso que o programa dos números primos da aula anterior estava correto sem nenhuma trava: o main só leu o array depois dos join.

As ferramentas das próximas aulas, como filas, executores e futures, estabelecem essas relações por dentro, e a documentação de cada uma diz quais garante. Na prática, você raramente precisa raciocinar diretamente com o JMM. Precisa saber que, sem uma dessas relações, não há garantia, e que "funcionou nos meus testes" não prova nada, porque o JIT, o processador e o número de núcleos mudam de uma máquina para outra.

Impasse

As travas trazem um novo tipo de problema. Uma transferência entre contas precisa travar as duas contas, para que nenhuma outra operação veja o dinheiro "no meio do caminho":

static class Conta {
    private final String nome;
    private double saldo;
    // ...

    static void transferir(Conta origem, Conta destino, double valor) {
        synchronized (origem) {
            dormir(50);                  // simula algum trabalho
            synchronized (destino) {
                origem.saldo -= valor;
                destino.saldo += valor;
            }
        }
    }
}

Duas transferências simultâneas, em sentidos opostos:

Thread t1 = new Thread(() -> Conta.transferir(ana, bia, 10), "transf-ana-para-bia");
Thread t2 = new Thread(() -> Conta.transferir(bia, ana, 20), "transf-bia-para-ana");
as duas transferências estão paradas

A primeira thread travou a conta da Ana e espera a da Bia. A segunda travou a da Bia e espera a da Ana. Nenhuma pode continuar, e as duas vão esperar para sempre. Isso é um impasse (deadlock). O programa não quebra, não lança exceção, não consome CPU: simplesmente para, exatamente o "travamento" que o exercício 5 de "A Máquina por Trás da Máquina" ensinou a investigar. O jcmd Thread.print detecta o impasse sozinho:

Found one Java-level deadlock:
=============================
"transf-ana-para-bia":
  waiting to lock monitor 0x00007fcee0061ce0 (object 0x00000000a8317ef8, a Impasse$Conta),
  which is held by "transf-bia-para-ana"

"transf-bia-para-ana":
  waiting to lock monitor 0x00007fceec001560 (object 0x00000000a8317eb0, a Impasse$Conta),
  which is held by "transf-ana-para-bia"

Java stack information for the threads listed above:
===================================================
"transf-ana-para-bia":
	at Impasse$Conta.transferir(Impasse.java:15)
	- waiting to lock <0x00000000a8317ef8> (a Impasse$Conta)
	- locked <0x00000000a8317eb0> (a Impasse$Conta)
...
Found 1 deadlock.

O diagnóstico é completo: quais threads, qual trava cada uma segura, qual cada uma espera, e em que linha. Os nomes dados às threads, como recomendou a aula anterior, tornam o relatório legível.

Um impasse exige quatro condições ao mesmo tempo, identificadas por Edward Coffman em 1971: as travas são exclusivas; uma thread segura uma trava enquanto espera outra; as travas não podem ser tomadas à força; e existe um ciclo de espera. A forma mais prática de evitá-lo é quebrar o ciclo: todas as threads obtêm as travas na mesma ordem. Dando a cada conta um identificador único:

static class Conta {
    private final long id;
    private final String nome;
    private double saldo;

    Conta(long id, String nome, double saldo) { ... }

    static void transferir(Conta origem, Conta destino, double valor) {
        Conta primeira = origem.id < destino.id ? origem : destino;
        Conta segunda = origem.id < destino.id ? destino : origem;
        synchronized (primeira) {
            synchronized (segunda) {
                origem.saldo -= valor;
                destino.saldo += valor;
            }
        }
    }
}

Agora, qualquer que seja o sentido da transferência, as duas threads travam primeiro a conta de menor identificador. A que chegar depois espera na primeira trava, sem segurar nada, e o ciclo não pode se formar. Com 100 mil transferências de cada lado, o programa termina com o total de dinheiro preservado. Outra técnica, tentar obter a trava com um prazo e desistir se não conseguir, exige as travas explícitas de "Ferramentas de Precisão".

Imutabilidade: a melhor sincronização

A forma mais segura de compartilhar dados entre threads é não ter o que sincronizar. Um objeto imutável, com todos os campos final e nenhuma forma de alterá-los, pode ser lido por qualquer número de threads sem nenhuma trava. O JMM dá uma garantia especial aos campos final: quando um objeto termina de ser construído, os valores dos seus campos final são visíveis para qualquer thread que veja uma referência a ele, desde que a referência não escape durante a construção. É a promessa de "O que Fica Dentro, o que Fica Fora": records, String, LocalDate, BigDecimal e as listas de List.of podem ser compartilhados livremente.

Daí um princípio de projeto para programas concorrentes, em ordem de preferência: primeiro, não compartilhe (cada thread com os seus próprios dados, como na contagem de primos); segundo, compartilhe só dados imutáveis; terceiro, se precisar compartilhar dados mutáveis, proteja todo acesso com a mesma trava, ou use as estruturas concorrentes das próximas aulas.

Criação preguiçosa segura

Uma aplicação clássica dessas regras: criar um objeto caro, como um catálogo carregado de um arquivo, só na primeira vez que alguém precisar, e garantir que threads simultâneas recebam o mesmo objeto. A solução mais elegante não usa nenhuma trava explícita, e aproveita a inicialização preguiçosa das classes de "A Máquina por Trás da Máquina":

static class Catalogo {
    private Catalogo() {
        IO.println("  [carregando o catálogo, operação cara]");
    }

    private static class Portador {
        static final Catalogo INSTANCIA = new Catalogo();
    }

    static Catalogo instancia() {
        return Portador.INSTANCIA;
    }
}

Com quatro threads chamando Catalogo.instancia() ao mesmo tempo:

programa começou
  [carregando o catálogo, operação cara]
Thread-0 recebeu 637978054
Thread-3 recebeu 637978054
Thread-1 recebeu 637978054
Thread-2 recebeu 637978054

A classe Portador só é inicializada no primeiro acesso a INSTANCIA, e a JVM garante que a inicialização de uma classe aconteça uma única vez, com as outras threads esperando o fim, e que o resultado seja visível para todas. O catálogo é criado uma vez, sob demanda, e sem que você escreva nenhuma sincronização. Essa técnica é conhecida como initialization-on-demand holder. As alternativas manuais, com synchronized ou com o famoso "double-checked locking", são fáceis de errar: a segunda, escrita sem volatile, é o exemplo mais conhecido de código que parece correto e não é, por causa das reordenações permitidas pelo JMM.

synchronized e volatile são as ferramentas fundamentais, e tudo o que vem a seguir é construído sobre elas. Mas usá-las diretamente é trabalhoso e cheio de armadilhas: criar e gerenciar threads à mão, esquecer uma trava num acesso, travar na ordem errada. Desde o Java 5, o pacote java.util.concurrent oferece ferramentas de nível mais alto, que resolvem os problemas mais comuns de forma correta e eficiente.

Fontes e leituras recomendadas

Exercícios

Exercício 1

No exemplo da visibilidade, um colega acrescentou um IO.println(voltas) dentro do laço para depurar, e o problema "desapareceu": a trabalhadora passou a parar. Explique por que, e por que isso não é uma correção.

Ver resposta

✓ Resposta: O println chama código que usa travas internamente, para que as linhas impressas por várias threads não se misturem. Obter e liberar travas cria relações acontece-antes e, na prática, impede o JIT de manter o valor de parar fora do laço, porque ele não consegue mais provar que nada dentro do laço altera o campo. Além disso, o println torna cada volta muito mais lenta, o que muda completamente o comportamento do laço. O problema desaparece, mas por acidente: a garantia não vem do código que o colega escreveu, e sim de um detalhe interno de implementação do println, que poderia mudar ou ser removido. Assim que o println sair, o defeito volta. É o comportamento típico dos defeitos de concorrência, que somem quando observados. A correção real é declarar parar como volatile, que estabelece a relação acontece-antes de forma explícita e garantida.

Exercício 2

Para cada situação, diga se volatile basta, se é preciso synchronized (ou outra forma de atomicidade), ou se nenhuma das duas é necessária: (a) um campo boolean ativo que uma thread de configuração altera e várias threads de trabalho leem; (b) um campo int pedidosProcessados que várias threads incrementam; (c) um campo final List<String> CIDADES = List.of(...) lido por várias threads; (d) dois campos, saldo e ultimaAtualizacao, que precisam ser alterados juntos.

Ver resposta

✓ Resposta: (a) volatile basta: uma só thread grava, as outras só leem, e a leitura não depende de nada além do próprio valor. (b) É preciso atomicidade: o incremento lê e grava com base no que leu, e com volatile os incrementos se perdem, como o exemplo mostrou. Um método synchronized resolve, e o AtomicInteger de "Ferramentas de Precisão" é mais eficiente. (c) Nenhuma das duas: o campo é final e a lista é imutável, então o JMM garante que todas as threads veem a lista completa e que ninguém pode alterá-la. (d) synchronized: mesmo que cada campo fosse volatile, uma thread poderia ler o saldo novo com a ultimaAtualizacao antiga, entre as duas escritas. As duas alterações precisam formar uma unidade, e a leitura também precisa obter a mesma trava para ver um par consistente. Outra solução, muitas vezes melhor, é juntar os dois valores num record imutável e trocar a referência inteira de uma vez.

Exercício 3

Explique, com as quatro condições de Coffman, por que ordenar as travas pelo identificador da conta elimina o impasse da transferência. Que condição foi quebrada?

Ver resposta

✓ Resposta: As quatro condições são: exclusão mútua (uma trava só pode ser segurada por uma thread), posse e espera (uma thread segura uma trava enquanto espera outra), ausência de preempção (ninguém tira a trava de quem a segura) e espera circular (um ciclo de threads, cada uma esperando uma trava que a seguinte segura). As três primeiras continuam valendo com a solução: as travas do synchronized são exclusivas, a thread segura a primeira enquanto espera a segunda, e ninguém as tira à força. A condição quebrada é a espera circular. Com uma ordem global, uma thread só espera por uma trava de identificador maior que todas as que ela já segura. Num ciclo, alguma thread teria de esperar por uma trava de identificador menor, o que a regra proíbe. Sem ciclo, não há impasse. A técnica funciona desde que todo o código que trava mais de uma conta siga a mesma ordem, e é por isso que ela fica dentro do próprio método transferir, e não espalhada pelo programa.

Exercício 4

Compare três formas de implementar Catalogo.instancia(): (a) um método static synchronized que cria o catálogo se ele ainda for null; (b) a mesma verificação sem synchronized; (c) o portador da aula. Quais são corretas, e qual você usaria?

Ver resposta

✓ Resposta: (a) É correta: a trava garante que só uma thread crie o catálogo e que todas vejam o objeto completo. O custo é que toda chamada, mesmo depois da criação, precisa obter a trava, e com muitas threads chamando o método o tempo todo isso vira um gargalo. (b) É incorreta: duas threads podem ver null ao mesmo tempo e criar dois catálogos. Pior, sem nenhuma relação acontece-antes, uma thread pode ver a referência ao catálogo antes de ver os campos dele inicializados, e usar um objeto pela metade. (c) É correta e não tem o custo da (a): a JVM garante a inicialização única e visível da classe Portador, e depois disso cada chamada é uma simples leitura de campo, que o JIT otimiza. Eu usaria a (c). Se o objeto não for caro, uma quarta forma é ainda mais simples: criá-lo diretamente num campo static final da própria classe, sem nenhuma preguiça. A preguiça só compensa quando a criação é cara e pode não ser necessária.

Exercício 5

Um record Configuracao(String servidor, int porta, List<String> permissoes) é criado uma vez e compartilhado por todas as threads de um servidor, através de um campo volatile Configuracao atual. Quando a configuração muda, uma nova instância é criada e atribuída ao campo. Explique por que esse desenho é seguro sem nenhuma trava, e que cuidado o construtor precisa ter.

Ver resposta

✓ Resposta: O desenho combina duas garantias. A primeira é a imutabilidade: os componentes de um record são final, e o JMM garante que qualquer thread que veja uma referência a um objeto completamente construído veja os valores dos seus campos final. Nenhuma thread consegue alterar uma Configuracao depois de criada, então não há o que proteger. A segunda é o volatile do campo atual: a troca de uma configuração por outra é uma única escrita de referência, e o volatile garante que todas as threads passem a ver a nova instância. Cada thread lê a referência uma vez e trabalha com uma configuração consistente, velha ou nova, mas nunca uma mistura das duas, o problema do item (d) do exercício 2. O cuidado está no componente permissoes: a imutabilidade do record é rasa, como mostrou "Dados sem Cerimônia". Se o construtor guardar a lista recebida, e ela for um ArrayList que o chamador continua alterando, as threads podem ver uma lista mudando, sem nenhuma garantia de visibilidade. O construtor compacto deve fazer permissoes = List.copyOf(permissoes);, como o record Turma de "Listas, Conjuntos e Mapas", e aí o objeto é imutável de verdade.

Comentários

Mais em Java

Iguais ou o Mesmo? — equals, hashCode e Identidade
Iguais ou o Mesmo? — equals, hashCode e Identidade

Identidade e igualdade em Java: o contrato do equals, Double.compare, hashCode…

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids

Nesta aula você aprende a detectar colisões com círculos, retângulos e…

Quem Recolhe o Lixo — os Coletores G1 e ZGC
Quem Recolhe o Lixo — os Coletores G1 e ZGC

Como a JVM decide o que é lixo pela alcançabilidade a partir das raízes, a…