Last active
September 4, 2026 06:43
-
-
Save alonsoir/6f9c970ee3175db434c6f007582a3336 to your computer and use it in GitHub Desktop.
Qué hacer si la app spring-boot se come la RAM?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Atencion, pregunta: | |
| Cuando ves que un servicio en Spring Boot se está comiendo la RAM, | |
| el 90% de las veces la culpa no es del framework, sino de cómo le metimos mano. | |
| Antes de saltar a pagar un servidor más grande para solucionar el problema a billetazos, | |
| suelo revisar 4 cosas clave: -El Garbage Collector: | |
| Si manejas heaps grandes y necesitas que la app no se "congele", | |
| dale una oportunidad a ZGC o Shenandoah en lugar del que viene por defecto. | |
| -Proyecciones en JPA: Si solo necesitas tres datos en pantalla, no te traigas la entidad completa de la base de datos | |
| con todas sus relaciones. Usa DTOs. | |
| -Procesar en batches: Cargar listas gigantescas de golpe a memoria es buscarse un OutOfMemoryError. | |
| Procesa todo paginado o por lotes. | |
| -El pool de HikariCP: No abras hilos a lo loco. Dimensiona bien las conexiones para no dejar memoria tirada en | |
| hilos que ni se están usando. | |
| -Entender qué está haciendo la JVM por detrás es la diferencia entre arreglar un bug o solo taparlo | |
| pagando más nube. Si ves un pico raro de RAM en producción, ¿qué es lo primerito que entras a revisar? | |
| Respuesta: | |
| Lo primerito: **mirar la forma del consumo de memoria y confirmar si es realmente el heap de Java**. | |
| Antes de tocar GC, JPA o Hikari, revisaría: | |
| 1. **Heap usado vs. heap máximo** | |
| * ¿La memoria sube y baja normalmente tras los GC? | |
| * ¿O sube continuamente y nunca vuelve a bajar? Eso huele a fuga o a retención de objetos. | |
| 2. **Memoria fuera del heap** | |
| * Metaspace. | |
| * Direct buffers / NIO. | |
| * Stacks de threads. | |
| * Memoria nativa de librerías. | |
| * Cachés. | |
| Muchas veces `docker stats` dice que el proceso consume 8 GB, mientras el heap de Java solo ocupa 3 GB. | |
| 3. **Número de threads** | |
| Un crecimiento inesperado de threads puede consumir bastante memoria por sus stacks y, además, ser síntoma de pools mal configurados. | |
| 4. **Heap dump, si el problema persiste** | |
| Ahí dejaría de especular. Ver qué clases dominan la memoria y, sobre todo, **por qué siguen referenciadas** suele señalar directamente al culpable. | |
| Mi orden mental sería: | |
| > **¿Es heap o memoria nativa? → ¿Es un pico temporal o crecimiento sostenido? → ¿Qué objetos o threads están reteniendo memoria? → Después optimizo GC, JPA, batches o pools.** | |
| Porque cambiar G1 por ZGC, por ejemplo, puede mejorar las pausas, pero **no arregla una aplicación que está acumulando objetos que nunca libera**. | |
| Y una pequeña puntualización a tu texto: mencionas “4 cosas clave”, pero en realidad enumeras **5**. 😄 | |
| El script debería detectar automáticamente: | |
| PID de la JVM. | |
| RSS real del proceso. | |
| Heap: used, committed, max. | |
| GC y sus contadores. | |
| Metaspace y Code Cache. | |
| Número de threads y consumo potencial de stacks. | |
| Direct buffers. | |
| Memoria nativa mediante NMT, si está habilitado. | |
| Límites y uso de memoria del cgroup v2. | |
| Si el contenedor está acercándose al límite de Kubernetes. | |
| Histogramas de objetos. | |
| Evolución temporal para distinguir un pico de una fuga. | |
| Ejecución en Kubernetes | |
| Si el contenedor ya incluye las herramientas del JDK: | |
| kubectl exec -it <pod> -c <container> -- bash | |
| Y dentro: | |
| chmod +x jvm-doctor.sh | |
| ./jvm-doctor.sh 1 10 60 | |
| Esto tomaría 60 muestras cada 10 segundos, durante diez minutos. | |
| El reporte generado en bruto, sería algo así: | |
| === JVM MEMORY REPORT === | |
| Process: | |
| PID: 12345 | |
| Command: java -Xmx4g ... | |
| Operating system memory: | |
| RSS: 5.8 GB | |
| VSZ: 12.4 GB | |
| Java Heap: | |
| Used: 3.2 GB | |
| Committed: 3.8 GB | |
| Max: 4.0 GB | |
| GC: | |
| Collector: G1 | |
| Young GC: 124 | |
| Full GC: 2 | |
| Threads: | |
| Total: 438 | |
| Top heap consumers: | |
| byte[] 1.2 GB | |
| java.lang.String 620 MB | |
| com.myapp.UserEntity 480 MB | |
| ... | |
| Assessment: | |
| WARNING: Heap utilisation is growing and does not return | |
| to its baseline after garbage collection. | |
| Possible object retention or memory leak. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # jvm-doctor.sh | |
| # Diagnóstico básico de memoria JVM en Linux/Kubernetes | |
| # | |
| # Uso: | |
| # ./jvm-doctor.sh [PID] [INTERVALO_SEGUNDOS] [MUESTRAS] | |
| # | |
| # Ejemplo: | |
| # ./jvm-doctor.sh 1 10 30 | |
| # | |
| set -u | |
| PID="${1:-1}" | |
| INTERVAL="${2:-10}" | |
| SAMPLES="${3:-30}" | |
| JCMD="$(command -v jcmd || true)" | |
| JSTAT="$(command -v jstat || true)" | |
| PS="$(command -v ps || true)" | |
| if ! kill -0 "$PID" 2>/dev/null; then | |
| echo "ERROR: El PID $PID no existe o no es accesible." | |
| exit 1 | |
| fi | |
| if [[ -z "$JCMD" ]]; then | |
| echo "ERROR: jcmd no está disponible." | |
| echo "Este diagnóstico necesita una imagen con JDK, no solo JRE." | |
| exit 1 | |
| fi | |
| bytes_to_mb() { | |
| awk -v b="$1" 'BEGIN { printf "%.2f", b / 1024 / 1024 }' | |
| } | |
| bytes_to_gb() { | |
| awk -v b="$1" 'BEGIN { printf "%.2f", b / 1024 / 1024 / 1024 }' | |
| } | |
| echo "======================================================" | |
| echo " JVM DOCTOR - MEMORY DIAGNOSTIC" | |
| echo "======================================================" | |
| echo | |
| echo "Timestamp: $(date -Is)" | |
| echo "PID: $PID" | |
| echo "Kernel: $(uname -r)" | |
| echo | |
| echo "---------------- PROCESS ----------------" | |
| if [[ -n "$PS" ]]; then | |
| $PS -p "$PID" -o pid,ppid,%cpu,%mem,rss,vsz,etime,args | |
| fi | |
| RSS_KB="$(awk '/VmRSS:/ {print $2}' /proc/$PID/status 2>/dev/null || true)" | |
| THREADS="$(awk '/Threads:/ {print $2}' /proc/$PID/status 2>/dev/null || true)" | |
| echo | |
| echo "RSS:" | |
| [[ -n "$RSS_KB" ]] && echo " $(awk -v k="$RSS_KB" 'BEGIN {printf "%.2f MB\n", k/1024}')" | |
| echo "Threads:" | |
| echo " ${THREADS:-unknown}" | |
| echo | |
| echo "---------------- CGROUP MEMORY ----------------" | |
| # Kubernetes moderno normalmente utiliza cgroup v2. | |
| if [[ -f /sys/fs/cgroup/memory.current ]]; then | |
| CURRENT="$(cat /sys/fs/cgroup/memory.current)" | |
| MAX="$(cat /sys/fs/cgroup/memory.max)" | |
| echo "cgroup version: v2" | |
| echo "Current: $(bytes_to_mb "$CURRENT") MB" | |
| if [[ "$MAX" == "max" ]]; then | |
| echo "Limit: unlimited" | |
| else | |
| echo "Limit: $(bytes_to_mb "$MAX") MB" | |
| PERCENT="$(awk -v c="$CURRENT" -v m="$MAX" \ | |
| 'BEGIN { printf "%.2f", (c/m)*100 }')" | |
| echo "Usage: ${PERCENT}%" | |
| if awk -v p="$PERCENT" 'BEGIN { exit !(p >= 90) }'; then | |
| echo "WARNING: Container memory is above 90%" | |
| elif awk -v p="$PERCENT" 'BEGIN { exit !(p >= 75) }'; then | |
| echo "WARNING: Container memory is above 75%" | |
| fi | |
| fi | |
| else | |
| echo "cgroup v1 or unsupported layout" | |
| fi | |
| echo | |
| echo "---------------- JVM HEAP ----------------" | |
| "$JCMD" "$PID" GC.heap_info 2>/dev/null || \ | |
| echo "Unable to obtain GC.heap_info" | |
| echo | |
| echo "---------------- JVM NATIVE MEMORY ----------------" | |
| "$JCMD" "$PID" VM.native_memory summary 2>/dev/null || \ | |
| echo "Native Memory Tracking is not enabled." | |
| echo | |
| echo "---------------- JVM FLAGS ----------------" | |
| "$JCMD" "$PID" VM.flags 2>/dev/null || true | |
| echo | |
| echo "---------------- GC STATISTICS ----------------" | |
| if [[ -n "$JSTAT" ]]; then | |
| "$JSTAT" -gcutil "$PID" 2>/dev/null || \ | |
| echo "Unable to obtain jstat GC statistics." | |
| else | |
| echo "jstat unavailable." | |
| fi | |
| echo | |
| echo "---------------- BUFFER POOLS ----------------" | |
| "$JCMD" "$PID" PerfCounter.print 2>/dev/null | \ | |
| grep -Ei 'buffer|direct|mapped' || \ | |
| echo "No buffer pool counters found." | |
| echo | |
| echo "---------------- THREAD SUMMARY ----------------" | |
| "$JCMD" "$PID" Thread.print 2>/dev/null | \ | |
| grep '^"' | \ | |
| sed 's/^"//' | \ | |
| cut -d'"' -f1 | \ | |
| sort | \ | |
| uniq -c | \ | |
| sort -nr | \ | |
| head -30 || true | |
| echo | |
| echo "---------------- TOP HEAP OBJECTS ----------------" | |
| "$JCMD" "$PID" GC.class_histogram 2>/dev/null | \ | |
| head -40 || \ | |
| echo "Unable to obtain class histogram." | |
| echo | |
| echo "======================================================" | |
| echo " TEMPORAL MEMORY SAMPLING" | |
| echo " Samples: $SAMPLES" | |
| echo " Interval: ${INTERVAL}s" | |
| echo "======================================================" | |
| printf "%-25s %-12s %-12s %-12s\n" \ | |
| "TIMESTAMP" "RSS_MB" "CGROUP_MB" "THREADS" | |
| declare -a RSS_HISTORY | |
| declare -a CGROUP_HISTORY | |
| for ((i=1; i<=SAMPLES; i++)); do | |
| NOW="$(date '+%Y-%m-%d %H:%M:%S')" | |
| RSS_KB="$(awk '/VmRSS:/ {print $2}' \ | |
| /proc/$PID/status 2>/dev/null || echo 0)" | |
| RSS_MB="$(awk -v k="$RSS_KB" \ | |
| 'BEGIN {printf "%.2f", k/1024}')" | |
| THREADS="$(awk '/Threads:/ {print $2}' \ | |
| /proc/$PID/status 2>/dev/null || echo 0)" | |
| if [[ -f /sys/fs/cgroup/memory.current ]]; then | |
| CGROUP_BYTES="$(cat /sys/fs/cgroup/memory.current)" | |
| CGROUP_MB="$(bytes_to_mb "$CGROUP_BYTES")" | |
| else | |
| CGROUP_MB="N/A" | |
| fi | |
| RSS_HISTORY+=("$RSS_MB") | |
| CGROUP_HISTORY+=("$CGROUP_MB") | |
| printf "%-25s %-12s %-12s %-12s\n" \ | |
| "$NOW" \ | |
| "$RSS_MB" \ | |
| "$CGROUP_MB" \ | |
| "$THREADS" | |
| if (( i < SAMPLES )); then | |
| sleep "$INTERVAL" | |
| fi | |
| done | |
| echo | |
| echo "======================================================" | |
| echo " BASIC ASSESSMENT" | |
| echo "======================================================" | |
| if (( ${#RSS_HISTORY[@]} >= 2 )); then | |
| FIRST="${RSS_HISTORY[0]}" | |
| LAST="${RSS_HISTORY[${#RSS_HISTORY[@]}-1]}" | |
| DELTA="$(awk -v a="$FIRST" -v b="$LAST" \ | |
| 'BEGIN {printf "%.2f", b-a}')" | |
| PERCENT="$(awk -v a="$FIRST" -v b="$LAST" \ | |
| 'BEGIN { | |
| if (a > 0) | |
| printf "%.2f", ((b-a)/a)*100 | |
| else | |
| print "0" | |
| }')" | |
| echo "Initial RSS: ${FIRST} MB" | |
| echo "Final RSS: ${LAST} MB" | |
| echo "Delta: ${DELTA} MB (${PERCENT}%)" | |
| if awk -v p="$PERCENT" 'BEGIN { exit !(p > 20) }'; then | |
| echo | |
| echo "WARNING:" | |
| echo "RSS grew significantly during the observation period." | |
| echo "Possible causes:" | |
| echo " - Object retention / memory leak" | |
| echo " - Growing caches" | |
| echo " - Direct/native memory" | |
| echo " - Thread growth" | |
| echo " - Workload increase" | |
| echo | |
| echo "Compare this with heap_info and native_memory." | |
| else | |
| echo | |
| echo "RSS remained relatively stable." | |
| fi | |
| fi | |
| echo | |
| echo "======================================================" | |
| echo " END OF REPORT" | |
| echo "======================================================" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment