Módulo 2 · Concurrencia y el Java Memory Model
This content is not available in your language yet.
Curso 01 · Java senior · 180 min · El módulo más rentable del curso
Por qué esto importa en la entrevista
Sección titulada «Por qué esto importa en la entrevista»La concurrencia es donde el entrevistador puede hacerte preguntas infinitas y ver hasta dónde llegas. Y es donde más candidatos repiten mantras sin entenderlos (“uso volatile para que sea thread-safe”). Si dominas visibilidad vs atomicidad, el resto se deduce.
Modelo mental: dos problemas distintos, no uno
Sección titulada «Modelo mental: dos problemas distintos, no uno»ATOMICIDAD "count++ son 3 operaciones: leer, sumar, escribir" → dos hilos pueden entrelazarlas y perder incrementos → se resuelve con locks o CAS
VISIBILIDAD/ORDEN "el hilo B puede no ver nunca lo que escribió A, o verlo en otro orden" → por caches de CPU, store buffers y reordenamiento del JIT → se resuelve con happens-before (volatile, synchronized, ...)Casi todo error de concurrencia es no distinguir estos dos ejes. volatile da visibilidad y orden, no atomicidad. synchronized da ambos. AtomicLong da atomicidad sobre una variable y visibilidad.
happens-before: la regla que define qué puedes ver
Sección titulada «happens-before: la regla que define qué puedes ver»El JMM (JLS §17.4) dice que si la acción A happens-before B, entonces B ve todo lo que hizo A. Las fuentes que debes citar de memoria:
- Orden de programa dentro de un hilo.
- Monitor:
unlockde un monitor hb cualquierlockposterior del mismo monitor. - Volatile: escritura volatile hb cualquier lectura posterior de esa variable — y arrastra consigo todo lo escrito antes.
- Thread:
t.start()hb todo lo que hagat; todo lo dethb el retorno det.join(). - Campos
final: correctamente construidos, son visibles tras la publicación del objeto (base de la inmutabilidad segura). - Transitividad: A hb B, B hb C ⇒ A hb C.
Corolario clave: las estructuras de java.util.concurrent ya establecen happens-before entre quien mete y quien saca (una BlockingQueue, un Executor, un CompletableFuture). Por eso en código de aplicación casi nunca necesitas volatile a mano: si pasas los datos por una cola concurrente, la visibilidad viene incluida.
class Roto { private boolean listo = false; // sin volatile private int valor = 0; void escritor() { valor = 42; listo = true; } // puede reordenarse void lector() { while (!listo) {} // puede no salir NUNCA System.out.println(valor); } // puede imprimir 0}Dos fallos independientes: el JIT puede sacar la lectura del bucle (if (!listo) while(true);) y las escrituras pueden no propagarse en ese orden. En x86 quizá “funciona”; en ARM (Graviton, Apple Silicon) explota. Un data race es un bug aunque no lo veas.
Herramientas, en orden de preferencia
Sección titulada «Herramientas, en orden de preferencia»| Necesidad | Herramienta | Nota |
|---|---|---|
| Flag de parada, publicar referencia inmutable | volatile |
barato; nada de read-modify-write |
| Contador, acumulador | AtomicLong / LongAdder |
LongAdder gana con alta contención (particiona celdas) |
| Invariante entre varias variables | synchronized / ReentrantLock |
el lock protege invariantes, no variables sueltas |
| Estructura compartida | ConcurrentHashMap, CopyOnWriteArrayList, colas de j.u.c. |
elige por patrón de lectura/escritura |
| Coordinación | CountDownLatch, Semaphore, CyclicBarrier, Phaser |
|
| Nada compartido | inmutabilidad + confinamiento | la mejor solución de concurrencia es no tenerla |
ReentrantLock vs synchronized: el segundo es más simple y hoy igual de rápido (biased locking se retiró, pero el JIT elide locks sin contención); el primero aporta tryLock con timeout, interrumpibilidad, equidad opcional y múltiples Condition. Con virtual threads, synchronized podía pinnear el carrier thread (mitigado en JDK 24; en 21 es real), así que en código que bloquea dentro de secciones críticas, ReentrantLock es más seguro.
ConcurrentHashMap: sin lock global; usa CAS para insertar en bins vacíos y synchronized sobre la cabeza del bin en colisión. Sus operaciones compuestas atómicas son computeIfAbsent, merge, putIfAbsent — usarlas es la diferencia entre thread-safe y “casi”. Cuidado: computeIfAbsent con una función que vuelve a tocar el mismo mapa puede bloquear o corromper; y size() es una estimación.
HashMap compartido sin sincronizar puede, además de perder datos, entrar en bucle infinito durante un resize concurrente (clásico en JDK 7; en 8+ ya no hay lista circular, pero sigue corrompiéndose y perdiendo entradas). Es una gran anécdota: CPU al 100% en un hilo sin razón aparente.
Pools de hilos: lo que de verdad preguntan
Sección titulada «Pools de hilos: lo que de verdad preguntan»new ThreadPoolExecutor( core, max, keepAlive, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), // ¡LIMITADA! new ThreadFactoryBuilder().setNameFormat("pagos-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // backpressure);Puntos que suman:
- Colas ilimitadas = OOM diferido.
Executors.newFixedThreadPoolusaLinkedBlockingQueuesin límite: el pool nunca crece amax, y la cola se come el heap. Es la razón de “no uses los factories deExecutors”. - Nombra tus hilos. Un thread dump con
pool-3-thread-7no dice nada; conpagos-7lo dice todo. - Política de rechazo = tu estrategia de backpressure.
CallerRunsPolicyfrena al productor (a menudo lo que quieres);AbortPolicyte deja decidir; descartar en silencio casi nunca es correcto. - Un pool por tipo de trabajo (bulkhead): no mezcles llamadas a un proveedor lento con trabajo rápido de CPU.
- Dimensionado: CPU-bound ≈ nº de núcleos; I/O-bound ≈ núcleos × (1 + espera/servicio) — o, más práctico, por la ley de Little (ver curso 00 módulo 5).
ForkJoinPool.commonPool()lo comparten los streams paralelos y muchosCompletableFuturesin executor explícito. Una tarea bloqueante ahí congela a toda la aplicación. Nunca hagas I/O en el common pool.
CompletableFuture sin dispararte en el pie
Sección titulada «CompletableFuture sin dispararte en el pie»CompletableFuture .supplyAsync(() -> clienteA.buscar(id), ioPool) // ← SIEMPRE tu executor .thenCombine(CompletableFuture.supplyAsync(() -> clienteB.buscar(id), ioPool), Resultado::unir) .orTimeout(800, MILLISECONDS) // JDK 9+ .exceptionally(ex -> Resultado.degradado()) // fallback explícito .thenAccept(this::responder);Pitfalls que debes nombrar: join() dentro de una cadena (bloquea un hilo del pool y puede deadlockear), olvidar el executor (cae en el common pool), tragarse excepciones al no encadenar exceptionally/handle, thenApply vs thenApplyAsync (dónde se ejecuta la continuación: en el hilo que completó vs en el pool), y que allOf no propaga resultados (hay que recogerlos después).
Virtual threads (Loom, JDK 21+)
Sección titulada «Virtual threads (Loom, JDK 21+)»- Son hilos gestionados por la JVM que se montan sobre carrier threads de un
ForkJoinPool. Al bloquearse en I/O, el virtual thread se desmonta y libera el carrier: millones de hilos concurrentes con estilo de código bloqueante. - Cambian el modelo mental: se acabó el “dimensiona el pool”; ahora se limita la concurrencia con un
Semaphoredonde haga falta (los recursos externos siguen siendo finitos). - Pinning: si el virtual thread bloquea dentro de un
synchronizedo en una llamada nativa, no puede desmontarse y ocupa el carrier. Diagnóstico:-Djdk.tracePinnedThreads=full(JDK 21). Mitigación:ReentrantLock. - No aceleran cómputo: para CPU-bound siguen valiendo los pools clásicos.
- No mezclar con
ThreadLocalcomo caché (un valor por tarea × millones = memoria); usarScopedValue.
Structured concurrency (StructuredTaskScope) resuelve lo que ExecutorService no: si una subtarea falla, se cancelan las hermanas; el scope no se cierra hasta que todas terminan; y el árbol de tareas queda reflejado en los dumps.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var usuario = scope.fork(() -> usuarios.buscar(id)); var pedidos = scope.fork(() -> pedidos.buscar(id)); scope.join().throwIfFailed(); // ambas o ninguna return new Vista(usuario.get(), pedidos.get());}Errores comunes que delatan a un no-senior
Sección titulada «Errores comunes que delatan a un no-senior»- “Le puse
volatilepara hacerlo thread-safe” en uncontador++. - Usar
Executors.newFixedThreadPoolen producción y no saber que la cola es ilimitada. - Bloquear en el common pool de ForkJoin (incluye
parallelStream()con I/O dentro). - Sincronizar sobre
thiso sobre objetos públicos (cualquiera puede lockear tu monitor). - Double-checked locking sin
volatile. - Creer que virtual threads sustituyen a los pools para todo.
- No saber sacar un thread dump ni leer un deadlock en él.
🧪 Laboratorio
Sección titulada «🧪 Laboratorio»- Reproduce el data race: clase
Rotode arriba con-XX:+UnlockDiagnosticVMOptions. Ejecútala en x86 y, si puedes, en ARM. Añadevolatiley observa la diferencia. - Lost update: 10 hilos × 100.000
contador++. Comparaint,volatile int,AtomicLong,LongAdderysynchronized. Mide tiempo y resultado. Grafícalo: es la mejor forma de recordar cuándoLongAdder. - Deadlock a propósito: dos locks tomados en orden inverso. Sácalo con
jcmd <pid> Thread.printy localiza el bloque “Found one Java-level deadlock”. Arréglalo imponiendo orden global de adquisición. - Pool agotado: pool de 10 hilos llamando a un servicio con 3 s de latencia, cola ilimitada, 200 rps. Observa el crecimiento del heap y del tiempo de espera. Arregla con cola limitada +
CallerRunsPolicy+ timeout. - Virtual threads: el mismo servicio I/O-bound con pool de plataforma vs
Executors.newVirtualThreadPerTaskExecutor(). Mide throughput y memoria. Luego mete unsynchronizedalrededor del I/O y detecta el pinning con-Djdk.tracePinnedThreads=full.
Entregable: la tabla comparativa del punto 2 y el thread dump comentado del punto 3.
✅ Autoevaluación
Sección titulada «✅ Autoevaluación»- Explica happens-before y da tres formas de establecerlo.
- ¿Por qué
volatileno basta para un contador? ¿Y qué usarías con 64 hilos incrementando? - ¿Qué tiene de malo
Executors.newFixedThreadPool(10)? - Un
parallelStream()con una llamada HTTP dentro: ¿qué puede salir mal? - ¿Qué es el pinning en virtual threads y cómo lo detectas?
- ¿Cómo lees un deadlock en un thread dump y cómo lo previenes por diseño?
ConcurrentHashMap: ¿por quégetno bloquea y qué garantizacomputeIfAbsent?
🎯 Preguntas del banco que ya puedes responder
Sección titulada «🎯 Preguntas del banco que ya puedes responder»java-microservicios/01-java-core-avanzado.md— 3–11 (JMM, volatile/synchronized/atomics, final, CompletableFuture, ForkJoin, Loom, structured concurrency, HashMap, ConcurrentHashMap), 14 (streams paralelos)java-microservicios/03-casos-y-problemas.md— 3 (thread pool exhaustion), 4 (deadlock)
Anterior: Módulo 1 · Siguiente: Módulo 3 · Spring por dentro y transacciones