Tanto si has utilizado Java para desarrollar programas como si no, es posible que hayas oído hablar de la máquina virtual de Java (JVM) en algún momento.
La JVM es el núcleo del ecosistema Java, y hace posible que los programas de software basados en Java sigan el enfoque de «escribir una vez, ejecutar en cualquier lugar». Se puede escribir código Java en una máquina y ejecutarlo en cualquier otra utilizando la JVM.
JVM se diseñó inicialmente para soportar sólo Java. Sin embargo, con el tiempo, muchos otros lenguajes como Scala, Kotlin y Groovy fueron adoptados en la plataforma Java. Todos estos lenguajes se conocen colectivamente como lenguajes JVM.
En este artículo, aprenderemos más sobre la JVM, cómo funciona y los diversos componentes que la componen.
Antes de saltar a la JVM, vamos a repasar el concepto de máquina virtual (VM).
Una máquina virtual es una representación virtual de un ordenador físico. Podemos llamar a la máquina virtual la máquina invitada, y el ordenador físico en el que se ejecuta es la máquina anfitriona.
Una sola máquina física puede ejecutar varias máquinas virtuales, cada una con su propio sistema operativo y aplicaciones. Estas máquinas virtuales están aisladas unas de otras.
¿Qué es la máquina virtual Java?
En lenguajes de programación como C y C++, el código se compila primero en código máquina específico de la plataforma. Estos lenguajes se denominan lenguajes compilados.
En cambio, en lenguajes como JavaScript y Python, el ordenador ejecuta las instrucciones directamente sin tener que compilarlas. Estos lenguajes se denominan lenguajes interpretados.
Java utiliza una combinación de ambas técnicas. El código Java se compila primero en código de bytes para generar un archivo de clase. Este archivo de clase es entonces interpretado por la máquina virtual de Java para la plataforma subyacente. El mismo archivo de clase puede ejecutarse en cualquier versión de JVM que se ejecute en cualquier plataforma y sistema operativo.
De forma similar a las máquinas virtuales, la JVM crea un espacio aislado en una máquina anfitriona. Este espacio puede ser utilizado para ejecutar programas Java independientemente de la plataforma o sistema operativo de la máquina.
Arquitectura de la máquina virtual de Java
La JVM consta de tres componentes distintos:
- Cargador de clases
- Memoria de tiempo de ejecución/área de datos
- Motor de ejecución
Veamos cada uno de ellos con más detalle.
Cargador de clases
Cuando compilas un archivo fuente .java
, se convierte en código de bytes como un archivo .class
. Cuando intentas utilizar esta clase en tu programa, el cargador de clases la carga en la memoria principal.
La primera clase que se carga en memoria suele ser la que contiene el método main()
.
Hay tres fases en el proceso de carga de clases: carga, enlace e inicialización.
Carga
La carga consiste en tomar la representación binaria (bytecode) de una clase o interfaz con un nombre determinado, y generar la clase o interfaz original a partir de ella.
Hay tres cargadores de clases incorporados disponibles en Java:
- Cargador de clases Bootstrap – Este es el cargador de clases raíz. Es la superclase de Extension Class Loader y carga los paquetes estándar de Java como
java.lang
java.util
java.io
, etc. Estos paquetes están presentes dentro del archivort.jar
y otras librerías del núcleo presentes en el directorio$JAVA_HOME/jre/lib
. - Cargador de clases de extensión: es la subclase del cargador de clases de Bootstrap y la superclase del cargador de clases de aplicación. Esto carga las extensiones de las bibliotecas estándar de Java que están presentes en el directorio
$JAVA_HOME/jre/lib/ext
. - Cargador de Clases de Aplicación – Este es el cargador de clases final y la subclase del Cargador de Clases de Extensión. Carga los archivos presentes en el classpath. Por defecto, el classpath se establece en el directorio actual de la aplicación. El classpath también se puede modificar añadiendo la opción de línea de comandos
-classpath
o-cp
.
La JVM utiliza el método ClassLoader.loadClass()
para cargar la clase en la memoria. Intenta cargar la clase basándose en un nombre completamente cualificado.
Si un cargador de clases padre no puede encontrar una clase, delega el trabajo a un cargador de clases hijo. Si el último cargador de clases hijo tampoco es capaz de cargar la clase, lanza NoClassDefFoundError
o ClassNotFoundException
.
Enlace
Después de cargar una clase en memoria, se somete al proceso de enlace. Enlazar una clase o interfaz implica combinar los diferentes elementos y dependencias del programa entre sí.
La vinculación incluye los siguientes pasos:
Verificación: En esta fase se comprueba la corrección estructural del fichero .class
cotejándolo con un conjunto de restricciones o reglas. Si la verificación falla por alguna razón, obtenemos un VerifyException
.
Por ejemplo, si el código se ha construido usando Java 11, pero se está ejecutando en un sistema que tiene instalado Java 8, la fase de verificación fallará.
Preparación: En esta fase, la JVM asigna memoria para los campos estáticos de una clase o interfaz, y los inicializa con valores por defecto.
Por ejemplo, suponga que ha declarado la siguiente variable en su clase:
private static final boolean enabled = true;
Durante la fase de preparación, la JVM asigna memoria para la variable enabled
y establece su valor al valor por defecto para un booleano, que es false
.
Resolución: En esta fase se sustituyen las referencias simbólicas por referencias directas presentes en el pool de constantes en tiempo de ejecución.
Por ejemplo, si tienes referencias a otras clases o variables constantes presentes en otras clases, se resuelven en esta fase y se sustituyen por sus referencias reales.
Inicialización
La inicialización implica ejecutar el método de inicialización de la clase o interfaz (conocido como <clinit>
). Esto puede incluir la llamada al constructor de la clase, la ejecución del bloque estático y la asignación de valores a todas las variables estáticas. Esta es la etapa final de la carga de la clase.
Por ejemplo, cuando declaramos el siguiente código anteriormente:
private static final boolean enabled = true;
La variable enabled
se estableció en su valor por defecto de false
durante la fase de preparación. En la fase de inicialización, a esta variable se le asigna su valor real de true
.
Nota: la JVM es multihilo. Puede ocurrir que varios hilos intenten inicializar la misma clase al mismo tiempo. Esto puede llevar a problemas de concurrencia. Es necesario manejar la seguridad de los hilos para garantizar que el programa funcione correctamente en un entorno multihilo.
Área de datos en tiempo de ejecución
Hay cinco componentes dentro del área de datos en tiempo de ejecución:
Veamos cada uno de ellos por separado.
Área de métodos
Aquí se almacenan todos los datos a nivel de clase, como el pool de constantes en tiempo de ejecución, los datos de los campos y métodos, y el código de los métodos y constructores.
Si la memoria disponible en el área de métodos no es suficiente para el inicio del programa, la JVM lanza un OutOfMemoryError
.
Por ejemplo, suponga que tiene la siguiente definición de clase:
public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}
En este ejemplo de código, los datos a nivel de campo como name
y age
y los detalles del constructor se cargan en el área de métodos.
El área de métodos se crea en el arranque de la máquina virtual, y sólo hay un área de métodos por JVM.
Área de la pila
Aquí se almacenan todos los objetos y sus correspondientes variables de instancia. Es el área de datos en tiempo de ejecución desde la que se asigna la memoria para todas las instancias de clase y arrays.
Por ejemplo, suponga que está declarando la siguiente instancia:
Employee employee = new Employee();
En este ejemplo de código, se crea una instancia de Employee
y se carga en el área de heap.
El heap se crea en el arranque de la máquina virtual, y sólo hay un área de heap por JVM.
Nota: Como las áreas de Método y Heap comparten la misma memoria para múltiples hilos, los datos almacenados aquí no son seguros para los hilos.
Área de Pila
Cada vez que se crea un nuevo hilo en la JVM, también se crea al mismo tiempo una pila de tiempo de ejecución independiente. Todas las variables locales, las llamadas a métodos y los resultados parciales se almacenan en el área de la pila.
Si el procesamiento que se realiza en un hilo requiere un tamaño de pila mayor que el disponible, la JVM lanza un StackOverflowError
.
Por cada llamada a un método, se realiza una entrada en la memoria de la pila que se denomina Stack Frame. Cuando la llamada al método se completa, el marco de la pila se destruye.
El marco de la pila se divide en tres subpartes:
- Variables locales – Cada marco contiene una matriz de variables conocidas como sus variables locales. Todas las variables locales y sus valores se almacenan aquí. La longitud de esta matriz se determina en tiempo de compilación.
- Pila de operandos – Cada marco contiene una pila de último en entrar, primero en salir (LIFO) conocida como su pila de operandos. Esto actúa como un espacio de trabajo en tiempo de ejecución para realizar cualquier operación intermedia. La profundidad máxima de esta pila se determina en tiempo de compilación.
- Datos del marco – Aquí se almacenan todos los símbolos correspondientes al método. Esto también almacena la información del bloque de captura en caso de excepciones.
Por ejemplo, suponga que tiene el siguiente código:
double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}
En este ejemplo de código, las variables como answers
y score
se colocan en la matriz de Variables Locales. La Pila de Operandos contiene las variables y operadores necesarios para realizar los cálculos matemáticos de la resta y la división.
Nota: Como el Área de Pila no se comparte, es inherentemente segura para los hilos.
Registros del Contador de Programa (PC)
La JVM soporta múltiples hilos al mismo tiempo. Cada hilo tiene su propio registro PC para mantener la dirección de la instrucción JVM que se está ejecutando en ese momento. Una vez ejecutada la instrucción, el registro PC se actualiza con la siguiente instrucción.
Pilas de métodos nativos
La JVM contiene pilas que soportan métodos nativos. Estos métodos están escritos en un lenguaje distinto al de Java, como C y C++. Para cada nuevo hilo, también se asigna una pila de métodos nativos independiente.
Motor de Ejecución
Una vez que el bytecode se ha cargado en la memoria principal, y los detalles están disponibles en el área de datos de tiempo de ejecución, el siguiente paso es ejecutar el programa. El motor de ejecución se encarga de ello ejecutando el código presente en cada clase.
Sin embargo, antes de ejecutar el programa, el bytecode necesita ser convertido en instrucciones de lenguaje máquina. La JVM puede utilizar un intérprete o un compilador JIT para el motor de ejecución.
Intérprete
El intérprete lee y ejecuta las instrucciones del bytecode línea por línea. Debido a la ejecución línea por línea, el intérprete es comparativamente más lento.
Otra desventaja del intérprete es que cuando un método es llamado varias veces, cada vez se requiere una nueva interpretación.
Compilador JIT
El Compilador JIT supera la desventaja del intérprete. El Motor de Ejecución utiliza primero el intérprete para ejecutar el código de bytes, pero cuando encuentra algún código repetido, utiliza el compilador JIT.
El compilador JIT compila entonces todo el código de bytes y lo cambia a código máquina nativo. Este código máquina nativo se utiliza directamente para las llamadas a métodos repetidos, lo que mejora el rendimiento del sistema.
El compilador JIT tiene los siguientes componentes:
- Generador de código intermedio – genera código intermedio
- Optimizador de código – optimiza el código intermedio para mejorar el rendimiento
- Generador de código destino – convierte el código intermedio en código máquina nativo
- Perfilador – encuentra los hotspots (código que se ejecuta repetidamente)
- Marcar – en este paso, el GC identifica los objetos no utilizados en la memoria
- Barrer – en este paso, el GC elimina los objetos identificados durante la fase anterior
- GC en serie – Esta es la implementación más simple de GC, y está diseñada para pequeñas aplicaciones que se ejecutan en entornos de un solo hilo. Utiliza un solo hilo para la recolección de basura. Cuando se ejecuta, lleva a un evento de «parar el mundo» en el que toda la aplicación está en pausa. El argumento de la JVM para utilizar Serial Garbage Collector es
-XX:+UseSerialGC
- Parallel GC – Esta es la implementación por defecto de GC en la JVM, y también se conoce como Throughput Collector. Utiliza múltiples hilos para la recolección de basura, pero sigue pausando la aplicación cuando se ejecuta. El argumento de la JVM para utilizar Parallel Garbage Collector es
-XX:+UseParallelGC
. - Garbage First (G1) GC – G1GC fue diseñado para aplicaciones multihilo que tienen un gran tamaño de heap disponible (más de 4GB). Particiona el heap en un conjunto de regiones de igual tamaño, y utiliza múltiples hilos para escanearlas. G1GC identifica las regiones con la mayor cantidad de basura y realiza la recolección de basura en esa región primero. El argumento de la JVM para utilizar el recolector de basura G1 es
-XX:+UseG1GC
Para entender mejor la diferencia entre intérprete y compilador JIT, suponga que tiene el siguiente código:
int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);
Un intérprete obtendrá el valor de sum
de la memoria para cada iteración del bucle, le añadirá el valor de i
y lo volverá a escribir en la memoria. Esta es una operación costosa porque está accediendo a la memoria cada vez que entra en el bucle.
Sin embargo, el compilador JIT reconocerá que este código tiene un HotSpot, y realizará optimizaciones en él. Almacenará una copia local de sum
en el registro PC para el hilo y seguirá añadiendo el valor de i
a éste en el bucle. Una vez completado el bucle, escribirá el valor de sum
de vuelta a la memoria.
Nota: un compilador JIT tarda más tiempo en compilar el código que el intérprete en interpretar el código línea por línea. Si vas a ejecutar un programa una sola vez, es mejor usar el intérprete.
Colector de basura
El Colector de basura (GC) recoge y elimina los objetos no referenciados del área del montón. Es el proceso de recuperar la memoria no utilizada en tiempo de ejecución de forma automática destruyéndolos.
La recolección de basura hace que la memoria de Java sea eficiente porque elimina los objetos no referenciados de la memoria del heap y deja espacio libre para nuevos objetos. Implica dos fases:
La recolección de basura es realizada automáticamente por la JVM a intervalos regulares y no necesita ser manejada por separado. También puede activarse llamando a System.gc()
, pero la ejecución no está garantizada.
La JVM contiene 3 tipos diferentes de recolectores de basura:
Nota: Existe otro tipo de recolector de basura llamado Concurrent Mark Sweep (CMS) GC. Sin embargo, ha sido obviado desde Java 9 y eliminado completamente en Java 14 en favor de G1GC.
Interfaz nativa de Java (JNI)
En ocasiones, es necesario utilizar código nativo (no Java) (por ejemplo, C/C++). Esto puede ser en casos en los que necesitamos interactuar con el hardware, o para superar las limitaciones de gestión de memoria y rendimiento en Java. Java admite la ejecución de código nativo a través de la interfaz nativa de Java (JNI).
JNI actúa como un puente para permitir los paquetes de soporte para otros lenguajes de programación como C, C++, etc. Esto es especialmente útil en los casos en los que necesitas escribir código que no está totalmente soportado por Java, como algunas características específicas de la plataforma que sólo pueden ser escritas en C.
Puedes utilizar la palabra clave native
para indicar que la implementación del método será proporcionada por una biblioteca nativa. También tendrá que invocar System.loadLibrary()
para cargar la biblioteca nativa compartida en la memoria, y hacer que sus funciones estén disponibles para Java.
Bibliotecas de métodos nativos
Las bibliotecas de métodos nativos son bibliotecas que están escritas en otros lenguajes de programación, como C, C++ y ensamblador. Estas bibliotecas suelen estar presentes en forma de archivos .dll
o .so
. Estas bibliotecas nativas se pueden cargar a través de JNI.
Errores comunes de la JVM
- ClassNotFoundExcecption – Esto ocurre cuando el cargador de clases está intentando cargar clases usando
Class.forName()
ClassLoader.loadClass()
oClassLoader.findSystemClass()
pero no se encuentra ninguna definición para la clase con el nombre especificado. - NoClassDefFoundError – Esto ocurre cuando un compilador ha compilado correctamente la clase, pero el cargador de clases no es capaz de localizar el archivo de clase en el tiempo de ejecución.
- OutOfMemoryError – Esto ocurre cuando la JVM no puede asignar un objeto porque está fuera de la memoria, y no más memoria podría estar disponible por el recolector de basura.
- StackOverflowError – Esto ocurre si la JVM se queda sin espacio mientras crea nuevos marcos de pila mientras procesa un hilo.
Conclusión
En este artículo, hemos hablado de la arquitectura de la máquina virtual de Java y sus diversos componentes. A menudo no profundizamos en la mecánica interna de la JVM ni nos preocupamos por su funcionamiento mientras nuestro código está funcionando.
Sólo cuando algo va mal, y necesitamos ajustar la JVM o arreglar una fuga de memoria, intentamos entender su mecánica interna.
Esta es también una pregunta muy popular en las entrevistas, tanto a nivel junior como senior para roles de backend. Un conocimiento profundo de la JVM te ayuda a escribir mejor código y a evitar escollos relacionados con errores de pila y memoria.
Gracias por seguir conmigo hasta ahora. Espero que te haya gustado el artículo. Puedes conectar conmigo en LinkedIn, donde hablo regularmente de tecnología y de la vida. También echa un vistazo a otros de mis artículos y a mi canal de YouTube. Feliz lectura 🙂