Java Virtual Machine (JVM) es uno de aquellos términos que pasa por desapercibido para la mayoría que incursiona en el mundo de Java. Incluso he conocido a desarrolladores que llevan años programando en Java con un escaso a nulo conocimiento de Java Virtual Machine (JVM). En los cursos de introducción a Java regularmente se enseña que la JVM es una maquina virtual que se utiliza para ejecutar los programas desarrollados en Java… y eso es todo! Prácticamente esa es toda la información que los tutores/catedráticos brindan acerca de un elemento tan importante, como crucial, del ecosistema de Java.
En base a lo anterior nos podemos preguntar ¿Si muchos pueden desarrollar en Java sin conocer el funcionamiento de la JVM, entonces es realmente es necesario saber el funcionamiento de esta?. Si deseas ser un mejor desarrollador Java, si deseas conocer la diferencia entre Java y otros lenguajes como Python o Javascript, si deseas conocer la razón por la cual Java se considera como un lenguaje “lento”, entonces la respuesta a la pregunta es un rotundo SI.
A continuación intentaré explicarte brevemente el funcionamiento de la Java Virtual Machine (JVM) y el impacto que esta tiene en el desempeño de sus aplicaciones. Además aprenderás conceptos básicos como: ClassLoader, JIT, Heap Space, etc. El objetivo es brindarte los fundamentos básicos del funcionamiento de la JVM para que en futuras publicaciones podamos ahondar en las buenas practicas que debes de seguir para obtener el mejor desempeño en tus proyectos.
Java Virtual Machine (JVM)
Es la abstracción de una maquina virtual que es capaz de analizar bytecode, interpretarlo y ejecutarlo en lenguaje maquina. La JVM fue implementada por Sun Microsystems bajo el concepto WORA (Write Once Run Anywhere) o (Escribelo una vez y ejecútalo en cualquier lado). El concepto WORA pretendía brindar la capacidad única, en su momento, de ser independiente de la plataforma.
Idealmente, se podía escribir un programa en Java y ejecutarlo en cualquier plataforma, siempre y cuando esta contará con una implementación de la JVM. Este era un gran cambio en la época, en donde escribir un programa en C/C++ significaba compilar para cada plataforma y condicionar el código para la carga de librerías propias de cada plataforma. El desarrollo de la JVM bajo el concepto de WORA desligaba a los desarrolladores de preocuparse en desarrollar sus programas teniendo la plataforma en mente y enfocarse únicamente en la solución de sus desarrollos.
Es importante resaltar que Java Virtual Machine (JVM) no tiene ningún conocimiento del lenguaje de programación Java, sino únicamente entiende el bytecode generado por el compilador javac o cualquier otro compilador de bytecode. Esto permite que se pueda desarrollar en más de un lenguaje, entre los cuales se encuentran:
- Java
- Clojure
- Groovy
- JRuby
- Jython
- Kotlin
- Scala
Componentes de JVM
La maquina virtual de Java se puede dividir en tres componentes principales:
- ClassLoader System (Sistema de carga de clases): Componente encargado en la carga, enlace e inicialización de las clases.
- Runtime Data Area (Área de memoria de ejecución): Componente donde se encuentra el área de memoria de ejecución del programa. Dentro de este componente se encuentran los subcomponentes como: Heap Memory, PC Registers, etc.
- Execution Engine (Motor de ejecución): Componente encargado en la interpretación y ejecución del clases bytecode. Dentro de este componente se encuentran los subcomponentes como: el interpretador, compilador JIT y Garbage Collector.
1. ClassLoader System (Sistema de Carga de Clases)
El sistema de carga de clases se encarga, tal como su nombre lo indica, de cargar dinámicamente las clases, enlazarlas e iniciarlas para su ejecución.
1.1 Loading (Fase de Carga)
Esta fase se encarga de realizar la carga dinámica de los archivos .class al ClassLoader. La Java Virtual Machine (JVM) cuenta con tres diferentes tipos de ClassLoaders, los cuales son:
Bootstrap ClassLoader: Es el ClassLoader principal, encargado de cargar el jar de ejecución o también llamado rt.jar. Este jar contiene todas las clases de inicialización de las API principales de Java. El Bootstrap ClassLoader está escrito en código nativo, por lo tanto, diferentes plataformas tendrán diferente implementación del mismo.
Extension ClassLoader: Es el segundo componente en la jerarquía de ClassLoaders. Este era el encargado de cargar los archivos .class del folder “jre/lib/ext”. Sin embargo, en la versión de Java 9 se renombró a “Platform ClassLoader” y ahora se encarga de cargar todas las librerías de plataforma de Java SE.
Application ClassLoader: Es el tercer componente en la jerarquía de ClassLoaders. El Application ClassLoader se encarga de cargar los archivos .class propios de la aplicación, los cuales se encuentran el classpath de la aplicación.
Es importante resaltar que la carga de clases no se realiza en un solo momento al arranque de la aplicación. En cambio, se cargan dinámicamente bajo demanda por la aplicación.
Proceso de Carga de Clases
Los sistemas de carga de clases funcionan bajo un algoritmo de delegación de jerarquía de clases, el cual se resume en los siguientes pasos:
- Cuando la aplicación requiere a la JVM un archivo .class, primero se verifica si el archivo ya esta cargado en memoria. Si dicho archivo existiera, entonces se procede a la ejecución. En caso contrario, se envía una solicitud de carga al sistema de ClassLoaders.
- El sistema de carga de clases recibe la solicitud y se delega al Application ClassLoader, el cual lo delega al Extension/Platform ClassLoader y el cual, por último, lo delega al Bootstrap ClassLoader.
- El Bootstrap ClasslLoader se encarga de buscar el archivo dentro de las clases principales de Java. Si la clase no se encontrará en el rt.jar, se delega la carga al Extension/Platform ClassLoader.
- El Extension/Platform ClassLoader se encarga de buscar el archivo en la ruta (jre/lib/ext). A partir de Java 9, se busca en las librerías de plataforma de Java, si la clase no se encontrará en esa ubicación entonces se delega la carga al Application ClassLoader.
- El Application ClassLoader busca el archivo dentro del classpath de la aplicación, sino se encuentra entonces se genera la excepción ClassNotFoundException.
1.2 Linking (Fase de Enlace)
Esta fase se ejecuta después de haber cargado la clase mediante el sistema de ClassLoaders. La fase de enlace se encarga de verificar que el archivo .class este formado correctamente bajo los estándares de la JVM y que su ejecución no comprometa la integridad de la JVM. La fase de enlace se divide en tres pasos:
- Verify (Verificar): Este paso se encarga de validar que el archivo .class este formado correctamente y sea adecuado para el uso en la JVM.
- Prepare (Preparación): Este paso se encarga de definir un espacio de memoria para las variables de clase (estáticas). Además, se le asigna a cada variable su valor por defecto.
- Resolve (Resolución): Este proceso se encarga de determinar los valores concretos de las referencias simbólicas del pool de referencias del Method Area. Quiere decir, que los nombres de las clases que se utilizan en el programa se almacenan en el pool de constantes y durante la fase de resolución, se sustituyen los nombres de dichas clases por referencias al Method Area.
1.3 Initialization (Fase de Inicialización)
Esta es la fase final del sistema de carga de clases, durante este paso se asignan los valores originales a las variables estáticas y se ejecutan los bloques estáticos de cada clase.
2. Runtime Data Area (Área de Memoria de Ejecución)
El área de memoria de ejecución es el componente de Java Virtual Machine (JVM), donde se administran los espacios de memoria utilizados en la ejecución de programas. Esta área se divide en 5 diferentes secciones que tienen diferentes objetivos durante la ejecución, las cuales son:
- PC Register (Registros de PC o Contadores): Es el espacio de memoria que contiene la dirección de la instrucción que se esta ejecutando en ese momento y la dirección de memoria de la siguiente instrucción por ejecutarse. Este espacio de memoria es pequeño y tiene un tamaño fijo.
- Java Virtual Machine Stack (Pila de ejecución): Este espacio de memoria almacena los punteros de las variables locales, así como la información relacionada a la invocación y resultado de métodos. Esta pila trabaja mediante marcos de trabajo, los cuales se van apilando para la ejecución de cada uno de ellos. Cada marco de trabajo almacena la información relacionada a variables locales y métodos, así como la referencia a los objetos creados en el heap. Al finalizar la ejecución de cada método, se retira el marco de la pila de ejecución y se libera el espacio de memoria. En este componente es donde se genera el error de ejecución StackOverflowError, el cual se da cuando la pila ya no tiene espacio para almacenar más marcos de ejecución.
- Heap Memory (Memoria Heap): Este espacio de memoria se comparte por todos los hilos de ejecución y se encarga de almacenar dinámicamente todas las instancias de clases (objetos) y arreglos que la aplicación necesita. En este componente es donde se generan los errores de OutOfMemoryError, el cual se da cuando ya no existe más espacio en el Heap para asignárselo a un objeto nuevo. Este error regularmente se da cuando se crean muchos objetos y el recolector de basura (Garbage Collector) no es capaz de liberar el espacio a tiempo o cuando el Heap se configura con un espacio muy reducido.
- Method Area (Área de método): En este espacio de memoria se almacena la información a nivel de clase, tal como: información acerca de los métodos, variable estáticas, pool de constantes, etc. Este espacio de memoria se comparte por todos los hilos de ejecución.
- Native Method Stack (Pila de métodos nativos): En este espacio se guarda la información acerca de los métodos nativos llamados por los hilos de ejecución.
Cada una de estas secciones de memorias se dividen en dos diferentes categorías:
- Memorias compartidas por hilos: Las cuales son aquellas que se inicializan durante el arranque de la JVM. Este espacio de memoria es compartido por todos los hilos. Las áreas de memoria que pertenecen a esta categoria son: Method Area, Heap Memory y el Pool de constantes de ejecución.
- Memorias asignadas a cada hilo de ejecución: Las cuales son aquellas que se inicializan durante la creación de cada hilo. Este espacio de memoria es independiente para cada hilo. Las áreas de memoria que pertenece a esta categoria son: PC Register, JVM Stack y Native Method Stack.
Es común que muchos desarrolladores sientan confusión acerca si los objetos se instancian en el Heap Memory o en el Stack. Esto debido a que JVM divide la creación de un objeto entre estos dos espacios de memoria. Al momento que se instancia un objeto, la JVM asigna un espacio de memoria en el Heap, lo que permite que pueda ser referenciado globalmente. Al mismo momento se crea una referencia a dicho objeto en el stack. Es decir, la pila de ejecución (Stack) almacena las variables locales primitivas y la dirección de memoria de los objetos que se encuentran en el heap.
3. Execution Engine (Motor de Ejecución)
Este componente se encarga de interpretar el bytecode, ejecutarlo en código maquina, analizar que partes del código se pueden mejorar en su desempeño compilandolas a código maquina mediante el compilador JIT y liberar memoria mediante el Garbage Collector.
3.1 Interprete
Cuando se compila un programa en Java se generan uno o más archivos .class. Estos archivos son llamados bytecode y no pueden ser ejecutados directamente debido a que no están compilados a código maquina. Bytecode únicamente puede ser ejecutado en una JVM, la cual interpretará cada instrucción y la ejecutará en instrucciones entendibles por el computador. Este procedimiento permite que Java pueda ser WORA (Write once Run Anywhere), debido a que el desarrollador no se debe de preocupar de compilar su código dependiente a cada plataforma como se realizaba en programas desarrollados en C/C++, sino compilarlo a bytecode. Una vez compilado a bytecode, se puede ejecutar en cualquier JVM sin importar la plataforma la que esta se encuentre ejecutandose.
3.2 JIT Compiler (Compilador JIT)
El interprete claramente representa una desventaja cuando se ejecuta múltiples veces una misma sección de código. Esto debido a que se debe de interpretar cada instrucción en cada iteración, sin importar si ya fue ejecutado anteriormente. Para solucionar esta lentitud ocasionada por el interprete, la JVM monitorea constantemente las secciones del código que se repiten en su ejecución y procede a compilarlas a código maquina. Es decir, en lugar de que el interprete ejecute cada línea del método en cada iteración, se traduce el bloque de bytecode completo a código maquina. Esto permite que en próximas iteraciones se ejecute el bloque completo sin interpretar linea por linea.
El compilador JIT (Just In Time) además de compilar el bytecode a código maquina, también realiza una optimización de código, lo cual ayuda al desempeño del programa en general.
3.3 Garbage Collector (Recolector de Basura)
El Garbage Collector es un tema tan amplio como la misma JVM. Este componente fue un hito en la historia de la programación. No es un componente propio de la JVM sino fue desarrollado originalmente para Lisp y la gestión manual de memoria.
En los desarrollos con lenguajes como C/C++, el desarrollador era el encargado de realizar las rutinas para la liberación de memoria, si esto no era realizado adecuadamente entonces se generaba un error de memoria. En Java Virtual Machine (JVM) no es necesario que el desarrollador lleve a cargo las tareas de liberación de memoria debido a que estas son llevadas a cabo automáticamente. La JVM es la encargada de monitorear mediante diferentes algoritmos, a aquellos objetos que ya no son referenciados por la aplicación y procede a liberar el espacio de memoria asignado a ellos.
El Garbage Collector cuenta con diferentes estrategias para la liberación de memoria y diferentes terminologias utilizadas durante el ciclo de vida de los objetos. Con el objetivo de mantener esta publicación como una introducción, no se estará ahondando en este tema hasta en futuras publicaciones.
¿Es Java un lenguaje compilado o interpretado?
Lo anterior nos lleva a preguntarnos ¿Es Java un lenguaje compilado o interpretado? La respuesta es ambos. Java claramente esta escrito en archivos .java, los cuales se compilan mediante javac a archivos .class. Sin embargo, a diferencia de lenguajes como C o C++ en los cuales el compilador los traduce directamente a código maquina para su ejecución. Java necesita de la JVM para que su código sea interpretado de bytecode a código maquina. Además, durante la ejecución del programa, se realiza una compilación a código maquina mediante el compilador JIT. Esta compilación únicamente se realiza bajo demanda, cuando la JVM defina que un bloque de código se ejecutará N veces y que su compilación mejorará el desempeño de la aplicación.
En base a lo anterior se define que Java es un lenguaje compilado e interpretado. Compilado porque se realiza una pseudo compilación a bytecode e interpretado porque se necesita que la Java Virtual Machine(JVM) interprete cada linea de bytecode a código maquina.
¿Es Java más lento que los lenguajes compilados?
Muchos detractrores de Java tachan al lenguaje como un lenguaje lento en comparación a programas desarrollados en C/C++. Para contestar correctamente esta pregunta es necesario que tengas en cuenta las fases de intepretación y compilación que realiza la JVM. Por lo tanto, si se compara por la cantidad de tiempo que realiza una misma tarea entonces lo más probable es que el programa desarrollado en C/C++ tenga una ventaja. La ventaja se obtiene debido a que Java debe de cargar, interpretar, compilar y ejecutar bajo demanda, lo cual no sucede en el programa de C/C++, el cual fue compilado directamente a código maquina. Es tambien importante tomar en cuenta que esta diferencia era más notoria en las primeras versiones de Java y JVM, sin embargo, con las mejoras realizadas en las últimas versiones, esta diferencia entre lenguajes es NO significativa.
¿Es Java mejor que los lenguajes compilados?
Primero es necesario que aclaremos no existen lenguajes de programación malos, sino existe un lenguaje ideal para cada solución. Si se toman en cuenta las condiciones actuales del mercado, es indiscutible negar el predominio que tiene Java en sistemas corporativos. Así como también es indiscutible el predominio de C/C++ en sistemas embebidos, el predominio de Python en Data Science y el de Javascript en el desarrollo web. Cada lenguaje de programación tiene su propio enfoque y es necesario que como especialista de software conozcas las diferencias de cada uno los lenguajes, pero sobretodo es importante que conozcas el ecosistema en el que se desarrolla y se ejecuta el lenguaje seleccionado para tus proyectos.
El tema de la JVM es extenso y puede ser un poco abrumador para los desarrolladores principiantes. Sin embargo, el aprendizaje de su funcionamiento básico te permitirá ser un mejor desarrollor, no solo en Java sino en cualquier lenguaje de bytecode. Si deseas expandir tu conocimiento acerca de Java Virtual Machine (JVM), se recomienda la lectura de la documentación oficial de Oracle.
Top comments (0)