Estructura de memoria de la JVM
Para comprender la arquitectura de la Máquina Virtual de Java (JVM), es esencial examinar su modelo de memoria. Un archivo fuente .java se compila en bytecode .class, que luego es cargado por el subsistema de carga de clases en las áreas de memoria designadas. El motor de ejecución de la JVM procesa estas instrucciones, interactuando con las diversas zonas de memoria, cada una con un propósito específico.
Área de Métodos
Esta área almacena información estática de las clases, como metadatos, la tabla de constantes, información de métodos y variables de clase. En un ejemplo típico, los detalles de la clase compilada y sus métodos residen aquí. Dos puntos clave sobre esta área:
- Si su tamaño excede los límites configurados, se puede lanzar un error
OutOfMemoryError: PermGen space. Herramientas como CGLIB pueden generar clases dinámicamente para probar este escenario. - En versiones anteriores a JDK 1.7, se denominaba "permgen", pero desde JDK 1.8 se conoce como "metaspace". Este cambio se implementó para reducir la carga de gestión, transfiriendo la administración del pool de constantes en tiempo de ejecución al heap.
Heap
El heap es donde se asignan los objetos de instancia. Por ejemplo, al usar la palabra clave new, la instancia del objeto se crea en el heap. La referencia al objeto (una variable local) se almacena en la pila. A continuación, se muestra un código de ejemplo adaptado que ilustra la creación de objetos y el uso de hilos:
/**
* Ejemplo para demostrar regiones de memoria de la JVM.
*/
public class EjemploHolaMundoTest {
public static void main(String[] args) {
// Crear una instancia de EjemploHolaMundoTest.
EjemploHolaMundoTest instancia = new EjemploHolaMundoTest();
// Lanzar dos hilos para ejecutar el método saludar.
for (int i = 0; i < 2; i++) {
new Thread(() -> instancia.saludar("mundo")).start();
}
}
/**
* Método que imprime un saludo.
* @param destinatario
*/
public void saludar(String destinatario) {
System.out.println(Thread.currentThread().getName() + " ¡hola! " + destinatario);
}
}
Este código crea un objeto y ejecuta múltiples hilos para invocar el método saludar. En la JVM, el heap se divide en generaciones para facilitar la recolección de basura (GC):
- Generación Joven: Incluye el Eden y los espacios From/To (en una proporción predeterminada de 8:1:1). Los nuevos objetos se asignan en Eden; cuando se llena, se ejecuta un Minor GC, moviendo objetos sobrevivientes a From o To. Los objetos que sobreviven múltiples ciclos de GC envejecen y, tras alcanzar un umbral (por defecto 15), se trasladan a la generación vieja.
- Generación Vieja: Almacena objetos de larga duración. Si este espacio se llena, se desencadena un Full GC. Si el GC no puede liberar suficiente memoria, puede ocurrir un error
OutOfMemoryError: Java heap space. Se pueden ajustar parámetros JVM como-Xmxpara definir el tamaño máximo del heap.
Pila (Stack)
La pila es un área de memoria por hilo, más pequeña que el heap, y opera con una política LIFO (último en entrar, primero en salir). Cada método invocado crea un marco de pila (stack frame) que contiene variables locales, parámetros, un puntero al método de retorno, y tablas de excepciones. Por ejemplo, si el método A invoca a B, y B a C, los marcos se apilan como C, B, A; al retornar, se desapilan en orden inverso. Un marco de pila también incluye una pila de operandos para cálculos intermedios. Si la profundidad de la pila excede su límite (por ejemplo, en recursión infinita), se puede producir un error OutOfMemoryError: Java stack space.
Contador de Programa
Este registro, también por hilo, almacena la dirección de la instrucción bytecode actual que está ejecutando un hilo. Cuando el CPU asigna y cambia entre hilos mediante segmentos de tiempo, el contador de programa permite reanudar la ejecución exacta desde donde se pausó, garantizando el comportamiento correcto de los hilos.
Pila de Métodos Nativos
Similar a la pila de Java, pero se utiliza para métodos nativos (implementados en otros lenguajes como C/C++). Es un área separada que gestiona estas llamadas específicas.
Memoria Directa
Esta memoria no es administrada por la JVM y se asigna directamente al sistema operativo. Se usa frecuentemente con la E/S de NIO (New I/O) para operaciones de buffer, lo que puede mejorar el rendimiento al evitar la copia de datos entre la JVM y el sistema.
Optimización de Asignación de Memoria: Análisis de Escape
Aunque normalmente los objetos creados con new se asignan en el heap, el análisis de escape permite a la JVM optimizar esto. Si un objeto no escapa del método (es decir, no se accede fuera de su ámbito), puede asignarse en la pila. Considere el siguiente código refactorizado:
/**
* Ejemplo para ilustrar el análisis de escape.
*/
public class PruebaEscape {
public static void asignar() {
// Objeto que no escapa; podría asignarse en la pila.
Object objetoLocal = new Object();
}
public static void main(String[] args) {
long inicio = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
asignar();
}
long fin = System.currentTimeMillis();
System.out.println("Tiempo transcurrido: " + (fin - inicio) + " ms");
}
}
Al ejecutar este código con parámetros JVM como -XX:+PrintGC -Xmx10M -XX:+DoEscapeAnalysis, se observa que se realizan menos ciclos de GC y el tiempo de ejecución es más bajo en comparación con deshabilitar el análisis de escape (-XX:-DoEscapeAnalysis). Esto ocurre porque objetoLocal es un objeto no escapista y puede asignarse en la pila, evitando la sobrecarga del heap y la recolecicón de basura. Si el objeto se hiciera estático (por ejemplo, private static Object objeto;), escaparía y se asignaría en el heap.