Buscar..


Observaciones

El modelo de memoria de Java es la sección del JLS que especifica las condiciones bajo las cuales se garantiza que un hilo vea los efectos de las escrituras en memoria realizadas por otro hilo. La sección relevante en las ediciones recientes es "Modelo de memoria JLS 17.4" (en Java 8 , Java 7 , Java 6 )

Hubo una revisión importante del modelo de memoria de Java en Java 5 que (entre otras cosas) cambió la forma en que funcionaba la volatile . Desde entonces, el modelo de memoria ha sido esencialmente sin cambios.

Motivación para el modelo de memoria.

Considere el siguiente ejemplo:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

Si se usa esta clase es una aplicación de un solo hilo, entonces el comportamiento observable será exactamente el que usted esperaría. Por ejemplo:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

saldrá:

0, 0
1, 1

En lo que el hilo "principal" puede decir , las declaraciones en el método main() y el método doIt() se ejecutarán en el orden en que están escritas en el código fuente. Este es un requisito claro de la especificación del lenguaje Java (JLS).

Ahora considere la misma clase utilizada en una aplicación de subprocesos múltiples.

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

¿Qué imprimirá esto?

De hecho, según el JLS no es posible predecir que esto se imprimirá:

  • Probablemente verá algunas líneas de 0, 0 para empezar.
  • Entonces probablemente veas líneas como N, N o N, N + 1 .
  • Es posible que veas líneas como N + 1, N
  • En teoría, podría incluso ver que las líneas 0, 0 continúan para siempre 1 .

1 - En la práctica, la presencia de las sentencias de println puede causar una sincronización inesperada y el println de la memoria caché. Es probable que oculte algunos de los efectos que podrían causar el comportamiento anterior.

Entonces, ¿cómo podemos explicar esto?

Reordenación de asignaciones

Una posible explicación de los resultados inesperados es que el compilador JIT ha cambiado el orden de las asignaciones en el método doIt() . El JLS requiere que las instrucciones parezcan ejecutarse en orden desde la perspectiva del hilo actual . En este caso, nada en el código del método doIt() puede observar el efecto de un reordenamiento (hipotético) de esas dos afirmaciones. Esto significa que al compilador JIT se le permitiría hacer eso.

¿Porqué haría eso?

En el hardware moderno típico, las instrucciones de la máquina se ejecutan utilizando un canal de instrucciones que permite que una secuencia de instrucciones se encuentre en diferentes etapas. Algunas fases de la ejecución de instrucciones toman más tiempo que otras, y las operaciones de memoria tienden a tomar más tiempo. Un compilador inteligente puede optimizar el rendimiento de las instrucciones de la tubería al ordenar las instrucciones para maximizar la cantidad de superposición. Esto puede llevar a la ejecución de partes de declaraciones fuera de orden. El JLS permite esto siempre que no afecte el resultado del cálculo desde la perspectiva del subproceso actual .

Efectos de cachés de memoria

Una segunda explicación posible es el efecto del almacenamiento en memoria caché. En una arquitectura de computadora clásica, cada procesador tiene un pequeño conjunto de registros y una mayor cantidad de memoria. El acceso a los registros es mucho más rápido que el acceso a la memoria principal. En las arquitecturas modernas, hay cachés de memoria que son más lentos que los registros, pero más rápidos que la memoria principal.

Un compilador explotará esto intentando mantener copias de variables en registros o en las memorias caché. Si no es necesario vaciar una variable en la memoria principal, o no es necesario leerla desde la memoria, hay ventajas significativas en el rendimiento al no hacer esto. En los casos en que el JLS no requiere que las operaciones de memoria sean visibles a otro hilo, es probable que el compilador JIT de Java no agregue las instrucciones de "barrera de lectura" y "barrera de escritura" que forzarán las lecturas y escrituras de la memoria principal. Una vez más, los beneficios de rendimiento de hacer esto son significativos.

Sincronización adecuada

Hasta ahora, hemos visto que el JLS permite al compilador JIT generar código que hace que el código de un solo subproceso sea más rápido al reordenar o evitar las operaciones de memoria. Pero, ¿qué sucede cuando otros hilos pueden observar el estado de las variables (compartidas) en la memoria principal?

La respuesta es que los otros subprocesos pueden observar estados variables que parecen imposibles ... según el orden del código de las declaraciones de Java. La solución a esto es usar la sincronización apropiada. Los tres enfoques principales son:

  • Usando mutexes primitivos y las construcciones synchronized .
  • Utilizando variables volatile .
  • Uso de soporte de concurrencia de nivel superior; Por ejemplo, clases en los paquetes java.util.concurrent .

Pero incluso con esto, es importante entender dónde se necesita la sincronización y en qué efectos puede confiar. Aquí es donde entra en juego el modelo de memoria Java.

El modelo de memoria

El modelo de memoria de Java es la sección del JLS que especifica las condiciones bajo las cuales se garantiza que un hilo vea los efectos de las escrituras en memoria realizadas por otro hilo. El modelo de memoria se especifica con un cierto grado de rigor formal y (como resultado) requiere una lectura detallada y cuidadosa para comprender. Pero el principio básico es que ciertas construcciones crean una relación "sucede antes de" entre la escritura de una variable por un hilo y una lectura posterior de la misma variable por otro hilo. Si la relación "sucede antes" existe, el compilador JIT está obligado a generar código que garantice que la operación de lectura vea el valor escrito por la escritura.

Armado con esto, es posible razonar acerca de la coherencia de la memoria en un programa Java y decidir si esto será predecible y consistente para todas las plataformas de ejecución.

Relaciones de antes-antes

(La siguiente es una versión simplificada de lo que dice la especificación del lenguaje Java. Para una comprensión más profunda, debe leer la especificación en sí).

Las relaciones Happens-Before son la parte del modelo de memoria que nos permite entender y razonar sobre la visibilidad de la memoria. Como dice el JLS ( JLS 17.4.5 ):

"Se pueden ordenar dos acciones por una relación de suceso-antes . Si ocurre una acción -antes de otra, entonces la primera es visible y ordenada antes de la segunda".

¿Qué significa esto?

Comportamiento

Las acciones a las que se refiere la cita anterior se especifican en JLS 17.4.2 . Hay 5 tipos de acciones enumeradas definidas por la especificación:

  • Leer: Leer una variable no volátil.

  • Escribir: Escribir una variable no volátil.

  • Acciones de sincronización:

    • Lectura volátil: Lectura de una variable volátil.

    • Escritura volátil: Escribiendo una variable volátil.

    • Bloquear. Bloqueo de un monitor

    • Desbloquear. Desbloqueo de un monitor.

    • Las primeras y últimas acciones (sintéticas) de un hilo.

    • Acciones que inician un hilo o detectan que un hilo ha terminado.

  • Acciones externas. Una acción que tiene un resultado que depende del entorno en el que se encuentra el programa.

  • Acciones de divergencia del hilo. Estos modelan el comportamiento de ciertos tipos de bucle infinito.

Orden del programa y orden de sincronización

Estos dos ordenamientos ( JLS 17.4.3 y JLS 17.4.4 ) rigen la ejecución de sentencias en un Java

El orden del programa describe el orden de ejecución de la declaración dentro de un solo hilo.

El orden de sincronización describe el orden de ejecución de la sentencia para dos sentencias conectadas mediante una sincronización:

  • Una acción de desbloqueo en el monitor se sincroniza con todas las acciones de bloqueo subsiguientes en ese monitor.

  • Una escritura en una variable volátil se sincroniza con todas las lecturas posteriores de la misma variable por cualquier hilo.

  • Una acción que inicia un hilo (es decir, la llamada a Thread.start() ) se sincroniza con la primera acción en el hilo que comienza (es decir, la llamada al método run() del hilo).

  • La inicialización predeterminada de los campos se sincroniza con la primera acción en cada hilo. (Vea el JLS para una explicación de esto.)

  • La acción final en un hilo se sincroniza con cualquier acción en otro hilo que detecta la terminación; por ejemplo, la devolución de una llamada join() o isTerminated() llamada que devuelve true .

  • Si un hilo interrumpe otro hilo, la llamada de interrupción en el primer hilo se sincroniza con el punto donde otro hilo detecta que el hilo se interrumpió.

Sucede antes de la orden

Este ordenamiento ( JLS 17.4.5 ) es lo que determina si se garantiza que una escritura de memoria sea visible para una lectura de memoria posterior.

Más específicamente, se garantiza que una lectura de una variable v observe una escritura en v si y solo si sucede la write(v) - antes de la read(v) Y no hay escritura v a la v . Si hay escrituras intermedias, entonces la read(v) puede ver los resultados de ellas en lugar de la anterior.

Las reglas que definen el suceso antes de ordenar son las siguientes:

  • Regla Happens-Before # 1 : si x e y son acciones del mismo hilo y x aparece antes de y en el orden del programa , entonces x sucede antes de y.

  • Regla Happens-Before # 2 : hay un borde antes del evento desde el final de un constructor de un objeto hasta el inicio de un finalizador para ese objeto.

  • Happens-Before Rule # 3 - Si una acción x se sincroniza con una acción subsiguiente y, entonces x sucede- y.

  • Happens-Before Rule # 4 - Si x sucede antes de y y y sucede antes de z, entonces x sucede antes de z.

Además, varias clases en las bibliotecas estándar de Java se especifican para definir relaciones de suceso antes . Puede interpretar que esto significa que sucede de alguna manera , sin necesidad de saber exactamente cómo se va a cumplir la garantía.

Razonamiento antes del razonamiento aplicado a algunos ejemplos.

Presentaremos algunos ejemplos para mostrar cómo aplicar el suceso antes del razonamiento para verificar que las escrituras sean visibles en las lecturas posteriores.

Código de un solo hilo

Como es de esperar, las escrituras siempre son visibles para lecturas posteriores en un programa de un solo hilo.

public class SingleThreadExample {
    public int a, b;
    
    public int add() {
       a = 1;         // write(a)
       b = 2;         // write(b)
       return a + b;  // read(a) followed by read(b)
    }
}

Por lo que sucede antes de la regla # 1:

  1. La acción de write(a) ocurre antes de la acción de write(b) .
  2. La acción de write(b) ocurre antes de la acción de read(a) .
  3. La acción de read(a) ocurre antes de la acción de read(a) .

Por lo que sucede antes de la regla # 4:

  1. write(a) sucede antes de write(b) y write(b) sucede antes de read(a) IMPLICA write(a) sucede antes de read(a) .
  2. write(b) sucede antes de read(a) y read(a) sucede antes de read(b) IMPLICA write(b) sucede antes de read(b) .

Resumiendo:

  1. La relación write(a) sucede antes de read(a) significa que se garantiza que la declaración a a + b ve el valor correcto de a .
  2. La relación write(b) sucede antes de read(b) significa que se garantiza que la declaración a a + b ve el valor correcto de b .

Comportamiento de 'volátil' en un ejemplo con 2 hilos

Usaremos el siguiente código de ejemplo para explorar algunas implicaciones del modelo de memoria para `volatile.

public class VolatileExample {
    private volatile int a;
    private int b;         // NOT volatile
    
    public void update(int first, int second) {
       b = first;         // write(b)
       a = second;         // write-volatile(a)
    }

    public int observe() {
       return a + b;       // read-volatile(a) followed by read(b)
    }
}

Primero, considere la siguiente secuencia de declaraciones que involucran 2 hilos:

  1. Se crea una instancia única de VolatileExample ; llámalo ve
  2. ve.update(1, 2) se llama en un hilo, y
  3. ve.observe() se llama en otro hilo.

Por lo que sucede antes de la regla # 1:

  1. La acción de write(a) ocurre antes de la acción volatile-write(a) .
  2. La acción de volatile-read(a) ocurre antes de la acción de read(b) .

Por lo que sucede antes de la regla 2:

  1. La acción de volatile-write(a) en el primer subproceso ocurre antes de la acción de volatile-read(a) en el segundo subproceso.

Por lo que sucede antes de la regla # 4:

  1. La acción de write(b) en el primer hilo ocurre antes de la acción de read(b) en el segundo hilo.

En otras palabras, para esta secuencia en particular, tenemos la garantía de que el segundo subproceso verá la actualización de la variable no volátil b realizada por el primer subproceso. Sin embargo, también debe quedar claro que si las asignaciones en el método de update fueran al revés, o el método de observe() leyera la variable b antes de a , entonces la cadena de suceso antes se rompería. La cadena también se rompería si volatile-read(a) en el segundo hilo no fuera posterior a la volatile-write(a) en el primer hilo.

Cuando se rompe la cadena, no hay garantía de que observe() verá el valor correcto de b .

Volátil con tres hilos.

Supongamos que agregamos un tercer hilo en el ejemplo anterior:

  1. Se crea una instancia única de VolatileExample ; llámalo ve
  2. update llamada a dos hilos:
    • ve.update(1, 2) se llama en un hilo,
    • ve.update(3, 4) se llama en el segundo hilo,
  3. ve.observe() se llama posteriormente en un tercer hilo.

Para analizar esto completamente, debemos considerar todas las posibles interrelaciones de las declaraciones en el subproceso uno y el subproceso dos. En su lugar, consideraremos sólo dos de ellos.

Escenario # 1: supongamos que la update(1, 2) precede a la update(3,4) obtenemos esta secuencia:

write(b, 1), write-volatile(a, 2)     // first thread
write(b, 3), write-volatile(a, 4)     // second thread
read-volatile(a), read(b)             // third thread

En este caso, es fácil ver que hay una cadena de write(b, 3) antes de write(b, 3) para read(b) . Además, no hay que intervenir escribir a b . Por lo tanto, para este escenario, se garantiza que el tercer subproceso verá que b tiene valor 3 .

Escenario n. ° 2: supongamos que la update(1, 2) y la update(3,4) superponen y las condiciones se entrelazan de la siguiente manera:

write(b, 3)                           // second thread
write(b, 1)                           // first thread
write-volatile(a, 2)                  // first thread
write-volatile(a, 4)                  // second thread
read-volatile(a), read(b)             // third thread

Ahora, mientras hay una cadena de write(b, 3) antes de write(b, 3) para read(b) , hay una acción de write(b, 1) interviene por el otro hilo. Esto significa que no podemos estar seguros de qué valor read(b) .

(Aparte: Esto demuestra que no podemos confiar en la volatile para garantizar la visibilidad de las variables no volátiles, excepto en situaciones muy limitadas).

Cómo evitar tener que entender el modelo de memoria.

El modelo de memoria es difícil de entender y difícil de aplicar. Es útil si necesita razonar acerca de la corrección del código de subprocesos múltiples, pero no desea tener que hacer este razonamiento para cada aplicación de subprocesos múltiples que escriba.

Si adopta los siguientes principios al escribir código concurrente en Java, puede evitar en gran medida la necesidad de recurrir al suceso antes del razonamiento.

  • Utilice estructuras de datos inmutables siempre que sea posible. Una clase inmutable implementada correctamente será segura para subprocesos y no presentará problemas de seguridad de subprocesos cuando la use con otras clases.

  • Entender y evitar "publicación insegura".

  • Use mutexes primitivos u objetos Lock para sincronizar el acceso al estado en objetos mutables que necesitan ser seguros para subprocesos 1 .

  • Use Executor / ExecutorService o el framework de unión de fork en lugar de intentar crear hilos de administración directamente.

  • Utilice las clases `java.util.concurrent que proporcionan bloqueos avanzados, semáforos, cierres y barreras, en lugar de usar esperar / notificar / notificar a todos directamente.

  • Utilice las versiones java.util.concurrent de mapas, conjuntos, listas, colas y deques en lugar de la sincronización externa de colecciones no concurrentes.

El principio general es tratar de usar las bibliotecas de concurrencia incorporadas de Java en lugar de "rodar su propia concurrencia". Puede confiar en que funcionen, si los usa correctamente.


1 - No todos los objetos deben estar a salvo de hilos. Por ejemplo, si un objeto u objetos están limitados a un hilo (es decir, solo es accesible a un hilo), entonces su seguridad no es relevante.



Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow