5 errores críticos que todos cometen con la memoria en Java (y cómo arreglarlos)

java 5 errores críticos que todos cometen con la memoria en Java (y cómo arreglarlos)

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

  1. Reproducir localmente con carga similar (profiling).
  2. Capturar jmap -histo:live y/o heap dump.
  3. Analizar con Eclipse MAT: identificar dominators y GC roots.
  4. Corregir patrón (referencia estática, ThreadLocal, listener, colección sin límite).
  5. 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión