HU

Humano

← Blog

9 de marzo de 2026

Cómo un hot path en Java estaba causando pausas de 200ms cada 30 segundos

Un servicio de ingesta con 2,000 RPS tenía spikes de latencia cada 30 segundos. El problema no era la lógica de negocio — era la presión de GC por miles de objetos intermedios creados en cada request.

jvmgcrustlatencia

Un servicio de ingesta de eventos recibe 2,000 requests por segundo en hora pico. Cada request trae un payload JSON de entre 15KB y 80KB que debe ser parseado, validado y transformado antes de persistirlo. El servicio corre en Java 17 con Spring Boot y usa G1GC como garbage collector.

El p50 es 12ms — excelente. Pero cada 25-35 segundos, el p99 salta a 200-350ms. Los dashboards muestran un patrón periódico de latencia que no correlaciona con el tráfico. No hay más requests, no hay más carga — simplemente cada medio minuto, todo se frena.

El equipo agregó instancias. El patrón no desapareció — se hizo más frecuente en cada instancia individual. Tunear los timeouts del load balancer mejoró la experiencia del cliente pero no el problema subyacente.

Qué está pasando en la JVM

El garbage collector G1 divide el heap en regiones. Las regiones jóvenes (Eden) reciben las allocaciones nuevas. Cuando Eden se llena, G1 ejecuta un young collection: pausa todos los threads de la aplicación (stop-the-world), copia los objetos vivos a regiones Survivor, y libera Eden.

Con payloads JSON de 15-80KB parseados por Jackson, cada request crea miles de objetos intermedios. Los tamaños de objeto se pueden verificar con JOL (Java Object Layout):

ObjetoCantidad por request (payload 50KB)Tamaño en heap
JsonNode (nodos del árbol)~300-50048 bytes cada uno
String (keys y valores)~200-40040 bytes header + contenido
byte[] (buffers internos de Jackson)3-58KB-64KB cada uno
HashMap$Node (entries del ObjectNode)~150-30032 bytes cada uno
ArrayList (ArrayNodes internos)~20-5056 bytes + array backing

Un solo request con un payload de 50KB produce ~800-1,200 objetos que suman entre 200KB y 400KB de allocaciones en el heap. A 2,000 RPS, eso es 400-800MB de allocaciones por segundo solo en parsing.

Todos estos objetos son efímeros — se crean durante el parsing, se usan para la transformación, y se descartan. Son candidatos perfectos para recolección en young GC. Pero la velocidad de allocación es tan alta que Eden se llena cada 25-35 segundos, forzando un young collection con stop-the-world pause.

Por qué tunear el GC no resuelve el problema

La respuesta habitual es ajustar G1:

Ninguna de estas opciones elimina el problema. Solo mueven el trade-off entre frecuencia y duración de las pausas. La raíz es la cantidad de allocaciones, no la configuración del collector.

El diagnóstico: allocation profiling

El diagnóstico toma 10 minutos con async-profiler en modo allocation:

./asprof -e alloc -d 30 -f profile.jfr <pid>

El flame graph de allocaciones muestra exactamente dónde se crean los objetos. En este caso, el 72% de las allocaciones venían de tres métodos:

  1. com.fasterxml.jackson.databind.node.ObjectNode.put() — 34% de las allocaciones
  2. com.fasterxml.jackson.core.JsonParser.getText() — 23% (crea un String nuevo por cada valor)
  3. La lógica de transformación que iteraba el JsonNode tree y construía un nuevo tree de salida — 15%

El negocio del servicio — las reglas de validación, el ruteo del evento, la persistencia — representaba menos del 8% de las allocaciones. El 72% era overhead de parsing y transformación del JSON.

La intervención: extraer el hot path a código nativo

Si el 72% de la presión de GC viene del parsing y transformación del JSON, y esa lógica es esencialmente determinística (no depende de estado de la aplicación, no llama a servicios externos), se puede extraer a un componente que no pase por el garbage collector.

La opción: una biblioteca nativa en Rust que recibe el payload como bytes, parsea, valida, transforma, y devuelve el resultado serializado. El servicio Java la invoca vía JNI, pasando el buffer de entrada y recibiendo el buffer de salida.

Por qué Rust y no otra opción

AlternativaProblema
C/C++Funciona, pero sin safety de memoria. Un buffer overflow en un parser de JSON en producción es un riesgo de seguridad real
GoTiene su propio GC. Resuelve la presión en la JVM pero crea presión de GC en el runtime de Go. No elimina el problema, lo mueve
C++ con smart pointersViable, pero el tooling de build y la complejidad de FFI son mayores. Más superficie de error
RustSin GC, memory safety garantizada por el compilador, serde es uno de los parsers JSON más rápidos disponibles, y el FFI con JNI es directo vía jni crate

La decisión no es "Rust es mejor que Java" — es que este componente específico tiene un patrón de allocación que genera presión de GC, y moverlo fuera del heap de Java elimina esa presión sin introducir riesgo de memoria.

Qué hace la biblioteca nativa

Input:  byte[] (payload JSON crudo, pasado desde Java vía JNI)
Output: byte[] (evento transformado, serializado como JSON o MessagePack)

Internamente, la biblioteca:

  1. Parsea con serde_json usando deserialización a structs tipados (no un árbol genérico). Serde puede deserializar un JSON de 50KB en ~35μs en un core moderno, contra ~180μs de Jackson con databind
  2. Valida las reglas de negocio sobre los structs directamente — sin crear objetos intermedios
  3. Transforma el struct de entrada al struct de salida con una función pura
  4. Serializa el resultado de vuelta a bytes

Todo se ejecuta en stack o en allocaciones de heap nativas (fuera de la JVM). Zero allocaciones en el Java heap durante el hot path.

El overhead de JNI

JNI tiene un costo por llamada: ~50-100ns para cruzar la frontera Java/nativo, más el costo de copiar el buffer de entrada. Para un payload de 50KB, la copia toma ~5μs. El overhead total del JNI roundtrip es ~10-15μs por request.

Comparado con los 200-350ms de pausa de GC cada 30 segundos, 15μs por request es insignificante. El JNI es viable cuando:

No usarías JNI para deserializar un campo individual — el overhead de cruce dominaría. Pero para procesar un payload completo de 50KB, el ratio trabajo/overhead es ~10:1.

El resultado

MétricaAntes (Jackson en Java)Después (serde en Rust vía JNI)
Allocaciones en heap por request800-1,200 objetos (200-400KB)0 objetos en Java heap
Tasa de allocación total400-800 MB/s15-30 MB/s (solo la lógica de negocio)
Young GC frequencyCada 25-35 segundosCada 8-12 minutos
Young GC pause duration200-350ms8-15ms
Latencia p5012ms8ms
Latencia p99200-350ms (durante GC)22ms
Memoria heap necesaria (-Xmx)4GB1.5GB
Memoria nativa (RSS de la lib Rust)~45MB
Memoria total del proceso~4.5GB~1.7GB

El heap de la JVM bajó de 4GB a 1.5GB porque ya no necesita Eden grande para absorber la tasa de allocación. La biblioteca nativa usa ~45MB de memoria RSS — sin fragmentación, sin GC, sin pauses.

Las instancias se redujeron de 10 a 6 manteniendo el mismo throughput, porque cada instancia ahora procesa más requests sin degradación periódica por GC.

Cuándo este patrón aplica

La extracción a código nativo tiene sentido cuando:

Cuándo no

No todo bottleneck en una JVM se resuelve con código nativo. Pero cuando el allocation profiler muestra un hot path dominante con lógica autocontenida, es uno de los cambios con mejor ratio de impacto por líneas de código tocadas.


Si tu servicio tiene spikes de latencia periódicos que correlacionan con actividad del garbage collector y quieres saber si el hot path es candidato a extracción, podemos revisarlo en una conversación de 15 minutos sin costo.

Referencias