Optimización del rendimiento de la JVM, Parte 3: Recolección de basura

El mecanismo de recolección de basura de la plataforma Java aumenta en gran medida la productividad de los desarrolladores, pero un recolector de basura mal implementado puede consumir en exceso los recursos de la aplicación. En este tercer artículo de la serie de optimización del rendimiento de la JVM, Eva Andreasson ofrece a los principiantes de Java una visión general del modelo de memoria de la plataforma Java y del mecanismo de GC. A continuación, explica por qué la fragmentación (y no la GC) es el principal “problema” del rendimiento de las aplicaciones Java, y por qué la recolección de basura generacional y la compactación son actualmente los enfoques principales (aunque no los más innovadores) para gestionar la fragmentación de la pila en las aplicaciones Java.

La recolección de basura (GC) es el proceso que tiene como objetivo liberar la memoria ocupada que ya no es referenciada por ningún objeto Java alcanzable, y es una parte esencial del sistema de gestión de memoria dinámica de la máquina virtual Java (JVM). En un ciclo típico de recogida de basura se conservan todos los objetos a los que todavía se hace referencia y que, por tanto, son accesibles. El espacio ocupado por los objetos previamente referenciados se libera y reclama para permitir la asignación de nuevos objetos.

Para entender la recolección de basura y los diversos enfoques y algoritmos de GC, primero debe saber algunas cosas sobre el modelo de memoria de la plataforma Java.

La recolección de basura y el modelo de memoria de la plataforma Java

Cuando se especifica la opción de inicio -Xmx en la línea de comandos de la aplicación Java (por ejemplo: java -Xmx:2g MyApp) se asigna memoria a un proceso Java. Esta memoria se conoce como el heap de Java (o simplemente heap). Es el espacio de direcciones de memoria dedicado donde se asignarán todos los objetos creados por su programa Java (o a veces la JVM). A medida que su programa Java sigue ejecutando y asignando nuevos objetos, el heap de Java (es decir, ese espacio de direcciones) se llenará.

Eventualmente, el heap de Java se llenará, lo que significa que un hilo de asignación es incapaz de encontrar una sección consecutiva suficientemente grande de memoria libre para el objeto que quiere asignar. En ese momento, la JVM determina que es necesario realizar una recolección de basura y se lo notifica al recolector de basura. Una recolección de basura también puede ser activada cuando un programa Java llama a System.gc(). El uso de System.gc() no garantiza una recolección de basura. Antes de que se inicie una recolección de basura, un mecanismo de GC determinará primero si es seguro iniciarla. Es seguro iniciar una recolección de basura cuando todos los hilos activos de la aplicación están en un punto seguro para permitirlo, por ejemplo, simplemente se explica que sería malo iniciar la recolección de basura en medio de una asignación de objetos en curso, o en medio de la ejecución de una secuencia de instrucciones optimizadas de la CPU (ver mi artículo anterior sobre los compiladores), ya que podría perder el contexto y, por tanto, estropear los resultados finales.

Un recolector de basura nunca debe reclamar un objeto referenciado activamente; hacerlo rompería la especificación de la máquina virtual de Java. Un recolector de basura tampoco está obligado a recoger inmediatamente los objetos muertos. Los objetos muertos se recogen eventualmente durante los siguientes ciclos de recolección de basura. Aunque hay muchas formas de implementar la recolección de basura, estas dos suposiciones son ciertas para todas las variedades. El verdadero reto de la recolección de basura es identificar todo lo que está vivo (todavía referenciado) y reclamar cualquier memoria no referenciada, pero hacerlo sin afectar a las aplicaciones en ejecución más de lo necesario. Un recolector de basura tiene por lo tanto dos mandatos:

  1. Liberar rápidamente la memoria no referenciada para satisfacer la tasa de asignación de una aplicación para que no se quede sin memoria.
  2. Reclamar la memoria afectando mínimamente el rendimiento (por ejemplo, latencia y rendimiento) de una aplicación en ejecución.

Dos tipos de recolección de basura

En el primer artículo de esta serie toqué los dos enfoques principales de la recolección de basura, que son el conteo de referencias y los recolectores de rastreo. Esta vez profundizaré en cada enfoque y luego presentaré algunos de los algoritmos utilizados para implementar colectores de rastreo en entornos de producción.

Lee la serie de optimización del rendimiento de la JVM

  • Optimización del rendimiento de la JVM, Parte 1: Visión general
  • Optimización del rendimiento de la JVM, Parte 2: Compiladores

Colectores de recuento de referencias

Los colectores de recuento de referencias llevan la cuenta de cuántas referencias apuntan a cada objeto Java. Una vez que el recuento de un objeto llega a cero, la memoria puede ser recuperada inmediatamente. Este acceso inmediato a la memoria recuperada es la mayor ventaja del enfoque de conteo de referencias para la recolección de basura. Hay muy poca sobrecarga cuando se trata de mantener la memoria sin referencias. Sin embargo, mantener todos los recuentos de referencias al día puede ser bastante costoso.

La principal dificultad con los recolectores de recuento de referencias es mantener los recuentos de referencias precisos. Otro reto bien conocido es la complejidad asociada al manejo de estructuras circulares. Si dos objetos se referencian mutuamente y ningún objeto vivo hace referencia a ellos, su memoria nunca será liberada. Ambos objetos permanecerán para siempre con un recuento distinto de cero. Recuperar la memoria asociada a las estructuras circulares requiere un análisis importante, lo que supone una costosa sobrecarga para el algoritmo y, por tanto, para la aplicación.

Colectores de rastreo

Los colectores de rastreo se basan en la suposición de que todos los objetos vivos pueden ser encontrados rastreando iterativamente todas las referencias y subsecuentes referencias de un conjunto inicial de objetos vivos conocidos. El conjunto inicial de objetos vivos (llamados objetos raíz o simplemente raíces para abreviar) se localizan analizando los registros, los campos globales y los marcos de pila en el momento en que se activa una recolección de basura. Una vez identificado el conjunto inicial de objetos vivos, el recolector de rastreo sigue las referencias de estos objetos y los pone en cola para marcarlos como vivos y posteriormente rastrear sus referencias. Marcar como vivos todos los objetos referenciados encontrados significa que el conjunto vivo conocido aumenta con el tiempo. Este proceso continúa hasta que se encuentran y marcan todos los objetos referenciados (y, por tanto, todos los vivos). Una vez que el recolector de rastreo ha encontrado todos los objetos vivos, recuperará la memoria restante.

Los recolectores de rastreo difieren de los recolectores de conteo de referencias en que pueden manejar estructuras circulares. El inconveniente de la mayoría de los colectores de rastreo es la fase de marcado, que implica una espera antes de poder recuperar la memoria no referenciada.

Los colectores de rastreo son los más utilizados para la gestión de la memoria en los lenguajes dinámicos; son, con mucho, los más comunes para el lenguaje Java y han sido probados comercialmente en entornos de producción durante muchos años. Me centraré en los recolectores de rastreo para el resto de este artículo, comenzando con algunos de los algoritmos que implementan este enfoque de la recolección de basura.

Algoritmos de recolectores de rastreo

La recolección de basura de copia y de marca y barrido no son nuevos, pero siguen siendo los dos algoritmos más comunes que implementan la recolección de basura de rastreo hoy en día.

Colectores de copia

Los colectores de copia tradicionales utilizan un espacio de origen y un espacio de destino, es decir, dos espacios de direcciones del montón definidos por separado. En el momento de la recogida de basura, los objetos vivos dentro del área definida como from-space se copian en el siguiente espacio disponible dentro del área definida como to-space. Cuando todos los objetos vivos dentro del espacio de origen se mueven fuera, todo el espacio de origen puede ser recuperado. Cuando la asignación comienza de nuevo, lo hace desde la primera ubicación libre en el espacio de destino.

En las implementaciones más antiguas de este algoritmo, el espacio de origen y el espacio de destino cambian de lugar, lo que significa que cuando el espacio de destino está lleno, la recolección de basura se activa de nuevo y el espacio de destino se convierte en el espacio de origen, como se muestra en la Figura 1.

Figura 1. Una secuencia de recogida de basura de copia tradicional (clic para ampliar)

Las implementaciones más modernas del algoritmo de copia permiten asignar espacios de direcciones arbitrarios dentro del montón como espacio de llegada y espacio de salida. En estos casos no tienen que cambiar necesariamente de ubicación entre sí, sino que cada uno se convierte en otro espacio de direcciones dentro del montón.

Una de las ventajas de los colectores de copia es que los objetos se asignan juntos de forma ajustada en el espacio de destino, eliminando completamente la fragmentación. La fragmentación es un problema común con el que luchan otros algoritmos de recolección de basura; algo que discutiré más adelante en este artículo.

Desventajas de los recolectores de copia

Los recolectores de copia suelen ser recolectores de parada del mundo, lo que significa que no se puede ejecutar ningún trabajo de la aplicación mientras la recolección de basura esté en ciclo. En una implementación de stop-the-world, cuanto mayor sea el área que se necesita copiar, mayor será el impacto en el rendimiento de la aplicación. Esto es una desventaja para las aplicaciones que son sensibles al tiempo de respuesta. Con un recopilador de copias también hay que tener en cuenta el peor de los casos, cuando todo está vivo en el espacio de origen. Siempre hay que dejar suficiente espacio para que los objetos vivos se muevan, lo que significa que el espacio de destino debe ser lo suficientemente grande como para albergar todo lo que hay en el espacio de origen. El algoritmo de copia es ligeramente ineficiente en cuanto a memoria debido a esta restricción.

Colectores de marcado y barrido

La mayoría de las JVM comerciales desplegadas en entornos de producción empresarial ejecutan colectores de marcado y barrido (o marking), que no tienen el impacto en el rendimiento que tienen los colectores de copia. Algunos de los colectores de marcado más famosos son CMS, G1, GenPar y DeterministicGC (ver Recursos).

Un colector de marcado y barrido rastrea las referencias y marca cada objeto encontrado con un bit “vivo”. Normalmente, un bit marcado corresponde a una dirección o, en algunos casos, a un conjunto de direcciones en el montón. El bit vivo puede, por ejemplo, ser almacenado como un bit en la cabecera del objeto, un vector de bits o un mapa de bits.

Después de que todo ha sido marcado como vivo, la fase de barrido se pondrá en marcha. Si un recolector tiene una fase de barrido, básicamente incluye algún mecanismo para recorrer el montón de nuevo (no sólo el conjunto vivo, sino toda la longitud del montón) para localizar todos los trozos no marcados de espacios de direcciones de memoria consecutivos. La memoria no marcada está libre y es recuperable. A continuación, el recopilador enlaza estos trozos no marcados en listas libres organizadas. Puede haber varias listas libres en un recolector de basura, normalmente organizadas por tamaños de trozos. Algunas JVM (como JRockit Real Time) implementan colectores con heurística que dinámicamente listas de rango de tamaño basado en los datos de perfil de la aplicación y las estadísticas de tamaño de los objetos.

Cuando la fase de barrido se completa la asignación comenzará de nuevo. Las nuevas áreas de asignación se asignan a partir de las listas libres y los trozos de memoria podrían ajustarse a los tamaños de los objetos, a los promedios de tamaño de los objetos por ID de hilo o a los tamaños TLAB ajustados por la aplicación. Ajustar el espacio libre más estrechamente al tamaño de lo que su aplicación está tratando de asignar optimiza la memoria y podría ayudar a reducir la fragmentación.

Más sobre los tamaños TLAB

La partición TLAB y TLA (Thread Local Allocation Buffer o Thread Local Area) se discuten en la optimización del rendimiento de la JVM, Parte 1.

Desventajas de los colectores de marcado y barrido

La fase de marcado depende de la cantidad de datos vivos en su montón, mientras que la fase de barrido depende del tamaño del montón. Dado que hay que esperar hasta que se completen las fases de marcado y barrido para recuperar la memoria, este algoritmo provoca problemas de tiempo de pausa para los heaps más grandes y los conjuntos de datos vivos más grandes.

Una forma de ayudar a las aplicaciones que consumen mucha memoria es utilizar las opciones de ajuste de GC que se adaptan a varios escenarios y necesidades de la aplicación. El ajuste puede, en muchos casos, ayudar al menos a posponer que cualquiera de estas fases se convierta en un riesgo para su aplicación o los acuerdos de nivel de servicio (SLA). (Un SLA especifica que la aplicación cumplirá ciertos tiempos de respuesta de la aplicación, es decir, la latencia). Sin embargo, la puesta a punto para cada cambio de carga y modificación de la aplicación es una tarea repetitiva, ya que la puesta a punto sólo es válida para una carga de trabajo y una tasa de asignación específicas.

Implementaciones de mark-and-sweep

Hay al menos dos enfoques comercialmente disponibles y probados para implementar la recogida de mark-and-sweep. Uno es el enfoque paralelo y el otro es el enfoque concurrente (o mayormente concurrente).

Recolectores paralelos

La recolección paralela significa que los recursos asignados al proceso se utilizan en paralelo con el propósito de la recolección de basura. La mayoría de los recolectores paralelos implementados comercialmente son recolectores monolíticos de parada del mundo: todos los hilos de la aplicación se detienen hasta que se completa el ciclo de recolección de basura. Detener todos los hilos permite que todos los recursos se utilicen eficientemente en paralelo para terminar la recolección de basura a través de las fases de marcado y barrido. Esto conduce a un nivel muy alto de eficiencia, que suele dar lugar a altas puntuaciones en las pruebas de rendimiento como SPECjbb. Si el rendimiento es esencial para su aplicación, el enfoque paralelo es una excelente elección.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.