Java Garbage Collector

Java Garbage Collector: introducción a los distintos algoritmos

 

Introducción

En este artículo describimos una situación problemática con la que nos encontramos durante el desarrollo de un proyecto en AIknow, y que nos impulsó a analizar en profundidad el funcionamiento del Recolector de Basura, así como las diferencias entre los distintos algoritmos existentes para la Garbage Collecting. Por último, veremos cómo conseguimos resolver el problema.

 

El caso práctico

Para un cliente nuestro que opera en el sector de las telecomunicaciones, desarrollamos una aplicación web que implementa la funcionalidad de despachador para todas las comunicaciones intercambiadas dentro de una red de radio. El backend, desarrollado en Java, debe ser capaz de procesar una gran cantidad de mensajes cuando la red de radio está formada por cientos o incluso miles de radios (algunas instalaciones procesan más de 300 mensajes por minuto). Por consiguiente, la aplicación consume grandes cantidades de memoria, lo que hace necesario un recolector de basura que sea muy rápido a la hora de liberar el espacio no utilizado en el espacio Heap.

 

El problema

En situaciones de estrés de aplicación, nos encontramos con dos problemas graves:

  • OutOfMemoryException: se lanza cuando la aplicación intenta utilizar más memoria de la asignada
  • Congelación de la aplicación: La aplicación Java permaneció bloqueada durante unos 15 segundos, sin producir ningún registro ni salida.
Este segundo punto era especialmente crítico, ya que todos los mensajes recibidos durante esos 15 segundos de inactividad se perdían por completo.

 

Nuestra investigación

Para resolver la excepción OOM, analizamos la utilización de memoria de la aplicación.

Creamos un entorno de pruebas con un conjunto de datos similar al de producción, generando volcados de memoria heap de la JVM a intervalos regulares y analizándolos con Eclipse MAT. Gracias al detector automático de anomalías de MAT, obtuvimos gráficos como el que se muestra en la figura y detectamos una memory leak que provocaba una utilización inadecuada de la misma.

 

En cuanto al problema de los bloqueos, fue necesario supervisar la utilización de la memoria en tiempo real, con la esperanza de detectar comportamientos anómalos durante los bloqueos.

Antes de mostrar los resultados de nuestra investigación, es útil introducir algunas definiciones relativas a la estructura del Heap y al funcionamiento del Garbage Collector, ya que utilizaremos terminología más específica en la última parte del artículo.

 

Cómo se estructura el espacio Heap de Java

El espacio de montón es la porción de memoria física utilizada por Java para asignar dinámicamente objetos y clases durante la ejecución de la aplicación. El recolector de basura interviene periódicamente para liberar memoria.

En teoría, el Recolector de Basura analiza todos los objetos en el Espacio de la Pila que aún pueden ser alcanzados por referencias activas; todos los demás son considerados como basura y eliminados (una operación llamada collection).
En la práctica, sin embargo, se utiliza un enfoque diferente. Empíricamente, se observa que la mayoría de los objetos Java tienen vidas cortas, mientras que unos pocos tienen vidas largas. De ahí la división del montón en Generations, donde los objetos se colocan en función de su edad:
  1. Young Generation
    Aquí se asignan los nuevos objetos. Cuando esta zona se llena, se realiza una minor collection. La Generación Joven se divide a su vez en Eden Spage e Survivor Space. Un objeto que sobrevive a un cierto número de recolecciones menores es promovido de Edén a Superviviente, y luego a Generación Antigua.
  2. Old Generation
    Contiene los objetos más longevos, también llamados Tenured Space. Cuando este espacio se llena, se inicia una major collection, que limpia todo el montón. Las recolecciones mayores son menos frecuentes pero mucho más costosas desde el punto de vista computacional.

El objetivo de Generaciones es minimizar la necesidad de realizar grandes recogidas.

 

Supervisión de la memoria Heap en tiempo real

Para supervisar la utilización de la memoria de montón en tiempo real, utilizamos Prometheus, una herramienta intuitiva que permite visualizar muchas métricas producidas por la JVM en gráficos personalizados.

El siguiente gráfico muestra la utilización de memoria Heap de las diferentes Generaciones:
  • Amarillo = Young/Eden
  • Azul claro = Young/Survivor
  • Rojo = Old/Tenured

aiknow-image

Analizando los gráficos, comprobamos que los bloques de aplicación coincidían con las grandes colecciones.

 

Cómo leer los registros del Recolector de Basura

Para confirmar nuestra hipótesis, activamos los registros del Recolector de Basura añadiendo el parámetro -verbose:gc al comando start de la JVM.

Por ejemplo, observamos:
[768327.260s][info][gc] GC(24076) Pause Full (Allocation Failure) 956M->215M(989M) 15921.040ms
Detalles:
  • [768327.280s]: marca de tiempo del inicio de la aplicación
  • [info]: nivel de registro
  • [gc]: indica el registro del recolector de basura.
  • GC(24076): Identificador GC
  • Pause Young/Full: tipo de colección (menor o mayor)
  • (Allocation Failure): causa de la recolección (normal, indica que la JVM no pudo asignar memoria y por eso activó la recolección)
  • 956M->215M(989M): memoria en uso antes y después de la recogida, y tamaño total del montón
  • 15921,040ms: duración de la recogida (aprox. 15 segundos)

 

Varios algoritmos de recogida de basura

Identificamos que el algoritmo utilizado era Serial. En la puesta en marcha, de hecho, apareció:

[0.034s][info][gc] Using Serial
Serial es un recolector de basura de un solo hilo, ineficaz para grandes cantidades de memoria. Como se indica en la documentación oficial:
“El recolector en serie utiliza un único hilo para todo el trabajo de recolección de basura, siendo eficiente sólo en máquinas con un único procesador o en aplicaciones con pequeños conjuntos de datos (hasta unos 100 MB).”
Era obvio que no era adecuado para nuestra aplicación:
  • el servidor es multiprocesador, por lo que se necesita un GC multihilo
  • el conjunto de datos es muy superior a 100 MB

Existen otros algoritmos multihilo, como el G1, que son mucho más eficientes. En nuestro caso, utilizando el GC G1, incluso las colecciones más importantes (denominadas en los registros Ciclo concurrente) se ejecutaron en segundo plano y en tiempos mucho más cortos (373 ms frente a los cerca de 15 segundos de Serial):

[1913.644s][info][gc] GC(209) Pause Young (Normal) (G1 Evacuation Pause) 220M->131M(256M) 84.159ms

[1915.310s][info][gc] GC(211) Concurrent Cycle

[1915.683s][info][gc] GC(211) Concurrent Cycle 372.997ms
Como se indica en la documentación:
“El Concurrent Mark Sweep (CMS) y el recolector de basura Garbage-First (G1) operan principalmente en paralelo, realizando costosas operaciones simultáneamente con la aplicación. G1 está diseñado para máquinas multiprocesador con gran cantidad de memoria, lo que garantiza pausas de GC cortas y un alto rendimiento.”

 

¿Qué algoritmo debo elegir para mi aplicación?

En resumen:

  • conjuntos de datos pequeños (hasta 100 MB): -XX:+UseSerialGC
  • ejecución en un solo procesador sin restricciones de pausa:-XX:+UseSerialGC
  • máximo rendimiento sin restricciones de pausa o pausas aceptables (≥1s): -XX:+UseParallelGC o por defecto.
  • pausas inferiores a 1 segundo aproximadamente y tiempo de respuesta prioritario: -XX:+UseG1GC o -XX:+UseConcMarkSweepGC
  • heaps muy grandes y alta prioridad en el tiempo de respuesta:-XX:+UseZGC

 

Conclusión

En este artículo hemos explicado qué es el Garbage Collector, la estructura de la memoria Heap y cómo activar e interpretar los logs del GC. A continuación, analizamos un caso real que ponía de manifiesto la importancia de configurar correctamente la JVM eligiendo el algoritmo de Garbage Collection más adecuado a las características de la aplicación Java. Por último, ilustramos las principales diferencias entre los distintos algoritmos de Garbage Collection.

¿Necesita ayuda para mejorar el rendimiento de su aplicación Java? Contáctenos