Category: Creational
El problema
Algunos recursos genuinamente necesitan exactamente una instancia compartida por proceso: un
registro de configuración, un pool de conexiones, una tabla de límites regulatorios. Si cada
llamador construye su propia copia, o se desperdicia el costo de construirla repetidamente o —
peor — distintas partes del sistema terminan viendo copias diferentes, posiblemente obsoletas,
de lo que debería ser una única fuente de verdad. Acertar esta garantía de "una instancia" bajo
acceso concurrente es más difícil de lo que parece: una comprobación ingenua
if (instance == null) instance = new Thing() tiene una condición de carrera en la que dos
hilos pueden pasar la comprobación de nulo antes de que cualquiera haya asignado el campo.
La solución
Ocultar el constructor, exponer un único punto de acceso, y hacer que ese punto de acceso sea seguro bajo el primer uso concurrente.
classDiagram
class LazyThreadSafeSingleton {
-static volatile instance
-LazyThreadSafeSingleton()
+static getInstance() LazyThreadSafeSingleton
+getSetting(key) String
}
class Caller
Caller --> LazyThreadSafeSingleton : getInstance()
Ejemplo clásico
classic/LazyThreadSafeSingleton
es el singleton de double-checked locking clásico de los libros: un campo volatile, una
comprobación de nulo fuera del lock (camino rápido una vez inicializado), y una segunda
comprobación de nulo dentro de un bloque synchronized (de modo que solo el primer hilo que
pasa realmente construye la instancia). El campo debe ser volatile — sin eso, un hilo podría
observar una referencia no nula a un objeto cuyo constructor todavía no terminó de escribir sus
campos, porque la JVM tiene permitido reordenar la escritura en instance antes de las
escrituras que ocurren dentro del constructor.
LazyThreadSafeSingletonTest
dispara 50 hilos contra getInstance() simultáneamente (sincronizados con un CountDownLatch
para que realmente compitan en la primera llamada) y verifica que cada hilo observó exactamente
la misma instancia.
Ejemplo aplicado: registro de límites regulatorios de PIX
applied/HandRolledLimitRegistry
modela una tabla central de límites de PIX definidos por BACEN (tope diario, tope nocturno
reducido) que lee cada validador de transacciones concurrente. Recargar estos límites en cada
llamada de validación sería un desperdicio, y los validadores que corren concurrentemente deben
ver todos los mismos valores — exactamente el escenario para el que existe el patrón, aplicado
con la misma mecánica de double-checked locking que el ejemplo clásico.
applied/SpringManagedLimitRegistry
implementa el mismo contrato LimitRegistry sin ninguna maquinaria de singleton — es una
clase simple. La garantía de instancia única proviene enteramente del alcance de bean
predeterminado de Spring (singleton), conectado en SingletonRegistryConfig.
SpringManagedLimitRegistryTest
lo demuestra: dos llamadas a context.getBean(...) devuelven la misma referencia, con cero
código de bloqueo escrito a mano. La misma garantía, dos formas de obtenerla — una la construye
usted mismo, la otra un contenedor se la da gratis una vez que acepta la dependencia.
Cuándo no usarlo
- Si el requisito de "instancia compartida" es en realidad solo "acceso global conveniente", prefiera pasar la dependencia explícitamente (inyección por constructor) — los singletons ocultan dependencias y dificultan aislar las pruebas.
- Si ya está dentro de un contenedor de DI (Spring, en el propio ejemplo de este repositorio),
deje que el contenedor gestione el alcance de singleton; el código
getInstance()hecho a mano junto a un contenedor es redundante y confuso. - Si la "instancia única" necesita variar por solicitud, por inquilino, o por hilo, ese es el
alcance equivocado por completo — recurra a un bean con alcance definido o a un
ThreadLocal.
Cobertura de pruebas
97% de cobertura de instrucciones, 87% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:
./gradlew :creational:singleton:jacocoTestReport
Informe en creational/singleton/build/reports/jacoco/test/html/index.html.
Lecturas adicionales
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — el Capítulo 3 formaliza Singleton en sí; todo módulo de este repositorio se remonta a este libro.
- Pugh, W., Bacon, D., Bloch, J., et al. "Double-Checked Locking is Broken" Declaration.
University of Maryland. — el razonamiento exacto de por qué
getInstance()necesita más que una comprobación de nulo bajo el modelo de memoria previo a Java 5, y por qué un campo simple (sinvolatile) no es suficiente. - Manson, J., Pugh, W., & Adve, S. V. (2005). "The Java Memory Model." En Proceedings of the
32nd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL '05),
378–391. — la formalización del JSR-133 que hace que
volatilesea suficiente para corregir el double-checked locking; este es el artículo en el que se apoya, en última instancia, la corrección enLazyThreadSafeSingleton.
Pruebas unitarias
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);
}
}