Arquitectura de hardware de memoria

La arquitectura de hardware de memoria moderna difiere del modelo de memoria interna de Java. Por lo tanto, debe comprender la arquitectura del hardware para saber cómo funciona el modelo Java con él. Esta sección describe la arquitectura general del hardware de la memoria, y la siguiente sección describe cómo funciona Java con ella.

Aquí hay un diagrama simplificado de la arquitectura de hardware de una computadora moderna:

Arquitectura de hardware de memoria

En el mundo moderno, una computadora tiene 2 o más procesadores y esto ya es la norma. Algunos de estos procesadores también pueden tener varios núcleos. En tales computadoras, es posible ejecutar múltiples subprocesos al mismo tiempo. Cada núcleo del procesador es capaz de ejecutar un subproceso en un momento dado. Esto significa que cualquier aplicación Java es, a priori, de subprocesos múltiples, y dentro de su programa, se puede ejecutar un subproceso por núcleo de procesador a la vez.

El núcleo del procesador contiene un conjunto de registros que residen en su memoria (dentro del núcleo). Realiza operaciones en los datos de registro mucho más rápido que en los datos que residen en la memoria principal (RAM) de la computadora. Esto se debe a que el procesador puede acceder a estos registros mucho más rápido.

Cada CPU también puede tener su propia capa de caché. La mayoría de los procesadores modernos lo tienen. El procesador puede acceder a su caché mucho más rápido que la memoria principal, pero no tan rápido como sus registros internos. El valor de la velocidad de acceso a la memoria caché está aproximadamente entre las velocidades de acceso a la memoria principal y los registros internos.

Además, los procesadores tienen un lugar para tener un caché de varios niveles. Pero esto no es tan importante de saber para entender cómo el modelo de memoria de Java interactúa con la memoria del hardware. Es importante saber que los procesadores pueden tener algún nivel de caché.

Cualquier computadora también contiene RAM (área de memoria principal) de la misma manera. Todos los núcleos pueden acceder a la memoria principal. El área de la memoria principal suele ser mucho más grande que la memoria caché de los núcleos del procesador.

En el momento en que el procesador necesita acceder a la memoria principal, lee parte de ella en su memoria caché. También puede leer algunos datos del caché en sus registros internos y luego realizar operaciones en ellos. Cuando la CPU necesita volver a escribir el resultado en la memoria principal, vaciará los datos de su registro interno a la memoria caché y, en algún momento, a la memoria principal.

Los datos almacenados en la memoria caché normalmente se devuelven a la memoria principal cuando el procesador necesita almacenar algo más en la memoria caché. El caché tiene la capacidad de borrar su memoria y escribir datos al mismo tiempo. El procesador no necesita leer o escribir el caché completo cada vez que se actualiza. Por lo general, el caché se actualiza en pequeños bloques de memoria, se les llama "línea de caché". Una o más "líneas de caché" se pueden leer en la memoria caché y una o más líneas de caché se pueden vaciar de nuevo a la memoria principal.

Combinando el modelo de memoria Java y la arquitectura de hardware de memoria

Como ya se mencionó, el modelo de memoria de Java y la arquitectura de hardware de memoria son diferentes. La arquitectura de hardware no distingue entre pilas de subprocesos y montones. En el hardware, la pila de subprocesos y HEAP (montón) residen en la memoria principal.

Partes de pilas y montones de subprocesos a veces pueden estar presentes en cachés y registros internos de la CPU. Esto se muestra en el diagrama:

pila de subprocesos y HEAP

Cuando los objetos y las variables se pueden almacenar en diferentes áreas de la memoria de la computadora, pueden surgir ciertos problemas. Aquí están los dos principales:

  • Visibilidad de los cambios que el subproceso ha realizado en las variables compartidas.
  • Condición de carrera al leer, comprobar y escribir variables compartidas.

Ambas cuestiones se explicarán a continuación.

Visibilidad de objetos compartidos

Si dos o más subprocesos comparten un objeto sin el uso adecuado de la declaración volátil o la sincronización, es posible que los cambios en el objeto compartido realizados por un subproceso no sean visibles para otros subprocesos.

Imagine que un objeto compartido se almacena inicialmente en la memoria principal. Un subproceso que se ejecuta en una CPU lee el objeto compartido en el caché de la misma CPU. Allí realiza cambios en el objeto. Hasta que la memoria caché de la CPU se haya vaciado en la memoria principal, la versión modificada del objeto compartido no es visible para los subprocesos que se ejecutan en otras CPU. Por lo tanto, cada subproceso puede obtener su propia copia del objeto compartido, cada copia estará en un caché de CPU separado.

El siguiente diagrama ilustra un esquema de esta situación. Un subproceso que se ejecuta en la CPU izquierda copia el objeto compartido en su caché y cambia el valor de conteo a 2. Este cambio es invisible para otros subprocesos que se ejecutan en la CPU derecha porque la actualización de conteo aún no se ha devuelto a la memoria principal.

Para resolver este problema, puede usar la palabra clave volatile al declarar una variable. Puede garantizar que una variable determinada se lea directamente desde la memoria principal y siempre se vuelva a escribir en la memoria principal cuando se actualice.

Condición de carrera

Si dos o más subprocesos comparten el mismo objeto y más de un subproceso actualiza las variables en ese objeto compartido, entonces puede ocurrir una condición de carrera.

Imagine que el subproceso A lee la variable de recuento del objeto compartido en la memoria caché de su procesador. Imagine también que el subproceso B hace lo mismo, pero en el caché de otro procesador. Ahora el subproceso A suma 1 al valor de count, y el subproceso B hace lo mismo. Ahora la variable se ha incrementado dos veces, por separado en +1 en el caché de cada procesador.

Si estos incrementos se realizaran secuencialmente, la variable de conteo se duplicaría y se volvería a escribir en la memoria principal (valor original + 2).

Sin embargo, se realizaron dos incrementos al mismo tiempo sin la sincronización adecuada. Independientemente de qué subproceso (A o B) escriba su versión actualizada de cuenta en la memoria principal, el nuevo valor será solo 1 más que el valor original, a pesar de los dos incrementos.

Este diagrama ilustra la aparición del problema de condición de carrera descrito anteriormente:

Para resolver este problema, puede usar el bloque sincronizado de Java. Un bloque sincronizado garantiza que solo un subproceso pueda ingresar a una sección crítica determinada del código en un momento dado.

Los bloques sincronizados también garantizan que todas las variables a las que se accede dentro del bloque sincronizado se leerán desde la memoria principal, y cuando el subproceso sale del bloque sincronizado, todas las variables actualizadas volverán a la memoria principal, independientemente de si la variable se declara volátil o no.