¿Qué significa 'happens-before' en el Java Memory Model y por qué el código sin sincronización puede ver valores obsoletos entre threads?
El JMM no solo rige el orden de las instrucciones — decide si una escritura de un thread tiene garantizado volverse visible para otro thread.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué significa 'happens-before' en el Java Memory Model y por qué el código sin sincronización puede ver valores obsoletos entre threads?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos Preguntas Distintas: Orden y Visibilidad
La mayoría de los desarrolladores piensa los bugs de concurrencia solo en términos de orden — ¿la escritura del thread A pasó antes que la lectura del thread B? El Java Memory Model (JMM) en realidad responde una pregunta sutilmente distinta y más estricta: dadas dos acciones en threads diferentes, ¿el thread B tiene garantizado observar el efecto de la acción del thread A, y si es así, tiene garantizado verlo en un orden particular respecto a otras acciones? Sin una garantía explícita, tanto la JVM como el hardware subyacente pueden comportarse de maneras que parecen imposibles si solo pensás en el orden del programa.
#Por Qué Reordenar y Leer Valores Obsoletos Es Legal
Dos mecanismos independientes se combinan para hacer que el código sin sincronización se comporte de forma impredecible entre threads:
- Reordenamiento del compilador y del JIT. Mientras no cambie el comportamiento observable de un único thread, el compilador y el JIT son libres de reordenar, elevar (hoist), o incluso eliminar lecturas y escrituras. Un loop que lee un campo repetidamente sin nada en el medio que parezca modificarlo (desde el punto de vista de ese thread) puede tener la lectura elevada fuera del loop por completo, convirtiendo
while (flag) { ... }en un loop efectivamente infinito siflaglo modifica otro thread. - Cacheo de CPU y store buffers. Cada núcleo puede mantener un valor en una línea de caché privada o en un store buffer en lugar de escribirlo directamente a la memoria principal. No hay garantía de que una escritura de un núcleo sea visible para la caché de otro núcleo hasta que alguna instrucción de ordenamiento de memoria fuerce un flush — y una escritura simple de campo no emite ninguna.
Ninguno de los dos es un bug de la JVM. Existen porque hacen rápido al código de un solo thread, y todo el diseño del JMM consiste en decir con precisión qué garantías entre threads obtenés sin renunciar a esas optimizaciones — siempre que uses las primitivas de sincronización correctas.
#La Relación happens-before
"Happens-before" no se trata del reloj de pared. A happens-before B significa: los efectos de A están garantizados como visibles para B, y ordenados antes que B, desde el punto de vista de B. Si ningún borde happens-before conecta dos acciones en threads diferentes, el JMM permite que B observe cualquier intercalado de los efectos de A — incluyendo ninguno en absoluto.
| Regla | Garantía |
|---|---|
| Regla de orden de programa | Cada acción en un thread happens-before toda acción posterior en el orden de ese mismo thread |
| Regla de lock de monitor | Desbloquear un monitor happens-before cada lock posterior de ese mismo monitor |
| Regla de variable volatile | Una escritura a un campo volatile happens-before cada lectura posterior de ese mismo campo |
| Regla de inicio de thread | Una llamada a Thread.start() happens-before cualquier acción dentro del thread iniciado |
| Regla de join de thread | Toda acción en un thread happens-before el retorno exitoso de otro thread desde join() sobre él |
| Transitividad | Si A happens-before B, y B happens-before C, entonces A happens-before C |
#Un Bug de Visibilidad Real
public class Worker implements Runnable {private boolean running = true; // BUG: no es volatile@Overridepublic void run() {int i = 0;while (running) {i++;}System.out.println("detenido tras " + i + " iteraciones");}public void stop() {running = false;}}public class Main {public static void main(String[] args) throws InterruptedException {Worker worker = new Worker();Thread t = new Thread(worker);t.start();Thread.sleep(1000);worker.stop();t.join(); // puede colgarse indefinidamente — esto es legal bajo el JMM}}
No existe ningún borde happens-before entre la escritura de worker.stop() sobre running y la lectura de running del thread worker dentro del loop. Dependiendo del JIT y del hardware, pueden pasar dos cosas: el JIT puede elevar la lectura de running fuera del loop por completo (nada en el cuerpo del loop parece, desde la perspectiva del thread worker, modificarlo), convirtiendo el loop en while (true); o la escritura puede simplemente quedarse en el store buffer / caché del thread principal y nunca llegar a donde el núcleo del worker pueda verla. De cualquier manera, t.join() puede bloquearse para siempre, y esto no es una rareza de hardware — está explícitamente permitido por la especificación cuando no hay sincronización.
#La Solución
public class Worker implements Runnable {private volatile boolean running = true; // SOLUCIÓN: volatile@Overridepublic void run() {int i = 0;while (running) {i++;}System.out.println("detenido tras " + i + " iteraciones");}public void stop() {running = false;}}
Marcar running como volatile invoca la regla de variable volatile: la escritura en stop() ahora happens-before cada lectura posterior de running, en cualquier thread. Eso garantiza dos cosas a la vez — visibilidad (el nuevo valor está garantizado como observable) y orden (el compilador ya no puede reordenar otras operaciones de memoria a través de ese acceso volatile, algo que importa en cuanto hay más campos involucrados). Bloques synchronized alrededor de la lectura y la escritura arreglarían el mismo bug a través de la regla de lock de monitor, y AtomicBoolean lo arreglaría dándote además operaciones compuestas atómicas, algo que volatile por sí solo no da.
#El Diagrama
🧠 Check your understanding
0/3 · 0/3 answered1. ¿Qué significa que la acción A 'happens-before' la acción B en el Java Memory Model?
2. En el ejemplo con bug de Worker, ¿por qué el loop while(running) puede correr para siempre aunque stop() ponga running = false?
3. ¿Por qué marcar un campo contador como volatile no alcanza para que contador++ sea thread-safe?