← Todos los patrones

Singleton

Creational · ver código fuente en GitHub

Leer en: English · Português · Español

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

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

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);
    }
}

Ver informe completo de cobertura JaCoCo →