Búsqueda de fugas de memoria de Java
Publicado: 2022-03-11Los programadores sin experiencia a menudo piensan que la recolección automática de basura de Java los libera por completo de la preocupación por la administración de la memoria. Esta es una percepción errónea común: mientras que el recolector de basura hace lo mejor que puede, es muy posible que incluso el mejor programador sea víctima de pérdidas de memoria paralizantes. Dejame explicar.
Se produce una fuga de memoria cuando se mantienen innecesariamente referencias a objetos que ya no se necesitan. Estas fugas son malas. Por un lado, ejercen una presión innecesaria sobre su máquina a medida que sus programas consumen más y más recursos. Para empeorar las cosas, detectar estas fugas puede ser difícil: el análisis estático a menudo tiene dificultades para identificar con precisión estas referencias redundantes, y las herramientas de detección de fugas existentes rastrean y reportan información detallada sobre objetos individuales, produciendo resultados que son difíciles de interpretar y carecen de precisión.
En otras palabras, las fugas son demasiado difíciles de identificar o se identifican en términos que son demasiado específicos para ser útiles.
En realidad, existen cuatro categorías de problemas de memoria con síntomas similares y superpuestos, pero con causas y soluciones variadas:
Rendimiento : generalmente asociado con la creación y eliminación excesiva de objetos, largas demoras en la recolección de basura, intercambio excesivo de páginas del sistema operativo y más.
Restricciones de recursos : ocurre cuando hay poca memoria disponible o su memoria está demasiado fragmentada para asignar un objeto grande; esto puede ser nativo o, más comúnmente, relacionado con el montón de Java.
Fugas de montón de Java : la fuga de memoria clásica, en la que los objetos de Java se crean continuamente sin ser liberados. Esto suele ser causado por referencias a objetos latentes.
Pérdidas de memoria nativa : asociadas con cualquier uso de memoria en continuo crecimiento que esté fuera del montón de Java, como asignaciones realizadas por código JNI, controladores o incluso asignaciones de JVM.
En este tutorial de gestión de memoria, me centraré en las fugas de montones de Java y describiré un enfoque para detectar dichas fugas en función de los informes de Java VisualVM y utilizaré una interfaz visual para analizar aplicaciones basadas en tecnología Java mientras se ejecutan.
Pero antes de que pueda prevenir y encontrar pérdidas de memoria, debe comprender cómo y por qué ocurren. ( Nota: si tiene un buen manejo de las complejidades de las fugas de memoria, puede saltar adelante).
Fugas de memoria: una introducción
Para empezar, piense en la fuga de memoria como una enfermedad y en el OutOfMemoryError
de Java (OOM, para abreviar) como un síntoma. Pero como con cualquier enfermedad, no todos los OOM implican necesariamente pérdidas de memoria : un OOM puede ocurrir debido a la generación de una gran cantidad de variables locales u otros eventos similares. Por otro lado, no todas las fugas de memoria necesariamente se manifiestan como OOM , especialmente en el caso de aplicaciones de escritorio o aplicaciones cliente (que no se ejecutan durante mucho tiempo sin reiniciar).
¿Por qué son tan malas estas fugas? Entre otras cosas, la fuga de bloques de memoria durante la ejecución del programa a menudo degrada el rendimiento del sistema con el tiempo, ya que los bloques de memoria asignados pero no utilizados deberán intercambiarse una vez que el sistema se quede sin memoria física libre. Eventualmente, un programa puede incluso agotar su espacio de direcciones virtuales disponible, lo que lleva al OOM.
Descifrando el OutOfMemoryError
Como se mencionó anteriormente, el OOM es una indicación común de una pérdida de memoria. Esencialmente, el error se produce cuando no hay suficiente espacio para asignar un nuevo objeto. Por mucho que lo intente, el recolector de basura no puede encontrar el espacio necesario y el montón no se puede expandir más. Por lo tanto, surge un error, junto con un seguimiento de la pila.
El primer paso para diagnosticar su OOM es determinar qué significa realmente el error. Esto suena obvio, pero la respuesta no siempre es tan clara. Por ejemplo: ¿Aparece el OOM porque el almacenamiento dinámico de Java está lleno o porque el almacenamiento dinámico nativo está lleno? Para ayudarlo a responder esta pregunta, analicemos algunos de los posibles mensajes de error:
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: PermGen space
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)
“Espacio de almacenamiento dinámico de Java”
Este mensaje de error no implica necesariamente una pérdida de memoria. De hecho, el problema puede ser tan simple como un problema de configuración.
Por ejemplo, yo era responsable de analizar una aplicación que constantemente producía este tipo de OutOfMemoryError
. Después de investigar un poco, descubrí que el culpable era una instanciación de matriz que exigía demasiada memoria; en este caso, no fue culpa de la aplicación, sino que el servidor de aplicaciones confiaba en el tamaño de almacenamiento dinámico predeterminado, que era demasiado pequeño. Resolví el problema ajustando los parámetros de memoria de la JVM.
En otros casos, y en particular para las aplicaciones de larga duración, el mensaje puede ser una indicación de que, sin querer, estamos reteniendo referencias a objetos , lo que impide que el recolector de elementos no utilizados los limpie. Este es el equivalente en lenguaje Java de una fuga de memoria . ( Nota: las API llamadas por una aplicación también podrían contener referencias de objetos sin querer).
Otra fuente potencial de estos OOM de "espacio de montón de Java" surge con el uso de finalizadores . Si una clase tiene un método de finalize
, los objetos de ese tipo no recuperan su espacio en el momento de la recolección de elementos no utilizados. En su lugar, después de la recolección de elementos no utilizados, los objetos se ponen en cola para su finalización, lo que ocurre más tarde. En la implementación de Sun, los finalizadores son ejecutados por un subproceso daemon. Si el subproceso del finalizador no puede seguir el ritmo de la cola de finalización, entonces el montón de Java podría llenarse y podría generarse un OOM.
“Espacio PermGen”
Este mensaje de error indica que la generación permanente está llena. La generación permanente es el área del montón que almacena objetos de clase y método. Si una aplicación carga una gran cantidad de clases, es posible que sea necesario aumentar el tamaño de la generación permanente mediante la opción -XX:MaxPermSize
.
Los objetos java.lang.String
internos también se almacenan en la generación permanente. La clase java.lang.String
mantiene un grupo de cadenas. Cuando se invoca el método interno, el método verifica el grupo para ver si hay una cadena equivalente presente. Si es así, lo devuelve el método interno; si no, la cadena se agrega al grupo. En términos más precisos, el método java.lang.String.intern
devuelve la representación canónica de una cadena; el resultado es una referencia a la misma instancia de clase que se devolvería si esa cadena apareciera como un literal. Si una aplicación interna una gran cantidad de cadenas, es posible que deba aumentar el tamaño de la generación permanente.
Nota: puede usar el comando jmap -permgen
para imprimir estadísticas relacionadas con la generación permanente, incluida información sobre las instancias de String internalizadas.
"El tamaño de la matriz solicitada excede el límite de VM"
Este error indica que la aplicación (o las API utilizadas por esa aplicación) intentaron asignar una matriz que es más grande que el tamaño del almacenamiento dinámico. Por ejemplo, si una aplicación intenta asignar una matriz de 512 MB pero el tamaño máximo de almacenamiento dinámico es de 256 MB, se generará un OOM con este mensaje de error. En la mayoría de los casos, el problema es un problema de configuración o un error que se produce cuando una aplicación intenta asignar una matriz masiva.
“Solicitar <tamaño> bytes por <razón>. ¿Fuera del espacio de intercambio?
Este mensaje parece ser un OOM. Sin embargo, la máquina virtual HotSpot genera esta aparente excepción cuando falla una asignación del almacenamiento dinámico nativo y el almacenamiento dinámico nativo podría estar a punto de agotarse. En el mensaje se incluye el tamaño (en bytes) de la solicitud que falló y el motivo de la solicitud de memoria. En la mayoría de los casos, el <motivo> es el nombre del módulo de origen que informa un error de asignación.
Si se lanza este tipo de OOM, es posible que deba usar las utilidades de solución de problemas en su sistema operativo para diagnosticar el problema más a fondo. En algunos casos, es posible que el problema ni siquiera esté relacionado con la aplicación. Por ejemplo, es posible que vea este error si:
El sistema operativo está configurado con espacio de intercambio insuficiente.
Otro proceso en el sistema está consumiendo todos los recursos de memoria disponibles.
También es posible que la aplicación falle debido a una fuga nativa (por ejemplo, si alguna parte del código de la aplicación o de la biblioteca está asignando memoria continuamente pero no la libera al sistema operativo).
<motivo> <seguimiento de pila> (método nativo)
Si ve este mensaje de error y el marco superior de su seguimiento de pila es un método nativo, entonces ese método nativo encontró una falla de asignación. La diferencia entre este mensaje y el anterior es que el error de asignación de memoria de Java se detectó en un método JNI o nativo en lugar de en el código de la VM de Java.
Si se lanza este tipo de OOM, es posible que deba usar utilidades en el sistema operativo para diagnosticar el problema con más detalle.
Bloqueo de la aplicación sin OOM
De vez en cuando, una aplicación puede bloquearse poco después de un error de asignación del montón nativo. Esto ocurre si está ejecutando código nativo que no verifica los errores devueltos por las funciones de asignación de memoria.
Por ejemplo, la llamada al sistema malloc
devuelve NULL
si no hay memoria disponible. Si no se marca el retorno de malloc
, la aplicación podría bloquearse cuando intente acceder a una ubicación de memoria no válida. Dependiendo de las circunstancias, este tipo de problema puede ser difícil de localizar.
En algunos casos, la información del registro de errores fatales o el volcado de memoria será suficiente. Si se determina que la causa de un bloqueo es la falta de manejo de errores en algunas asignaciones de memoria, entonces debe buscar el motivo de dicha falla de asignación. Al igual que con cualquier otro problema de almacenamiento dinámico nativo, el sistema puede estar configurado con espacio de intercambio insuficiente, otro proceso puede estar consumiendo todos los recursos de memoria disponibles, etc.
Diagnóstico de fugas
En la mayoría de los casos, el diagnóstico de fugas de memoria requiere un conocimiento muy detallado de la aplicación en cuestión. Advertencia: el proceso puede ser largo e iterativo.
Nuestra estrategia para buscar fugas de memoria será relativamente sencilla:
Identificar los síntomas
Habilitar la recolección de basura detallada
Habilitar creación de perfiles
Analizar el rastro
1. Identificar los síntomas
Como se mencionó, en muchos casos, el proceso de Java finalmente arrojará una excepción de tiempo de ejecución OOM, un claro indicador de que sus recursos de memoria se han agotado. En este caso, debe distinguir entre un agotamiento normal de la memoria y una fuga. Analizar el mensaje del OOM y tratar de encontrar al culpable en base a las discusiones proporcionadas anteriormente.
A menudo, si una aplicación Java solicita más almacenamiento del que ofrece el montón de tiempo de ejecución, puede deberse a un diseño deficiente. Por ejemplo, si una aplicación crea varias copias de una imagen o carga un archivo en una matriz, se quedará sin almacenamiento cuando la imagen o el archivo sean muy grandes. Este es un agotamiento normal de los recursos. La aplicación funciona según lo diseñado (aunque este diseño es claramente estúpido).
Pero si una aplicación aumenta constantemente su uso de memoria mientras procesa el mismo tipo de datos, es posible que tenga una pérdida de memoria.
2. Habilitar la recolección detallada de basura
Una de las formas más rápidas de afirmar que efectivamente tiene una pérdida de memoria es habilitar la recolección detallada de elementos no utilizados. Los problemas de restricción de memoria generalmente se pueden identificar examinando patrones en la salida de verbosegc
.

Específicamente, el argumento -verbosegc
le permite generar un seguimiento cada vez que se inicia el proceso de recolección de elementos no utilizados (GC). Es decir, a medida que la memoria se recolecta como basura, los informes resumidos se imprimen con un error estándar, lo que le da una idea de cómo se administra su memoria.
Aquí hay algunos resultados típicos generados con la opción –verbosegc
:
Cada bloque (o estrofa) en este archivo de seguimiento de GC está numerado en orden ascendente. Para dar sentido a este seguimiento, debe observar las estrofas sucesivas de fallas de asignación y buscar la disminución de la memoria liberada (bytes y porcentaje) con el tiempo, mientras que la memoria total (aquí, 19725304) aumenta. Estos son signos típicos de agotamiento de la memoria.
3. Habilitar creación de perfiles
Diferentes JVM ofrecen diferentes formas de generar archivos de seguimiento para reflejar la actividad del montón, que normalmente incluyen información detallada sobre el tipo y el tamaño de los objetos. Esto se llama perfilar el montón .
4. Analizar el rastro
Esta publicación se centra en el seguimiento generado por Java VisualVM. Los rastros pueden venir en diferentes formatos, ya que pueden ser generados por diferentes herramientas de detección de fugas de memoria de Java, pero la idea detrás de ellos es siempre la misma: encontrar un bloque de objetos en el montón que no debería estar allí y determinar si estos objetos se acumulan. en lugar de liberar. De particular interés son los objetos transitorios que se sabe que se asignan cada vez que se activa un determinado evento en la aplicación Java. La presencia de muchas instancias de objetos que deberían existir solo en pequeñas cantidades generalmente indica un error en la aplicación.
Finalmente, resolver las fugas de memoria requiere que revise su código a fondo. Aprender sobre el tipo de objeto que se filtra puede ser muy útil y acelerar considerablemente la depuración.
¿Cómo funciona la recolección de basura en la JVM?
Antes de comenzar nuestro análisis de una aplicación con un problema de pérdida de memoria, primero veamos cómo funciona la recolección de elementos no utilizados en la JVM.
La JVM utiliza una forma de recolector de elementos no utilizados llamado recolector de seguimiento , que esencialmente opera pausando el mundo que lo rodea, marcando todos los objetos raíz (objetos a los que se hace referencia directamente mediante subprocesos en ejecución) y siguiendo sus referencias, marcando cada objeto que ve en el camino.
Java implementa algo llamado recolector de basura generacional basado en la suposición de la hipótesis generacional, que establece que la mayoría de los objetos que se crean se descartan rápidamente y que los objetos que no se recolectan rápidamente probablemente permanezcan por un tiempo .
Según esta suposición, Java divide los objetos en varias generaciones. He aquí una interpretación visual:
Generación joven : aquí es donde comienzan los objetos. Tiene dos subgeneraciones:
Eden Space - Los objetos comienzan aquí. La mayoría de los objetos se crean y destruyen en el Espacio Edén. Aquí, el GC hace Minor GC , que son recolecciones de basura optimizadas. Cuando se realiza un GC menor, cualquier referencia a los objetos que aún se necesitan se migran a uno de los espacios supervivientes (S0 o S1).
Survivor Space (S0 y S1) : los objetos que sobreviven a Eden terminan aquí. Hay dos de estos, y solo uno está en uso en un momento dado (a menos que tengamos una pérdida de memoria grave). Uno se designa como vacío y el otro como vivo , alternando con cada ciclo de GC.
Generación Titular - También conocida como la generación anterior (espacio antiguo en la Fig. 2), este espacio contiene objetos más antiguos con una vida útil más larga (movidos de los espacios de sobrevivientes, si viven lo suficiente). Cuando se llena este espacio, el GC hace un GC completo , que cuesta más en términos de rendimiento. Si este espacio crece sin límites, la JVM generará un
OutOfMemoryError - Java heap space
.Generación permanente : una tercera generación estrechamente relacionada con la generación permanente, la generación permanente es especial porque contiene los datos requeridos por la máquina virtual para describir objetos que no tienen una equivalencia en el nivel del lenguaje Java. Por ejemplo, los objetos que describen clases y métodos se almacenan en la generación permanente.
Java es lo suficientemente inteligente como para aplicar diferentes métodos de recolección de basura a cada generación. La generación joven se maneja mediante un recopilador de rastreo y copia llamado Parallel New Collector . Este coleccionista detiene el mundo, pero debido a que la generación joven es generalmente pequeña, la pausa es breve.
Para obtener más información sobre las generaciones de JVM y cómo funcionan con más detalle, visite la documentación de Administración de memoria en la máquina virtual Java HotSpot.
Detectar una fuga de memoria
Para encontrar pérdidas de memoria y eliminarlas, necesita las herramientas de pérdida de memoria adecuadas. Es hora de detectar y eliminar dicha fuga utilizando Java VisualVM.
Perfilado remoto del montón con Java VisualVM
VisualVM es una herramienta que proporciona una interfaz visual para ver información detallada sobre las aplicaciones basadas en tecnología Java mientras se ejecutan.
Con VisualVM, puede ver datos relacionados con aplicaciones locales y aquellas que se ejecutan en hosts remotos. También puede capturar datos sobre instancias de software JVM y guardar los datos en su sistema local.
Para beneficiarse de todas las funciones de Java VisualVM, debe ejecutar la versión 6 o superior de la plataforma Java, Standard Edition (Java SE).
Habilitación de la conexión remota para la JVM
En un entorno de producción, a menudo es difícil acceder a la máquina real en la que se ejecutará nuestro código. Afortunadamente, podemos perfilar nuestra aplicación Java de forma remota.
Primero, debemos otorgarnos acceso a JVM en la máquina de destino. Para hacerlo, cree un archivo llamado jstatd.all.policy con el siguiente contenido:
grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; };
Una vez que se ha creado el archivo, debemos habilitar las conexiones remotas a la VM de destino utilizando la herramienta jstatd - Virtual Machine jstat Daemon, de la siguiente manera:
jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>
Por ejemplo:
jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy
Con jstatd iniciado en la máquina virtual de destino, podemos conectarnos a la máquina de destino y perfilar de forma remota la aplicación con problemas de pérdida de memoria.
Conexión a un host remoto
En la máquina cliente, abra un aviso y escriba jvisualvm
para abrir la herramienta VisualVM.
A continuación, debemos agregar un host remoto en VisualVM. Como la JVM de destino está habilitada para permitir conexiones remotas desde otra máquina con J2SE 6 o superior, iniciamos la herramienta Java VisualVM y nos conectamos al host remoto. Si la conexión con el host remoto fue exitosa, veremos las aplicaciones Java que se ejecutan en la JVM de destino, como se ve aquí:
Para ejecutar un perfilador de memoria en la aplicación, simplemente hacemos doble clic en su nombre en el panel lateral.
Ahora que ya tenemos todo listo con un analizador de memoria, investiguemos una aplicación con un problema de pérdida de memoria, que llamaremos MemLeak .
Fuga de memoria
Por supuesto, hay varias formas de crear pérdidas de memoria en Java. Para simplificar, definiremos una clase para que sea una clave en un HashMap
, pero no definiremos los métodos equals() y hashcode().
Un HashMap es una implementación de tabla hash para la interfaz Map y, como tal, define los conceptos básicos de clave y valor: cada valor está relacionado con una clave única, por lo que si la clave para un par clave-valor dado ya está presente en el HashMap, se reemplaza su valor actual.
Es obligatorio que nuestra clase clave proporcione una implementación correcta de los métodos equals()
y hashcode()
. Sin ellos, no hay garantía de que se genere una buena clave.
Al no definir los métodos equals()
y hashcode()
, agregamos la misma clave al HashMap una y otra vez y, en lugar de reemplazar la clave como debería, el HashMap crece continuamente, no logra identificar estas claves idénticas y genera un OutOfMemoryError
.
Aquí está la clase MemLeak:
package com.post.memory.leak; import java.util.Map; public class MemLeak { public final String key; public MemLeak(String key) { this.key =key; } public static void main(String args[]) { try { Map map = System.getProperties(); for(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } }
Nota: la fuga de memoria no se debe al bucle infinito de la línea 14: el bucle infinito puede provocar el agotamiento de los recursos, pero no una fuga de memoria. Si hubiéramos implementado correctamente los métodos equals()
y hashcode()
, el código funcionaría bien incluso con el bucle infinito, ya que solo tendríamos un elemento dentro del HashMap.
(Para aquellos interesados, aquí hay algunos medios alternativos para generar (intencionalmente) fugas).
Uso de Java VisualVM
Con Java VisualVM, podemos monitorear la memoria del montón de Java e identificar si su comportamiento es indicativo de una fuga de memoria.
Aquí hay una representación gráfica del analizador Java Heap de MemLeak justo después de la inicialización (recuerde nuestra discusión de las diversas generaciones):
Después de solo 30 segundos, Old Generation está casi lleno, lo que indica que, incluso con un GC completo, Old Generation está en constante crecimiento, una clara señal de una pérdida de memoria.
Una forma de detectar la causa de esta fuga se muestra en la siguiente imagen ( haga clic para ampliar ), generada usando Java VisualVM con un volcado de pila. Aquí, vemos que el 50 % de los objetos Hashtable$Entry están en el montón , mientras que la segunda línea nos señala la clase MemLeak . Por lo tanto, la fuga de memoria es causada por una tabla hash utilizada dentro de la clase MemLeak .
Finalmente, observe el Java Heap justo después de nuestro OutOfMemoryError
en el que las generaciones Young y Old están completamente llenas .
Conclusión
Las fugas de memoria se encuentran entre los problemas de aplicaciones Java más difíciles de resolver, ya que los síntomas son variados y difíciles de reproducir. Aquí, describimos un enfoque paso a paso para descubrir fugas de memoria e identificar sus fuentes. Pero, sobre todo, lea atentamente sus mensajes de error y preste atención a los rastros de su pila; no todas las fugas son tan simples como parecen.
Apéndice
Junto con Java VisualVM, existen varias otras herramientas que pueden realizar la detección de fugas de memoria. Muchos detectores de fugas funcionan a nivel de biblioteca al interceptar llamadas a rutinas de administración de memoria. Por ejemplo, HPROF
es una herramienta de línea de comandos simple que se incluye con Java 2 Platform Standard Edition (J2SE) para generar perfiles de CPU y almacenamiento dinámico. La salida de HPROF
puede analizarse directamente o usarse como entrada para otras herramientas como JHAT
. Cuando trabajamos con aplicaciones Java 2 Enterprise Edition (J2EE), hay una serie de soluciones de análisis de volcado de almacenamiento dinámico que son más amigables, como IBM Heapdumps para servidores de aplicaciones Websphere.