Garbage Collector de Java, Kubernetes y cómo evitar el error OOMKilled.
Por: Ronald Escalona. VP of Engineering en Platzi.
Un amigo me escribió que no entendía lo que estaba sucediendo con su aplicación de procesamiento de imágenes en Java con 1GB de RAM en Kubernetes, porque a las horas de estar en ejecución el consumo de memoria crecía sin parar hasta que Kubernetes mataba el Pod con un mensaje de error OOMKilled (Out Of Memory Kill error) código 137.
Le expliqué que el problema estaba en el Garbage Collector y el uso de la memoria en Java, no en Kubernetes directamente. Al final te mostraré el resultado de nuestra conversación, pero antes comencemos por los detalles que muchos desarrolladores en Java desconocen o simplemente deciden no prestarle atención.
Qué es el Garbage Collector de Java
El Garbage Collector (GC), o recolector de basura, es el encargado de gestionar la memoria en Java, liberando espacio utilizado por objetos que ya no son necesarios. Aunque es un aliado esencial, su configuración inadecuada puede causar problemas, especialmente en entornos donde el límite de memoria que la aplicación pueda usar es crítico.
En este artículo, exploramos cómo ajustar el GC para evitar el temido OOMKilled y optimizar el rendimiento de tus aplicaciones Java.
Veamos muy por encima cómo la JVM (Java Virtual Machine) distribuye la memoria para entender mejor cómo se comporta todo el entorno:

-
El Heap está compuesto por los objetos creados, organizados en generaciones que explicaré más adelante en la sección del G1 GC.
-
Los survivors o sobrevivientes son áreas que no son limpiadas porque aún están en uso (objetos con referencias vivas).
-
Adicional, está el metaspace de java que es quien se encarga de almacenar la metadata de las clases generadas.
-
El code cache se emplea sobre todo en el OpenJDK por el compilador Just-In-Time (JIT) y hace que la ejecución sea más rápida.
-
El Stack de los hilos/thread es donde se almacena la información de las variables locales y llamadas a métodos y finalmente el espacio para las bibliotecas compartidas.
Los tipos de Garbage Collector en Java
Cada tipo de GC está diseñado para un caso de uso específico. Aquí repasamos los más importantes:
1. Serial GC
Uso recomendado: Ideal para aplicaciones de un solo hilo o baja demanda de memoria.
Comando de configuración:
java -XX:+UseSerialGC -jar mi-aplicacion.jar
Pros:
- Sencillo y eficiente para aplicaciones pequeñas
- Baja sobrecarga de recursos
Contras:
- Pausas largas debido a la recolección secuencial (Stop-the-world).
2. Parallel GC
Uso recomendado: Aplicaciones multihilo que priorizan el rendimiento general.
Comando de configuración:
java -XX:+UseParallelGC -jar mi-aplicacion.jar
Pros:
- Utiliza múltiples hilos para recolección, reduciendo el tiempo total
- Buen rendimiento en sistemas con muchos núcleos
Contras:
- No es ideal para aplicaciones que requieren pausas cortas
3. Concurrent Mark-Sweep (CMS) GC
Uso recomendado: Aplicaciones que requieren baja latencia y pausas mínimas.
Comando de configuración:
java -XX:+UseConcMarkSweepGC -jar mi-aplicacion.jar
Pros:
- Realiza gran parte de su trabajo concurrentemente con la aplicación, minimizando pausas
Contras:
- Elevado consumo de CPU
- Mantenimiento complejo debido a su diseño
Estado actual
- Descontinuado a partir de Java 9 y eliminado en Java 14
- ¿Por qué? Fue reemplazado por G1 GC debido a su mejor equilibrio entre rendimiento y predictibilidad de pausas
4. Garbage-First (G1) GC
Uso recomendado: Aplicaciones que necesitan pausas predecibles (configuradas con -XX:GCPauseIntervalMillis
y -XX:MaxGCPauseTimeMillis
) y buena escalabilidad.
Comando de configuración:
java -XX:+UseG1GC -jar mi-aplicacion.jar
Pros:
- Divide el heap en regiones pequeñas y prioriza la recolección en las áreas con más basura
- Pausas cortas y predecibles, ideal para aplicaciones sensibles al rendimiento
Contras:
- Puede no ser tan eficiente en aplicaciones pequeñas (100MB de RAM o menos) debido a su mayor complejidad
¿Por qué se divide el heap en regiones?
El G1 GC organiza la memoria (heap) en pequeñas regiones en lugar de tratarlo como un bloque único. Estas regiones pueden pertenecer a diferentes generaciones de objetos:
- Young Generation: Objetos recién creados.
- Old Generation: Objetos de larga vida que sobrevivieron múltiples ciclos de GC.
- Humongous Objects: Objetos muy grandes que ocupan varias regiones.
El GC analiza las regiones y da prioridad a las que tienen más basura para ser recolectadas primero, logrando un equilibrio entre liberar memoria y minimizar las pausas.
5. The Z Garbage Collector (ZGC)
Uso recomendado: Aplicaciones modernas con heaps grandes (hasta terabytes) que necesitan pausas ultracortas.
Comando de configuración:
java -XX:+UseZGC -jar mi-aplicacion.jar
Pros:
- Pausas consistentemente menores a 10 ms
- Escalabilidad para heaps masivos (hasta 16 TB)
- Reduce significativamente los tiempos de respuesta en aplicaciones sensibles a la latencia.
Contras:
- Mayor consumo de CPU
- Compatible sólo con Java 11 y posteriores
¿Por qué fue creado?
Diseñado para abordar los desafíos de las aplicaciones modernas, donde los tiempos de pausa y la capacidad de manejar heaps grandes son críticos para el rendimiento. Responde a la demanda de sistemas que requieren baja latencia y capacidad para procesar grandes volúmenes de datos.
¿Cuál Garbage Collector usar?
La recomendación inicial es que dejes que la JVM elija automáticamente estos parámetros si la aplicación no tiene exigencias particulares de rendimiento o uso de memoria.
Como regla general para mejorar el rendimiento se deben seguir estas recomendaciones:
- Si la aplicación tiene un conjunto de datos pequeño (hasta aproximadamente 100 MB), seleccione el recolector serial con la opción
-XX:+UseSerialGC
- Si la aplicación se ejecutará en un único procesador (single processor) y no hay requisitos de tiempo de pausa, seleccione el recolector serial con la opción
-XX:+UseSerialG
- Si (a) el rendimiento máximo de la aplicación es la prioridad principal y (b) no hay requisitos de tiempo de pausa o las pausas de un segundo o más son aceptables, deja que la máquina virtual seleccione el recolector o selecciona el recolector paralelo con
-XX:+UseParallelGC
. - Si el tiempo de respuesta es más importante que el rendimiento general y las pausas de recolección de basura deben ser más breves, seleccione el recolector mayormente concurrente con
-XX:+UseG1GC
. - Si el tiempo de respuesta es una prioridad alta, y se espera usar gran cantidad de memoria RAM, selecciona el recolector totalmente concurrente con
-XX:+UseZGC
.
Estas recomendaciones son sólo un punto de partida para seleccionar un recolector, ya que el rendimiento depende del tamaño de la memoria asignada (heap), la cantidad de datos activos que mantiene la aplicación y la cantidad y velocidad de los procesadores disponibles. Te toca probar y encontrar el mejor ajuste para el entorno y aplicación que estás configurando.
¿Qué es un OOMKill y por qué lo usa Kubernetes?
Un OOMKill ocurre cuando un pod supera los límites de memoria asignados en Kubernetes. Es una medida de seguridad que protege al servidor o nodo y otros pods al matar el proceso que se consume toda la memoria pre-asignada e intenta usar más de lo permitido.
Esto nos asegura que tengamos:
Aislamiento de recursos: Garantiza que cada pod respete los límites asignados.
Protección del nodo/servidor: Evita que un pod sobrecargue el sistema, afectando el rendimiento global.
El proceso terminado genera un evento OOMKilled, con código de salida 137, indicando que la memoria excedida provocó la acción.
Límites de memoria en Kubernetes
En Kubernetes, cada pod puede tener definidos límites de CPU y memoria para garantizar un uso eficiente de los recursos. Estos límites se establecen en el archivo de configuración del pod:
apiVersion: v1
kind: Pod
metadata:
name: ejemplo-pod
spec:
containers:
- name: ejemplo-contenedor
image: ejemplo-imagen
resources:
limits:
memory: "512Mi"
cpu: "500m"
En este ejemplo, el contenedor tiene un límite de 512 MiB de memoria y 0.5 CPU. Si la aplicación Java dentro del contenedor supera este límite de memoria, Kubernetes puede terminar el pod con un OOMKill para proteger el nodo.
La JVM y los contenedores: Configurando la armonía
Normalmente lo primero que solemos hacer es agregar más memoria a la configuración del Pod en Kubernetes y vemos si mejora el rendimiento, pero como el caso en cuestión de este artículo es que la aplicación muere por falta de memoria al tiempo de estar en ejecución, lo más seguro es que hay una descoordinación entre la memoria disponible y el trabajo del Garbage Collector para liberar la memoria que ya no se requiera, en estos casos hay que tomar en cuenta los parámetros que mostraré a continuación.
La Máquina Virtual de Java (JVM), aunque intenta ajustar distintos parámetros de uso de memoria de forma automática, no siempre reconoce los límites de recursos impuestos para los contenedores. Para asegurarnos que la JVM reconozca y respete estos límites, podemos utilizar las siguientes opciones y dejar que el resto de los parámetros se ajusten autimáticamente:
-XX:+UseContainerSupport
Habilita el soporte de la JVM para detectar límites de recursos en contenedores.-XX:MaxRAMPercentage
Define el porcentaje máximo de la memoria disponible que la JVM puede utilizar (heap) y con esto forzar al GC que realice su trabajo al llegar a este límite.
Con estos dos parámetros la JVM ajustará los siguientes parámetros de forma automática pero que también podrás hacerlo si quieres tener un mayor control de cómo debe comportarse la gestión de memoria con tu aplicación: -XX:InitialHeapSize
, -XX:MaxHeapSize
, -XX:MinHeapFreeRatio
y -XX:MaxHeapFreeRatio
.
Configuración recomendada
Si nuestro contenedor tiene un límite de 512 MiB de memoria y queremos que la JVM utilice hasta el 80% de esa memoria, hay que dejar espacio para las operaciones de limpieza y todo el resto que vimos en el gráfico anterior, podemos iniciar la aplicación Java con las siguientes opciones:
java -XX:+UseContainerSupport -XX:MaxRAMPercentage=80.0 -XX:+UseG1GC -jar mi-aplicacion.jar
En el comando especifico el G1 GC, es una aplicación de 512MiB de RAM y este GC proporciona el mejor rendimiento y costo beneficio si se cuenta con suficiente poder de CPU, son cosas que debes ir ajustando y midiendo para entender cuál es la fórmula ganadora en tu entorno de ejecución.
Esto garantiza que la JVM reconozca los límites del contenedor y ajuste su uso de memoria en consecuencia, reduciendo el riesgo de un OOMKill.
¿Dónde aplicar la configuración? Dockerfile vs manifiesto del pod
Otra discusión que tuve con mi amigo fue si colocar esta configuración en el Dockerfile o en el manifiesto del Pod, acá mi recomendación:
En el Dockerfile
FROM openjdk:11-jre-slim
COPY mi-aplicacion.jar /app/mi-aplicacion.jar
CMD ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=80.0", "-XX:+UseG1GC", "-jar", "/app/mi-aplicacion.jar"]
Ventajas:
- Los parámetros quedan incorporados en la imagen del contenedor, garantizando la uniformidad en múltiples entornos.
- Reutilización sin necesidad de ajustes externos.
En el manifiesto del pod
apiVersion: v1
kind: Pod
metadata:
name: ejemplo-pod
spec:
containers:
- name: ejemplo-contenedor
image: ejemplo-imagen
command:
- "java"
- "-XX:+UseContainerSupport"
- "-XX:MaxRAMPercentage=80.0"
- "-XX:+UseG1GC"
- "-jar"
- "/app/mi-aplicacion.jar"
Ventajas:
- Mayor flexibilidad para diferentes despliegues.
- Ideal para configuraciones dinámicas.
¿Cuál es la mejor opción?
- Si usas la misma configuración en múltiples entornos, configúralo en el Dockerfile.
- Si necesitas personalizar parámetros en diferentes despliegues o cluster, opta por el manifiesto del pod.
Evitando el OOMKill: Buenas prácticas
- Monitoreo constante: Utiliza herramientas como Prometheus y Grafana para supervisar el uso de memoria de tus aplicaciones en Kubernetes.
- Pruebas de carga: Realiza pruebas bajo diferentes cargas para identificar posibles fugas de memoria o configuraciones inadecuadas.
- Ajuste de parámetros: Configura adecuadamente las opciones de la JVM y los límites de recursos en Kubernetes para adaptarse a las necesidades específicas de tu aplicación.
- Actualizaciones periódicas: Mantén tu JVM y Kubernetes actualizados siempre que sea posible.
Al final mi amigo aplicó las recomendaciones y me pasó una pantalla de cómo su aplicación se comporta, no más OOMKilled error, gracias al Garbage Collector y los parámetros ajustados en la JVM:

Observen que la cantidad de memoria usada comienza a acumularse hasta que el Garbage Collector realiza la limpieza de los objetos que ya no están siendo referenciados, por eso ve como abruptamente se libera la memoria.
Si la cantidad de memoria acumulada no puede liberarse y sigue el problema del OOMKilled quiere decir que la aplicación requiere más memoria RAM para los objetos activos y el problema se traslada al diseño propio de la aplicación.
Como podrás notar, este tipo de problemas son los que abordamos cuando gestionamos infraestructura Cloud, que también implica entender cómo funcionan los elementos que componen nuestra solución: como los lenguajes de programación, sus máquinas virtuales, cómo se comporta el sistema operativo (normalmente Linux) y la gestión de los recursos. Por esto, te invito a que estudies en nuestra escuela de DevOps y Cloud Computing.
Curso de Java SE: SQL y Bases de Datos