Las aplicaciones Java se ejecutan en JVM, pero ¿conoces la tecnología JVM? Este artículo (la primera parte de esta serie) explica cómo funciona la máquina virtual Java clásica, como: los pros y los contras de la escritura única de Java, motores multiplataforma, conceptos básicos de recolección de basura, algoritmos GC clásicos y optimización de compilación. Los artículos siguientes hablarán sobre la optimización del rendimiento de JVM, incluido el último diseño de JVM, que respalda el rendimiento y la escalabilidad de las aplicaciones Java altamente concurrentes de hoy.
Si eres desarrollador, debes haber encontrado este sentimiento especial: de repente tienes un destello de inspiración, todas tus ideas están conectadas y puedes recordar tus ideas anteriores desde una nueva perspectiva. Personalmente me encanta la sensación de aprender nuevos conocimientos. He tenido esta experiencia muchas veces mientras trabajaba con tecnología JVM, especialmente con la recolección de basura y la optimización del rendimiento de JVM. En este nuevo mundo de Java, espero compartir estas inspiraciones contigo. Espero que esté tan emocionado de aprender sobre el rendimiento de JVM como yo escribo este artículo.
Esta serie de artículos está escrita para todos los desarrolladores de Java que estén interesados en aprender más sobre el conocimiento subyacente de la JVM y lo que realmente hace la JVM. En un nivel alto, hablaré sobre la recolección de basura y la búsqueda interminable de seguridad y velocidad de la memoria libre sin afectar el funcionamiento de la aplicación. Aprenderá las partes clave de la JVM: recolección de basura y algoritmos de GC, optimización de compilación y algunas optimizaciones de uso común. También explicaré por qué el marcado de Java es tan difícil y brindaré consejos sobre cuándo debería considerar realizar pruebas de rendimiento. Finalmente, hablaré sobre algunas innovaciones nuevas en JVM y GC, incluido Zing JVM de Azul, IBM JVM y el enfoque de recolección de basura Garbage First (G1) de Oracle.
Espero que termine de leer esta serie con una comprensión más profunda de la naturaleza de las restricciones de escalabilidad de Java y cómo estas restricciones nos obligan a crear una implementación de Java de manera óptima. Con suerte, tendrás una sensación de iluminación y una buena inspiración de Java: ¡deja de aceptar esas limitaciones y cámbialas! Si aún no eres un trabajador del código abierto, esta serie puede animarte a desarrollarte en esta área.
Rendimiento de JVM y el desafío de "compilar una vez y ejecutar en cualquier lugar"
Tengo una nueva noticia para aquellos creyentes obstinados de que la plataforma Java es inherentemente lenta. Cuando Java se convirtió por primera vez en una aplicación de nivel empresarial, los problemas de rendimiento de Java por los que se criticó a la JVM ya existían hace más de diez años, pero esta conclusión ahora está desactualizada. Es cierto que si hoy ejecuta tareas estáticas y deterministas simples en diferentes plataformas de desarrollo, lo más probable es que descubra que usar código optimizado para máquina funcionará mejor que usar cualquier entorno virtual, bajo la misma JVM. Sin embargo, el rendimiento de Java ha mejorado mucho en los últimos 10 años. La demanda del mercado y el crecimiento de la industria Java han dado como resultado un puñado de algoritmos de recolección de basura, nuevas innovaciones en la compilación y una serie de heurísticas y optimizaciones que han avanzado la tecnología JVM. Cubriré algunos de estos en capítulos futuros.
La belleza técnica de la JVM es también su mayor desafío: nada puede considerarse una aplicación de "compilar una vez y ejecutar en cualquier lugar". En lugar de optimizar para un caso de uso, una aplicación o una carga de usuario específica, la JVM rastrea continuamente lo que la aplicación Java está haciendo actualmente y optimiza en consecuencia. Esta operación dinámica conduce a una serie de problemas dinámicos. Los desarrolladores que trabajan en JVM no dependen de la compilación estática ni de tasas de asignación predecibles cuando diseñan innovaciones (al menos no cuando exigimos rendimiento en entornos de producción).
La causa del rendimiento de JVM
En mis primeros trabajos me di cuenta de que la recolección de basura era muy difícil de "resolver" y siempre me han fascinado las JVM y la tecnología middleware. Mi pasión por las JVM comenzó cuando estaba en el equipo de JRockit, codificando una nueva forma de aprender y depurar yo mismo algoritmos de recolección de basura (ver Recursos). Este proyecto (que se convirtió en una característica experimental de JRockit y se convirtió en la base del algoritmo de recolección de basura determinista) inició mi viaje hacia la tecnología JVM. He trabajado en BEA Systems, Intel, Sun y Oracle (desde que Oracle adquirió BEA Systems, trabajé para Oracle brevemente). Luego me uní al equipo de Azul Systems para administrar Zing JVM y ahora trabajo para Cloudera.
El código optimizado para máquina puede lograr un mejor rendimiento (pero a expensas de la flexibilidad), pero esta no es una razón para considerarlo para aplicaciones empresariales con carga dinámica y funcionalidad que cambia rápidamente. Para aprovechar las ventajas de Java, la mayoría de las empresas están más dispuestas a sacrificar el rendimiento apenas perfecto que ofrece el código optimizado para máquina.
1. Fácil de codificar y desarrollar funciones (lo que significa menor tiempo para responder al mercado)
2. Consiga programadores expertos
3. Utilice API de Java y bibliotecas estándar para un desarrollo más rápido.
4. Portabilidad: no es necesario reescribir aplicaciones Java para nuevas plataformas
Del código Java al código de bytes
Como programador de Java, probablemente esté familiarizado con la codificación, compilación y ejecución de aplicaciones Java. Ejemplo: supongamos que tiene un programa (MyApp.java) y ahora desea ejecutarlo. Para ejecutar este programa, primero debe compilarlo con javac (el compilador estático de lenguaje Java a código de bytes integrado en el JDK). Basado en el código Java, javac genera el código de bytes ejecutable correspondiente y lo guarda en el archivo de clase con el mismo nombre: MyApp.class. Después de compilar el código Java en código de bytes, puede iniciar el archivo de clase ejecutable mediante el comando java (a través de la línea de comando o el script de inicio, sin usar la opción de inicio) para ejecutar su aplicación. De esta manera, su clase se carga en el tiempo de ejecución (es decir, la ejecución de la máquina virtual Java) y el programa comienza a ejecutarse.
Esto es lo que cada aplicación ejecuta en la superficie, pero ahora exploremos qué sucede exactamente cuando ejecuta un comando Java. ¿Qué es una máquina virtual Java? La mayoría de los desarrolladores interactúan con la JVM a través de la depuración continua, también conocida como selección y asignación de valores de opciones de inicio para hacer que sus programas Java se ejecuten más rápido y al mismo tiempo evitar los infames errores de "falta de memoria". Pero, ¿alguna vez te has preguntado por qué necesitamos una JVM para ejecutar aplicaciones Java?
¿Qué es una máquina virtual Java?
En pocas palabras, una JVM es un módulo de software que ejecuta el código de bytes de una aplicación Java y lo convierte en instrucciones específicas del hardware y del sistema operativo. Al hacer esto, la JVM permite que un programa Java se ejecute en un entorno diferente después de que se escribió por primera vez, sin requerir cambios en el código original. La portabilidad de Java es la clave para un lenguaje de aplicación empresarial: los desarrolladores no necesitan reescribir el código de la aplicación para diferentes plataformas porque la JVM se encarga de la traducción y la optimización de la plataforma.
Una JVM es básicamente un entorno de ejecución virtual que actúa como una máquina de instrucciones de código de bytes y se utiliza para asignar tareas de ejecución y realizar operaciones de memoria interactuando con la capa subyacente.
Una JVM también se encarga de la gestión dinámica de recursos para ejecutar aplicaciones Java. Esto significa que domina la asignación y liberación de memoria, mantiene un modelo de subprocesos consistente en cada plataforma y organiza instrucciones ejecutables donde la aplicación se ejecuta de una manera adecuada para la arquitectura de la CPU. La JVM libera a los desarrolladores de realizar un seguimiento de las referencias a objetos y de cuánto tiempo deben existir en el sistema. Del mismo modo, no requiere que administremos cuándo liberar memoria, un problema en lenguajes no dinámicos como C.
Puede pensar en la JVM como un sistema operativo diseñado específicamente para ejecutar Java; su trabajo es administrar el entorno de ejecución de las aplicaciones Java. Una JVM es básicamente un entorno de ejecución virtual que interactúa con el entorno subyacente como una máquina de instrucciones de código de bytes para asignar tareas de ejecución y realizar operaciones de memoria.
Descripción general de los componentes JVM
Hay muchos artículos escritos sobre los aspectos internos de JVM y la optimización del rendimiento. Como base de esta serie, resumiré y resumiré los componentes de JVM. Esta breve descripción general es particularmente útil para los desarrolladores que son nuevos en JVM y les hará querer aprender más sobre las discusiones más profundas que siguen.
De un lenguaje a otro: acerca de los compiladores de Java
Un compilador toma un idioma como entrada y luego genera otra declaración ejecutable. El compilador de Java tiene dos tareas principales:
1. Hacer que el lenguaje Java sea más portátil y ya no sea necesario fijarlo en una plataforma específica al escribir por primera vez;
2. Asegúrese de que se produzca un código ejecutable válido para una plataforma específica.
Los compiladores pueden ser estáticos o dinámicos. Un ejemplo de compilación estática es javac. Toma código Java como entrada y lo convierte en código de bytes (un lenguaje ejecutado en la máquina virtual Java). El compilador estático interpreta el código de entrada una vez y genera un formulario ejecutable, que se utilizará cuando se ejecute el programa. Como la entrada es estática, siempre verás el mismo resultado. Solo si modifica el código original y lo vuelve a compilar verá un resultado diferente.
Los compiladores dinámicos , como los compiladores Just-In-Time (JIT), convierten un lenguaje a otro dinámicamente, lo que significa que lo hacen mientras se ejecuta el código. El compilador JIT le permite recopilar o crear análisis en tiempo de ejecución (insertando recuentos de rendimiento), utilizando las decisiones del compilador y los datos del entorno disponibles. Un compilador dinámico puede implementar mejores secuencias de instrucciones durante el proceso de compilación en un lenguaje, reemplazar una serie de instrucciones por otras más eficientes e incluso eliminar operaciones redundantes. Con el tiempo, recopilará más datos de configuración de código y tomará más y mejores decisiones de compilación. Todo el proceso es lo que normalmente llamamos optimización y recompilación de código.
La compilación dinámica le brinda la ventaja de adaptarse a cambios dinámicos basados en el comportamiento o nuevas optimizaciones a medida que aumenta la cantidad de cargas de aplicaciones. Por eso los compiladores dinámicos son perfectos para las operaciones de Java. Vale la pena señalar que el compilador dinámico solicita estructuras de datos externas, recursos de subprocesos, análisis y optimización del ciclo de la CPU. Cuanto más profunda sea la optimización, más recursos necesitará. Sin embargo, en la mayoría de los entornos, la capa superior añade muy poco al rendimiento: un rendimiento de 5 a 10 veces más rápido que su interpretación pura.
La asignación provoca la recolección de basura.
Asignado en cada hilo en función de cada "espacio de direcciones de memoria asignado al proceso Java", o llamado montón de Java, o directamente llamado montón. En el mundo Java, la asignación de un solo subproceso es común en las aplicaciones cliente. Sin embargo, la asignación de un solo subproceso no es beneficiosa en aplicaciones empresariales y servidores de cargas de trabajo porque no aprovecha el paralelismo de los entornos multinúcleo actuales.
El diseño de aplicaciones en paralelo también obliga a la JVM a garantizar que varios subprocesos no asignen el mismo espacio de direcciones al mismo tiempo. Puedes controlar esto colocando un candado en todo el espacio asignado. Pero esta técnica (a menudo llamada bloqueo de montón) requiere mucho rendimiento y mantener o poner en cola subprocesos puede afectar la utilización de recursos y el rendimiento de optimización de las aplicaciones. Lo bueno de los sistemas multinúcleo es que crean la necesidad de una variedad de métodos nuevos para evitar cuellos de botella de un solo subproceso al asignar recursos y serializar.
Un enfoque común es dividir el montón en partes, donde cada partición tiene un tamaño razonable para la aplicación; obviamente, es necesario ajustarlas, las tasas de asignación y los tamaños de objetos varían significativamente entre aplicaciones y la cantidad de subprocesos para la misma también es diferente. El búfer de asignación local de subprocesos (TLAB), o a veces el área local de subprocesos (TLA), es una partición especializada en la que los subprocesos pueden asignarse libremente sin declarar un bloqueo de montón completo. Cuando el área está llena, el montón está lleno, lo que significa que no hay suficiente espacio libre en el montón para colocar objetos y es necesario asignar espacio. Cuando el montón esté lleno, comenzará la recolección de basura.
fragmentos
El uso de TLAB para detectar excepciones fragmenta el montón para reducir la eficiencia de la memoria. Si una aplicación no puede aumentar o asignar completamente un espacio TLAB al asignar objetos, existe el riesgo de que el espacio sea demasiado pequeño para generar nuevos objetos. Este espacio libre se considera "fragmentación". Si la aplicación mantiene una referencia al objeto y luego asigna el espacio restante, eventualmente el espacio estará libre durante mucho tiempo.
La fragmentación se produce cuando los fragmentos se dispersan por el montón, desperdiciando espacio del montón en pequeñas secciones de espacio de memoria no utilizado. La asignación de espacio TLAB "incorrecto" para su aplicación (con respecto al tamaño del objeto, el tamaño del objeto mixto y la proporción de retención de referencias) es la causa del aumento de la fragmentación del montón. A medida que se ejecuta la aplicación, la cantidad de fragmentos aumenta y ocupa espacio en el montón. La fragmentación provoca una degradación del rendimiento y el sistema no puede asignar suficientes subprocesos y objetos a nuevas aplicaciones. El recolector de basura tendrá entonces dificultades para evitar excepciones por falta de memoria.
Los residuos de TLAB se generan en el trabajo. Una forma de evitar la fragmentación total o temporalmente es optimizar el espacio TLAB en cada operación subyacente. Un enfoque típico de este enfoque es que, siempre que la aplicación tenga un comportamiento de asignación, es necesario volver a sintonizarla. Esto se puede lograr mediante algoritmos JVM complejos. Otro método es organizar particiones del montón para lograr una asignación de memoria más eficiente. Por ejemplo, la JVM puede implementar listas libres, que están vinculadas entre sí como una lista de bloques de memoria libres de un tamaño específico. Un bloque de memoria libre contiguo se conecta a otro bloque de memoria contiguo del mismo tamaño, creando así una pequeña cantidad de listas enlazadas, cada una con sus propios límites. En algunos casos, las listas libres dan como resultado una mejor asignación de memoria. Los subprocesos pueden asignar objetos en bloques de tamaño similar, creando potencialmente menos fragmentación que si solo confiara en TLAB de tamaño fijo.
curiosidades de GC
Algunos de los primeros recolectores de basura tenían varias generaciones anteriores, pero tener más de dos generaciones anteriores haría que los gastos generales superaran el valor. Otra forma de optimizar las asignaciones y reducir la fragmentación es crear lo que se llama la generación joven, que es un espacio de montón dedicado a asignar nuevos objetos. El montón restante se convierte en la llamada vieja generación. La generación anterior se utiliza para asignar objetos de larga duración. Los objetos que se supone que existen durante mucho tiempo incluyen objetos que no son basura recolectada u objetos grandes. Para comprender mejor este método de asignación, debemos hablar sobre algunos conocimientos sobre recolección de basura.
Recolección de basura y rendimiento de aplicaciones.
La recolección de basura es el recolector de basura de la JVM para liberar la memoria del montón ocupada a la que no se hace referencia. Cuando se activa la recolección de basura por primera vez, todas las referencias a objetos aún se conservan y el espacio ocupado por las referencias anteriores se libera o reasigna. Una vez recopilada toda la memoria recuperable, el espacio espera a ser capturado y asignado nuevamente a nuevos objetos.
El recolector de basura nunca puede volver a declarar un objeto de referencia; hacerlo violaría la especificación estándar de JVM. La excepción a esta regla es una referencia suave o débil que puede detectarse si el recolector de basura está a punto de quedarse sin memoria. Sin embargo, le recomiendo encarecidamente que intente evitar referencias débiles, porque la ambigüedad de la especificación Java conduce a malas interpretaciones y errores de uso. Es más, Java está diseñado para la gestión dinámica de la memoria, porque no es necesario pensar en cuándo y dónde liberar memoria.
Uno de los desafíos del recolector de basura es asignar memoria de una manera que no afecte las aplicaciones en ejecución. Si no recolecta tanta basura como sea posible, su aplicación consumirá memoria; si recolecta con demasiada frecuencia, perderá rendimiento y tiempo de respuesta, lo que tendrá un impacto negativo en la aplicación en ejecución.
algoritmo GC
Existen muchos algoritmos diferentes de recolección de basura. Varios puntos se discutirán en profundidad más adelante en esta serie. En el nivel más alto, los dos métodos principales de recolección de basura son el recuento de referencias y los recolectores de seguimiento.
El recopilador de recuento de referencias realiza un seguimiento de cuántas referencias apunta un objeto. Cuando la referencia de un objeto llega a 0, la memoria se recuperará inmediatamente, lo cual es una de las ventajas de este enfoque. La dificultad con el enfoque de recuento de referencias radica en la estructura de datos circular y en mantener todas las referencias actualizadas en tiempo real.
El recopilador de seguimiento marca los objetos a los que todavía se hace referencia y utiliza los objetos marcados para seguir y marcar repetidamente todos los objetos a los que se hace referencia. Cuando todos los objetos a los que todavía se hace referencia estén marcados como "activos", se recuperará todo el espacio no marcado. Este enfoque gestiona estructuras de datos en anillo, pero en muchos casos el recopilador debe esperar hasta que se completen todas las marcas antes de recuperar la memoria sin referencia.
Hay varias formas de realizar el método anterior. Los algoritmos más famosos son los algoritmos de marcado o copia, los algoritmos paralelos o concurrentes. Hablaré de esto en un artículo posterior.
En términos generales, el significado de la recolección de basura es asignar espacio de direcciones a objetos nuevos y antiguos en el montón. Los "objetos antiguos" son objetos que han sobrevivido a muchas recolecciones de basura. Utilice la nueva generación para asignar objetos nuevos y la generación anterior a objetos antiguos. Esto puede reducir la fragmentación al reciclar rápidamente objetos de corta duración que ocupan memoria. También agrega objetos de larga duración y los coloca en direcciones de generación anterior en el espacio. Todo esto reduce la fragmentación entre objetos de larga duración y evita la fragmentación de la memoria del montón. Un efecto positivo de la nueva generación es que retrasa la recolección más costosa de objetos de la vieja generación, y puedes reutilizar el mismo espacio para objetos efímeros. (La recopilación del espacio antiguo costará más porque los objetos de larga duración contendrán más referencias y requerirán más recorridos).
El último algoritmo que vale la pena mencionar es la compactación, que es un método para gestionar la fragmentación de la memoria. La compactación básicamente mueve objetos juntos para liberar un mayor espacio de memoria contiguo. Si está familiarizado con la fragmentación de discos y las herramientas que se ocupan de ella, encontrará que la compactación es muy similar, excepto que ésta se ejecuta en la memoria dinámica de Java. Hablaré de la compactación en detalle más adelante en la serie.
Resumen: revisión y aspectos destacados
La JVM permite la portabilidad (programar una vez, ejecutar en cualquier lugar) y administración dinámica de la memoria, todas características clave de la plataforma Java que contribuyen a su popularidad y mayor productividad.
En el primer artículo sobre sistemas de optimización del rendimiento JVM, expliqué cómo un compilador convierte el código de bytes al lenguaje de instrucciones de la plataforma de destino y ayuda a optimizar dinámicamente la ejecución de programas Java. Diferentes aplicaciones requieren diferentes compiladores.
También cubrí brevemente la asignación de memoria y la recolección de basura, y cómo se relacionan con el rendimiento de las aplicaciones Java. Básicamente, cuanto más rápido llene el montón y active la recolección de basura con mayor frecuencia, mayor será la tasa de utilización de su aplicación Java. Un desafío para el recolector de basura es asignar memoria de una manera que no afecte a la aplicación en ejecución, pero antes de que la aplicación se quede sin memoria. En artículos futuros analizaremos con más detalle la recolección de basura nueva y tradicional y las optimizaciones del rendimiento de JVM.