Demostración de visibilidad, atomicidad y orden en Java mediante volatile y synchronized

Introducción

Este artículo explora los conceptos de visibilidad, atomicidad y orden en Java mediante ejemplos de código que utilizan las palabras clave volatile y synchronized. Analizaremos cómo estos modificadores afectan el comportamiento de los hilos en un entorno concurrente.

  1. Visibilidad

1.1 Prueba de falta de visibilidad

La falta de visibilidad ocurre cuando un hilo modifica una variable compartida pero el cambio no es visible para otro hilo. Consideremos el siguiente código:


public class PruebaVisibilidad {
    private static boolean condicion = true;

    public static void main(String[] args) throws InterruptedException {
        Thread hiloWorker = new Thread(() -> {
            while (condicion) {
                // Bucle activo sin operaciones visibles
            }
            System.out.println("Hilo worker finalizado");
        });
        hiloWorker.start();
        Thread.sleep(100);
        condicion = false;
    }
}

En este ejemplo, el hilo principal modifica condicion a false después de dormir 100 milisegundos. Sin embargo, el hilo worker nunca ve este cambio porque trabaja con una copia local de la variable en su caché de CPU, lo que resulta en un bucle infinito. Este problema demuestra la falta de visibilidad entre hilos.

1.2 Visibilidad con volatile

La palabra clave volatile garantiza que los cambios en una variable sean visibles para todos los hilos. Al declarar la variable como volatile, el hilo worker se verá obligado a leer el valor actualizado desde la memoria principal:


public class PruebaVisibilidadVolatile {
    private static volatile boolean condicion = true;

    public static void main(String[] args) throws InterruptedException {
        Thread hiloWorker = new Thread(() -> {
            while (condicion) {
                // Bucle activo
            }
            System.out.println("Hilo worker finalizado");
        });
        hiloWorker.start();
        Thread.sleep(100);
        condicion = false;
    }
}

Ahora el programa termina normalmente porque volatile asegura que el hilo worker observe el cambio en la variable.

1.3 Visibilidad con synchronized

synchronized también garantiza visibilidad, ya que al adquirir un bloqueo se leen las variables más recientes desde la memoria principle:


public class PruebaVisibilidadSincronizada {
    private static boolean condicion = true;
    private static final Object candado = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread hiloWorker = new Thread(() -> {
            while (condicion) {
                synchronized (candado) {
                    // Sección crítica vacía
                }
            }
            System.out.println("Hilo worker finalizado");
        });
        hiloWorker.start();
        Thread.sleep(100);
        condicion = false;
    }
}

El hilo worker ahora finaliza porque adquiere el bloqueo en cada iteración, lo que fuerza la actualización de la variable desde la memoria principal.

  1. Atomicidad

2.1 Definición de atomicidad

Una operación atómica es aquella que se ejecuta como una sola unidad indivisible, sin posibilidad de interferencia de otros hilos durante su ejecución.

2.2 Falta de atomicidad con volatile

La operación contador++ no es atómica. Consiste en tres pasos: leer el valor actual, incrementarlo y escribir el nuevo valor. volatile no garantiza atomicidad en operaciones compuestas:


public class PruebaAtomicidadVolatile {
    private static volatile int contador = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable tarea = () -> {
            for (int i = 0; i < 10000; i++) {
                contador++;
            }
        };

        Thread hiloA = new Thread(tarea);
        Thread hiloB = new Thread(tarea);
        hiloA.start();
        hiloB.start();
        hiloA.join();
        hiloB.join();
        System.out.println("Valor final del contador: " + contador);
    }
}

El resultado será menor a 20000 debido a condiciones de carrera en la operación contador++.

2.3 Atomicidad con synchronized

Para garantizar atomicidad, podemos usar synchronized para proteger la sección crítica:


public class PruebaAtomicidadSincronizada {
    private static int contador = 0;
    private static final Object candado = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable tarea = () -> {
            synchronized (candado) {
                for (int i = 0; i < 10000; i++) {
                    contador++;
                }
            }
        };

        Thread hiloA = new Thread(tarea);
        Thread hiloB = new Thread(tarea);
        hiloA.start();
        hiloB.start();
        hiloA.join();
        hiloB.join();
        System.out.println("Valor final del contador: " + contador);
    }
}

Ahora el resultado es siempre 20000 porque el bloqueo garantiza que solo un hilo ejecute la sección crítica a la vez.

  1. Orden

3.1 Introducción al orden

El compilador y la máquina virtual pueden reordenar instrucciones para optimizar el rendimiento. Esto puede causar comportamientos inesperados en programas concurrentes.

3.2 Reordenamiento de instrucciones

El siguiente código demuestra que las asignaciones pueden reordenarse:


public class PruebaOrden {
    private static int x, y, a, b;

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            x = y = a = b = 0;
            Thread hiloUno = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread hiloDos = new Thread(() -> {
                b = 1;
                y = a;
            });
            hiloUno.start();
            hiloDos.start();
            hiloUno.join();
            hiloDos.join();
            if (x == 0 && y == 0) {
                break;
            }
        }
        System.out.println("Se detectó reordenamiento: x=0, y=0");
    }
}

En algunas ejecuciones, ambos hilos pueden observar los valores iniciales de las variables, lo que indica que las asignaciones fueron reordenadas.

3.3 Barreras de memoria con volatile

volatile establece barreras de memoria que preveinen ciertas reordenaciones. Podemos usarlo para controlar el orden de ejecución:


public class PruebaOrdenVolatile {
    private static int x, y, a, b;
    private static volatile int barrera1, barrera2;

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            x = y = a = b = 0;
            barrera1 = barrera2 = 0;
            Thread hiloUno = new Thread(() -> {
                a = 1;
                barrera1 = 1;
                x = b;
            });
            Thread hiloDos = new Thread(() -> {
                b = 1;
                barrera2 = 1;
                y = a;
            });
            hiloUno.start();
            hiloDos.start();
            hiloUno.join();
            hiloDos.join();
            if (x == 0 && y == 0) {
                System.out.println("La barrera previno el reordenamiento");
                break;
            }
        }
    }
}

Las variables volatile barrera1 y barrera2 actúa como barreras que impiden que las instrucciones se reordenen a través de ellas.

3.4 Orden con synchronized

synchronized también garantiza orden, ya que las secciones críticas se ejecutan de manera secuencial:


public class PruebaOrdenSincronizada {
    private static int x, y, a, b;
    private static final Object candado = new Object();

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            x = y = a = b = 0;
            Thread hiloUno = new Thread(() -> {
                synchronized (candado) {
                    a = 1;
                    x = b;
                }
            });
            Thread hiloDos = new Thread(() -> {
                synchronized (candado) {
                    b = 1;
                    y = a;
                }
            });
            hiloUno.start();
            hiloDos.start();
            hiloUno.join();
            hiloDos.join();
            if (x == 0 && y == 0) {
                break;
            }
        }
        System.out.println("El bloqueo mantuvo el orden");
    }
}

Al usar el mismo bloqueo, los hilos ejecutan sus secciones críticas en un orden determinado, evitando condiciones de carrera.

  1. Conclusión

volatile y synchronized son mecanismos fundamentales para manejar la concurrencia en Java. Mientras que volatile se enfoca principalmente en visibilidad y orden, synchronized proporciona atomicidad además de visibilidad y orden. Comprender estas diferencias es crucial para escribir código concurrente correcto y eficiente.

Etiquetas: java volatile synchronized concurrencia visibilidad

Publicado el 6-2 03:38