viernes, 2 de junio de 2017

Anatomía de la máquina virtual Java (JVM)

En muchas entrevistas que se hacen, cuando se buscan los programadores Java en nivel “senior”, se espera que ellos sepan un poco cómo JVM funciona. Sin entrar en muchos detalles, pero en general. La mayoría de gente empieza por el famoso recolector de basura (Garbage Collector). Asombrosamente muy poca gente sepa que es lo que realmente está pasando dentro la JVM cuando su código viene ejecutado. Aquí intentamos a presentar una pequeña excursión en la anatomía de la JVM.
Vamos a cubrir los temas siguientes (dibujados en el diagrama de arriba):
- Código binario (byte code) en la JVM y sus especificaciones
- Java Runtime y para que sirve
- Como la JVM carga las clases
Como la JVM verifica las clases
Como la JVM ejecuta las clases (JIT)
- Sincronización de los hilos de ejecución (Multi-Thread)
- Recolección de la basura (Garbage Collection)
- Monitoring
- Realizaciones de la JVM (HotSpot, IBM J9, etc)



-         La clase Java y el código binario

Ya sabemos que cada clase Java que viene compilado, se transforma en exactamente 1 file .class  y nuestro código Java se transforma en el código intermedio (bytecode Java) de bajo nivel a traverso del compilador javac. El file class consiste de :
  • La tabla de los constantes (son los valores constantes que aparecen en la clase y los punteros que se refieren a otros clases, métodos y campos).
  • La descripción de la clase (nombres, modificadores, super clase/interface, campos, métodos y atributos)
El código binario (bytecode) esta considerado como un atributo de un método.

JVM funciona a traverso de las pilas:
  •  La pila de las instrucciones
  •  La pila de los operandos
  •  La pila de los variables locales
Vamos a mirar un ejemplo de bytecode:



Lo que estamos haciendo aquí es:
  • Cargamos un integer desde el registro numero 3
  • Metemos una constante en el registro 5
  • Sumamos
  • Guardamos el resultado en el registro 4

La diferencia principal entre un código Java normal y el código intermedio es la tipificación estricta.  P.e. el prefijo "i"- significa Integer y es la llave para ser compatible con varias plataformas.

Java Runtime

Sabemos que cada programa ejecutada por la JVM necesita 3 cosas:
  • Método "main()". 
  • Classpath
  • En el mundo de las aplicaciones Web, será el servidor Web (p.e. Tomcat) que actúa como el programa para la JVM y este servidor tiene su método "main()", su classpath, etc.
Para ejecutar un programa solo la JVM no es suficiente, se necesita también un conjunto de bibliotecas Java y otros componentes necesarios para que una aplicación escrita en lenguaje Java pueda ser ejecutada. Todo eso forma la JRE (Java Runtime Environment) que consiste de:

- JVM
- La biblioteca Java estándar (j.l.Object, j.l.String, IO, NET, NIO, Awt/Swing, etc)
- Métodos nativos (relacionados a la OS específica)
- Clases suplementarios (TimeZone, Media, etc)

Classloading Engine 

Ahora vamos a mirar como las clases vienen cargadas en la JVM. Por eso tenemos un modulo de la JVM (marcado amarillo en el diagrama de arriba) que se llama Classloading Engine - el motor de carga de clases.

De donde carga este motor las clases para la ejecución?

- desde la Java Runtime (biblioteca estándar)
- Classpath de la aplicación
- Auto-generados ("on the go") - Proxy, Reflection, invoke dynamic
- Clases proporcionadas por la aplicación

 Cada clase viene cargada con el ayuda de un cargador de las clases.

- La Biblioteca estándar viene cargada con el cargador bootstrap
- Las bibliotecas de la classpath - con AppClassLoader
- Las clases de la aplicación pueden crear sus proprios cargadores que pueden cargar las clases

Que es lo que pasa se lanza la JVM:

- El cargador del sistema carga la clase principal .class que contiene el método main(). Al mismo tiempo se carga la biblioteca estándar y las bibliotecas de la classpath.
              - Se verifica el código binario
              - Si el formato esta ok, se crea una representación Runtime de esta clase en los registros de memoria Meta Space
              - Se cargan las superclases o superinterfaces
- El método main() viene ejecutado

Verification del código binario:

La verificación del formato del código binario (bytecode) es muy importante. Todos los enlaces en bytecode de la file .class que se refieren a los campos o métodos de la claseson simbólicos, para ejecutarlo, deben ser resueltos. Se estos enlaces no pueden ser resueltos - una excepción sera producida durante la ejecución de la JVM. 
Ahora una pregunta: que puede pasar, si intentamos ejecutar el código siguiente, donde intentamos
meter en el mismo registro dos números, los sumar y redirigir el programa al inicio?



Respuesta 1: entraremos en un bucle infinito.
Respuesta 2: StackOverflowError
Respuesta 3: VerifyError
Respuesta 4: el codigo no sera ejecutado

La respuesta correcta es 4. La verificación del código se hace antes de cualquiera ejecución del código. Se hace solo 1 vez y, si en este momento, cualquier fragmento del codigo no esta bien - todo la clase sera marcado "no verificado" y ningun otro metodo de la clase viene ejecutado.
Pero claro, también podemos lanzar nuestro codigo con "-Xverify:none":


En este caso obtenemos el error StackOverflow y la JVM produce un error interno. Es un gran ventaja que la JVM verifica el código antes de ejecutarlo, lo que hace a la JVM muy eficiente (mas eficiente que un programa en p.e. C++ donde hay mucha potencial para errores, pq el código no viene verificado).

Execution Engine

Sera la próxima etapa después de la verificación.

- Un inicializador estático de la clase viene llamado, cuando se llama o el método new(), o el método estático.
- Se inicializa la superclase o superinterface con los métodos predeterminados (default).

La ejecución de byte code en la JVM pasa en 2 maneras:

- Con un interpretador:  "paso-por-paso"

Desde la 1ra instrucción (opcode) en el bytecode, toma la instrucción, mira el numero de los argumentos que espera una instrucción (p.e. 2 números para sumar) , ejecuta la instrucción (por ejemplo, suma 2 constantes, mete el resultado en un registro ) y sigue así por la instrucción siguiente. Pero esa manera para ejecutar el bytecode es muy lenta.

- Con un compilador JIT/AOT
En este caso, en vez de ir "paso-por-paso", todo el código binario del programa viene compilado a codigo maquina a la manera dinámica o estática que permite mejorar el rendimiento de sistemas de programación. Se utiliza para optimizar varias partes "pesadas" del código binario para un procesador o un sistema concreto (p.e. HotSpot). Varios compiladores pueden trabajar en la manera dinámica (JIT, just-in-time), en tiempo de ejecución o anticipada (AOT, ahead-of-time). La manera dinámica (JIT) puede ser aplicada p.e. para optimizar la ejecución de las expresiones que aparecen regularmente en el código (hot code), pero ralentiza la ejecución del programa, porque compila en tiempo de ejecución. Los compiladores AOT compilan antes de la ejecución y no tienen limites en tiempo o recursos para optimizar el programa. En este caso el programa esta mejor optimizada y al momento de la ejecución el codigo ya esta listo y compilado -> no se pierden los recursos. 

En este sentido el código binario (bytecode) puede ser considerado como intermedio entre el código Java y el código maquina.

código Java -> javac -> byte code -> JIT (AOT) -> codigo maquina.

Meta Information
El concepto de "java reflection" permite a la JVM de acceder a los métodos del código Java con su nombre. Para realizar eso, la JVM utiliza "meta space". Se utiliza mucho varios framework y varios lenguajes que utilizan la JVM (Ruby, Groovy). Para invocar los métodos nativos relacionados al sistema operativo concreto se utiliza JNI (Java Native Interface). Este framework nos permite de ejecutar los métodos del código C o C++ para acceder a los recursos dependientes de la plataforma, p.e. Java IO, Java .NET o AWT/Swing. 

Thread Synchronisation

Para ejecutar el código java a la manera mas eficaz se utilizan los hilos de ejecución (threads). Cada hilo Java corresponde a 1 hilo del sistema 1:1. La memoria que se utiliza para los variables locales y los argumentos de los métodos (method frames) se llama "pila" o "stack". Cada hilo de ejecución en una aplicación Java tiene su propia pila y el tamaño de esta pila esta definido por el parámetro siguiente:
-Xss. 
Todos los métodos llamados por la JVM vienen trazados en la "stack trace". Stack trace funciona a la misma manera como funciona en el código nativo como C. JVM busca un handler cuando una excepción viene producida por el código y, si lo encuentra le pasa la gestión. 

Ahora miramos que va pasar cuando tenemos 2 hilos que interactúan entre ellos:



Aquí tenemos 2 hilos: uno carga los datos, el otro espera estos datos. A traverso de un argumento booleano "Shared.ready" el 2ndo hilo va entender cuando el primero acaba de cargar los datos. El problema es que la optimización puede cambiar el orden de la ejecución de las instrucciones y el hilo #2 (Thread 2) puede recibir Shared.ready=true desde el hilo #1 antes que shared.data viene cargada. Para evitar eso hay un mecanismo en el modelo de memoria Java (Java Memory Model). 
Podemos declarar "Shared.ready" como volatile - y eso sera un señal para la JVM que el orden de la ejecución no se puede cambiar. Así garantizamos que los datos vienen cargados antes que shared.ready será igual a "true". También un método puede ser monitoreado por un thread, si lo declaramos "synchronized".

Recolector de basura - gestión de memoria  (Garbage Collection).

Alocación de los objetos de la memoria:
Cuando un nuevo objeto viene creado con el operador "new", la JVM inicializa este objeto y asigna la memoria para todas las instancias de este objeto, esta memoria se llama "java heap". La JVM pregunta la memoria al sistemo operativo, si hay espacio libre. Si no hay espacio libre, la JVM inicia el proceso de la "recolección de basura". También podemos forzar al programa de iniciar la recolección manualmente. 
Thread-safe (seguridad de los hilos)
Para que un hilo de ejecución no "come" la memoria al otro hilo de ejecución, existe "thread local heap" - cada hilo "come" solo su parte de la memoria alocada. 

Basura.
Que es lo que basura en sentido Java? Los objetos sin enlaces son basura. Pero que pasa si objeto son relacionados entre ellos? En este sentido es mejor definir lo que no es basura (GC Roots):
- Todos los objetos en los campos estáticos de las clases (variables estáticos)
- Todos los objetos accesibles desde los hilos de ejecución (Threads)
- Todos los objetos de los enlaces JNI en los métodos nativos
- Todos los objetos que referencian los primeros 3

Como ejemplo, los GC Roots de una aplicación simple serán:
- Variables locales del método main()
- El hilo principal de ejecución (main thread)
- Variables estáticos de la clase principal 

Todo el resto es basura.

Para determinar qué objetos ya no están en uso, la JVM funciona a traverso de un algoritmo que se llama  "marcar y barrer" (mark-and-sweep). Como se puede intuir, tiene 2 pasos:

1) Marcar: el algoritmo recorre todas las referencias a objetos, a partir de las raíces de GC, y marca cada objeto encontrado como vivo.

2) Barrer: toda la memoria heap que no está ocupada por los objetos marcados se recupera. Simplemente se marca como libre, esencialmente libre de objetos no utilizados.

El efecto de "marcar y barrer" son pequeñas micro-interrupciones (que gracias a los sistemas nuevas con mucho espacio y mucha optimización se han hecho casi invisibles) que ocurren cuando el recolector de basura hace un trabajo. Estas interrupciones eran responsables por los "lags" en los primeros aparatos Android. Ahora los aparatos Android se han hecho mucho mas potentes y los "lags" no son tan obvios y son casi innotables. También fueran desarrolladas varias estrategias para disminuir estas interrupciones:
- Estrategia incremental: recoger solo una parte de la basura durante la interrupción
Estrategia parallel: recoger la basura en varios threads a la vez
- Estrategia "concurenta ": recoger la basura durante el funcionamiento del programa 

En el pasado había otra estrategia para recoger la basura, se llamaba "stop-and-copy". Con esta estrategia todos los objetos "vivos" eran copiados en otro espacio heap vacío, con lo cual en el primer espacio solo quedaban objetos "muertos" que estaban para ser recogidos. Pero esta estrategia era menos eficaz y ralentizaba mucho la recolección de basura en la aplicación.

Monitoring

Visto que la JVM sabe todo sobre nuestra aplicación, tiene varios almacenes para monitorear le ejecución de esta aplicación:
- JVM Tool Interface - es una interfaz que viene utilizada, p.e. por el debugger, cuando intentamos a depurar nuestro programa. 
- Java Management Beans - una colección de la información sobre la cantidad de nuestros objetos, cuando fueron creados, cuanta memoria fue alocada para ellos, etc. Herramientos como JConsole o Visual VM toman la información desde Java Management Beans.

Resumen
Tenemos bytecode o los métodos nativos que pueden o entrar en la JVM directamente o primero ser compilado en el código maquina y después ser ejecutado directement en el procesador. En el tiempo de la ejecución se organiza la JVM, la memoria viene alocada según las necesidades del programa (en verdad un pelin mas), A traverso de la meta informacion tenemos acceso a todos los elementos de nuestro programa durante la ejecución.  

- JVM es una cosa muy complicada, pero bastante interesante
- Gracias a la JVM, Java es bastante flexible, pero eficaz
- Las tecnologías JVM se están mejorando de un dia a otro.  

Realizaciones de JVM.
- Oracle HotSpot (mas común)
- IBM J9 (utilizada en Websphere)
- Excelsior JET (tiene AOT)
- Azul (basada en HotSpot, han cambiado el GC)
- SAP, Redhat (adaptados a sus sistemas).





 

No hay comentarios:

Publicar un comentario