Radiografia de um Singleton: debugando a criação de objetos (Race Condition) - Java Intermediário
- #Java
👋 Por que este artigo existe
A maioria dos conteúdos sobre o padrão Singleton costuma estagnar nos mesmos clichês pragmáticos: ensinam a versão naive (inocente) e disparam orientações prontas como "a versão lazy não é thread-safe, use synchronized ou Double-Checked Locking", "se quiser performance, aplique o Singleton Holder", ou a clássica fuga conceitual: "em projetos reais, o Spring Framework já resolve tudo via escopo de Beans".
Neste laboratório desci aos detalhes para explicar o problema da versão Lazy: usei o debugger do IntelliJ IDEA para acompanhar, instrução por instrução, o ciclo de vida completo de um SingletonLazy, desde o atributo estático null, passando pela alocação na Heap, até a reutilização da mesma referência na segunda chamada. Depois, simulei manualmente (com uma tabela de tempo) o que aconteceria se duas threads chamassem getInstancia() ao mesmo tempo.
O resultado é um roteiro que qualquer estudante de Design Patterns em Java pode (e DEVE) reproduzir na própria IDE, e que faz como que a race condition deixe de ser um conceito abstrato pra se tornar algo que você literalmente vê acontecer no painel Variables.
O que você vai encontrar neste artigo:
- Uma revisão rápida do padrão Singleton Lazy;
- O passo a passo de debug real, com prints do IntelliJ;
- Uma matriz de rastreamento de estado da execução;
- A anatomia de uma race condition no padrão
check-then-act; - As alternativas de correção — e por que a mais elegante delas dispensa
synchronized.
1. Relembrando o padrão Singleton Lazy
A ideia do Singleton "Preguiçoso" (Lazy) é adiar a criação do objeto até o momento em que ele é efetivamente solicitado pela primeira vez:
package one.digitalinnovation.gof;
public class SingletonLazy {
// O atributo 'instancia' começa como null (ainda não foi criado)
private static SingletonLazy instancia;
// Construtor PRIVADO: ninguém de fora pode fazer 'new SingletonLazy()'
private SingletonLazy() {}
// Método público para obter a instância única
public static SingletonLazy getInstancia() {
if (instancia == null) {
// Só cria se ainda não existir
instancia = new SingletonLazy();
}
return instancia;
}
}
Vantagem: economia de memória e processamento na inicialização da aplicação. Problema (ainda invisível aqui, mas que existe): a checagem if (instancia == null) não é atômica ‼️. Vamos ver isso na prática: para investigar o comportamento, criei uma classe de apoio para observar as duas chamadas:
package one.digitalinnovation.gof.singletonlazy;
import one.digitalinnovation.gof.SingletonLazy;
public class SingletonLazyDebug {
public static void main(String[] args) {
System.out.println("=== INÍCIO DO TESTE DE DEBUG: SINGLETON LAZY ===");
// Primeira chamada: deve instanciar o objeto na memória Heap
SingletonLazy lazy1 = SingletonLazy.getInstancia();
System.out.println("Endereço/Hash de lazy1: " + lazy1);
// Segunda chamada: deve reaproveitar a instância pré-existente
SingletonLazy lazy2 = SingletonLazy.getInstancia();
System.out.println("Endereço/Hash de lazy2: " + lazy2);
// Validação de identidade
if (lazy1 == lazy2) {
System.out.println("[SUCESSO] Ambas as variáveis apontam para a MESMA instância!");
} else {
System.out.println("[FALHA] Foram criadas instâncias diferentes!");
}
}
}
2. Colocando o debugger pra trabalhar
Setup: dois breakpoints, um em cada chamada de SingletonLazy.getInstancia(), execução em modo Debug (Shift + F9) e Step Into (F7) para entrar no método da classe base.

Primeira chamada — o nascimento da instância
Ao entrar no método pela primeira vez, o painel Variables confirma o que o código promete: o atributo estático instancia ainda está null.

O fluxo entra na linha instancia = new SingletonLazy();:

O construtor privado é executado — e a pilha de chamadas (Call Stack) mostra o <init> sendo disparado logo após a validação instancia == null, alocando a nova referência (@1075) na Heap:

Ao alcançar o return instancia;, o painel Variables confirma que o atributo estático agora referencia o objeto recém-criado, pronto para ser devolvido:

Avançando com Step Over (F8), a variável local lazy1 recebe o ponteiro:

Segunda chamada — a reutilização
Na chamada seguinte, o fluxo entra de novo em getInstancia(). Desta vez, como instancia já contém @1075, a condição do if é avaliada como false:

A instrução new SingletonLazy() é saltada por completo, evitando uma alocação duplicada:

O método retorna diretamente a referência pré-existente. Ao final da execução, os dois endereços no painel Variables são idênticos:

Validação final
Comparando os hashes de lazy1 e lazy2 diretamente no console de debug, fica comprovado: as duas variáveis apontam para o mesmo objeto.

3. Matriz de rastreamento de estado
Consolidando o que o debugger mostrou, passo a passo:

Com uma única thread, o comportamento é perfeitamente previsível. É aqui que a história muda de figura.
4. O "Elefante na sala": a Race Condition do check-then-act
Repare de novo na lógica do método:
public static SingletonLazy getInstancia() {
if (instancia == null) { // (1) verifica
instancia = new SingletonLazy(); // (2) age
}
return instancia;
}
Esse padrão é conhecido como check-then-act (verifica-então-age): o método primeiro lê o valor de instancia e só depois decide se cria o objeto. O problema é que, para a JVM, ler e escrever não são uma operação atômica única. Nada impede que o escalonador de threads interrompa a execução exatamente entre o if e o new, cedendo a vez para outra thread.
Simulando duas threads concorrentes
Imagine as threads A e B chamando getInstancia() ao mesmo tempo, no instante em que instancia ainda vale null:

Resultado: duas instâncias diferentes foram criadas (@111 e @222), e a segunda sobrescreveu a referência da primeira. Se alguma outra parte do sistema já tivesse recebido @111 entre t4 e t6, a aplicação passaria a conviver com dois "Singletons" divergentes ‼️ (o que quebra a premissa fundamental do padrão).
Isso é uma race condition: o resultado final do programa passa a depender da ordem imprevisível de execução das threads, em vez de depender só da lógica escrita no código.
5. Como corrigir isso
Existem três caminhos clássicos:
synchronizedno métodogetInstancia()— força as threads a executarem o método uma de cada vez. Simples, mas adiciona overhead de lock em toda chamada, mesmo depois que a instância já existe.- Double-Checked Locking — sincroniza só o trecho de criação, verificando
instancia == nullduas vezes (uma fora e outra dentro do blocosynchronized). Reduz o overhead, mas exige que o campo sejavolatile, e é uma técnica historicamente propensa a erros de implementação. - ⭐Initialization-on-demand holder (
SingletonLazyHolder) — a alternativa mais elegante: elimina oifpor completo, delegando a criação a uma classe interna estática. Quem garante a criação única, mesmo com várias threads disputando ao mesmo tempo, é o próprio ClassLoader da JVM, conforme a especificação JLS §12.4.2, que garante que a inicialização de uma classe é serializada automaticamente entre threads concorrentes. Mesma segurança de umsynchronized, sem escrevê-lo manualmente e sem custo de lock a cada chamada.
Essa terceira variação merece um laboratório de debug só dela: será próximo artigo desta série (onde também exploraremos os conceitos de INICIALIZAÇÃO vs. INSTANCIAÇÃO de classes).
Conclusão
Debugar não é só "achar o bug". É também a melhor ferramenta que temos para transformar teoria em evidência: vemos com os próprios olhos o null virando referência, o if sendo saltado, os endereços de heap se repetindo. Foi exatamente isso que fizemos aqui com o Singleton Lazy. E o mesmo exercício pode (e deve) ser reproduzido para qualquer padrão de projeto que estejamos estudando.
#Java #DesignPatterns #Singleton #Debug #IntelliJIDEA #Concorrência #BoasPráticas



