Fugas de memoria en Java: causas y cómo detectarlas
Tu aplicación Java arranca rápido, pero tras horas o días se ralentiza hasta arrastrarse, lanza OutOfMemoryError o el contenedor la mata. La reinicias y el ciclo se repite. Esa es la firma clásica de una fuga de memoria en Java: objetos que ya no se necesitan pero siguen siendo alcanzables, por lo que el recolector de basura no puede recuperarlos. A diferencia de C/C++, el GC de Java gestiona la mayor parte de la memoria automáticamente, pero las fugas siguen ocurriendo cuando se mantienen referencias de forma no intencionada. Este artículo explica las causas más comunes y te ofrece un flujo de trabajo práctico para detectarlas y solucionarlas.
¿Qué es una fuga de memoria en Java?
Una fuga de memoria ocurre cuando los objetos ya no son usados por la aplicación pero siguen referenciados, lo que impide la recolección de basura. Con el tiempo, el uso del heap crece, el GC se ejecuta con más frecuencia y finalmente la JVM lanza OutOfMemoryError: Java heap space. Las fugas pueden ser pequeñas y lentas (unos pocos KB por petición) o grandes y rápidas (cachear conjuntos de resultados completos).
Causas comunes de fugas de memoria en Java
1. Colecciones estáticas que crecen sin límite
Los campos estáticos viven durante toda la vida de la JVM. Un Map estático usado como caché sin evicción es una fuga clásica. Por ejemplo:
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // nunca se elimina
}
}
Cada usuario añadido se queda para siempre. Solución: usa una caché acotada como Caffeine o Guava con límites de tamaño y expiración.
2. Recursos sin cerrar
Los streams, conexiones y readers deben cerrarse. Si lo olvidas, se filtran los recursos nativos y sus envoltorios Java. Usa siempre try-with-resources:
try (InputStream in = new FileInputStream(file)) {
// usar in
} // se cierra automáticamente
Esto también aplica a conexiones de base de datos, clientes HTTP y pools de hilos.
3. Listeners y callbacks que no se eliminan
Cuando registras un listener (por ejemplo, addListener) pero nunca lo eliminas, el publicador mantiene una referencia al listener, que a su vez puede mantener una referencia a todo tu grafo de objetos. Esto es común en GUIs, buses de eventos y contextos de larga vida.
4. Variables ThreadLocal
Los valores de ThreadLocal se almacenan en el ThreadLocalMap del hilo. En pools de hilos, los hilos se reutilizan, así que si no llamas a remove(), el valor permanece asociado al hilo y se filtra. Limpia siempre:
try {
threadLocal.set(context);
// trabajo
} finally {
threadLocal.remove();
}
5. Clases internas que retienen referencias externas
Las clases internas no estáticas (incluidas las anónimas) mantienen una referencia implícita a la instancia externa. Si una instancia de clase interna sobrevive al objeto externo (por ejemplo, almacenada en una lista estática), el objeto externo no se puede recolectar. Haz las clases internas estáticas cuando no necesiten la instancia externa.
6. equals() y hashCode() mal implementados
Si usas objetos como claves en un HashMap pero sobrescribes equals() sin hashCode() (o viceversa), las búsquedas fallan y las entradas se acumulan. Sobrescribe siempre ambos de forma coherente.
7. String interning y substrings
Antes de Java 7, String.substring() compartía el array de char original, por lo que un substring pequeño podía retener un array grande. El Java moderno copia, pero internar muchas cadenas únicas (por ejemplo, String.intern()) puede seguir llenando el pool de cadenas.
Cómo detectar fugas de memoria en Java
La detección sigue un proceso sistemático. Aquí tienes un enfoque paso a paso:
- Monitoriza el uso del heap a lo largo del tiempo. Usa JVisualVM, JConsole o tu APM. Una app sana muestra un patrón en diente de sierra: el heap crece, el GC recupera, el heap baja. Una fuga muestra una línea base creciente tras cada GC.
- Activa el logging de GC. Añade
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+) o-XX:+PrintGCDetails(Java 8). Analiza los logs con herramientas como GCeasy para ver si la generación antigua sigue creciendo. - Captura un heap dump. Cuando el heap esté alto, ejecuta
jmap -dump:live,format=b,file=heap.hprof <pid>o usajcmd <pid> GC.heap_dump heap.hprof. También puedes provocar un dump ante OutOfMemoryError con-XX:+HeapDumpOnOutOfMemoryError. - Analiza el heap dump. Ábrelo en Eclipse MAT o VisualVM. Busca el árbol de dominadores para encontrar los objetos que retienen más memoria. Revisa colecciones sospechosas con grandes tamaños retenidos.
- Toma un segundo dump y compáralo. Si los mismos objetos crecen entre dumps, has encontrado la fuga.
- Usa un profiler para análisis en vivo. Herramientas como YourKit, JProfiler o async-profiler pueden rastrear puntos de asignación y cadenas de referencias sin detener la app.
Aquí tienes una comparación rápida de herramientas comunes:
| Herramienta | Tipo | Ideal para |
|---|---|---|
| Eclipse MAT | Analizador de heap dumps | Encontrar dominadores y sospechosos de fuga |
| VisualVM | Monitorización + heap dump | Comprobaciones rápidas en vivo y análisis básico |
| JProfiler / YourKit | Profiler | Seguimiento de asignaciones y cadenas de referencias |
| async-profiler | Profiler de bajo overhead | Profiling en producción con flame graphs |
Solucionar y prevenir fugas
Una vez identificada la fuga, soluciónala eliminando la referencia no intencionada. Soluciones comunes:
- Reemplaza cachés sin límite por otras acotadas (Caffeine, Ehcache).
- Usa try-with-resources para todos los recursos Closeable.
- Desregistra listeners en un bloque finally o usa referencias débiles.
- Llama siempre a
ThreadLocal.remove(). - Haz las clases internas estáticas cuando sea posible.
- Sobrescribe equals() y hashCode() juntos.
Prevenir es mejor que curar. Añade monitorización de memoria a tu pipeline de CI/CD, ejecuta pruebas de carga con comprobaciones de heap y configura -Xmx adecuadamente. Usa herramientas de análisis estático como SpotBugs para detectar patrones comunes.
Preguntas frecuentes
¿Cuál es la diferencia entre una fuga de memoria y un pico de memoria?
Un pico de memoria es un aumento temporal del uso del heap que el GC recupera. Una fuga es un aumento constante a lo largo del tiempo porque los objetos siguen siendo alcanzables y no se pueden recolectar.
¿Puede una fuga de memoria en Java causar OutOfMemoryError aunque el heap sea grande?
Sí. Si la tasa de fuga supera la capacidad del GC para recuperar, el heap acabará llenándose independientemente de su tamaño. Aumentar el heap solo retrasa el fallo.
¿Cómo encuentro una fuga de memoria en producción sin tiempo de inactividad?
Usa herramientas de bajo overhead como async-profiler o JFR (Java Flight Recorder) para registrar asignaciones y estadísticas del heap. También puedes provocar un heap dump con jcmd mientras la app se ejecuta, aunque puede pausarse brevemente.
¿Necesitas analizar logs de GC u otros logs basados en texto? Prueba nuestro Nginx Log Analyzer para parsear y visualizar patrones de logs rápidamente.