Démarrer avec le Profilage d'Allocations

Autres langues : English Español Deutsch 日本語 한국어 Português 中文

Nous nous trouvons souvent dans des situations où le code ne fonctionne pas correctement, et nous n’avons aucune idée par où commencer l’investigation.

Ne pouvons-nous pas simplement fixer le code jusqu’à ce que la solution nous vienne ? Bien sûr, mais cette méthode ne fonctionnera probablement pas sans une connaissance approfondie du projet et beaucoup d’effort mental. Une approche plus intelligente serait d’utiliser les outils à votre disposition. Ils peuvent vous orienter dans la bonne direction.

Dans cet article, nous verrons comment nous pouvons profiler les allocations mémoire pour résoudre un problème d’exécution.

Le problème

Commençons par cloner le dépôt suivant : https://github.com/flounder4130/party-parrot.

Lancez l’application en utilisant la configuration d’exécution Parrot incluse avec le projet. L’application semble bien fonctionner : vous pouvez ajuster la couleur et la vitesse de l’animation. Cependant, les choses commencent rapidement à mal tourner.

L'animation du perroquet est bloquée

Après avoir fonctionné pendant un certain temps, l’animation se fige sans aucune indication de la cause. Le programme peut parfois lancer une OutOfMemoryError , dont la pile d’appels ne nous dit rien sur l’origine du problème.

Il n’y a pas de moyen fiable de savoir exactement comment le problème va se manifester. Ce qui est intéressant avec ce blocage de l’animation, c’est que nous pouvons toujours utiliser le reste de l’interface après qu’il se soit produit.

Info icon

J’ai utilisé Amazon Corretto 11 pour exécuter cette application. Le résultat peut différer sur d’autres JVM ou même sur la même JVM si elle utilise une configuration différente.

Le débogueur

Il semble que nous ayons un bug. Essayons d’utiliser le débogueur ! Lancez l’application en mode débogage, attendez que l’animation se fige, puis appuyez sur Pause Program.

Vue des threads dans le débogueur montrant une pile, qui semble ne pas être liée au bogue Vue des threads dans le débogueur montrant une pile, qui semble ne pas être liée au bogue

Malheureusement, cela ne nous a pas appris grand-chose car tous les threads impliqués dans la fête du perroquet sont en état d’attente. L’inspection de leurs piles ne donne aucune indication sur la raison du blocage. Clairement, nous devons essayer une autre approche.

Surveiller l’utilisation des ressources

Puisque nous obtenons une OutOfMemoryError , un bon point de départ pour l’analyse est CPU and Memory Live Charts. Ils nous permettent de visualiser l’utilisation des ressources en temps réel pour les processus en cours d’exécution. Ouvrons les graphiques pour notre application perroquet et voyons si nous pouvons repérer quelque chose lorsque l’animation se fige.

Le graphique d'utilisation de la mémoire montre que la quantité de mémoire utilisée augmente, puis se stabilise. Le graphique d'utilisation de la mémoire montre que la quantité de mémoire utilisée augmente, puis se stabilise.

En effet, nous voyons que l’utilisation de la mémoire augmente continuellement avant d’atteindre un plateau. C’est précisément le moment où l’animation se bloque, et après cela, elle semble se bloquer indéfiniment.

Cela nous donne un indice. Habituellement, la courbe d’utilisation de la mémoire est en forme de dents de scie : le graphique monte lorsque de nouveaux objets sont alloués et descend périodiquement lorsque la mémoire est récupérée après la collecte des objets inutilisés. Vous pouvez voir un exemple d’un programme fonctionnant normalement dans l’image ci-dessous :

Capture d'écran d'un graphique d'utilisation de la mémoire où la mémoire utilisée augmente constamment, puis diminue régulièrement Capture d'écran d'un graphique d'utilisation de la mémoire où la mémoire utilisée augmente constamment, puis diminue régulièrement

Si les dents de scie deviennent trop fréquentes, cela signifie que le garbage collector travaille intensivement pour libérer la mémoire. Un plateau signifie qu’il ne peut en libérer aucune.

Nous pouvons tester si la JVM est capable d’effectuer une collecte des déchets en en demandant explicitement une :

Bouton 'Perform GC' dans la barre d'outils des 'CPU et Memory Live Charts' Bouton 'Perform GC' dans la barre d'outils des 'CPU et Memory Live Charts'

L’utilisation de la mémoire ne diminue pas après que notre application atteint le plateau, même si nous la sollicitons manuellement pour libérer de la mémoire. Cela soutient notre hypothèse qu’il n’y a pas d’objets éligibles à la collecte des déchets.

Une solution naïve serait simplement d’ajouter plus de mémoire. Pour cela, ajoutez l’option VM -Xmx500m à la configuration d’exécution.

Ajouter l'option VM -Xmx500m dans la boîte de dialogue 'Exécuter/Debugger Configurations' Ajouter l'option VM -Xmx500m dans la boîte de dialogue 'Exécuter/Debugger Configurations'
Tip icon

Pour accéder rapidement aux paramètres de la configuration d’exécution actuellement sélectionnée, maintenez ‘Shift’ et cliquez sur le nom de la configuration d’exécution dans la barre d’outils principale.

Quelle que soit la mémoire disponible, le perroquet en manque quand même. Encore une fois, nous voyons la même image. Le seul effet visible de la mémoire supplémentaire était que nous avons retardé la fin de la “fête”.

Le graphique d'utilisation de la mémoire montre que maintenant 500M de mémoire sont disponibles, mais l'application les utilise entièrement Le graphique d'utilisation de la mémoire montre que maintenant 500M de mémoire sont disponibles, mais l'application les utilise entièrement

Profilage d’allocations

Puisque nous savons que notre application n’a jamais assez de mémoire, il est raisonnable de suspecter une fuite mémoire et d’analyser son utilisation de la mémoire. Pour cela, nous pouvons collecter un dump mémoire en utilisant l’option VM -XX:+HeapDumpOnOutOfMemoryError . C’est une approche parfaitement acceptable pour inspecter le tas ; cependant, elle ne pointera pas vers le code responsable de la création de ces objets.

Nous pouvons obtenir ces informations à partir d’un snapshot du profileur : non seulement il fournira des statistiques sur les types d’objets, mais il révélera également les piles d’appels correspondant au moment de leur création. Bien que ce soit un cas d’utilisation un peu inhabituel pour le profilage d’allocations, rien ne nous empêche de l’utiliser pour identifier le problème.

Exécutons l’application avec IntelliJ Profiler attaché. Pendant l’exécution, le profileur enregistrera périodiquement l’état des threads et collectera des données sur les événements d’allocation mémoire. Ces données sont ensuite agrégées sous une forme lisible pour nous donner une idée de ce que faisait l’application lors de l’allocation de ces objets.

Après avoir exécuté le profileur pendant un certain temps, ouvrons le rapport et sélectionnons Memory Allocations :

L'élément 'Memory Allocations' dans le menu 'Show' dans le coin supérieur droit de la fenêtre d'outil 'Profiler' L'élément 'Memory Allocations' dans le menu 'Show' dans le coin supérieur droit de la fenêtre d'outil 'Profiler'

Il existe plusieurs vues disponibles pour les données collectées. Dans ce tutoriel, nous utiliserons le flame graph. Il agrège les piles collectées en une seule structure de type pile, ajustant la largeur des éléments en fonction du nombre d’échantillons collectés. Les éléments les plus larges représentent les types les plus massivement alloués pendant la période de profilage.

Une chose importante à noter ici est que beaucoup d’allocations n’indiquent pas nécessairement un problème. Une fuite mémoire se produit uniquement si les objets alloués ne sont pas collectés par le garbage collector. Bien que le profilage d’allocations ne nous dise rien sur la collecte des déchets, il peut tout de même nous donner des indices pour une investigation plus poussée.

Les deux plus grands cadres dans le graphique d'allocation sont int[] et byte[]. Les deux plus grands cadres dans le graphique d'allocation sont int[] et byte[].

Voyons d’où viennent les deux éléments les plus massifs, byte[] et int[] . Le haut de la pile nous dit que ces tableaux sont créés pendant le traitement d’images par le code du package java.awt.image . Le bas de la pile nous dit que tout cela se passe dans un thread séparé géré par un executor service. Nous ne cherchons pas de bugs dans le code de la bibliothèque, alors regardons le code du projet qui se trouve entre les deux.

En allant du haut vers le bas, la première méthode de l’application que nous voyons est recolor() , qui à son tour est appelée par updateParrot() . À en juger par le nom, cette méthode est exactement ce qui fait bouger notre perroquet. Voyons comment cela est implémenté et pourquoi il a besoin d’autant de tableaux.

Pointant vers la méthode updateParrot() sur le graphique en flammes Pointant vers la méthode updateParrot() sur le graphique en flammes

Cliquer sur le cadre nous amène au code source de la méthode correspondante :

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));
}

Il semble que updateParrot() prenne une image de base puis la recolore. Afin d’éviter un travail supplémentaire, l’implémentation essaie d’abord de récupérer l’image depuis un cache. La clé pour la récupération est un objet State , dont le constructeur prend une image de base et une teinte :

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

Analyser le flux de données

En utilisant l’analyseur statique intégré, nous pouvons tracer la plage des valeurs d’entrée pour l’appel du constructeur State . Faites un clic droit sur l’argument du constructeur baseImage , puis dans le menu, sélectionnez Analyze | Data Flow to Here.

La fenêtre d'outil 'Analyze dataflow to' montre les sources possibles des valeurs comme des nœuds La fenêtre d'outil 'Analyze dataflow to' montre les sources possibles des valeurs comme des nœuds

Développez les nœuds et prêtez attention à ImageIO.read(path.toFile()) . Cela nous montre que les images de base proviennent d’un ensemble de fichiers. Si nous double-cliquons sur cette ligne et regardons la constante PARROTS_PATH à proximité, nous découvrons l’emplacement des fichiers :

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

En naviguant vers ce répertoire, nous pouvons voir ce qui suit :

10 fichiers image sous src/main/java dans la fenêtre d'outils 'Project' 10 fichiers image sous src/main/java dans la fenêtre d'outils 'Project'

Ce sont dix images de base qui correspondent aux positions possibles du perroquet. Eh bien, qu’en est-il de l’argument du constructeur hue ?

'Analyze dataflow to' fenêtre d'outil montre les sources possibles des valeurs comme des nœuds 'Analyze dataflow to' fenêtre d'outil montre les sources possibles des valeurs comme des nœuds

Si nous inspectons le code qui modifie la variable hue , nous voyons qu’elle a une valeur de départ de 50 . Ensuite, elle est soit définie avec un curseur, soit mise à jour automatiquement depuis la méthode updateHue() . Dans tous les cas, elle est toujours dans la plage de 1 à 100 .

Donc, nous avons 100 variantes de teinte et 10 images de base, ce qui devrait garantir que le cache ne dépasse jamais 1000 éléments. Vérifions si cela est vrai.

Points d’arrêt conditionnels

Maintenant, c’est là que le débogueur peut être utile. Nous pouvons vérifier la taille du cache avec un point d’arrêt conditionnel.

Info icon

Définir un point d’arrêt conditionnel dans du code critique pourrait considérablement ralentir l’application cible.

Définissons un point d’arrêt à l’action de mise à jour et ajoutons une condition pour qu’il ne suspende l’application que lorsque la taille du cache dépasse 1000 éléments.

Boîte de dialogue des paramètres du point d'arrêt avec la condition 'cache.size() > 1000' Boîte de dialogue des paramètres du point d'arrêt avec la condition 'cache.size() > 1000'

Maintenant, exécutez l’application en mode débogage.

Une ligne surlignée indique que le point d'arrêt a fonctionné et que le débogueur a suspendu l'application. Une ligne surlignée indique que le point d'arrêt a fonctionné et que le débogueur a suspendu l'application.

En effet, nous nous arrêtons à ce point d’arrêt après avoir exécuté le programme pendant un certain temps, ce qui signifie que le problème est bien dans le cache.

Inspecter le code

Cmd + B sur cache nous amène à son site de déclaration :

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

Si nous vérifions la documentation de HashMap , nous découvrirons que son implémentation repose sur les méthodes equals() et hashcode() , et le type utilisé comme clé doit les redéfinir correctement. Vérifions cela. Cmd + B sur State nous amène à la définition de la classe.

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; }
}

Il semble que nous ayons trouvé le coupable : l’implémentation de equals() et hashcode() n’est pas seulement incorrecte. Elle est complètement absente !

Redéfinir les méthodes

Écrire des implémentations pour equals() et hashcode() est une tâche fastidieuse. Heureusement, les outils modernes peuvent les générer pour nous.

Pendant que vous êtes dans la classe State , appuyez sur Cmd + N et sélectionnez equals() and hashcode(). Acceptez les suggestions et cliquez sur Next jusqu’à ce que les méthodes apparaissent au curseur.

@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);
}

Vérifier la correction

Redémarrons l’application et voyons si les choses se sont améliorées. Encore une fois, nous pouvons utiliser CPU and Memory Live Charts pour cela :

Le graphique dans les 'Graphiques en direct CPU et Mémoire' ne s'aplatit plus et descend régulièrement. Le graphique dans les 'Graphiques en direct CPU et Mémoire' ne s'aplatit plus et descend régulièrement.

C’est bien mieux !

Résumé

Dans cet article, nous avons vu comment nous pouvons partir des symptômes généraux d’un problème et ensuite, en utilisant notre raisonnement et la variété d’outils à notre disposition, réduire la portée de la recherche étape par étape jusqu’à ce que nous trouvions la ligne de code exacte qui cause le problème. Plus important encore, nous nous sommes assurés que la fête du perroquet continuera quoi qu’il arrive !

Comme toujours, je serai heureux d’entendre vos retours ! Bon profilage !

all posts ->