Introducción

La Java Virtual Machine (JVM) es el corazón del ecosistema Java. Es el componente que ejecuta aplicaciones Java traduciendo el bytecode a código máquina nativo. A pesar de su importancia, muchos programadores junior no comprenden bien su funcionamiento interno.

Tanto si has desarrollado en Java como si no, probablemente hayas oído hablar de la máquina virtual Java (JVM).

La JVM es el núcleo del ecosistema Java. Permite que los programas basados en Java sigan el principio de "escribir una vez, ejecutar en cualquier lugar". Puede escribir código Java en una máquina y ejecutarlo en cualquier otra máquina gracias a la JVM.

La JVM fue diseñada originalmente exclusivamente para soportar Java. Sin embargo, con el tiempo, muchos otros lenguajes, como Scala, Kotlin y Groovy, se han asentado en la plataforma Java. Estos se conocen colectivamente como lenguajes JVM.

En este artículo, te contaremos más sobre la JVM: cómo funciona y de qué componentes se compone.

¿Qué es una máquina virtual?

Antes de pasar a la JVM, centrémonos en el concepto de máquina virtual (VM).

Una máquina virtual es una representación virtual de un equipo físico. Se puede hacer referencia a una máquina virtual como invitado, y al equipo físico en el que se ejecuta se le puede denominar máquina host.

Tanto si has desarrollado en Java como si no, probablemente hayas oído hablar de la máquina virtual Java (JVM).

La JVM es el núcleo del ecosistema Java. Permite que los programas basados en Java sigan el principio de "escribir una vez, ejecutar en cualquier lugar". Puede escribir código Java en una máquina y ejecutarlo en cualquier otra máquina gracias a la JVM.

La JVM fue diseñada originalmente exclusivamente para soportar Java. Sin embargo, con el tiempo, muchos otros lenguajes, como Scala, Kotlin y Groovy, se han asentado en la plataforma Java. Estos se conocen colectivamente como lenguajes JVM.

En este artículo, te contaremos más sobre la JVM: cómo funciona y de qué componentes se compone.

¿Qué es una máquina virtual?

Antes de pasar a la JVM, centrémonos en el concepto de máquina virtual (VM).

Una máquina virtual es una representación virtual de un equipo físico. Se puede hacer referencia a una máquina virtual como invitado, y al equipo físico en el que se ejecuta se le puede denominar máquina host.

Máquina virtual Java - Wikipedia, la enciclopedia libre

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 entre sí.

¿Qué es la máquina virtual Java?

En lenguajes de programación como C y C++, el código se compila primero en nativo para una plataforma específica. Estos lenguajes se denominan lenguajes compilados.

Por otro lado, en lenguajes como JavaScript y Python, el ordenador ejecuta las instrucciones directamente, sin necesidad de compilación. Estos lenguajes se denominan lenguajes interpretados.

Java utiliza una combinación de ambos métodos. El código Java se compila primero en código de bytes y genera un archivo de class(). A continuación, la JVM interpreta este archivo de clase para la plataforma subyacente. El mismo archivo de clase puede ejecutarse en cualquier versión de la JVM, en cualquier plataforma o sistema operativo..class

Al igual que las máquinas virtuales normales, la JVM crea un espacio aislado en la máquina host. Este espacio se puede utilizar para ejecutar programas Java independientemente de la plataforma o el sistema operativo de la computadora.

Arquitectura de máquina virtual Java

La JVM consta de tres componentes distintos:

  • Cargador de clases
  • Memoria de tiempo de ejecución/área de datos
  • Mecanismo de ejecución.

Echemos un vistazo más de cerca a cada uno de ellos.

Cargador de clases

Al compilar un archivo de código fuente, se convierte en código de bytes como un archivo . Cuando se llama a esta clase en el programa, el cargador de clases la carga en la memoria principal..java.class

Normalmente, la primera clase que contiene el archivo .main()

El proceso de carga de clases consta de tres pasos: carga, enlace e inicialización.

Carga

La carga implica la representación binaria (código de bytes) de una clase o interfaz con un nombre específico y la creación de una clase o interfaz de origen basada en él.

Hay tres cargadores de clases incorporados disponibles en Java:

  • El cargador de clases de arranque es el cargador de clases raíz. Se trata de una superclase de cargador de clases de extensión que carga paquetes Java estándar como , , , etc. Estos paquetes también se encuentran dentro de las otras bibliotecas principales presentes en el archivo .java.langjava.netjava.utiljava.iort.jar$JAVA_HOME/jre/lib
  • El cargador de clases de extensión es una subclase del programa previo y una superclase del cargador de clases de aplicación. Carga extensiones a las bibliotecas Java estándar que están presentes en el archivo .$JAVA_HOME/jre/lib/ext
  • El cargador de clases de aplicación es un cargador de clases final y una subclase del cargador de clases de extensión. Carga los archivos que se encuentran en la ruta de clase. De forma predeterminada, la ruta de acceso de la clase se establece como el directorio de la aplicación actual. También puede cambiar la ruta de acceso de la clase agregando la línea de comandos o .-classpath-cp

La JVM utiliza un método para cargar la clase en la memoria. Intenta cargar la clase en función del nombre completo.ClassLoader.loadClass()

Si el cargador de clases primario no puede encontrar la clase, delega el trabajo al cargador de clases secundario. Si este último cargador tampoco puede cargar la clase, produce una excepción o .NoClassDefFoundErrorClassNotFoundException

Encuadernación

Una vez que la clase se carga en la memoria, se produce el proceso de enlace. Vincular una clase o interfaz implica combinar diferentes elementos y dependencias de un programa.

La vinculación incluye los siguientes pasos:

  • Comprobar. En esta etapa, se comprueba la corrección estructural del archivo comparándolo con un conjunto de restricciones y reglas. Si se produce un error en la comprobación por cualquier motivo, se produce una excepción..classVerifyException

Por ejemplo, si el código se escribió en Java 11 pero se ejecuta en un sistema que tiene Java 8 instalado, se producirá un error en el paso de validación.

  • Preparación. En este punto, la JVM asigna memoria para los campos estáticos de la clase o interfaz y los inicializa con valores predeterminados.

Supongamos que declara la siguiente variable en su clase:

private static final boolean enabled = true;

En la fase de preparación, la JVM asigna memoria para la variable y establece su valor en el valor predeterminado para el valor booleano, que es .enabledfalse

  • Decisión. En este punto, los vínculos simbólicos se reemplazan por vínculos directos que están presentes en el grupo de constantes en tiempo de ejecución.

Por ejemplo, si tiene referencias a otras clases o variables constantes presentes en otras clases, se resuelven en este punto y se reemplazan por sus referencias reales.

Inicialización

La inicialización implica la ejecución de un método para inicializar una clase o interfaz (conocida como ). Esto puede incluir llamar al constructor de la clase, ejecutar un bloque estático y asignar valores a todas las variables estáticas. Esta es la etapa final de carga de la clase.<clinit>

Por ejemplo, anteriormente anunciamos lo siguiente:

private static final boolean enabled = true;

En la fase de preparación, la variable se estableció en un valor predeterminado. Durante la fase de inicialización, a esta variable se le asigna su valor real.enabledfalsetrue

Nota: La JVM es de naturaleza multiproceso. Puede suceder que varios subprocesos intenten inicializar la misma clase al mismo tiempo. Esto puede dar lugar a problemas de simultaneidad. Para garantizar que el programa funcione correctamente en un entorno multiproceso, se debe garantizar la seguridad de los subprocesos.

Ámbito de datos en tiempo de ejecución

Hay cinco componentes en el área de datos en tiempo de ejecución:

Echemos un vistazo a cada uno de ellos por separado.

Alcance del método

Aquí es donde se almacenan todos los datos de nivel de clase, como el grupo de constantes en tiempo de ejecución, los datos de campo y método, y el código de método y constructor.

Si no hay suficiente memoria disponible en el área de métodos para ejecutar el programa, la JVM genera un error.OutOfMemoryError

Por ejemplo, supongamos que declaramos la siguiente 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, los datos de nivel de campo, como y, así como la información del diseñador, se cargan en el panel de métodos.name,age

Cuando se inicia una máquina virtual, se crea un ámbito de método y solo hay un ámbito de método por máquina virtual.

Región de montón

Aquí es donde se almacenan todos los objetos y sus correspondientes variables de instancia. Esta es la región de datos en tiempo de ejecución desde la que se asigna la memoria para todas las instancias de clases y matrices.

Por ejemplo, supongamos que declara la siguiente instancia:

Employee employee = new Employee();

En este ejemplo, se crea una instancia de la clase y se carga en el área del montón.Employee

El montón se crea cuando se inicia la máquina virtual y solo hay un área de montón por máquina virtual.

Nota: Dado que los ámbitos y montones de métodos comparten la misma memoria para varios subprocesos, los datos almacenados aquí no son seguros para subprocesos.

Área de apilamiento

Cada vez que se crea una nueva hebra en la JVM, se crea una pila de tiempo de ejecución independiente al mismo tiempo. Todas las variables locales, las llamadas a métodos y los resultados parciales se almacenan en el área de la pila.

Si una hebra requiere más tamaño de pila del que está disponible, la JVM produce un error.StackOverflowError

Para cada llamada al método, se realiza una sola entrada en la memoria de la pila, que se denomina marco de pila. Cuando se completa la llamada al método, se destruye el marco de pila.

El marco de la pila se divide en tres partes.

  • Variables locales. Cada fotograma contiene una matriz de variables conocidas como variables locales. Aquí es donde se almacenan todas las variables locales y sus valores. La longitud de esta matriz se determina en tiempo de compilación.
  • Pila de operandos. Cada fotograma contiene una pila LIFO (último en entrar, primero en salir), conocida como pila de operandos. Actúa como un espacio de trabajo en tiempo de ejecución para cualquier operación intermedia. La profundidad máxima de esta pila se determina en tiempo de compilación.
  • Datos de trama. Aquí es donde se almacenan todos los símbolos que coinciden con el método. También almacena información sobre el bloque en caso de excepciones.catch

Por ejemplo, existe 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, variables como y se colocan en una matriz de variables locales. La pila de operandos contiene las variables y los operadores necesarios para realizar operaciones matemáticas de resta y división.answersscore

Nota: Debido a que el área de pila no se comparte, es inherentemente segura para subprocesos.

Registros de contadores de programas

La JVM soporta multithreading. Cada hebra tiene su propio registro de contador de programa para almacenar la dirección de la instrucción JVM que se está ejecutando actualmente. Tan pronto como se ejecuta la instrucción, el registro se actualiza con la siguiente instrucción.

Pilas de métodos nativos

La JVM contiene pilas que admiten métodos nativos, que son métodos que se escriben en un lenguaje distinto de Java, como C o C++. Cada nuevo subproceso también tiene su propia pila de métodos nativos.

Sistema de cumplimiento

Una vez que el código de bytes se carga en la memoria principal y la información detallada está disponible en la región de datos en tiempo de ejecución, el siguiente paso es ejecutar el programa. Para ello, el motor en tiempo de ejecución ejecuta el código de cada clase.

Sin embargo, el código de bytes debe convertirse en instrucciones de lenguaje de máquina antes de que se pueda ejecutar el programa. La JVM puede utilizar un intérprete Just-In-Time o un compilador Just-In-Time (JIT) como mecanismo de ejecución.

Intérprete

El intérprete lee y ejecuta las instrucciones de código de bytes 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 si llama a un método varias veces, necesita una nueva interpretación cada vez.

compilador Just-In-Time (JIT)

El compilador Just-In-Time (JIT) supera la falta de un 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 duplicado, utiliza el compilador Just-In-Time (JIT).

A continuación, el compilador JIT compila todo el código de bytes y lo modifica a su propio código máquina. Este código de máquina nativo se utiliza directamente para la recuperación de métodos, lo que mejora el rendimiento del sistema.

El compilador Just-In-Time (JIT) contiene 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 de destino: convierte el código intermedio en código nativo nativo.
  • Generador de perfiles: busca puntos calientes (código que se ejecuta repetidamente).

Para comprender mejor la diferencia entre un intérprete y un compilador Just-In-Time (JIT), supongamos que tiene el siguiente código:

int sum = 10;
for (int i = 0; i <= 10; i++) {
    sum += i;
}
System.out.println(sum);

El intérprete recuperará el valor de la memoria para cada iteración del bucle, le agregará el valor y lo volverá a escribir en la memoria. Esta es una operación costosa porque cada vez que entra un bucle, se accede a la memoria.sumi

Sin embargo, el compilador JIT reconocerá que hay un "punto caliente" en este código y realizará la optimización. Almacenará una copia local en el registro del subproceso y continuará agregando valor en el bucle. Una vez que se completa el bucle, el compilador volverá a escribir el valor en la memoria.sumisum

Nota: El compilador tarda más tiempo en compilar el código que el intérprete en interpretar el código línea por línea. Si tiene la intención de ejecutar el programa solo una vez, se preferirá el intérprete.

Recolector

El recolector de elementos no utilizados (GC) recopila y quita los objetos no referenciados del montón. Este es el proceso de recuperación automática de la memoria no utilizada en tiempo de ejecución mediante la destrucción de objetos no utilizados.

La recolección de elementos no utilizados hace que la memoria Java sea eficiente, ya que elimina los objetos no referenciados de la memoria del montón y libera espacio para nuevos objetos. Consta de dos etapas:

  • Marcado : en este punto, el GC identifica los objetos no utilizados en la memoria.
  • Limpieza : en este paso, el GC quita los objetos identificados en el paso anterior.

La JVM realiza automáticamente la recolección de basura a intervalos regulares y no requiere un procesamiento separado. También se puede iniciar llamando, pero la ejecución no está garantizada.System.gc()

La JVM contiene tres tipos diferentes de recolectores de basura.

  • Recolección secuencial de elementos no utilizados. Esta es la implementación más sencilla de GC. Está diseñado para aplicaciones pequeñas que se ejecutan en entornos de un solo subproceso. Se utiliza un solo subproceso para la recolección de elementos no utilizados. El inicio da como resultado un evento de "detención mundial" en el que toda la aplicación se detiene. El argumento de JVM para ejecutar el recolector de basura secuencial es: .-XX:+UseSerialGC
  • Recolección simultánea de elementos no utilizados. Esta es la implementación predeterminada de GC, también conocida como recopilador de ancho de banda. Utiliza varios subprocesos para la recolección de elementos no utilizados, pero la aplicación sigue suspendida al inicio. El argumento de JVM para el recolector de basura paralelo es: .-XX:+UseParallelGC
  • Primero la basura (G1). El G1 fue diseñado para aplicaciones multiproceso con un gran tamaño de pila disponible (superior a 4 GB). Divide el montón en un conjunto de áreas del mismo tamaño y utiliza varios subprocesos para examinarlas. El recolector G1 identifica las regiones con la mayor cantidad de elementos no utilizados y realiza primero la recolección de elementos no utilizados en ellas. El argumento de JVM para este recolector de elementos no utilizados es: .-XX:+UseG1GC

Nota: Existe otro tipo de recolector de elementos no utilizados llamado recolector de etiquetas paralelo (CMS). Sin embargo, ha quedado obsoleto desde Java 9 y se ha eliminado por completo en Java 14, y el recopilador G1 está ocupando su lugar.

Interfaz nativa de Java (JNI)

A veces es necesario utilizar código nativo (no Java) (por ejemplo, escrito en C/C++). Por ejemplo, cuando necesita interactuar con hardware físico o superar las limitaciones de memoria y rendimiento en Java. Java admite la ejecución de código nativo a través de Java Native Interface (JNI).

JNI actúa como un puente para proporcionar paquetes auxiliares a otros lenguajes de programación como C, C++, etc. Esto es especialmente útil en los casos en los que necesita escribir código que no es totalmente compatible con Java, por ejemplo, es posible que algunas funciones específicas de la plataforma solo se escriban en C.

Puede utilizar la palabra clave para indicar que la implementación del método será proporcionada por la biblioteca nativa. También deberá llamar para cargar la biblioteca nativa compartida en la memoria y hacer que sus funciones estén disponibles para Java.nativeSystem.LoadLibrary()

Bibliotecas de métodos nativos

Las bibliotecas de métodos nativos son bibliotecas escritas en otros lenguajes de programación, como C, C++ y ensamblador. Estas bibliotecas suelen tener la forma de o . Estas bibliotecas se pueden cargar a través de JNI..dll.so

Errores comunes de JVM

  • <strong>ClassNotFoundException</strong>. Tiene lugar cuando un cargador de clases intenta cargar clases con , o , pero no se encuentra ninguna definición de clase con el nombre especificado.Class.forName()ClassLoader.loadClass()ClassLoader.findsystemclass()
  • <strong>NoClassDefFoundError</strong>. Tiene lugar cuando el compilador ha compilado correctamente una clase, pero el cargador de clases no puede encontrar el archivo de clase en tiempo de ejecución.
  • <strong>OutOfMemoryError</strong>. Se produce cuando la JVM no puede asignar un objeto debido a la falta de memoria y el recolector de basura no puede proporcionar más memoria.
  • <strong>StackOverflowError</strong>. Se produce cuando la JVM se queda sin espacio al crear nuevas tramas de pila mientras se procesa la hebra.
Compartir:
Categorías: Programación