5 errores críticos que todos cometen con la memoria en Java (y cómo arreglarlos)
La gestión de memoria en Java no es mágica: los desarrolladores cometen errores repetidos que causan fugas, pausas GC largas y OOMs en producción. Aquí tienes 5 errores frecuentes, cómo detectarlos y soluciones concretas con ejemplos y comandos prácticos.
Cómo usar esto
- Para cada error verás: síntoma, ejemplo mínimo, diagnóstico y solución/práctica recomendada.
- Comandos útiles al final: jmap, jcmd, jstack, VisualVM, Eclipse MAT, Java Flight Recorder.
1) Mantener referencias estáticas (singletons mal diseñados)
Síntoma: memoria crece constantemente hasta OOM cuando se cargan muchos objetos (p. ej. usuarios, sesiones, datos caché).
Ejemplo
public class GlobalCache {
// Problema: referencia estática sin límites
private static final List<byte[]> items = new ArrayList<>();
public static void add(byte[] b) { items.add(b); }
}
Diagnóstico
- Heap histograms (jmap -histo:live) muestran muchas entradas del tipo byte[] y tu clase GlobalCache aparece entre las retenciones fuertes.
- Eclipse MAT muestra referencias GC roots desde campos estáticos.
Solución
- Evitar colecciones estáticas para datos de vida indefinida. Si necesitas caché, usa una implementación con límites: Caffeine o Guava Cache con tamaño máximo y políticas de expiración.
- Considera WeakReference/SoftReference con cuidado: SoftReference puede causar retenciones impredecibles; Caffeine es mejor.
// Uso recomendado: Caffeine con tamaño máximo
Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build();
2) Fugas por listeners, callbacks y registros sin remove
Síntoma: objetos que deberían recolectarse siguen siendo accesibles porque un listener global los referencia.
Ejemplo
public class MyService {
public void start() {
EventBus.register(this); // si nunca se unregister, 'this' se queda vivo
}
}
Diagnóstico
- Heap dump: tu instancia aparece referenciada por clases de EventBus o colecciones internas.
Solución
- Siempre anula (unregister) listeners al cerrar/descargar componentes.
- Usa referencias débiles para listeners cuando el modelo lo permita (WeakReference) o usa patrones de ciclo de vida claros.
// Ejemplo seguro: unregister en close()
public class MyService implements AutoCloseable {
public void start() { EventBus.register(this); }
public void close() { EventBus.unregister(this); }
}
3) Uso indebido de ThreadLocal
Síntoma: crecimiento constante de memoria relacionado con hilos de pool y objetos grandes referenciados por ThreadLocal.
Ejemplo
private static final ThreadLocal<BigObject> tl = new ThreadLocal<>();
// tl.set(new BigObject()); // si no se limpia, el hilo del pool mantiene referencia
Diagnóstico
- Heap dump: objetos referenciados desde Thread->threadLocals.
- El problema aparece con pools de hilos reutilizados (Tomcat, executors).
Solución
- Cuando uses ThreadLocal, limpia siempre tl.remove() en finally o al terminar la tarea.
- Alternativa: evitar ThreadLocal para datos grandes; pasar contexto explícitamente.
try {
tl.set(new BigObject());
// trabajo
} finally {
tl.remove();
}
4) Colecciones que crecen sin control (cachés malos y acumulación en buffers)
Síntoma: picos de memoria en momentos de carga alta; GC frecuente; objetos temporales retenidos por colecciones.
Ejemplo
List<String> buffer = new ArrayList<>();
void onEvent(String data) { buffer.add(data); }
// nunca se purga o el flush es muy infrecuente
Diagnóstico
- Monitorea el tamaño de colecciones críticas en métricas (Prometheus/Grafana).
- Heap dump y profiling muestran muchas instancias de String/ArrayList vinculadas a la clase que gestiona el buffer.
Solución
- Control de tamano: límites, flushing periódico, backpressure (reactive streams) o rejillas bounded.
- Usa estructuras con límites (ArrayBlockingQueue, LinkedBlockingQueue con capacidad) y estrategias de rechazo o throttling.
// Ejemplo: cola acotada con backpressure
BlockingQueue<String> q = new ArrayBlockingQueue<>(10_000);
if (!q.offer(event, 100, TimeUnit.MILLISECONDS)) {
// aplicar política de rechazo o log explícito
}
5) Confiar en finalizers / no usar try-with-resources
Síntoma: recursos nativos (archivos, sockets, buffers directos) no liberados rápidamente y spikes en memoria nativa.
Ejemplo
protected void finalize() throws Throwable {
// Finalizers son impredecibles y costosos
super.finalize();
}
Diagnóstico
- GC logs muestran pausas largas por finalizer; JFR muestra objetos finalizables pendientes.
- DirectByteBuffers retenidos: look for java.nio.DirectByteBuffer in heap or native memory.
Solución
- No uses finalizers. Usa try-with-resources y AutoCloseable para asegurar liberación inmediata.
- Para buffers directos, evita crear muchos; considera usar pool de buffers (Netty/PooledByteBufAllocator).
// Correcto: try-with-resources
try (InputStream in = new FileInputStream(path)) {
// procesar
}
Herramientas y comandos imprescindibles
- jcmd <pid> GC.class_histogram > histo.txt — histograma de objetos en heap (similar a jmap -histo:live).
- jmap -dump:live,format=b,file=heap.hprof <pid> — generar heap dump para análisis con Eclipse MAT o VisualVM.
- jstack <pid> — hilos y posibles deadlocks; útil si sospechas de hilos bloqueados que retienen estado.
- Java Flight Recorder (JFR) — perfiles de producción de baja sobrecarga: allocations, GC, locks.
- VisualVM / Mission Control / Eclipse MAT — análisis visual de dumps y paths to GC roots.
Parámetros JVM útiles
- -Xms / -Xmx: ajusta límites razonables (no demasiada memoria para evitar larga GC stop-the-world).
- -XX:+UseG1GC (o ZGC/ Shenandoah en JDKs compatibles) para heaps grandes y pausas más predecibles.
- -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof — captura heap al OOM.
- -XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeConcMark — para debugging GC.
Checklist rápido para debugging de memoria
- Reproducir localmente con carga similar (profiling).
- Capturar jmap -histo:live y/o heap dump.
- Analizar con Eclipse MAT: identificar dominators y GC roots.
- Corregir patrón (referencia estática, ThreadLocal, listener, colección sin límite).
- Agregar test de integración que simule carga y valida uso de memoria en CI si es crítico.
Ejemplos de comandos
# Histograma en vivo
jmap -histo:live <pid> | head -n 50
# Crear heap dump
jmap -dump:live,format=b,file=/tmp/heap-<pid>.hprof <pid>
# JFR (start recording 60s)
jcmd <pid> JFR.start name=memrec settings=profile duration=60s filename=memrec.jfr
# GC logs (JDK8)
-XX:+PrintGCDetails -Xloggc:/var/log/gc.log
Evitar los errores descritos reduce dramaticamente las fugas y mejora la predictibilidad del JVM en producción. Como siguiente paso avanzado, integra pruebas de memoria en CI (escenarios de carga ligera a moderada) y habilita JFR en staging para capturar patrones reales. Cuidado: cambiar la estrategia de GC o los límites sin diagnosticar puede enmascarar la causa real.
Consejo avanzado: para casos persistentes, usa async-profiler o async heap sampling en producción y compara diferencias de asignación por generación y por método para encontrar hot spots de asignación.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación