CodeGym /Cursos /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
Nivel 58 , Lección 4
Disponible

1. Introducción a Java Memory Model (JMM)

El problema de la visibilidad y el orden

En un programa de un solo hilo todo es simple: escribes un valor en una variable y enseguida puedes leerlo. En el mundo multihilo funciona de otra manera. Los procesadores almacenan valores en caché, el compilador y la JVM a veces cambian el orden de las instrucciones, y un hilo puede ver un valor «antiguo» incluso si otro hilo lo acaba de modificar.

Java Memory Model (JMM) describe cómo los hilos se comunican a través de la memoria: cuándo los cambios de un hilo se vuelven visibles para otros y en qué orden ocurren las operaciones. Si no se tiene en cuenta, el programa puede comportarse de forma impredecible, aunque a primera vista todo parezca normal.

Entender el JMM ayuda a comprender por qué a veces un hilo no ve datos recientes, cómo usar correctamente volatile, synchronized y las clases atómicas, y por qué los bugs en código concurrente pueden manifestarse solo en producción. En pocas palabras, el JMM son las reglas del juego de la memoria; si las ignoras, incluso el código más cuidadoso puede volverse en tu contra.

Analogía

Imagina que tienes dos personas (hilos) que escriben y leen notas (variables) en una pizarra (memoria). A veces uno escribe y el otro aún no ve la nueva nota, porque está mirando su copia de la pizarra (caché). El JMM determina cuándo y cómo esas notas se vuelven visibles para todos.

2. happens-before: el fundamento del JMM

¿Qué es happens-before?

happens-before es una relación entre dos acciones en un programa: si la acción A happens-before la acción B, entonces todos los cambios hechos en A están garantizados como visibles en B.

Importante: happens-before no es simplemente «ocurrió antes», sino «es visible con garantía».

Reglas básicas de happens-before

1. Dentro de un mismo hilo

Todo lo que sucede en un mismo hilo está ordenado: si escribes en una variable y luego la lees, verás tu propio cambio.

2. Bloques sincronizados/monitores

Todo lo que ocurre antes de salir de un bloque sincronizado (synchronized) se vuelve visible para el hilo que entre después en ese bloque.

synchronized(lock) {
    sharedVar = 42; // escritura
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // veremos 42 de forma garantizada
}

3. Escritura/lectura con volatile

Una escritura en un campo volatile happens-before cualquier lectura posterior de ese campo por otro hilo.

volatile boolean ready = false;

// Hilo 1
data = 123;
ready = true; // escritura volatile

// Hilo 2
if (ready) { // lectura volatile
    System.out.println(data); // veremos data = 123 de forma garantizada
}

4. Inicio y finalización de hilos

  • La llamada a Thread.start() happens-before el comienzo del hilo.
  • La finalización de un hilo happens-before el retorno de Thread.join().

5. Finalización de una tarea en un Executor

Si envías una tarea a un Executor y esperas a que termine (Future.get()), todos los cambios realizados en la tarea serán visibles tras get().

6. Campos finales

La inicialización de campos con el modificador final en el constructor happens-before la publicación de la referencia al objeto. Esto es importante para los objetos inmutables.

3. Publicación segura de objetos

Problema: objeto «no actualizado»

Si un hilo crea un objeto y se lo pasa a otro hilo sin sincronización, el otro hilo puede ver valores «en bruto» en los campos (por ejemplo, no inicializados o antiguos).

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// Hilo 1
holder = new Holder(); // creamos el objeto

// Hilo 2
if (holder != null) {
    System.out.println(holder.value); // puede ver 0 en vez de 42
}

¿Cómo publicar objetos correctamente?

1. Mediante campos final

Si todos los campos del objeto son final y se inicializan en el constructor, el objeto puede publicarse de forma segura sin sincronización adicional.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. Mediante una referencia volatile

Si la referencia al objeto se declara como volatile, tras la asignación el objeto es visible con garantía para otros hilos.

volatile Holder holder;

// Hilo 1
holder = new Holder();

// Hilo 2
if (holder != null) {
    System.out.println(holder.value); // veremos 42 de forma garantizada
}

3. Mediante inicialización en un solo hilo antes de la publicación

Si el objeto se crea e inicializa antes de que su referencia sea accesible por otros hilos, todo es seguro.

Holder holder = new Holder(); // solo en un hilo
// ... después holder pasa a estar disponible para otros hilos

4. Mediante bloqueo

Si el objeto se crea dentro de un bloque sincronizado y la referencia se lee solo dentro de ese mismo tipo de bloque, todo es seguro.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... en otro hilo
synchronized(lock) {
    if (holder != null) {
        // seguro
    }
}

4. Double-checked locking y volatile

¿Qué es double-checked locking?

Es un patrón para la inicialización perezosa de un objeto singleton:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 1.ª comprobación
            synchronized (Singleton.class) {
                if (instance == null) { // 2.ª comprobación
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Problema: ¡Sin una referencia volatile para instance este código no funciona correctamente! Un hilo puede ver un objeto no completamente inicializado.

¿Por qué es malo sin volatile?

La JVM puede reordenar instrucciones de forma que la referencia al objeto se asigne antes de que termine el constructor. Otro hilo vería un objeto no inicializado.

¿Cómo hacerlo bien?

Declara instance como volatile:

private static volatile Singleton instance;

Ahora double-checked locking funciona correctamente: volatile garantiza la relación happens-before entre la escritura y la lectura de la referencia.

Alternativa: inicialización estadística

La forma más simple y segura de implementar un singleton es usar inicialización estática:

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

Aquí la JVM garantiza por sí misma la inicialización correcta.

5. VarHandle: acceso moderno de bajo nivel

¿Qué es VarHandle?

VarHandle es una API moderna (Java 9+) que permite trabajar con variables a bajo nivel: leer, escribir, hacer operaciones atómicas y gestionar la visibilidad y el orden de las instrucciones.

¿Para qué sirve VarHandle si ya existen las clases atómicas?
VarHandle permite trabajar con cualquier campo (no solo con int/long/Reference).
— Permite elegir explícitamente la semántica de acceso: volatile, acquire/release, opaque.
— Se utiliza para implementar estructuras de datos de alto rendimiento.

Semánticas de acceso

  • Volatile: garantía completa de happens-before (como en un campo volatile).
  • Acquire/Release: garantía más débil, pero más rápida (se usa en estructuras lock-free).
  • Opaque: garantía mínima de visibilidad, pero máxima rendimiento.

Ejemplo de uso de VarHandle

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

¿Cuándo usar VarHandle?

  • Para implementar tus propias estructuras de datos lock-free.
  • Cuando necesitas el máximo rendimiento y control sobre el orden de las instrucciones.
  • En aplicaciones normales suelen bastar las clases atómicas y synchronized.

6. False sharing y alineación de caché

False sharing es la situación en la que dos hilos trabajan con variables distintas, pero dichas variables residen en la misma línea de caché del procesador. Como resultado, los hilos se estorban entre sí porque el cambio de una variable invalida la caché de la otra.

Analogía: Dos personas se sientan en la misma mesa (línea de caché), pero cada una escribe en su mitad. Si una cambia algo, la otra tiene que «releer» todo el papel.

¿Por qué es malo?

  • El rendimiento cae drásticamente: los procesadores gastan tiempo sincronizando cachés.
  • Es especialmente crítico para variables «calientes» que cambian con frecuencia desde distintos hilos.

¿Cómo evitarlo?

Separa los campos «calientes» en distintos objetos o usa anotaciones/estructuras especiales para la alineación (por ejemplo, @Contended). En JVM modernas puedes activar la opción -XX:-RestrictContended y usar @sun.misc.Contended (Java 8+) para alinear campos.

Ejemplo:

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

Nota: La anotación @Contended no forma parte del API estándar, pero se usa en la JDK para optimizar clases atómicas.

7. Práctica: arreglamos un singleton y microbenchmarks JMH

Arreglamos un singleton defectuoso

Malo (sin volatile):

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

Bien (con volatile):

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

Lo mejor: inicialización estática:

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

Microbenchmarks JMH: visibilidad y atomicidad

Atención: JMH es un framework específico para microbenchmarks en Java. No intentes sacar conclusiones sobre el rendimiento sin JMH: ¡los resultados pueden ser engañosos!

Ejemplo: comprobar la visibilidad de volatile

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // bucle hasta ver true
        }
        // vimos el cambio
    }
}

Ejemplo: no atomicidad de volatile

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // ¡no es atómico!
    }
}

A pesar de volatile, con incrementos concurrentes desde varios hilos el valor final será menor de lo esperado (la operación counter++ se descompone en lectura, cálculo y escritura).

8. Errores típicos al trabajar con JMM, volatile y publicación

Error n.º 1: Esperar atomicidad de volatile.
volatile solo garantiza visibilidad de los cambios, no atomicidad de las operaciones. La operación counter++ no se vuelve atómica aunque la variable sea volatile.

Error n.º 2: Publicar objetos sin sincronización.
Si creas un objeto en un hilo y lo entregas a otro sin volatile, synchronized o campos final, el otro hilo puede ver valores «en bruto».

Error n.º 3: Double-checked locking sin volatile.
Sin una referencia volatile para el singleton puedes obtener un objeto no inicializado en otro hilo.

Error n.º 4: Usar bloqueos antiguos con hilos virtuales.
Algunos mecanismos de sincronización antiguos (native monitor) pueden dificultar que la JVM gestione de forma eficiente los hilos virtuales.

Error n.º 5: Ignorar el false sharing.
Si las variables «calientes» están cerca en memoria, los hilos se estorbarán entre sí debido a las líneas de caché.

Error n.º 6: Sacar conclusiones de rendimiento sin JMH.
Los microbenchmarks sin JMH a menudo arrojan resultados engañosos por las optimizaciones de la JVM y las cachés del procesador.

1
Tarea
JAVA 25 SELF, nivel 58, lección 4
Bloqueada
Casa inteligente: Publicación segura de la configuración 🏡
Casa inteligente: Publicación segura de la configuración 🏡
1
Tarea
JAVA 25 SELF, nivel 58, lección 4
Bloqueada
Administrador de Recursos: El Único en el Universo 🌌
Administrador de Recursos: El Único en el Universo 🌌
1
Cuestionario/control
Profundizamos en la multithreading, nivel 58, lección 4
No disponible
Profundizamos en la multithreading
Profundizamos en la multithreading
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION