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.
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):
| Objeto | Cantidad por request (payload 50KB) | Tamaño en heap |
|---|---|---|
JsonNode (nodos del árbol) | ~300-500 | 48 bytes cada uno |
String (keys y valores) | ~200-400 | 40 bytes header + contenido |
byte[] (buffers internos de Jackson) | 3-5 | 8KB-64KB cada uno |
HashMap$Node (entries del ObjectNode) | ~150-300 | 32 bytes cada uno |
ArrayList (ArrayNodes internos) | ~20-50 | 56 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:
- Aumentar el heap (-Xmx): Eden más grande → las pausas son menos frecuentes pero más largas. Con heap de 8GB, el young GC pasa de 200ms cada 30s a 400ms cada 60s. El p99 empeora
- Reducir
MaxGCPauseMillis: G1 intenta respetar el target haciendo colecciones más frecuentes con menos trabajo. Pero con 400-800MB/s de allocación, no puede mantener pausas bajo 50ms sin fragmentar las regiones - Cambiar a ZGC o Shenandoah: Pausas más cortas (~1-5ms) pero throughput reducido en ~5-15%. Y el costo real — la presión de memoria — sigue ahí. Solo se esconde mejor
- Usar Parallel GC: Pausas más largas (300-500ms) pero mejor throughput total. Trade-off inaceptable para un servicio con SLA de latencia
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:
com.fasterxml.jackson.databind.node.ObjectNode.put()— 34% de las allocacionescom.fasterxml.jackson.core.JsonParser.getText()— 23% (crea unStringnuevo por cada valor)- La lógica de transformación que iteraba el
JsonNodetree 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
| Alternativa | Problema |
|---|---|
| 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 |
| Go | Tiene 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 pointers | Viable, pero el tooling de build y la complejidad de FFI son mayores. Más superficie de error |
| Rust | Sin 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:
- Parsea con
serde_jsonusando 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 - Valida las reglas de negocio sobre los structs directamente — sin crear objetos intermedios
- Transforma el struct de entrada al struct de salida con una función pura
- 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:
- La operación nativa es sustancial (>100μs de trabajo)
- Se pasan buffers de bytes, no objetos Java complejos
- La llamada es batch-oriented (un payload completo, no campo por campo)
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étrica | Antes (Jackson en Java) | Después (serde en Rust vía JNI) |
|---|---|---|
| Allocaciones en heap por request | 800-1,200 objetos (200-400KB) | 0 objetos en Java heap |
| Tasa de allocación total | 400-800 MB/s | 15-30 MB/s (solo la lógica de negocio) |
| Young GC frequency | Cada 25-35 segundos | Cada 8-12 minutos |
| Young GC pause duration | 200-350ms | 8-15ms |
| Latencia p50 | 12ms | 8ms |
| Latencia p99 | 200-350ms (durante GC) | 22ms |
| Memoria heap necesaria (-Xmx) | 4GB | 1.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:
- Un componente genera >60% de la presión de GC (medido con allocation profiling)
- La lógica es autocontenida: recibe bytes, devuelve bytes, sin dependencias externas
- El throughput es alto (>500 RPS) y la latencia importa (SLA < 100ms p99)
- El componente es estable — no cambia cada sprint
Cuándo no
- Si la presión de GC viene de muchas fuentes distribuidas (no hay un hot path dominante), extraer un componente no cambia mucho
- Si el servicio está en un lenguaje que no tiene GC (Go con escape analysis eficiente, o ya es nativo), el problema es otro
- Si la lógica requiere estado compartido con el resto de la aplicación (acceso a cache local, conexiones a DB), el costo de serializar ese estado a través del FFI puede superar el beneficio
- Si el equipo no tiene capacidad para mantener código nativo en producción y no hay soporte externo para ese componente
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
- Oracle Java 17: Garbage-First (G1) Garbage Collector — documentación oficial de G1GC incluyendo el modelo de regiones, young collections, y stop-the-world pauses
- Oracle Java 17: G1 Garbage Collector Tuning — referencia para
MaxGCPauseMillisy otros parámetros de tuning - Oracle Java 17: Z Garbage Collector — documentación de ZGC, collector de baja latencia mencionado como alternativa
- Shenandoah GC — OpenJDK Wiki — collector concurrente alternativo a ZGC
- Jackson Databind — GitHub — biblioteca de parsing JSON para JVM usada como baseline de comparación
- JOL (Java Object Layout) — OpenJDK — herramienta para medir tamaños reales de objetos en el heap de la JVM, usada para las estimaciones de la tabla de allocaciones
- async-profiler — GitHub — profiler de allocaciones y CPU para JVM usado en el diagnóstico con
asprof -e alloc - serde — Rust serialization framework — framework de serialización de Rust, base de
serde_json - serde_json — docs.rs — parser JSON de Rust usado en la biblioteca nativa para deserialización a structs tipados
- jni crate — docs.rs — bindings de JNI para Rust que facilitan la integración con la JVM
- Oracle Java 17: JNI Specification — especificación oficial del Java Native Interface
- Oracle Java 17: JNI Design — Compiling, Loading, and Linking Native Methods — detalles del overhead de cruce de frontera Java/nativo
- The Rust Programming Language: Understanding Ownership — modelo de ownership de Rust que garantiza memory safety sin garbage collector