|

Memoria JVM en microservicios: por qué el heap no es todo

La memoria JVM en microservicios sobre contenedores no se agota en el heap: Metaspace, thread stacks, direct buffers y metadata del garbage collector consumen memoria nativa invisible para -Xmx. Ese fue el problema real de una plataforma iPaaS que sufría reinicios de pods en Kubernetes con el heap en niveles sanos, según el análisis publicado en dev.to en julio de 2026.

La memoria de una JVM en contenedores es el total de memoria residente (RSS) que el proceso Java reserva del sistema operativo, repartida entre heap (objetos de la aplicación), Metaspace (metadata de clases), pilas de threads, buffers directos de red y estructuras internas del recolector de basura. Kubernetes mide ese número único y mata el pod cuando supera el límite, sin distinguir de dónde viene el consumo.

En 30 segundos

  • El heap no es todo: Metaspace, thread stacks (256KB a 1MB cada uno) y direct buffers viven en memoria nativa, fuera de -Xmx.
  • El error clásico: asignar -Xmx al 100% del límite del contenedor. Con 4GB de límite, dejá el heap en 2,5 a 3GB.
  • Metaspace reemplazó a PermGen en Java 8 y crece sin tope si no configurás -XX:MaxMetaspaceSize. Java 17+ lo devuelve más rápido al SO con Elastic Metaspace (JEP 387).
  • Kubernetes ve el RSS, no el heap: un dashboard con heap al 70% y pod que se reinicia significa problema en memoria non-heap.
  • Monitoreá RSS, Metaspace y buffers con jcmd, JMX y Native Memory Tracking, no solo el heap.

¿Cuántas fuentes de memoria consume la JVM más allá del heap?

Ponele que tenés un runtime de integraciones procesando mensajes sin problemas. Los tiempos de respuesta están estables, el profiling no muestra memory leaks y el heap está cómodo debajo del límite. Y aun así el pod se reinicia varias veces por día. ¿Dónde se está yendo la memoria? Fuera del heap.

Ese es exactamente el caso que describe el ingeniero detrás de la plataforma iPaaS en el artículo original. La investigación arrancó buscando un leak a nivel aplicación (mucha lógica de transformación, ruteo de mensajes, ejecución de conectores) y no encontró nada raro en el heap. El consumo real estaba repartido en componentes que los dashboards estándar ni miran.

La JVM reparte su memoria en varias zonas:

  • Heap: donde viven los objetos de tu aplicación. Es lo único que limita -Xmx y lo único que la mayoría monitorea.
  • Metaspace: metadata de clases y bytecode de métodos. Reside en memoria nativa, no en el heap.
  • Thread stacks: cada thread reserva entre 256KB y 1MB de pila. Un servicio con cientos de threads suma cientos de MB.
  • Direct buffers: buffers NIO que usan Netty, servidores HTTP y drivers de base de datos para I/O eficiente.
  • Metadata del GC y código JIT: estructuras internas del recolector y el código ya compilado a nativo.

Ninguno de estos por separado parece un problema. Juntos, en un servicio con muchos conectores y SDKs, la cuenta se dispara. Metaspace crecía a medida que se cargaban miles de clases, las pilas se acumulaban por los pools de procesamiento y los buffers directos de las librerías de red comían memoria nativa. El heap sano era una pista falsa.

¿Cómo afecta el classloading al consumo de memoria en microservicios Java?

Spring Boot no es liviano en el arranque. Carga miles de clases: el framework, los proxies dinámicos, el bytecode enhancement, la reflection. Cada clase cargada deja overhead en Metaspace, y ese overhead no se libera solo mientras el classloader siga vivo.

Acá viene lo bueno: en una arquitectura cloud-native con 50 servicios, si cada uno desperdicia 100MB en clases redundantes, son 5GB de RAM que estás pagando sin necesidad. Un Spring Boot básico carga bastante menos que un stack con Spring Cloud completo, y esa diferencia se multiplica por réplica. Cubrimos ese tema en detalle en pipeline de CI/CD para microservicios.

La herramienta para atacar esto es Class Data Sharing (CDS) y su variante Application CDS (AppCDS). Con CDS armás un archivo compartido con las clases del sistema ya parseadas, que la JVM mapea en memoria en lugar de reprocesar en cada arranque. Reduce el tiempo de boot y baja la presión sobre Metaspace, algo clave cuando escalás muchas réplicas del mismo servicio.

¿Qué es Metaspace y por qué causa OutOfMemory en contenedores?

Metaspace es la región de memoria nativa donde la JVM guarda las definiciones de clases y el bytecode de los métodos. Reemplazó a PermGen en Java 8. La diferencia clave: PermGen vivía dentro del heap con un tope fijo, mientras que Metaspace vive en memoria nativa y, sin configuración, crece sin límite hasta chocar con el límite del contenedor.

El tema es que en microservicios con classloaders dinámicos (proxies, reflection intensiva, generación de clases en runtime) Metaspace puede crecer rápido y de forma difícil de predecir. Y limpiarlo no es gratis: recuperar Metaspace requiere un full GC, que es lento y frena la aplicación.

Sin -XX:MaxMetaspaceSize configurado, no hay freno. Java 17 y posteriores incluyen Elastic Metaspace (JEP 387), que devuelve memoria no usada al sistema operativo de forma más agresiva. Ayuda, pero no te exime de poner un tope explícito y monitorear el crecimiento.

¿Cuál es la diferencia entre Metaspace y PermGen?

PermGen era parte del heap con un tamaño máximo fijo, y desbordarlo tiraba el famoso OutOfMemoryError: PermGen space. Metaspace vive en memoria nativa y por defecto no tiene tope. El error cambió de forma: ahora aparece como OutOfMemoryError: Metaspace, y en contenedores muchas veces ni llega a tirarlo porque el kernel mata el proceso por OOM antes.

¿Por qué los thread stacks consumen tanta memoria?

Cada thread que arranca reserva su propia pila, entre 256KB y 1MB. Suena poco. Ahora multiplicá: un servicio con 500 threads consume hasta 500MB solo en pilas, íntegramente fuera del heap.

Y los threads aparecen sin que los cuentes: colas de mensajes, thread pools para procesamiento asíncrono, tareas programadas, pools de conexiones a sistemas externos. En un runtime de integraciones como el del caso iPaaS, esos pools son parte del diseño, no una anomalía.

Hagamos la cuenta que revienta un contenedor. Tenés 4GB de límite, asignás 2GB al heap pensando que dejás mitad libre, pero perdés 500MB en 500 threads, 256MB en Metaspace y hasta 1GB en direct buffers. Sumás casi todo el margen que creías tener, y el pod se muere aunque el heap use apenas 1,5GB.

¿Qué son los direct buffers y la memoria nativa en la JVM?

Los direct buffers son buffers NIO que la JVM reserva fuera del heap para hacer I/O más eficiente. Los usan Netty, los servidores HTTP, los drivers de base de datos y cualquier librería que mueva datos de red o disco a alta velocidad. Son invisibles para -Xmx, pero cuentan igual contra el límite de memoria del contenedor.

Un ejemplo concreto: un consumer de Kafka en Spring Boot usa buffers NIO para el I/O de red. Cada conexión abierta suma memoria nativa. Con muchos consumers o muchas particiones, ese consumo crece y no aparece en ningún gráfico de heap.

A eso sumale las librerías JNI, la metadata del GC y el código compilado por el JIT. Todo eso es memoria nativa. Es la parte del iceberg que no ves hasta que el proceso muere por OOM con el heap medio vacío.

¿Cuál es el error crítico al configurar -Xmx en Kubernetes?

El error más común y más caro: asignar -Xmx al 100% del límite de memoria del contenedor. Si el pod tiene 4GB de límite y ponés -Xmx4g, Metaspace, las pilas y los buffers se quedan sin lugar. Resultado: crash por OOM aunque tu código esté impecable. Esto se conecta con lo que analizamos en contenido en múltiples idiomas.

La regla práctica: dejá margen para lo non-heap. Con un límite de 4GB, un -Xmx máximo de 2,5 a 3GB deja espacio para el resto. Kubernetes no distingue entre heap y non-heap: ve un solo proceso, mide su RSS y lo mata si cruza el límite.

Este cálculo importa tanto en un cluster propio como en instancias cloud gestionadas. Si tu stack corre sobre un VPS o servidor en donweb.com con memoria acotada, dimensionar mal el heap te lleva a reinicios en cadena que parecen fallas de infraestructura y son de configuración de JVM.

Zona de memoria¿La limita -Xmx?Consumo típicoCómo controlarla
HeapEl grueso, según carga-Xmx / -Xms
MetaspaceNoCientos de MB en apps Spring-XX:MaxMetaspaceSize
Thread stacksNo256KB a 1MB por threadTamaño de pools + -Xss
Direct buffersNoEscala con conexiones de red-XX:MaxDirectMemorySize
GC + código JITNoOverhead variableElección de GC, tiering
memoria jvm microservicios contenedores diagrama explicativo

¿Cómo monitorear el consumo real de memoria en producción?

Mirar solo el heap en el dashboard es engañarte. Si el heap está al 70% pero el RSS del proceso está al 95%, tu problema no está en el código: está en la memoria non-heap. Ahí es donde tenés que mirar.

Qué conviene monitorear de verdad:

  • RSS (Resident Set Size): la memoria total del proceso. Es lo que Kubernetes usa para decidir si te mata.
  • Uso de Metaspace: expuesto por JMX. Vigilá que no crezca sin techo.
  • Direct buffer allocation: también vía JMX, para detectar fugas en buffers NIO.
  • Pausas de GC y allocation rate: te dicen si el recolector está sufriendo antes de que reviente.

Las herramientas para esto: jcmd para consultas puntuales, JMX remoto, el JVM exporter de Prometheus para series de tiempo y async-profiler para ir al detalle. Y una que muchos ignoran: Native Memory Tracking. Arrancá la JVM con -XX:NativeMemoryTracking=summary y con jcmd VM.native_memory summary obtenés el desglose real de dónde se fue cada MB, según la documentación de Baeldung sobre NMT.

¿Qué ajustes de JVM conviene usar para microservicios en Kubernetes?

No hay una config única. Depende de la carga del servicio. Pero hay líneas base razonables según el perfil.

Servicios chicos y de bajo tráfico

Java 21 con G1GC por defecto alcanza. No sobreoptimices algo que procesa pocas requests. Poné un -Xmx conservador y dejá que la JVM haga su trabajo.

APIs de alto tráfico

Acá el allocation rate y las pausas de GC son lo que te va a doler. Monitoreá ambos y ajustá el heap para que el GC no frene las respuestas en horas pico.

Servicios sensibles al arranque

Si escalás para arriba y para abajo seguido, el tiempo de boot cuenta. CDS y AppCDS te dan arranques más rápidos y menos presión sobre Metaspace en cada réplica nueva. Te puede servir nuestra cobertura de ejecutar servicios sin API externa.

Servicios con memoria acotada

Reservá espacio explícito para el overhead non-heap y ponele tope a Metaspace. Flags útiles para empezar: -XX:MaxMetaspaceSize, -XX:+UseG1GC, -XX:MaxDirectMemorySize y -XX:NativeMemoryTracking=summary para diagnosticar.

Qué está confirmado y qué no

  • Confirmado: Metaspace reemplazó a PermGen en Java 8 y vive en memoria nativa. Es documentación oficial de la JVM.
  • Confirmado: JEP 387 (Elastic Metaspace) está en Java 17+ y devuelve memoria al SO de forma más agresiva.
  • Confirmado: Kubernetes decide matar pods por RSS del proceso, sin distinguir heap de non-heap.
  • Depende de tu caso: los rangos de 256KB a 1MB por thread y la regla de heap al 60-75% del límite son referencias prácticas, no leyes. Medí tu carga real antes de fijar valores.
  • Pendiente en el caso iPaaS: el artículo describe el diagnóstico pero no publica cifras finales de cuánto bajó el consumo tras el tuning. Tomá la anécdota como patrón, no como benchmark.

Errores comunes al configurar la memoria de una JVM en contenedores

  • Poner -Xmx igual al límite del contenedor. Dejás sin lugar a Metaspace, pilas y buffers. Corrección: heap entre 60% y 75% del límite, medí y ajustá.
  • Monitorear solo el heap. Un heap sano con pod que se reinicia es la firma de un problema non-heap. Corrección: sumá RSS, Metaspace y direct buffers al monitoreo.
  • No poner tope a Metaspace. Sin -XX:MaxMetaspaceSize crece hasta chocar con el límite. Corrección: fijá un máximo acorde a cuántas clases carga tu stack.
  • Ignorar la cantidad de threads. Pools sobredimensionados suman cientos de MB en pilas. Corrección: dimensioná los thread pools según la concurrencia real, no “por las dudas”.
  • Culpar a la infraestructura. Muchos reinicios que parecen fallas del cluster son heap mal dimensionado. Corrección: corré Native Memory Tracking antes de escalar el hardware.

Preguntas Frecuentes

¿Por qué una aplicación Java consume más memoria que la configurada en -Xmx?

-Xmx limita solo el heap. La JVM reserva memoria adicional para Metaspace, pilas de threads, direct buffers, metadata del GC y código compilado por el JIT, todo fuera del heap. Por eso el RSS del proceso siempre es mayor que el valor de -Xmx.

¿Cómo debo configurar la memoria de un contenedor con JVM?

Asigná al heap entre el 60% y el 75% del límite de memoria del contenedor y reservá el resto para lo non-heap. Con un límite de 4GB, un -Xmx de 2,5 a 3GB es un punto de partida seguro. Después ajustá midiendo el RSS real bajo carga.

¿Qué es Metaspace y cómo afecta a aplicaciones en Kubernetes?

Metaspace es la región de memoria nativa donde la JVM guarda metadata de clases y bytecode de métodos, desde Java 8. En Kubernetes crece sin tope si no configurás -XX:MaxMetaspaceSize, y ese crecimiento cuenta contra el límite del pod, lo que puede provocar reinicios por OOM.

¿Cómo monitorear el consumo real de memoria en una JVM en producción?

Medí el RSS del proceso, no solo el heap, con herramientas como jcmd, JMX y el JVM exporter de Prometheus. Activá Native Memory Tracking con -XX:NativeMemoryTracking=summary para ver el desglose exacto entre heap, Metaspace, pilas y buffers.

¿Por qué se reinicia mi pod si el heap está bajo?

Porque Kubernetes mide el RSS total del proceso, no el heap. Si Metaspace, las pilas de threads o los direct buffers crecieron, el RSS puede superar el límite del pod aunque el heap esté cómodo. Es la causa típica de reinicios con dashboards de heap en verde.

Conclusión

Lo que cambió no es la JVM, es dónde tenés que mirar. En contenedores, el heap dejó de ser el indicador que importa: Kubernetes mata por RSS, y el RSS incluye Metaspace, pilas, buffers y metadata que ningún dashboard de heap te muestra. El caso iPaaS de julio de 2026 es el recordatorio de que un heap sano puede convivir con reinicios diarios.

Qué hacer con esto, concreto: dejá el heap en 60-75% del límite del contenedor, ponele tope a Metaspace, dimensioná los thread pools con criterio y activá Native Memory Tracking antes de pedir más RAM. Si migrás a Java 17 o superior, Elastic Metaspace te ayuda a devolver memoria al SO. Y si tu servicio corre sobre infraestructura con memoria acotada, este ajuste vale más que escalar el hardware.

Fuentes

Te puede interesar...