Cómo Manejar Fugas de Memoria en Aplicaciones Java
1. Introducción
Uno de los beneficios fundamentales de Java es la gestión automática de memoria con la ayuda del recolector de basura integrado (o GC por sus siglas en inglés). El GC se encarga de forma implícita de asignar y liberar memoria, y así maneja la mayoría de los problemas de fugas de memoria. Sin embargo, aunque el GC se encarga efectivamente de una buena parte de la memoria, no garantiza una solución infalible a las fugas de memoria. A pesar de que el GC es bastante inteligente, no es infalible. Las fugas de memoria pueden surgir, incluso en las aplicaciones de un desarrollador cuidadoso.
Puede haber situaciones en las que la aplicación genere una cantidad sustancial de objetos superfluos, agotando los recursos de memoria cruciales, lo que a veces resulta en la falla total de la aplicación. Las fugas de memoria son un problema genuino en Java. En este artículo, aprenderemos cuáles son las posibles causas de las fugas de memoria, cómo reconocerlas en tiempo de ejecución y cómo lidiar con ellas en nuestra aplicación.
2. ¿Qué es una fuga de memoria?
Una fuga de memoria se refiere a una situación donde hay objetos presentes en el heap que ya no se utilizan, pero el recolector de basura no puede eliminarlos de la memoria, manteniéndolos innecesariamente. Esto es perjudicial porque bloquea los recursos de memoria y degrada el rendimiento del sistema con el tiempo. Si no se trata, la aplicación eventualmente agotará sus recursos, terminando finalmente con un error fatal java.lang.OutOfMemoryError
.
Hay dos tipos de objetos que residen en la memoria Heap: los objetos referenciados y no referenciados. Los objetos referenciados son aquellos que aún tienen referencias activas dentro de la aplicación, mientras que los objetos no referenciados no tienen referencias activas.
El recolector de basura elimina periódicamente los objetos que no están referenciados, pero nunca recopila los objetos que aún están siendo referenciados. Aquí es donde pueden ocurrir las fugas de memoria.
Síntomas de una fuga de memoria
- Deterioro severo en el rendimiento cuando la aplicación está en ejecución continua durante mucho tiempo.
- Error de heap
OutOfMemoryError
en la aplicación. - Caídas espontáneas y extrañas de la aplicación.
- La aplicación ocasionalmente se queda sin objetos de conexión.
Veamos de cerca algunos de estos escenarios y cómo abordarlos.
3. Tipos de fugas de memoria en Java
Las fugas de memoria pueden ocurrir en cualquier aplicación por diversas razones. En esta sección, trataremos las más comunes.
3.1. Fuga de memoria a través de campos static
El primer escenario que puede causar una posible fuga de memoria es el uso intensivo de variables static
. En Java, los campos static
tienen una vida que generalmente coincide con toda la vida de la aplicación en ejecución (a menos que el ClassLoader
se vuelva elegible para la recolección de basura). Veamos un programa simple que llena una lista estática:
public class StaticFieldsMemoryLeakUnitTest {
public static List<Double> list = new ArrayList<>();
public void populateList() {
for (int i = 0; i < 10000000; i++) {
list.add(Math.random());
}
System.out.println("Debug Point 2");
}
public static void main(String[] args) {
System.out.println("Debug Point 1");
new StaticFieldsMemoryLeakUnitTest().populateList();
System.out.println("Debug Point 3");
}
}
Si analizamos la memoria Heap durante la ejecución de este programa, veremos que entre los puntos de depuración 1 y 2, la memoria del heap aumentó como era de esperar. Sin embargo, al abandonar el método populateList()
, la memoria del heap no se recolectó, como se observa en la respuesta de VisualVM.
Si simplemente eliminamos la palabra clave static
en la línea 2 del programa anterior, esto traerá un cambio drástico en el uso de la memoria. En este escenario, al salir de populateList()
, toda la memoria de la lista se recolectará como basura porque no tenemos referencia a ella.
¿Cómo prevenirlo?
- Minimizar el uso de variables
static
. - Cuando se usen singletons, confiar en una implementación que cargue el objeto de manera perezosa, en lugar de cargarlo ágilmente.
3.2. A través de recursos no cerrados
Siempre que se establece una nueva conexión o se abre un flujo, la JVM asigna memoria para estos recursos. Ejemplos de esto incluyen conexiones a bases de datos, flujos de entrada y objetos de sesión. Olvidar cerrar estos recursos puede bloquear la memoria, manteniéndolos fuera del alcance del GC. Este problema puede surgir incluso si una excepción impide que la ejecución del programa alcance la instrucción que maneja el cierre de estos recursos.
Prevención:
- Siempre usar un bloque
finally
para cerrar recursos. - El código que cierra los recursos (incluso en el bloque
finally
) no debería generar excepciones.
En Java 7+, podemos usar el bloque try-with-resources
.
3.3. Implementaciones incorrectas de equals()
y hashCode()
Al definir nuevas clases, un descuido común es no escribir métodos adecuados sobrescritos para equals()
y hashCode()
. Cuando no se sobrescriben correctamente, pueden convertirse en una fuente de problemas potenciales de fuga de memoria.
Vamos a ver un ejemplo de una trivial clase Person
, que se utiliza como clave en un HashMap
:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
Ahora insertamos objetos duplicados de Person
en un Map
que utiliza esta clave:
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<100; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertFalse(map.size() == 1);
}
Aquí, el Map
no puede contener claves duplicadas. Dado que no se definieron los métodos equals()
, los objetos duplicados se acumulan y aumentan la memoria. Si hubiéramos sobrescrito adecuadamente los métodos, solo existiría un objeto Person
en este Map
.
Prevención:
- Al definir nuevas entidades, siempre sobreescriba los métodos
equals()
yhashCode()
.
3.4. Clases internas que referencian clases externas
Este problema ocurre en el caso de clases internas no estáticas (también conocidas como clases anónimas). Para la inicialización, estas clases internas siempre requieren una instancia de la clase que las contiene. Cada clase interna no estática tiene, por defecto, una referencia implícita a su clase contenedora.
¿Cómo prevenirlo?
- Migrar a la versión más reciente de Java que use recolectores de basura modernos.
- Si la clase interna no necesita acceso a los miembros de la clase contenedora, considere convertirla en una clase
static
.
3.5. A través de métodos finalize()
El uso de finalizadores es otra fuente potencial de problemas de fugas de memoria. Cuando se sobrescribe el método finalize()
de una clase, los objetos de esa clase no se recogen inmediatamente como basura. En su lugar, el GC los coloca en una cola para la finalización, que ocurre en un tiempo posterior.
Prevención:
- Evitar los finalizadores.
3.6. Cadenas internadas
El pool de String
de Java experimentó un cambio importante en Java 7 cuando se trasladó de PermGen a HeapSpace. Si leemos un gran objeto String
y llamamos a intern()
en ese objeto, va a la pool de cadenas y permanecerá allí mientras nuestra aplicación esté ejecutándose.
Prevención:
- La forma más sencilla de resolver este problema es actualizar a la versión más reciente de Java.
3.7. Usando ThreadLocal
El uso de ThreadLocal
puede introducir fugas de memoria si no se utiliza correctamente. Cada hilo tendrá una referencia implícita a su copia de una variable ThreadLocal
y mantendrá su propia copia mientras el hilo esté vivo.
Prevención:
- Es buena práctica limpiar los
ThreadLocals
cuando ya no los usamos y debemos considerarThreadLocal
como un recurso que necesitamos cerrar.
4. Otras estrategias para lidiar con fugas de memoria
4.1. Habilitar el perfilado
Los perfiles de Java son herramientas que monitorean y diagnostican las fugas de memoria en la aplicación. Mediante el uso de perfiles, podemos comparar diferentes enfoques y encontrar áreas donde podemos optimizar el uso de recursos.
4.2. Recolección de basura detallada
Habilitando la recolección de basura detallada, podemos rastrear los detalles de lo que está ocurriendo dentro del GC.
4.3. Usar objetos de referencia
Podemos utilizar referencias especiales del paquete java.lang.ref
para permitir la recolección de basura más eficiente.
4.4. Advertencias de fuga de memoria en Eclipse
Para proyectos en JDK 1.5 y superiores, Eclipse muestra advertencias y errores cada vez que encuentra casos obvios de fugas de memoria.
4.5. Benchmarking
Podemos medir y analizar el rendimiento del código Java ejecutando benchmarks, ayudándonos a elegir el mejor enfoque.
4.6. Revisiones de código
Finalmente, un simple recorrido de código puede ayudar a eliminar algunos problemas comunes de fugas de memoria.
5. Conclusión
En términos simples, podemos pensar en una fuga de memoria como una enfermedad que degrada el rendimiento de nuestra aplicación al bloquear recursos vitales de memoria. Las fugas de memoria son complicadas de resolver, y encontrarlas requiere un dominio intrincado del lenguaje Java. Sin embargo, si optamos por las mejores prácticas y realizamos regularmente revisiones de código rigurosas y perfilado, podemos minimizar el riesgo de fugas de memoria en nuestra aplicación.