El Java Memory Model (JMM) es la especificación que define cómo los hilos interactúan con la memoria. No se trata de una estructura física, sino de un conjunto de reglas que el compilador, la JVM y el hardware deben cumplir para garantizar un comportamiento predecible en entornos concurrentes.
Reglas básicas del JMM
- Antes de liberar un candado, un hilo debe descargar al almacenamiento principal las variables compartidas que modificó.
- Antes de adquirir un candado, un hilo debe leer del almacenamiento principal los valores más recientes.
- El candado que se libera debe ser exactamente el mismo que se adquirió.
Según el modelo, cada hilo mantiene una copia privada de las variables que necesita, llamada copia de trabajo, mientras que el almacenamiento principal mantiene el valor global. El JMM describe ocho operaciones atómicas para mover datos entre estas dos áreas:
- lock: marca una variable del almacenamiento principal como exclusiva de un hilo.
- unlock: libera esa exclusividad.
- read: lee el valor del almacenamiento principal hacia un buffer intermedio.
- load: traslada el valor del buffer a la copia de trabajo del hilo.
- use: pasa el valor de la copia de trabajo al motor de ejecución.
- assign: escribe un valor nuevo en la copia de trabajo.
- store: copia el valor de la copia de trabajo a un buffer.
- write: escribe el valor del buffer en el almacenamiento principal.
Cada lock debe ir acompañado de su correspondiente unlock, y viceversa.
- Visibilidad
La palabra clave volatile garantiza que cualquier cambio a una variable se propagará inmediatamente a todos los hilos.
package ejemplo.memoria;
public class DemoVisibilidad {
private static volatile boolean enEjecucion = true;
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
while (enEjecucion) {
// espera activa
}
});
hilo.start();
Thread.sleep(1000);
enEjecucion = false;
System.out.println("enEjecucion = " + enEjecucion);
}
}
Si se elimina volatile, el hilo podría no observar la modificación de enEjecucion y quedar atrapado en el bucle.
- Atomicidad
La visibilidad no implica atomicidad. Una operación como total++ consta de leer, incrementar y escribir, por lo que varios hilos pueden intercalar sus pasos y perderse actualizaciones.
package ejemplo.memoria;
import java.util.concurrent.*;
public class ContadorNoAtomico {
private volatile int total = 0;
public static void main(String[] args) throws Exception {
ContadorNoAtomico c = new ContadorNoAtomico();
ExecutorService pool = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
c.incrementar();
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
System.out.println(c.total);
}
public void incrementar() {
total++;
}
}
El resultado suele ser menor que 20000. Para evitarlo se puede utilizar un entero atómico:
package ejemplo.memoria;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ContadorAtomico {
private final AtomicInteger total = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
ContadorAtomico c = new ContadorAtomico();
ExecutorService pool = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
c.incrementar();
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
System.out.println(c.total.get());
}
public void incrementar() {
total.getAndIncrement();
}
}
También sería válido marcar incrementar como synchronized; en ese caso total ya no necesitaría ser volatile.
- Reordenamiento de instrucciones
Los compiladores y procesadores pueden cambiar el orden de las instrucciones siempre que no alteren el resultado en un único hilo. En programas concurrentes esto puede provocar comportamientos inesperados.
package ejemplo.memoria;
public class Reordenamiento {
private volatile int a = 0;
private volatile int b = 0;
void escribir() {
a = 1;
b = 1;
}
void leer() {
while (b == 0) { }
System.out.println(a);
}
}
Si a y b no fueran volatile, la asignación a = 1 podría verse retrasada y el consumidor imprimiría 0. volatile inserta barreras de memoria que impiden ese reordenamiento y, al mismo tiempo, refrescan el valor para todos los hilos.
Uno de los usos más frecuentes de volatile es la implementación del patrón singleton con inicialización diferida.