1. Introdução ao Java Memory Model (JMM)
O problema de visibilidade e ordenação
Em um programa de thread única tudo é simples: você grava um valor em uma variável — e pode lê-lo imediatamente. Na realidade multithreaded isso funciona de outra forma. Os processadores fazem cache de valores, o compilador e a JVM às vezes trocam a ordem das instruções, e uma thread pode ver um valor “antigo”, mesmo que outra thread tenha acabado de alterá-lo.
Java Memory Model (JMM) descreve como as threads se comunicam pela memória: quando as alterações de uma thread se tornam visíveis para as outras e em que ordem as operações ocorrem. Se isso não for considerado, o programa pode se comportar de forma imprevisível, embora à primeira vista tudo pareça normal.
Entender o JMM ajuda a saber por que às vezes a thread não vê dados recentes, como usar corretamente volatile, synchronized e classes atômicas, e por que bugs em código concorrente podem aparecer só em produção. Em poucas palavras, o JMM são as regras do jogo da memória e, se você as ignorar, até o código mais cuidadoso pode jogar contra você.
Analogia
Imagine que você tem duas pessoas (threads) que escrevem e leem bilhetes (variáveis) em um quadro (memória). Às vezes uma escreve e a outra ainda não vê a nova anotação — porque está olhando para sua própria cópia do quadro (cache). O JMM define quando e como esses bilhetes se tornam visíveis para todos.
2. happens-before: o fundamento do JMM
O que é happens-before?
happens-before é a relação entre duas ações no programa: se a ação A acontece-before a ação B, então todas as alterações feitas em A são garantidamente visíveis em B.
Importante: happens-before não é simplesmente “aconteceu antes”, mas sim “é garantidamente visível”.
Regras principais de happens-before
1. Dentro de uma única thread
Tudo o que acontece em uma thread é ordenado: se você gravou em uma variável e depois a leu — verá sua própria alteração.
2. Blocos/monitores sincronizados
Tudo o que ocorre antes da saída de um bloco sincronizado (synchronized) se torna visível para a thread que depois entrar nesse bloco.
synchronized(lock) {
sharedVar = 42; // escrita
}
// ...
synchronized(lock) {
System.out.println(sharedVar); // veremos 42 garantidamente
}
3. escrita/leitura de volatile
Uma escrita em um campo volatile acontece-before qualquer leitura subsequente desse campo por outra thread.
volatile boolean ready = false;
// Thread 1
data = 123;
ready = true; // volatile write
// Thread 2
if (ready) { // volatile read
System.out.println(data); // garantido: veremos data = 123
}
4. Início e término de threads
- A chamada de Thread.start() acontece-before o início da execução da thread.
- O término da thread acontece-before o retorno de Thread.join().
5. Conclusão de tarefa no Executor
Se você enviou uma tarefa para um Executor e aguardou sua conclusão (Future.get()), todas as alterações feitas na tarefa são visíveis após o get().
6. Campos finais
A inicialização de campos com o modificador final no construtor acontece-before a publicação da referência ao objeto. Isso é importante para objetos imutáveis.
3. Publicação segura de objetos
Problema: “objeto com valores desatualizados”
Se uma thread cria um objeto e o entrega para outra thread sem sincronização, a outra thread pode ver valores “crus” nos campos (por exemplo, não inicializados ou antigos).
class Holder {
int value;
Holder() { value = 42; }
}
Holder holder = null;
// Thread 1
holder = new Holder(); // criamos o objeto
// Thread 2
if (holder != null) {
System.out.println(holder.value); // pode ver 0, não 42!
}
Como publicar objetos corretamente?
1. Por meio de campos final
Se todos os campos do objeto são final e são inicializados no construtor, o objeto pode ser publicado com segurança sem sincronização adicional.
class SafeHolder {
final int value;
SafeHolder() { value = 42; }
}
2. Por meio de uma referência volatile
Se a referência ao objeto é declarada como volatile, após a atribuição o objeto fica garantidamente visível para outras threads.
volatile Holder holder;
// Thread 1
holder = new Holder();
// Thread 2
if (holder != null) {
System.out.println(holder.value); // veremos 42 garantidamente
}
3. Por meio de inicialização em thread única antes da publicação
Se o objeto é criado e inicializado antes que a referência a ele se torne acessível a outras threads, tudo é seguro.
Holder holder = new Holder(); // apenas em uma thread
// ... depois holder se torna acessível a outras threads
4. Por meio de bloqueio
Se o objeto é criado dentro de um bloco sincronizado e a referência a ele é lida somente dentro de um bloco igual, tudo é seguro.
Holder holder;
synchronized(lock) {
if (holder == null) {
holder = new Holder();
}
}
// ... em outra thread
synchronized(lock) {
if (holder != null) {
// seguro
}
}
4. Double-checked locking e volatile
O que é double-checked locking?
É um padrão para inicialização preguiçosa de um objeto singleton:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // primeira verificação
synchronized (Singleton.class) {
if (instance == null) { // segunda verificação
instance = new Singleton();
}
}
}
return instance;
}
}
Problema: Sem uma referência volatile para instance, esse código não funciona corretamente! Uma thread pode ver um objeto não totalmente inicializado.
Por que sem volatile é ruim?
A JVM pode “reordenar” as instruções de modo que a referência ao objeto seja atribuída antes da conclusão do construtor. Outra thread verá um objeto não inicializado.
Como fazer corretamente?
Declare instance como volatile:
private static volatile Singleton instance;
Agora o double-checked locking funciona corretamente: volatile garante a relação happens-before entre a escrita e a leitura da referência.
Alternativa: inicialização estática
O jeito mais simples e seguro de fazer um singleton é usar inicialização estática:
class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() { return INSTANCE; }
}
Aqui a JVM garante a inicialização correta.
5. VarHandle: acesso moderno de baixo nível
O que é VarHandle?
VarHandle é uma API moderna (Java 9+) que permite trabalhar com variáveis em baixo nível: ler, escrever, fazer operações atômicas, controlar visibilidade e ordenação de instruções.
Para que serve o VarHandle se existem classes atômicas?
— VarHandle permite trabalhar com quaisquer campos (não apenas com int/long/Reference).
— Permite escolher explicitamente a semântica de acesso: volatile, acquire/release, opaque.
— É usado para implementar estruturas de dados de alto desempenho.
Semânticas de acesso
- Volatile: garantia completa de happens-before (como em um campo volatile).
- Acquire/Release: garantia mais fraca, porém mais rápida (usada para estruturas lock-free).
- Opaque: garantia mínima de visibilidade, mas máxima performance.
Exemplo de uso de VarHandle
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
int value;
static final VarHandle VALUE_HANDLE;
static {
try {
VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
} catch (Exception e) {
throw new Error(e);
}
}
}
Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);
Quando usar VarHandle?
- Para implementar suas próprias estruturas de dados lock-free.
- Quando é necessária máxima performance e controle sobre a ordem das instruções.
- Em aplicações comuns, geralmente as classes atômicas e synchronized são suficientes.
6. False sharing e alinhamento de cache
False sharing é a situação em que duas threads trabalham com variáveis diferentes, mas essas variáveis estão na mesma linha de cache do processador. Como resultado, as threads atrapalham uma à outra, porque a alteração de uma variável invalida o cache da outra.
Analogia: Duas pessoas sentam-se na mesma mesa (linha de cache), mas cada uma escreve em sua metade. Se uma muda algo, a outra precisa “reler” toda a folha.
Por que isso é ruim?
- A performance cai drasticamente: os processadores gastam tempo sincronizando caches.
- É especialmente crítico para variáveis “quentes”, que são frequentemente alteradas por diferentes threads.
Como evitar?
Separe os campos “quentes” em objetos diferentes ou use anotações/estruturas especiais para alinhamento (por exemplo, @Contended). Em JVMs modernas é possível ativar a opção -XX:-RestrictContended e usar @sun.misc.Contended (Java 8+) para alinhar campos.
Exemplo:
@sun.misc.Contended
public volatile long value1;
@sun.misc.Contended
public volatile long value2;
NB: A anotação @Contended não faz parte da API padrão, mas é usada no JDK para otimização de classes atômicas.
7. Prática: corrigindo o singleton e microbenchmarks JMH
Corrigindo o singleton defeituoso
Ruim (sem volatile):
class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton();
}
}
}
return instance;
}
}
Bom (com volatile):
class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
Melhor de tudo — inicialização estática:
class StaticSingleton {
private static final StaticSingleton INSTANCE = new StaticSingleton();
public static StaticSingleton getInstance() { return INSTANCE; }
}
Microbenchmarks JMH: visibilidade e atomicidade
Atenção: JMH é um framework específico para microbenchmarks em Java. Não tente tirar conclusões de desempenho sem o JMH — os resultados podem enganar!
Exemplo: verificando visibilidade de volatile
public class VolatileVisibility {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// loop até ver true
}
// mudança observada
}
}
Exemplo: a não atomicidade de volatile
public class VolatileNotAtomic {
volatile int counter = 0;
public void increment() {
counter++; // não é atômico!
}
}
Apesar de volatile, ao incrementar simultaneamente a partir de várias threads o valor final será menor que o esperado (a operação counter++ se decompõe em leitura, cálculo e escrita).
8. Erros típicos ao trabalhar com JMM, volatile e publicação
Erro nº 1: esperar atomicidade de volatile.
volatile garante apenas visibilidade das alterações, não a atomicidade das operações. A operação counter++ não se torna atômica se a variável for volatile.
Erro nº 2: publicar objetos sem sincronização.
Se você cria um objeto em uma thread e o entrega a outra sem volatile, synchronized ou campos final, a outra thread pode ver valores “crus”.
Erro nº 3: double-checked locking sem volatile.
Sem uma referência volatile no singleton, você pode obter um objeto não inicializado em outra thread.
Erro nº 4: uso de bloqueios antigos com threads virtuais.
Alguns mecanismos antigos de sincronização (native monitor) podem atrapalhar a JVM a gerenciar eficientemente threads virtuais.
Erro nº 5: ignorar false sharing.
Se variáveis “quentes” estão próximas na memória, as threads vão se atrapalhar por causa das linhas de cache.
Erro nº 6: tirar conclusões de performance sem JMH.
Microbenchmarks sem JMH frequentemente produzem resultados enganosos devido às otimizações da JVM e aos caches do processador.
GO TO FULL VERSION