Introducción al modelo de memoria de Java

El modelo de memoria de Java (JMM) describe el comportamiento de los subprocesos en el entorno de tiempo de ejecución de Java. El modelo de memoria es parte de la semántica del lenguaje Java y describe lo que un programador puede y no debe esperar cuando desarrolla software no para una máquina Java específica, sino para Java como un todo.

El modelo de memoria Java original (que, en particular, se refiere a "memoria percolocal"), desarrollado en 1995, se considera un fracaso: muchas optimizaciones no se pueden realizar sin perder la garantía de seguridad del código. En particular, hay varias opciones para escribir "único" de subprocesos múltiples:

  • cada acto de acceder a un singleton (incluso cuando el objeto se creó hace mucho tiempo y nada puede cambiar) provocará un bloqueo entre subprocesos;
  • o bajo ciertas circunstancias, el sistema emitirá un loner sin terminar;
  • o bajo un determinado conjunto de circunstancias, el sistema creará dos solitarios;
  • o el diseño dependerá del comportamiento de una máquina en particular.

Por lo tanto, el mecanismo de memoria ha sido rediseñado. En 2005, con el lanzamiento de Java 5, se presentó un nuevo enfoque, que se mejoró aún más con el lanzamiento de Java 14.

El nuevo modelo se basa en tres reglas:

Regla n.º 1 : los programas de un solo subproceso se ejecutan pseudosecuencialmente. Es decir: en realidad, el procesador puede realizar varias operaciones por reloj, al mismo tiempo cambiando su orden, sin embargo, todas las dependencias de datos permanecen, por lo que el comportamiento no difiere del secuencial.

Regla número 2 : no hay valores de la nada. Leer cualquier variable (excepto long y double no volátiles, para los cuales esta regla puede no cumplirse) devolverá el valor predeterminado (cero) o algo escrito allí por otro comando.

Y regla número 3 : el resto de los eventos se ejecutan en orden, si están conectados por una estricta relación de orden parcial “ejecutes before” ( sucede antes ).

sucede antes

A Leslie Lamport se le ocurrió el concepto de Happens before . Esta es una relación estricta de orden parcial introducida entre los comandos atómicos (++ y -- no son atómicos) y no significa "físicamente antes".

Dice que el segundo equipo estará "al tanto" de los cambios realizados por el primero.

sucede antes

Por ejemplo, uno se ejecuta antes que el otro para tales operaciones:

Sincronización y monitores:

  • Capturar el monitor ( método de bloqueo , inicio sincronizado) y lo que suceda en el mismo hilo después.
  • El regreso del monitor (método de desbloqueo , final de sincronizado) y lo que suceda en el mismo subproceso anterior.
  • Devolviendo el monitor y luego capturándolo por otro hilo.

Escritura y lectura:

  • Escribir en cualquier variable y luego leerlo en la misma secuencia.
  • Todo en el mismo hilo antes de escribir en la variable volátil y la escritura en sí. lectura volátil y todo en el mismo hilo después de eso.
  • Escribiendo en una variable volátil y luego leyéndola de nuevo. Una escritura volátil interactúa con la memoria de la misma manera que un retorno de monitor, mientras que una lectura es como una captura. Resulta que si un subproceso escribió en una variable volátil y el segundo la encontró, todo lo que precede a la escritura se ejecuta antes que todo lo que viene después de la lectura; ver imagen

Mantenimiento de objetos:

  • Inicialización estática y cualquier acción con cualquier instancia de objetos.
  • Escribiendo a los campos finales en el constructor y todo después del constructor. Como excepción, la relación "sucede antes" no se conecta transitivamente a otras reglas y, por lo tanto, puede provocar una carrera entre subprocesos.
  • Cualquier trabajo con el objeto y finalize() .

Servicio de transmisión:

  • Comenzando un hilo y cualquier código en el hilo.
  • Poner a cero las variables relacionadas con el hilo y cualquier código en el hilo.
  • Código en hilo y join() ; código en el hilo y isAlive() == false .
  • interrupt() el hilo y detecta que se ha detenido.

Sucede antes de los matices del trabajo.

La liberación de un monitor que sucede antes ocurre antes de adquirir el mismo monitor. Vale la pena señalar que es el lanzamiento y no la salida, es decir, no tiene que preocuparse por la seguridad cuando usa esperar.

Veamos cómo este conocimiento nos ayudará a corregir nuestro ejemplo. En este caso, todo es muy simple: simplemente elimine el control externo y deje la sincronización como está. Ahora se garantiza que el segundo subproceso verá todos los cambios, porque solo obtendrá el monitor después de que el otro subproceso lo libere. Y como no lo soltará hasta que todo esté inicializado, veremos todos los cambios a la vez, y no por separado:

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

La escritura en una variable volátil sucede antes de leer de la misma variable. El cambio que hemos hecho, por supuesto, soluciona el error, pero vuelve a colocar a quien escribió el código original en el lugar de donde provino, bloqueándolo todo el tiempo. La palabra clave volátil puede guardar. De hecho, la declaración en cuestión significa que al leer todo lo que se declara volátil, siempre obtendremos el valor real.

Además, como dije antes, para los campos volátiles, la escritura es siempre (incluso larga y doble) una operación atómica. Otro punto importante: si tiene una entidad volátil que tiene referencias a otras entidades (por ejemplo, una matriz, Lista o alguna otra clase), solo una referencia a la entidad en sí siempre será "nueva", pero no a todo en es entrante

Entonces, volvamos a nuestros arietes de doble bloqueo. Usando volatile, puedes arreglar la situación así:

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Aquí todavía tenemos un bloqueo, pero solo si data == null. Filtramos los casos restantes usando lectura volátil. La corrección está garantizada por el hecho de que el almacenamiento volátil ocurre antes de la lectura volátil, y todas las operaciones que ocurren en el constructor son visibles para cualquiera que lea el valor del campo.