Comenzar con el Perfil de Asignaciones

Otros idiomas: English 한국어 Português 中文

A menudo nos encontramos en situaciones donde el código no funciona correctamente, y no tenemos idea de por dónde empezar a investigar.

¿No podemos simplemente mirar el código hasta que la solución eventualmente nos llegue? Claro, pero este método probablemente no funcionará sin un conocimiento profundo del proyecto y mucho esfuerzo mental. Un enfoque más inteligente sería usar las herramientas que tienes a mano. Pueden indicarte la dirección correcta.

En esta publicación, veremos cómo podemos perfilar las asignaciones de memoria para resolver un problema en tiempo de ejecución.

El problema

Comencemos clonando el siguiente repositorio: https://github.com/flounder4130/party-parrot.

Lanza la aplicación usando la configuración de ejecución Parrot incluida con el proyecto. La aplicación parece funcionar bien: puedes ajustar el color y la velocidad de la animación. Sin embargo, no pasa mucho tiempo antes de que las cosas empiecen a salir mal.

La animación del loro está congelada

Después de trabajar durante algún tiempo, la animación se congela sin ninguna indicación de cuál es la causa. El programa a veces puede lanzar un error OutOfMemoryError , cuyo stack trace no nos dice nada sobre el origen del problema.

No hay una forma segura de decir cómo se manifestará exactamente el problema. Lo interesante de este bloqueo de animación es que aún podemos usar el resto de la interfaz de usuario después de que ocurra.

Info icon

Utilicé Amazon Corretto 11 para ejecutar esta aplicación. El resultado puede variar en otras JVMs o incluso en la misma JVM si utiliza una configuración diferente.

El depurador

Parece que tenemos un error. ¡Intentemos usar el depurador! Lanza la aplicación en modo de depuración, espera hasta que la animación se congele, luego presiona Pause Program.

La vista de hilos en el depurador muestra una pila, que parece no estar relacionada con el error La vista de hilos en el depurador muestra una pila, que parece no estar relacionada con el error

Desafortunadamente, esto no nos dijo mucho porque todos los hilos involucrados en la fiesta del loro están en estado de espera. Inspeccionar sus pilas no da ninguna indicación de por qué ocurrió el bloqueo. Claramente, necesitamos probar otro enfoque.

Monitorizar el uso de recursos

Dado que estamos obteniendo un error OutOfMemoryError , un buen punto de partida para el análisis es CPU and Memory Live Charts. Permiten visualizar el uso de recursos en tiempo real para los procesos que están en ejecución. Vamos a abrir los gráficos para nuestra aplicación de loro y ver si podemos detectar algo cuando la animación se congela.

El gráfico de uso de memoria muestra que la cantidad de memoria utilizada aumenta y luego se aplana El gráfico de uso de memoria muestra que la cantidad de memoria utilizada aumenta y luego se aplana

En efecto, vemos que el uso de memoria va aumentando continuamente antes de alcanzar un plateau. Este es precisamente el momento en que la animación se cuelga, y después de eso parece que se queda colgada para siempre.

Esto nos da una pista. Por lo general, la curva de uso de memoria tiene forma de sierra: el gráfico sube cuando se asignan nuevos objetos y periódicamente baja cuando la memoria se recupera después de recoger los objetos no utilizados. Puedes ver un ejemplo de un programa que funciona normalmente en la imagen de abajo:

Captura de pantalla de un gráfico de uso de memoria donde la memoria utilizada sube constantemente pero luego baja regularmente Captura de pantalla de un gráfico de uso de memoria donde la memoria utilizada sube constantemente pero luego baja regularmente

Si los dientes de sierra se vuelven demasiado frecuentes, significa que el recolector de basura está trabajando intensivamente para liberar la memoria. Un plateau significa que no puede liberar ninguno.

Podemos probar si la JVM es capaz de realizar una recolección de basura solicitándola explícitamente:

'Perform GC' button on the toolbar of 'CPU and Memory Live Charts' 'Perform GC' button on the toolbar of 'CPU and Memory Live Charts'

El uso de la memoria no disminuye después de que nuestra aplicación llega al plateau, incluso si solicitamos manualmente que libere algo de memoria. Esto respalda nuestra hipótesis de que no hay objetos elegibles para la recolección de basura.

Una solución ingenua sería simplemente agregar más memoria. Para ello, añade la opción VM -Xmx500m a la configuración de ejecución.

Añadiendo la opción VM -Xmx500m en el diálogo 'Run/Debug Configurations' Añadiendo la opción VM -Xmx500m en el diálogo 'Run/Debug Configurations'

Independientemente de la memoria disponible, el loro se queda sin ella de todos modos. Una vez más, vemos la misma imagen. El único efecto visible de la memoria extra fue que retrasamos el final de la “fiesta”.

El gráfico de uso de memoria muestra que ahora hay 500M de memoria disponible, pero la aplicación la usa toda de todos modos El gráfico de uso de memoria muestra que ahora hay 500M de memoria disponible, pero la aplicación la usa toda de todos modos

Perfil de asignación

Dado que sabemos que nuestra aplicación nunca tiene suficiente memoria, es razonable sospechar una fuga de memoria y analizar su uso de memoria. Para ello, podemos recoger un volcado de memoria utilizando la opción VM -XX:+HeapDumpOnOutOfMemoryError . Este es un enfoque perfectamente aceptable para inspeccionar el heap; sin embargo, no señalará el código responsable de crear estos objetos.

Podemos obtener esta información de una instantánea del perfil: no sólo proporcionará estadísticas sobre los tipos de los objetos, sino que también revelará los seguimientos de pila correspondientes a cuando fueron creados. Aunque este es un caso de uso un poco inconvencional para el perfil de asignación, nada nos impide usarlo para identificar el problema.

Vamos a ejecutar la aplicación con IntelliJ Profiler adjunto. Mientras se ejecuta, el perfilador grabará periódicamente el estado de los hilos y recogerá datos sobre los eventos de asignación de memoria. Estos datos se agregan luego en una forma legible para humanos para darnos una idea de qué estaba haciendo la aplicación al asignar estos objetos.

Después de ejecutar el perfilador durante algún tiempo, abramos el informe y seleccionemos Memory Allocations:

En la esquina superior derecha de la ventana de herramientas de 'Profiler' hay un gráfico del uso de la memoria En la esquina superior derecha de la ventana de herramientas de 'Profiler' hay un gráfico del uso de la memoria

Hay varias vistas disponibles para los datos recogidos. En este tutorial, utilizaremos el flame graph. Agrega las pilas recogidas en una única estructura similar a una pila, ajustando el ancho del elemento según el número de muestras. Los elementos más anchos representan los tipos más masivamente asignados durante el período de perfilamiento.

Es importante señalar aquí que muchas asignaciones no necesariamente indican un problema. Una fuga de memoria ocurre sólo si los objetos asignados no son recogidos por el recolector de basura. Mientras que el perfil de asignación no nos dice nada sobre la recolección de basura, todavía puede darnos pistas para la investigación posterior.

Los dos marcos más grandes en el gráfico de asignación son int[] y byte[] Los dos marcos más grandes en el gráfico de asignación son int[] y byte[]

Veamos de dónde vienen los dos elementos más masivos, byte[] y int[] . La parte superior de la pila nos indica que estos arrays se crean durante la procesamiento de imágenes por el código del paquete java.awt.image . La parte inferior de la pila nos dice que todo esto sucede en un hilo separado gestionado por un servicio de ejecutor. No estamos buscando errores en el código de la biblioteca, así que miremos el código del proyecto que está en medio.

Partiendo de arriba hacia abajo, el primer método de aplicación que vemos es recolor() , que a su vez es llamado por updateParrot() . A juzgar por el nombre, este método es exactamente lo que hace que nuestro loro se mueva. Veamos cómo se implementa esto y por qué necesita tantos arrays.

Señalando el método updateParrot() en el gráfico de llama Señalando el método updateParrot() en el gráfico de llama

Hacer clic en el frame nos lleva al código fuente del método correspondiente:

public void updateParrot() {
    currentParrotIndex = (currentParrotIndex + 1) % parrots.size();
    BufferedImage baseImage = parrots.get(currentParrotIndex);
    State state = new State(baseImage, getHue());
    BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue));
    parrot.setIcon(new ImageIcon(coloredImage));
}

Parece que updateParrot() toma una imagen base y luego la recolorea. Para evitar trabajo extra, la implementación primero intenta recuperar la imagen de alguna cache. La clave para recuperarla es un objeto State , cuyo constructor toma una imagen base y un matiz:

public State(BufferedImage baseImage, int hue) {
    this.baseImage = baseImage;
    this.hue = hue;
}

Analizar el flujo de datos

Usando el analizador estático incorporado, podemos rastrear el rango de valores de entrada para la llamada al constructor de State . Haz clic con el botón derecho en el argumento del constructor baseImage , luego desde el menú, selecciona Analyze | Data Flow to Here.

La ventana de herramienta 'Analyze dataflow to' muestra las posibles fuentes de valores como nodos La ventana de herramienta 'Analyze dataflow to' muestra las posibles fuentes de valores como nodos

Expande los nodos y presta atención a ImageIO.read(path.toFile()) . Nos muestra que las imágenes base provienen de un conjunto de archivos. Si hacemos doble clic en esta línea y miramos la constante PARROTS_PATH que está cerca, descubrimos la ubicación de los archivos:

public static final String PARROTS_PATH = "src/main/resources";

Al navegar a este directorio, podemos ver lo siguiente:

10 archivos de imagen bajo src/main/java en la ventana de herramientas 'Project' 10 archivos de imagen bajo src/main/java en la ventana de herramientas 'Project'

Son diez imágenes base que corresponden a las posibles posiciones del loro. Bueno, ¿qué hay del argumento del constructor hue ?

La ventana de herramientas 'Analyze dataflow to' muestra las posibles fuentes de valores como nodos La ventana de herramientas 'Analyze dataflow to' muestra las posibles fuentes de valores como nodos

Si inspeccionamos el código que modifica la variable hue , vemos que tiene un valor de inicio de 50 . Luego se establece con un deslizador o se actualiza automáticamente desde el método updateHue() . De cualquier manera, siempre está dentro del rango de 1 a 100 .

Entonces, tenemos 100 variantes de matiz y 10 imágenes base, lo que debería garantizar que la cache nunca crezca más de 1000 elementos. Veamos si eso se cumple.

Puntos de interrupción condicionales

Ahora, aquí es donde el depurador puede ser útil. Podemos verificar el tamaño de la cache con un punto de interrupción condicional.

Info icon

Poner un punto de interrupción condicional en un código caliente puede ralentizar de manera significativa la aplicación objetivo.

Pongamos un punto de interrupción en la acción de actualización y añadamos una condición para que solo suspenda la aplicación cuando el tamaño de la cache exceda los 1000 elementos.

Diálogo de ajustes de punto de interrupción con la condición 'cache.size() > 1000' Diálogo de ajustes de punto de interrupción con la condición 'cache.size() > 1000'

Ahora ejecuta la aplicación en modo de depuración.

Una línea resaltada indica que el punto de interrupción funcionó y el depurador suspendió la aplicación Una línea resaltada indica que el punto de interrupción funcionó y el depurador suspendió la aplicación

En efecto, nos detenemos en este punto de interrupción después de ejecutar el programa durante algún tiempo, lo que significa que el problema está de hecho en la cache.

Inspeccionar el código

Cmd + B en cache nos lleva a su sitio de declaración:

private static final Map<State, BufferedImage> cache = new HashMap<>();

Si comprobamos la documentación de HashMap , encontraremos que su implementación se basa en los métodos equals() y hashcode() , y el tipo que se utiliza como clave tiene que sobrescribirlos correctamente. Comprobémoslo. Cmd + B en State nos lleva a la definición de la clase.

class State {
private final BufferedImage baseImage;
private final int hue;

    public State(BufferedImage baseImage, int hue) {
        this.baseImage = baseImage;
        this.hue = hue;
    }

    public BufferedImage getBaseImage() { return baseImage; }

    public int getHue() { return hue; }
}

Parece que hemos encontrado al culpable: la implementación de equals() y hashcode() no es solo incorrecta. ¡Está completamente ausente!

Sobrescribir métodos

Escribir implementaciones para equals() y hashcode() es una tarea mundana. Afortunadamente, las herramientas modernas pueden generarlos por nosotros.

Mientras esté en la clase State , presione Cmd + N y seleccione equals() and hashcode(). Acepte las sugerencias y haga clic en Next hasta que los métodos aparezcan en el caret.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    State state = (State) o;
    return hue == state.hue && Objects.equals(baseImage, state.baseImage);
}

@Override
    public int hashCode() {
    return Objects.hash(baseImage, hue);
}

Verificar la corrección

Reiniciemos la aplicación y veamos si las cosas han mejorado. Una vez más, podemos usar CPU and Memory Live Charts para eso:

El gráfico en 'CPU and Memory Live Charts' ya no se aplana y baja regularmente El gráfico en 'CPU and Memory Live Charts' ya no se aplana y baja regularmente

¡Eso es mucho mejor!

Resumen

En esta publicación, analizamos cómo podemos comenzar con los síntomas generales de un problema y luego, utilizando nuestro razonamiento y la variedad de herramientas disponibles para nosotros, reducir el alcance de la búsqueda paso a paso hasta encontrar la línea de código exacta que está causando el problema. ¡Más importante aún, nos aseguramos de que la fiesta del loro continúe sin importar qué!

Como siempre, estaré encantado de escuchar tus comentarios! ¡Feliz perfilado!

all posts ->