Guía de palabras clave volátiles en Java

1. Información general

En ausencia de sincronizaciones necesarias, el compilador, el tiempo de ejecución o los procesadores pueden aplicar todo tipo de optimizaciones. Aunque estas optimizaciones son beneficiosas la mayor parte del tiempo, a veces pueden causar problemas sutiles.

El almacenamiento en caché y el reordenamiento se encuentran entre las optimizaciones que pueden sorprendernos en contextos concurrentes. Java y la JVM proporcionan muchas formas de controlar el orden de la memoria, y la palabra clave volátil es una de ellas.

En este artículo, nos centraremos en este concepto fundamental pero a menudo mal entendido en el lenguaje Java: la palabra clave volátil . Primero, comenzaremos con algunos antecedentes sobre cómo funciona la arquitectura de la computadora subyacente, y luego nos familiarizaremos con el orden de la memoria en Java.

2. Arquitectura multiprocesador compartida

Los procesadores son responsables de ejecutar las instrucciones del programa. Por lo tanto, necesitan recuperar tanto las instrucciones del programa como los datos requeridos de la RAM.

Como las CPU son capaces de llevar a cabo una cantidad significativa de instrucciones por segundo, buscar desde la RAM no es tan ideal para ellas. Para mejorar esta situación, los procesadores están utilizando trucos como ejecución fuera de orden, predicción de rama, ejecución especulativa y, por supuesto, almacenamiento en caché.

Aquí es donde entra en juego la siguiente jerarquía de memoria:

A medida que los diferentes núcleos ejecutan más instrucciones y manipulan más datos, llenan sus cachés con datos e instrucciones más relevantes. Esto mejorará el rendimiento general a expensas de introducir desafíos de coherencia de caché .

En pocas palabras, deberíamos pensar dos veces sobre lo que sucede cuando un hilo actualiza un valor almacenado en caché.

3. Cuándo usar volátiles

Para ampliar más la coherencia de la caché, tomemos prestado un ejemplo del libro Concurrencia de Java en la práctica:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

La clase TaskRunner mantiene dos variables simples. En su método principal, crea otro hilo que gira en la variable ready siempre que sea falsa. Cuando la variable se convierte en verdadera, el hilo simplemente imprimirá la variable numérica .

Muchos pueden esperar que este programa simplemente imprima 42 después de un breve retraso. Sin embargo, en realidad, el retraso puede ser mucho mayor. ¡Incluso puede colgarse para siempre, o incluso imprimir cero!

La causa de estas anomalías es la falta de visibilidad y reordenamiento adecuados de la memoria . Evaluémoslos con más detalle.

3.1. Visibilidad de la memoria

En este ejemplo simple, tenemos dos hilos de aplicación: el hilo principal y el hilo del lector. Imaginemos un escenario en el que el sistema operativo programa esos subprocesos en dos núcleos de CPU diferentes, donde:

  • El hilo principal tiene su copia de variables listas y numéricas en su caché central
  • El hilo del lector termina con sus copias también
  • El hilo principal actualiza los valores en caché

En la mayoría de los procesadores modernos, las solicitudes de escritura no se aplicarán inmediatamente después de su emisión. De hecho, los procesadores tienden a poner en cola esas escrituras en un búfer de escritura especial . Después de un tiempo, aplicarán esas escrituras a la memoria principal de una vez.

Dicho todo esto, cuando el hilo principal actualiza el número y las variables listas , no hay garantía de lo que puede ver el hilo del lector. En otras palabras, el hilo del lector puede ver el valor actualizado de inmediato, o con cierta demora, ¡o nunca!

Esta visibilidad de la memoria puede causar problemas de vitalidad en programas que dependen de la visibilidad.

3.2. Reordenamiento

Para empeorar las cosas, el hilo del lector puede ver esas escrituras en cualquier orden que no sea el orden real del programa . Por ejemplo, desde que actualizamos por primera vez la variable numérica :

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Podemos esperar que el hilo del lector imprima 42. Sin embargo, ¡en realidad es posible ver cero como valor impreso!

El reordenamiento es una técnica de optimización para mejorar el rendimiento. Curiosamente, diferentes componentes pueden aplicar esta optimización:

  • El procesador puede vaciar su búfer de escritura en cualquier orden que no sea el orden del programa
  • El procesador puede aplicar una técnica de ejecución fuera de orden
  • El compilador JIT puede optimizar mediante reordenación

3.3. orden de memoria volátil

Para garantizar que las actualizaciones de las variables se propaguen de manera predecible a otros subprocesos, debemos aplicar el modificador volátil a esas variables:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

De esta manera, nos comunicamos con el tiempo de ejecución y el procesador para no reordenar ninguna instrucción que involucre la variable volátil . Además, los procesadores entienden que deben eliminar cualquier actualización de estas variables de inmediato.

4. Sincronización de subprocesos y volátiles

Para aplicaciones multiproceso, necesitamos asegurar un par de reglas para un comportamiento consistente:

  • Exclusión mutua: solo un hilo ejecuta una sección crítica a la vez
  • Visibilidad: los cambios realizados por un hilo en los datos compartidos son visibles para otros hilos para mantener la coherencia de los datos

Los métodos y bloques sincronizados proporcionan las dos propiedades anteriores, a costa del rendimiento de la aplicación.

volátil es una palabra clave bastante útil porque puede ayudar a garantizar el aspecto de visibilidad del cambio de datos sin, por supuesto, proporcionar una exclusión mutua . Por lo tanto, es útil en los lugares en los que estamos de acuerdo con varios subprocesos que ejecutan un bloque de código en paralelo, pero debemos garantizar la propiedad de visibilidad.

5. Sucede antes de realizar el pedido

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Al hacer uso de esta semántica, podemos definir solo algunas de las variables de nuestra clase como volátiles y optimizar la garantía de visibilidad.

6. Conclusión

En este tutorial, hemos explorado más sobre la palabra clave volátil y sus capacidades, así como las mejoras que se le hicieron a partir de Java 5.

Como siempre, los ejemplos de código se pueden encontrar en GitHub.