Category: Creational
O problema
Alguns recursos genuinamente precisam de exatamente uma instância compartilhada por processo:
um registro de configuração, um pool de conexões, uma tabela de limites regulatórios. Se cada
chamador constrói sua própria cópia, ou se desperdiça o custo de construí-la repetidamente ou —
pior — partes diferentes do sistema acabam enxergando cópias diferentes, possivelmente
desatualizadas, do que deveria ser uma única fonte de verdade. Acertar essa garantia de "uma
instância" sob acesso concorrente é mais difícil do que parece: uma checagem ingênua
if (instance == null) instance = new Thing() tem uma condição de corrida em que duas threads
podem passar pela checagem de nulo antes que qualquer uma tenha atribuído o campo.
A solução
Esconder o construtor, expor um único ponto de acesso, e tornar esse ponto de acesso seguro sob primeiro uso concorrente.
classDiagram
class LazyThreadSafeSingleton {
-static volatile instance
-LazyThreadSafeSingleton()
+static getInstance() LazyThreadSafeSingleton
+getSetting(key) String
}
class Caller
Caller --> LazyThreadSafeSingleton : getInstance()
Exemplo clássico
classic/LazyThreadSafeSingleton
é o singleton de double-checked locking clássico dos livros: um campo volatile, uma checagem
de nulo fora do lock (caminho rápido depois de inicializado), e uma segunda checagem de nulo
dentro de um bloco synchronized (de modo que só a primeira thread que passa de fato constrói a
instância). O campo precisa ser volatile — sem isso, uma thread poderia observar uma
referência não nula a um objeto cujo construtor ainda não terminou de escrever seus campos,
porque a JVM tem permissão de reordenar a escrita em instance antes das escritas que acontecem
dentro do construtor.
LazyThreadSafeSingletonTest
dispara 50 threads em getInstance() simultaneamente (sincronizadas com um CountDownLatch pra
que de fato disputem na primeira chamada) e verifica que toda thread observou exatamente a
mesma instância.
Exemplo aplicado: registro de limites regulatórios do PIX
applied/HandRolledLimitRegistry
modela uma tabela central de limites do PIX definidos pelo BACEN (teto diário, teto noturno
reduzido) que todo validador de transação concorrente lê. Recarregar esses limites a cada
chamada de validação seria um desperdício, e validadores rodando concorrentemente precisam
todos enxergar os mesmos valores — exatamente o cenário pro qual o padrão existe, aplicado com a
mesma mecânica de double-checked locking do exemplo clássico.
applied/SpringManagedLimitRegistry
implementa o mesmo contrato LimitRegistry sem nenhuma maquinaria de singleton — é uma
classe simples. A garantia de instância única vem inteiramente do escopo de bean padrão do
Spring (singleton), conectado em SingletonRegistryConfig.
SpringManagedLimitRegistryTest
prova isso: duas chamadas a context.getBean(...) retornam a mesma referência, com zero código
de locking escrito à mão. Mesma garantia, duas formas de obtê-la — uma você constrói você
mesmo, a outra um container te dá de graça uma vez que você aceita a dependência.
Quando não usar
- Se a exigência de "instância compartilhada" é na verdade só "acesso global conveniente", prefira passar a dependência explicitamente (injeção via construtor) — singletons escondem dependências e dificultam isolar testes.
- Se você já está dentro de um container de DI (Spring, no próprio exemplo deste repositório),
deixe o container gerenciar o escopo de singleton; código
getInstance()feito à mão ao lado de um container é redundante e confuso. - Se a "instância única" precisa variar por requisição, por tenant, ou por thread, esse é o
escopo errado por completo — recorra a um bean com escopo ou a um
ThreadLocal.
Cobertura de testes
97% de cobertura de instrução, 87% de cobertura de branch (JaCoCo). Reproduza você mesmo:
./gradlew :creational:singleton:jacocoTestReport
Relatório em creational/singleton/build/reports/jacoco/test/html/index.html.
Leitura complementar
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — o Capítulo 3 formaliza o Singleton em si; todo módulo deste repositório remonta a esse livro.
- Pugh, W., Bacon, D., Bloch, J., et al. "Double-Checked Locking is Broken" Declaration.
University of Maryland. — o raciocínio exato de por que
getInstance()precisa de mais do que uma checagem de nulo sob o modelo de memória pré-Java-5, e por que um campo simples (semvolatile) não é suficiente. - Manson, J., Pugh, W., & Adve, S. V. (2005). "The Java Memory Model." Em Proceedings of the
32nd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL '05),
378–391. — a formalização do JSR-133 que torna
volatilesuficiente pra corrigir o double-checked locking; esse é o artigo em que a correção emLazyThreadSafeSingletonse apoia, em última instância.
Testes unitários
src/test/java/com/designpatterns/creational/singleton/classic/LazyThreadSafeSingletonTest.java
package com.designpatterns.creational.singleton.classic;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Ordered on purpose: the concurrency test below must be the one that constructs the
* singleton (all 50 threads racing through the synchronized block), not a coincidence of
* JUnit's default method order. If {@code exposesTheSettingsLoadedAtConstruction} ran first,
* it would construct the instance single-threaded, and the "concurrent" test would never
* actually exercise contention on the lock - it would just find the instance already there.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class LazyThreadSafeSingletonTest {
@Test
@Order(1)
void returnsTheSameInstanceUnderConcurrentFirstAccess() throws InterruptedException {
int threadCount = 50;
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
CountDownLatch ready = new CountDownLatch(threadCount);
CountDownLatch start = new CountDownLatch(1);
Set<LazyThreadSafeSingleton> seenInstances = Collections.newSetFromMap(new ConcurrentHashMap<>());
try {
for (int i = 0; i < threadCount; i++) {
pool.submit(() -> {
ready.countDown();
awaitUninterruptibly(start);
seenInstances.add(LazyThreadSafeSingleton.getInstance());
});
}
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
pool.shutdown();
assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
} finally {
pool.shutdownNow();
}
assertThat(seenInstances).hasSize(1);
}
@Test
@Order(2)
void exposesTheSettingsLoadedAtConstruction() {
assertThat(LazyThreadSafeSingleton.getInstance().getSetting("environment")).isEqualTo("production");
}
private static void awaitUninterruptibly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
src/test/java/com/designpatterns/creational/singleton/applied/HandRolledLimitRegistryTest.java
package com.designpatterns.creational.singleton.applied;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Ordered on purpose - see {@code LazyThreadSafeSingletonTest} for why: the concurrency test
* must be the one that actually constructs the singleton under contention, not whichever test
* happens to call getInstance() first.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class HandRolledLimitRegistryTest {
@Test
@Order(1)
void everyConcurrentValidatorSeesTheSameRegistryInstance() throws InterruptedException {
int threadCount = 50;
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
CountDownLatch ready = new CountDownLatch(threadCount);
CountDownLatch start = new CountDownLatch(1);
Set<HandRolledLimitRegistry> seenInstances = Collections.newSetFromMap(new ConcurrentHashMap<>());
try {
for (int i = 0; i < threadCount; i++) {
pool.submit(() -> {
ready.countDown();
awaitUninterruptibly(start);
seenInstances.add(HandRolledLimitRegistry.getInstance());
});
}
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
pool.shutdown();
assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
} finally {
pool.shutdownNow();
}
assertThat(seenInstances).hasSize(1);
}
@Test
@Order(2)
void exposesTheRegulatoryLimits() {
LimitRegistry registry = HandRolledLimitRegistry.getInstance();
assertThat(registry.dailyLimitCents()).isEqualTo(100_000_00L);
assertThat(registry.nightlyLimitCents()).isEqualTo(1_000_00L);
}
private static void awaitUninterruptibly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
src/test/java/com/designpatterns/creational/singleton/applied/SpringManagedLimitRegistryTest.java
package com.designpatterns.creational.singleton.applied;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
class SpringManagedLimitRegistryTest {
private AnnotationConfigApplicationContext context;
@AfterEach
void closeContext() {
if (context != null) {
context.close();
}
}
@Test
void springReturnsTheSameBeanInstanceOnEveryLookup() {
context = new AnnotationConfigApplicationContext(SingletonRegistryConfig.class);
SpringManagedLimitRegistry first = context.getBean(SpringManagedLimitRegistry.class);
SpringManagedLimitRegistry second = context.getBean(SpringManagedLimitRegistry.class);
assertThat(first).isSameAs(second);
assertThat(first.dailyLimitCents()).isEqualTo(100_000_00L);
}
}