De la red volvemos a casa: dentro de cada servicio de PideYa hay decenas de hilos atendiendo peticiones a la vez, y todos comparten la misma memoria. Aquí los fallos no son timeouts visibles sino condiciones de carrera: bugs que aparecen una vez cada mil ejecuciones y desaparecen al poner un log. En 02-02 mostramos el double-checked locking del Singleton casi como un conjuro; esta lección da por fin el fundamento, y con él un catálogo de patrones para que los hilos cooperen sin pisarse. La distribución entre procesos quedó cubierta en la lección anterior; aquí todo ocurre dentro de una JVM.

Contenido

  1. El problema: estado compartido mutable
  2. El double-checked locking, ahora con fundamento
  3. Inmutabilidad como patrón
  4. Confinamiento de estado
  5. Producer-Consumer con BlockingQueue
  6. Thread Pool / Worker con ExecutorService
  7. Future, Promise y CompletableFuture
  8. Read-Write Lock
  9. Active Object (mención) y virtual threads de Java 21
  10. Guía: cuándo usar qué

El problema: estado compartido mutable

Dos hilos ejecutan contadorPedidos++ a la vez. Esa línea son tres operaciones (leer, sumar, escribir); si se intercalan, un incremento se pierde. Peor aún: aunque no se intercalen, un hilo puede no ver lo que escribió otro, porque cada núcleo cachea memoria y el compilador reordena instrucciones. El Java Memory Model solo garantiza visibilidad entre hilos cuando hay una relación happens-before: la crean synchronized, volatile, los java.util.concurrent y el arranque/join de hilos.

La ecuación del peligro es: estado compartido + mutabilidad + concurrencia. Todos los patrones de esta lección eliminan al menos un factor:

Patrón Factor que elimina
Inmutabilidad La mutabilidad
Confinamiento El compartir
Producer-Consumer, Active Object El acceso simultáneo (serializa por colas)
Locks (incluido Read-Write) El acceso simultáneo (excluye)

El double-checked locking, con fundamento

Recordemos el Singleton perezoso de ConfiguracionPideYa:

public class ConfiguracionPideYa {
    private static volatile ConfiguracionPideYa instancia; // volatile: imprescindible

    public static ConfiguracionPideYa getInstancia() {
        if (instancia == null) {                    // 1ª comprobación, sin lock (rápida)
            synchronized (ConfiguracionPideYa.class) {
                if (instancia == null) {            // 2ª comprobación, con lock
                    instancia = new ConfiguracionPideYa();
                }
            }
        }
        return instancia;
    }
}

Ahora podemos explicar el porqué de cada pieza. Sin volatile, la JVM puede reordenar: publicar la referencia instancia antes de terminar el constructor. Otro hilo pasaría la primera comprobación y usaría un objeto a medio construir — el bug fantasma por excelencia. volatile prohíbe ese reorden y garantiza la visibilidad (happens-before entre la escritura y las lecturas). La primera comprobación evita pagar el lock en el 99,9 % de llamadas; la segunda evita la doble creación entre hilos que esperaban el lock. Y la moraleja de 02-02 sigue en pie: el holder idiom o un enum logran lo mismo sin escribir nada de esto.

Inmutabilidad como patrón

El objeto inmutable es el patrón de concurrencia más barato: lo que no cambia puede compartirse entre mil hilos sin ningún lock. Java lo ha ido facilitando:

// Un record es final, con campos finales: inmutable por construcción
public record LineaPedido(String platoId, int cantidad, BigDecimal precio) { }

public final class Pedido {
    private final List<LineaPedido> lineas;

    private Pedido(Builder b) {
        // List.copyOf: copia inmutable — ni el builder ni nadie puede mutarla después
        this.lineas = List.copyOf(b.lineas);
    }
    public List<LineaPedido> getLineas() { return lineas; } // seguro: es inmutable
}

Aquel List.copyOf que pusimos en el Pedido.Builder de 02-05 por "buenas prácticas" era, en realidad, un patrón de concurrencia: la copia defensiva que hace al Pedido compartible. ¿Y si el pedido "cambia"? No se muta: se crea otro pedido (como hacía clonar() en Prototype, 02-06) o, mejor, se registra el cambio como evento — la conexión con el Event Sourcing de 06-01. Los objetos que viajaban por los topics de 06-03 debían ser inmutables por esta misma razón.

Confinamiento de estado

Si un dato lo toca un solo hilo, no necesita sincronización aunque sea mutable. Formas de confinar:

  • Confinamiento a pila: variables locales; cada hilo tiene su pila. Un StringBuilder local no necesita locks.
  • Confinamiento por hilo: ThreadLocal<T> da a cada hilo su propia copia (así guarda Spring la transacción actual).
  • Confinamiento por diseño: un único hilo posee la estructura y los demás le piden cosas por mensajes — que es exactamente el siguiente patrón.

Producer-Consumer con BlockingQueue

Las comandas de la cocina de PideYa: los hilos web que confirman pedidos (productores) no deben esperar a que la cocina procese; la depositan en una cola y siguen.

BlockingQueue<Comanda> colaCocina = new LinkedBlockingQueue<>(100); // capacidad acotada

// Productor (hilo web): si la cola está llena, put() BLOQUEA → contrapresión
public void confirmarPedido(Pedido p) throws InterruptedException {
    colaCocina.put(new Comanda(p.getId(), p.getLineas()));
}

// Consumidor (hilo de cocina): take() bloquea hasta que haya comanda
Runnable cocinero = () -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Comanda c = colaCocina.take();
            prepararComanda(c);          // solo ESTE hilo toca el estado de la comanda
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // restaurar el flag y salir
        }
    }
};

La BlockingQueue encapsula toda la sincronización: put y take son seguros y bloquean cuando toca. La capacidad acotada (100) es la decisión de diseño importante: si la cocina no da abasto, los productores se frenan (contrapresión) en vez de agotar la memoria. Es la versión intra-proceso de las colas de mensajes de 06-03: misma intención, sin red ni broker — y sin sus garantías de durabilidad: si el proceso muere, la cola en memoria se pierde.

Thread Pool / Worker con ExecutorService

Crear un hilo por tarea es caro (memoria y arranque) y peligroso (un pico de tráfico crea diez mil hilos y tumba la JVM). El patrón Thread Pool reutiliza un conjunto fijo de workers que consumen tareas de una cola interna — es un Producer-Consumer empaquetado:

// Dimensionado para el procesado de pedidos:
// tareas con I/O (BD, red) → más hilos que núcleos; tareas de puro CPU → ~nº de núcleos
int nucleos = Runtime.getRuntime().availableProcessors();
ExecutorService poolPedidos = Executors.newFixedThreadPool(nucleos * 4);

poolPedidos.submit(() -> procesarPedido(pedido)); // encolar tarea, no crear hilo

Regla de dimensionado clásica: hilos ≈ núcleos × (1 + tiempoEspera/tiempoCpu). El procesado de un pedido de PideYa pasa ~75 % del tiempo esperando a la BD y a Pagos, de ahí el × 4. Un pool para CPU pura (calcular rutas de reparto) se quedaría en nucleos. Y fíjate: tener pools separados para "llamadas a Pagos" y "llamadas a Catálogo" es exactamente el bulkhead de 06-03, versión local.

Future, Promise y CompletableFuture

Un Future<T> es un recibo: "el resultado llegará; mientras, sigue con tu vida". Un promise es el lado escribible del recibo. En Java ambos papeles los cumple CompletableFuture, que además permite componer pasos asíncronos. El checkout de PideYa: cobrar y reservar stock no dependen entre sí — en el monolito secuencial sumaban sus latencias; en paralelo, solo cuesta la mayor:

CompletableFuture<ResultadoCobro> cobro =
    CompletableFuture.supplyAsync(() -> pasarela.cobrar(pedido, tarjeta), poolPedidos);

CompletableFuture<Reserva> stock =
    CompletableFuture.supplyAsync(() -> almacen.reservar(pedido.getLineas()), poolPedidos);

// Combinar ambos resultados cuando AMBOS terminen
CompletableFuture<Confirmacion> confirmacion =
    cobro.thenCombine(stock, (r, s) -> confirmar(pedido, r, s))
         .orTimeout(3, TimeUnit.SECONDS)                  // el timeout de 06-03, local
         .exceptionally(ex -> compensarYRechazar(pedido, ex));

Léelo como una tubería declarativa: supplyAsync lanza cada tarea en el pool, thenCombine espera a las dos y combina, orTimeout acota la espera y exceptionally es el plan B (que aquí debe compensar: si el cobro fue bien pero el stock falló, hay que reembolsar — la lógica de saga de 06-02 en miniatura). Nada bloquea al hilo llamador: los callbacks se encadenan como los Decorator encadenaban comportamiento.

Read-Write Lock

La carta de PideYa (el Composite de 03-04) se lee en cada petición de cada cliente y se escribe unas pocas veces al día. Un synchronized normal serializa también las lecturas entre sí — un desperdicio, porque leer en paralelo es inofensivo. El Read-Write Lock distingue:

public class CartaCompartida {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private SeccionCarta raiz;

    public Menu buscarMenu(String id) {
        lock.readLock().lock();          // N lectores pueden entrar A LA VEZ
        try { return raiz.buscar(id); }
        finally { lock.readLock().unlock(); }
    }

    public void actualizar(SeccionCarta nuevaRaiz) {
        lock.writeLock().lock();         // el escritor entra SOLO, sin lectores
        try { this.raiz = nuevaRaiz; }
        finally { lock.writeLock().unlock(); }
    }
}

Muchos lectores simultáneos, escritores en exclusiva. Alternativa aún mejor cuando las escrituras son tan raras: publicar una carta inmutable y que actualizar reemplace la referencia (volatile o AtomicReference) — copy-on-write, que combina los patrones 3 y 8 de esta lección y no bloquea a nadie.

Active Object y virtual threads

Active Object (POSA): un objeto con su propio hilo y su propia cola de peticiones; los métodos públicos no ejecutan, encolan, y devuelven futures. Es Command + Producer-Consumer + Future en un paquete: así podría implementarse la CentralReparto para que todo su estado quede confinado a un hilo. Lo dejamos en mención: la combinación de piezas ya la conoces.

Virtual threads (Java 21): hilos baratísimos gestionados por la JVM (millones, no miles), que liberan el hilo de plataforma al bloquearse en I/O. Qué cambian: el estilo "un hilo por petición" con código bloqueante secuencial vuelve a ser viable — menos necesidad de encadenar CompletableFuture para escalar (siguen valiendo para paralelizar, como el cobro+stock). Qué no cambian: nada de la corrección — las carreras, la visibilidad, los locks y la inmutabilidad siguen exactamente igual. Los patrones de esta lección no caducan con ellos.

Guía: cuándo usar qué

Situación en PideYa Patrón Herramienta Java
Datos que viajan entre hilos Inmutabilidad record, List.copyOf, final
Estado auxiliar por petición Confinamiento Variables locales, ThreadLocal
Trabajo desacoplado con contrapresión (comandas) Producer-Consumer BlockingQueue acotada
Muchas tareas, hilos controlados Thread Pool ExecutorService dimensionado
Pasos independientes en paralelo (cobro + stock) Future/Promise CompletableFuture.thenCombine
Se lee mucho, se escribe poco (carta) Read-Write Lock / copy-on-write ReentrantReadWriteLock, AtomicReference
Estado complejo con un solo dueño Active Object Hilo propio + cola + futures
Miles de peticiones bloqueantes de I/O Virtual threads Executors.newVirtualThreadPerTaskExecutor()
Contador o referencia suelta compartida (atómicos) AtomicInteger, LongAdder

Errores Comunes y Consejos

  • Sincronizar "por si acaso" o no sincronizar "porque funciona": ambos nacen de no razonar el modelo de memoria. Pregunta sistemática: ¿qué hilos tocan este dato y qué relación happens-before los ordena? Si no sabes responder, hay un bug latente.
  • Double-checked locking sin volatile: compila, pasa los tests y falla en producción bajo carga. Si necesitas inicialización perezosa, prefiere el holder idiom.
  • Colas sin acotar: new LinkedBlockingQueue<>() sin capacidad convierte un pico de tráfico en un OutOfMemoryError diferido. Acota y decide qué pasa al llenarse (bloquear, rechazar, descartar).
  • Bloquear dentro de un CompletableFuture con join()/get() en medio de la cadena: anula la asincronía y puede provocar deadlocks de pool. Componer, no esperar.
  • Compartir el pool para todo: las tareas de I/O lentas de Pagos ahogan a las rápidas del catálogo. Pools separados por tipo de trabajo (bulkhead local).
  • Locks anidados en orden distinto: hilo A toma lock1→lock2, hilo B toma lock2→lock1: deadlock. Establece un orden global de adquisición o rediseña para no anidar.
  • Tragarse InterruptedException: un catch vacío impide parar el sistema limpiamente. Restaura el flag (Thread.currentThread().interrupt()) y termina.

Ejercicios

  1. Caza la carrera. ContadorPedidosDia tiene private int total; y el método public void incrementar() { total++; }, llamado desde los hilos web. Explica los dos problemas distintos (atomicidad y visibilidad) y da dos soluciones: una con lock y una sin lock.
  2. Dimensiona el pool. El servicio de notificaciones envía emails: cada envío usa ~5 ms de CPU y espera ~195 ms a la red del proveedor. La máquina tiene 8 núcleos. Calcula el tamaño de pool razonable con la fórmula de la lección y explica qué patrón de 06-03 conviene poner delante del pool.
  3. Paraleliza el checkout. Al confirmar un pedido hay que: (a) cobrar, (b) reservar stock, (c) calcular puntos de fidelización — las tres independientes entre sí — y (d) con los resultados de las tres, emitir la confirmación. Escribe la composición con CompletableFuture y añade un timeout global de 2 segundos.

Soluciones

  1. Atomicidad: total++ son tres pasos; dos hilos pueden leer el mismo valor y perderse un incremento. Visibilidad: sin happens-before, un hilo puede no ver jamás las escrituras de otro (y volatile solo arreglaría la visibilidad, no la atomicidad). Con lock: public synchronized void incrementar() { total++; } (y también synchronized en el getter). Sin lock: private final AtomicInteger total = new AtomicInteger(); public void incrementar() { total.incrementAndGet(); } — o LongAdder si la contención es alta.

  2. hilos ≈ 8 × (1 + 195/5) = 8 × 40 = 320 hilos. Con tanta espera de I/O el pool sale enorme — señal de que los virtual threads encajarían aún mejor. Delante del pool: una cola productor-consumidor acotada con contrapresión, y conceptualmente el envío ya venía de un broker (06-03), con reintentos y DLQ para los emails que fallen.

  3. La composición queda así:

    var cobro   = CompletableFuture.supplyAsync(() -> pasarela.cobrar(pedido, tarjeta), pool);
    var stock   = CompletableFuture.supplyAsync(() -> almacen.reservar(pedido.getLineas()), pool);
    var puntos  = CompletableFuture.supplyAsync(() -> fidelizacion.calcularPuntos(pedido), pool);
    
    CompletableFuture<Confirmacion> conf =
        cobro.thenCombine(stock, ParPagoStock::new)
             .thenCombine(puntos, (par, pts) -> emitirConfirmacion(pedido, par.cobro(), par.reserva(), pts))
             .orTimeout(2, TimeUnit.SECONDS)
             .exceptionally(ex -> compensarYRechazar(pedido, ex));
    

    thenCombine en cascada junta los tres resultados (con un record auxiliar ParPagoStock); orTimeout acota la operación completa y exceptionally centraliza la compensación (reembolsar/liberar lo que sí llegó a ejecutarse).

Conclusión

Hemos bajado al nivel donde los bugs no dan la cara: el estado compartido mutable. El catálogo de respuesta — inmutabilidad, confinamiento, colas con contrapresión, pools bien dimensionados, futures compuestos, read-write locks, y los virtual threads que abaratan los hilos sin derogar ninguna regla — reutiliza intenciones que ya dominabas: el Builder que copiaba listas era concurrencia preventiva, el Producer-Consumer es la cola de mensajes sin red, el Active Object es Command más Future. Con esto se cierra la parte técnica del catálogo moderno. Queda una pregunta distinta, ni de red ni de hilos sino de personas y de tiempo: ¿cómo conviven todos estos patrones con el desarrollo iterativo, los sprints y el "no lo vas a necesitar"? Lo vemos en Patrones de Diseño en Desarrollo Ágil.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados