Mejore el rendimiento de las aplicaciones con estas técnicas avanzadas de GC

El rendimiento de las aplicaciones está en primera línea, y la optimización de la Recolección de Basura es un buen lugar para realizar pequeños, pero significativos avances

La Recolección de Basura Automatizada (junto con el Compilador JIT HotSpot) es uno de los componentes más avanzados y más valorados de la JVM, pero muchos desarrolladores e ingenieros están mucho menos familiarizados con la Recolección de Basura (GC), cómo funciona y cómo afecta al rendimiento de las aplicaciones.

Primero, ¿para qué sirve la GC? La recolección de basura es el proceso de gestión de la memoria de los objetos en el montón. A medida que los objetos se asignan al montón, se ejecutan a través de algunas fases de recolección – por lo general bastante rápido ya que la mayoría de los objetos en el montón tienen una vida corta.

Los eventos de recolección de basura contienen tres fases – marcado, eliminación y copia/compactación. En la primera fase, el GC recorre el montón y marca todo como objetos vivos (referenciados), objetos no referenciados o espacio de memoria disponible. A continuación, los objetos no referenciados se eliminan y los restantes se compactan. En las recolecciones de basura generacionales, los objetos «envejecen» y son promovidos a través de 3 espacios en sus vidas – el espacio Eden, el espacio Survivor y el espacio Tenured (Viejo). Este cambio también se produce como parte de la fase de compactación.

Pero basta de eso, vamos a la parte divertida.

¿Busca una solución para mejorar el rendimiento de las aplicaciones? OverOps ayuda a las empresas a identificar no sólo cuándo y dónde se producen las ralentizaciones, sino también por qué y cómo se producen. Vea una demostración en vivo para ver cómo funciona.

Conociendo la recolección de basura (GC) en Java

Una de las cosas buenas de la GC automatizada es que los desarrolladores no necesitan realmente entender cómo funciona. Desafortunadamente, eso significa que muchos desarrolladores NO entienden cómo funciona. Entender la recolección de basura y los muchos GCs disponibles, es algo así como conocer los comandos CLI de Linux. Técnicamente no necesitas usarlos, pero conocerlos y sentirte cómodo usándolos puede tener un impacto significativo en tu productividad.

Al igual que con los comandos de la CLI, existen los básicos absolutos. El comando ls para ver una lista de carpetas dentro de una carpeta padre, mv para mover un archivo de una ubicación a otra, etc. En la GC, ese tipo de comandos equivaldría a saber que hay más de una GC para elegir, y que la GC puede causar problemas de rendimiento. Por supuesto, hay mucho más que aprender (sobre el uso de la CLI de Linux Y sobre la recolección de basura).

El propósito de aprender sobre el proceso de recolección de basura de Java no es sólo para iniciar una conversación gratuita (y aburrida), el propósito es aprender cómo implementar y mantener efectivamente la GC correcta con un rendimiento óptimo para su entorno específico. Saber que la recolección de basura afecta al rendimiento de la aplicación es básico, y hay muchas técnicas avanzadas para mejorar el rendimiento de la GC y reducir su impacto en la fiabilidad de la aplicación.

Preocupaciones sobre el rendimiento de la GC

Fugas de memoria –

Conociendo la estructura del heap y cómo se realiza la recolección de basura, sabemos que el uso de la memoria aumenta gradualmente hasta que se produce un evento de recolección de basura y el uso vuelve a bajar. La utilización del heap para los objetos referenciados normalmente se mantiene estable, por lo que la caída debería ser más o menos del mismo volumen.

Con una fuga de memoria, cada evento de GC borra una porción más pequeña de objetos del heap (aunque muchos objetos que quedan no están en uso) por lo que la utilización del heap continuará aumentando hasta que la memoria del heap se llene y se lance una excepción OutOfMemoryError. La causa de esto es que la GC sólo marca los objetos no referenciados para su eliminación. Por lo tanto, incluso si un objeto referenciado ya no está en uso, no se borrará de la pila. Hay algunos trucos de codificación útiles para prevenir esto que cubriremos un poco más tarde.

Eventos continuos «Stop the World» –

En algunos escenarios, la recolección de basura puede ser llamada un evento «Stop the World» porque cuando ocurre, todos los hilos en la JVM (y por lo tanto, la aplicación que se está ejecutando en ella) se detienen para permitir que la GC se ejecute. En aplicaciones sanas, el tiempo de ejecución de la GC es relativamente bajo y no tiene un gran efecto en el rendimiento de la aplicación.

Sin embargo, en situaciones subóptimas, los eventos Stop the World pueden afectar enormemente al rendimiento y la fiabilidad de una aplicación. Si un evento de GC requiere una pausa de Stop the World y tarda 2 segundos en ejecutarse, el usuario final de esa aplicación experimentará un retraso de 2 segundos mientras los hilos que ejecutan la aplicación se detienen para permitir la GC.

Cuando se producen fugas de memoria, los eventos continuos de Stop the World también son problemáticos. Como se purga menos espacio de memoria del heap con cada ejecución de la GC, la memoria restante tarda menos en llenarse. Cuando la memoria está llena, la JVM lanza otro evento de GC. Eventualmente, la JVM estará ejecutando repetidos eventos de Stop the World causando grandes problemas de rendimiento.

Uso de la CPU –

Y todo se reduce al uso de la CPU. Un síntoma importante de los continuos eventos de GC / Stop the World es un pico en el uso de la CPU. La GC es una operación computacionalmente pesada, y por lo tanto puede tomar más de su cuota de potencia de la CPU. En el caso de las CGs que ejecutan hilos concurrentes, el uso de la CPU puede ser incluso mayor. La elección del GC adecuado para su aplicación tendrá el mayor impacto en el uso de la CPU, pero también hay otras maneras de optimizar para mejorar el rendimiento en esta área.

Podemos entender a partir de estas preocupaciones de rendimiento en torno a la recolección de basura que, por muy avanzados que sean los GCs (y se están volviendo bastante avanzados), su talón de Aquiles sigue siendo el mismo. Asignaciones de objetos redundantes e impredecibles. Para mejorar el rendimiento de las aplicaciones, no basta con elegir la GC adecuada. Tenemos que saber cómo funciona el proceso, y tenemos que optimizar nuestro código para que nuestras GCs no tiren de recursos excesivos o causen pausas excesivas en nuestra aplicación.

Generational GC

Antes de sumergirnos en las diferentes GCs de Java y su impacto en el rendimiento, es importante entender los fundamentos de la recolección de basura generacional. El concepto básico de la GC generacional se basa en la idea de que cuanto más tiempo exista una referencia a un objeto en el montón, menos probable es que se marque para su eliminación. Al etiquetar los objetos con una «edad» figurativa, podrían separarse en diferentes espacios de almacenamiento para ser marcados por la GC con menos frecuencia.

Cuando un objeto se asigna al montón, se coloca en lo que se llama el espacio Eden. Ahí es donde los objetos comienzan, y en la mayoría de los casos es donde se marcan para ser borrados. Los objetos que sobreviven a esa etapa «celebran un cumpleaños» y se copian al espacio Survivor. Este proceso se muestra a continuación:

Los espacios Edén y Superviviente conforman lo que se llama la Generación Joven. Aquí es donde ocurre el grueso de la acción. Cuando (si) un objeto de la Generación Joven alcanza una cierta edad, es promovido al espacio de los Veteranos (también llamado Viejo). La ventaja de dividir las memorias de los objetos en función de su edad es que la CG puede operar a diferentes niveles.

Una CG menor es una colección que se centra sólo en la Generación Joven, ignorando por completo el espacio Tenured. Generalmente, la mayoría de los Objetos en la Generación Joven están marcados para ser borrados y una GC Mayor o Completa (incluyendo la Generación Vieja) no es necesaria para liberar memoria en el heap. Por supuesto, una GC Mayor o Completa se activará cuando sea necesario.

Un truco rápido para optimizar el funcionamiento de la GC basado en esto es ajustar los tamaños de las áreas del heap para que se ajusten mejor a las necesidades de sus aplicaciones.

Tipos de Colectores

Hay muchos GCs disponibles para elegir, y aunque el G1 se convirtió en el GC por defecto en Java 9, fue originalmente pensado para reemplazar el colector CMS que es de Pausa Baja, por lo que las aplicaciones que se ejecutan con colectores de Rendimiento pueden ser más adecuadas para permanecer con su colector actual. Entender las diferencias operativas, y las diferencias en el impacto del rendimiento, para los recolectores de basura de Java sigue siendo importante.

Colectores Throughput

Mejor para aplicaciones que necesitan ser optimizadas para un alto rendimiento y pueden negociar una mayor latencia para lograrlo.

Serial –

El recolector serial es el más simple, y el que es menos probable que utilices, ya que está diseñado principalmente para entornos de un solo hilo (por ejemplo, 32 bits o Windows) y para heaps pequeños. Este recolector puede escalar verticalmente el uso de la memoria en la JVM, pero requiere varias GCs Major/Full para liberar los recursos de la pila no utilizados. Esto provoca frecuentes pausas de Stop the World, lo que lo descalifica a todos los efectos para ser utilizado en entornos de cara al usuario.

Parallel –

Como su nombre describe, este GC utiliza múltiples hilos que se ejecutan en paralelo para escanear y compactar el heap. Aunque el GC Paralelo utiliza múltiples hilos para la recolección de basura, sigue pausando todos los hilos de la aplicación mientras se ejecuta. El recolector Paralelo es el más adecuado para aplicaciones que necesitan ser optimizadas para un mejor rendimiento y pueden tolerar una mayor latencia a cambio.

Recolectores de baja pausa

La mayoría de las aplicaciones orientadas al usuario requerirán un GC de baja pausa, para que la experiencia del usuario no se vea afectada por pausas largas o frecuentes. Estas GCs tratan de optimizar la capacidad de respuesta (tiempo/evento) y un fuerte rendimiento a corto plazo.

Concurrent Mark Sweep (CMS) –

Similar al colector Parallel, el colector Concurrent Mark Sweep (CMS) utiliza múltiples hilos para marcar y barrer (eliminar) los objetos no referenciados. Sin embargo, este GC sólo inicia eventos de Stop the World en dos instancias específicas:

(1) cuando se inicializa el marcado inicial de las raíces (objetos de la antigua generación que son alcanzables desde puntos de entrada de hilos o variables estáticas) o cualquier referencia del método main(), y algunos más

(2) cuando la aplicación ha cambiado el estado del montón mientras el algoritmo se estaba ejecutando de forma concurrente, lo que le obliga a volver atrás y hacer algunos retoques finales para asegurarse de que tiene los objetos correctos marcados

G1 –

El primer recolector de basura (comúnmente conocido como G1) utiliza múltiples hilos de fondo para escanear el montón que divide en regiones. Funciona escaneando primero las regiones que contienen más objetos basura, lo que le da su nombre (Garbage first).

Esta estrategia reduce la posibilidad de que el montón se agote antes de que los hilos de fondo hayan terminado de escanear los objetos no utilizados, en cuyo caso el recolector tendría que detener la aplicación. Otra ventaja del recolector G1 es que compacta el montón sobre la marcha, algo que el recolector CMS sólo hace durante las recolecciones completas de Stop the World.

Mejorar el rendimiento de la GC

El rendimiento de la aplicación se ve directamente afectado por la frecuencia y la duración de las recolecciones de basura, lo que significa que la optimización del proceso de GC se realiza reduciendo esas métricas. Hay dos formas principales de hacerlo. En primer lugar, ajustando los tamaños del heap de las generaciones jóvenes y viejas, y en segundo lugar, reduciendo la tasa de asignación y promoción de objetos.

En cuanto al ajuste de los tamaños del heap, no es tan sencillo como cabría esperar. La conclusión lógica sería que aumentar el tamaño de la pila disminuiría la frecuencia de la GC y aumentaría la duración, y que disminuir el tamaño de la pila disminuiría la duración de la GC y aumentaría la frecuencia.

La realidad, sin embargo, es que la duración de una GC menor no depende del tamaño de la pila, sino del número de objetos que sobreviven a la recolección. Esto significa que para las aplicaciones que crean principalmente objetos de corta duración, el aumento del tamaño de la generación joven puede realmente reducir tanto la duración como la frecuencia de la GC. Sin embargo, si al aumentar el tamaño de la generación joven se produce un aumento significativo de los objetos que deben copiarse en los espacios de supervivencia, las pausas de la GC tardarán más tiempo, lo que provocará un aumento de la latencia.

3 Consejos para escribir código con GC eficiente

Consejo #1: Predecir las capacidades de las colecciones –

Todas las colecciones estándar de Java, así como la mayoría de las implementaciones personalizadas y extendidas (como Trove y Guava de Google), utilizan arrays subyacentes (ya sean primitivos o basados en objetos). Dado que los arrays son inmutables en tamaño una vez asignados, la adición de elementos a una colección puede, en muchos casos, hacer que un antiguo array subyacente sea eliminado en favor de un array más grande recién asignado.

La mayoría de las implementaciones de colecciones intentan optimizar este proceso de reasignación y mantenerlo en un mínimo amortizado, incluso si no se proporciona el tamaño esperado de la colección. Sin embargo, los mejores resultados se pueden conseguir proporcionando a la colección su tamaño esperado en el momento de la construcción.

Consejo #2: Procesar flujos directamente –

Cuando se procesan flujos de datos, como los datos leídos de archivos, o los datos descargados a través de la red, por ejemplo, es muy común ver algo parecido a:

La matriz de bytes resultante podría entonces ser analizada en un documento XML, un objeto JSON o un mensaje Protocol Buffer, por nombrar algunas opciones populares.

Cuando se trata de archivos grandes o de tamaño imprevisible, esto es obviamente una mala idea, ya que nos expone a OutOfMemoryErrors en caso de que la JVM no pueda realmente asignar un buffer del tamaño de todo el archivo.

Una mejor manera de enfocar esto es utilizar el InputStream apropiado (FileInputStream en este caso) y alimentarlo directamente en el parser, sin leer primero todo el asunto en una matriz de bytes. Todas las bibliotecas principales exponen APIs para analizar flujos directamente, por ejemplo:

Consejo #3: Usar objetos inmutables –

La inmutabilidad tiene muchas ventajas. Una a la que rara vez se presta la atención que merece es su efecto sobre la recolección de basura.

Un objeto inmutable es un objeto cuyos campos (y específicamente los campos no primitivos en nuestro caso) no pueden ser modificados después de que el objeto haya sido construido.

La inmutabilidad implica que todos los objetos referenciados por un contenedor inmutable han sido creados antes de que la construcción del contenedor se complete. En términos de GC: El contenedor es al menos tan joven como la referencia más joven que contiene. Esto significa que cuando se realizan ciclos de recolección de basura en las generaciones jóvenes, el GC puede omitir los objetos inmutables que se encuentran en las generaciones más antiguas, ya que sabe con certeza que no pueden hacer referencia a nada en la generación que se está recogiendo.

Menos objetos para escanear significan menos páginas de memoria para escanear, y menos páginas de memoria para escanear significan ciclos de GC más cortos, lo que significa pausas de GC más cortas y un mejor rendimiento general.

Para obtener más consejos y ejemplos detallados, echa un vistazo a este post que cubre las tácticas en profundidad para escribir un código más eficiente en cuanto a la memoria.

*** ¡Enorme agradecimiento a Amit Hurvitz del equipo de R&D de OverOps por su pasión y la visión que ha puesto en este post!

Deja un comentario