Analyser la mémoire Java

Les applications Java et Kotlin gèrent la mémoire via un tas avec récupération de mémoire. Lorsque les objets ne sont plus accessibles, le récupérateur de mémoire finit par récupérer leur espace. Les fuites de mémoire se produisent lorsque des objets qui ne sont plus nécessaires sont toujours détenus par des "racines GC", ce qui les empêche d'être récupérés.

Concepts fondamentaux

Racines de récupération de mémoire

Une racine GC est un type d'objet spécial que le garbage collector traite comme toujours accessible. Voici quelques exemples :

  • Threads actifs (et objets référencés à partir de leurs frames de pile Java en cours d'exécution).
  • Classes avec des méthodes en cours d'exécution.
  • Références JNI (références globales ou locales détenues par le code natif).

Chemin d'accès à la racine GC

Tant qu'il existe une chaîne de références d'une racine GC à un objet, cet objet est "accessible" et ne peut pas être collecté en tant que déchet. Cette chaîne est appelée chemin vers la racine GC. Pour corriger une fuite de mémoire, vous devez identifier et rompre cette chaîne.

Chemin d'accès à la racine GC

Arbres de dominateurs

Bien que le chemin d'accès à la racine GC vous indique pourquoi un objet est actif, il ne vous indique pas la quantité de mémoire qui serait récupérée si cette référence était rompue. Pour cela, nous utilisons les arbres de dominateurs.

On dit que l'objet A domine l'objet B si chaque chemin d'accès d'une racine GC à B doit passer par A. Si A domine B, la récupération de A garantit également la récupération de B, car il n'existe aucun autre chemin d'une racine à B.

Le diagramme suivant montre un graphique d'objets et son arbre de dominateurs correspondant. Notez que l'objet D est accessible à la fois par A et B dans le graphique. Par conséquent, ni A ni B ne dominent D. Au lieu de cela, la racine GC est son dominateur le plus proche.

Arbre des dominateurs

Obtenir des empreintes de la mémoire Java

Une empreinte de la mémoire est un instantané de tous les objets de la mémoire du tas Java à un moment précis.

Utiliser ADB

Pour capturer une empreinte de la mémoire à partir d'un processus en cours d'exécution, vous pouvez transmettre le nom du package directement à am dumpheap. Pour exécuter cette commande, vous devez compiler votre application avec <profileable android:shell="true"/> ou <debuggable>.

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

Utiliser Perfetto

Perfetto peut également capturer des vidages du tas Java dans le cadre d'une trace à l'échelle du système en activant la source de données android.java_hprof dans votre configuration Perfetto. Cela est utile pour corréler l'état du tas avec d'autres événements système.

Pour capturer une empreinte de la mémoire pour l'application MemoryLab à l'aide de Perfetto, vous pouvez utiliser la commande suivante :

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

Consultez la section Empreintes de la mémoire Java dans la documentation Perfetto.

Analyser avec AHAT

AHAT (Android Heap Analysis Tool) est l'outil recommandé pour afficher les fichiers .hprof dans un navigateur Web.

Démarrer AHAT

Si ahat est installé dans votre chemin d'accès, lancez-le avec :

ahat heap.hprof

Ou exécutez le fichier jar autonome :

java -jar ahat.jar heap.hprof

Ouvrez ensuite votre navigateur sur http://localhost:7100.

Pour savoir comment obtenir ou créer AHAT, consultez le dépôt source AHAT.

Principaux workflows d'analyse

Détecter les fuites

Recherchez votre classe d'activité (MainActivity) dans la vue Attributions.

Vue AHAT montrant les instances

Cliquez sur le cours pour trouver toutes les instances.

Vue AHAT montrant les instances MainActivity Cliquez sur l'instance MainActivity pour l'inspecter.

AHAT affichant les détails d'une instance

Dans la vue des instances, vous trouverez Exemple de chemin d'accès à partir de la racine GC, qui affiche la chaîne de références empêchant l'objet d'être collecté par le garbage collector, et Taille de l'objet, qui indique la quantité de mémoire conservée par cette instance spécifique.

Chemin d'échantillon AHAT à partir de la racine GC et de la taille de l'objet

Analyser les bitmaps

AHAT est compatible avec l'affichage des objets android.graphics.Bitmap, qui consomment souvent beaucoup de mémoire. Cliquez sur une instance Bitmap pour afficher un aperçu rendu de son contenu.

Aperçu du bitmap AHAT

Page "Fuites d'activité"

AHAT dispose d'une vue spécialisée pour identifier les activités divulguées, qui sont l'une des fuites de mémoire les plus courantes et les plus impactantes dans Android.

  1. Action : dans MemoryLab, appuyez sur Leak an Activity (Divulguer une activité). Cela lance LeakedActivity, qui se divulgue intentionnellement.
  2. Vider : prendre une empreinte de la mémoire.
  3. Analyser : cliquez sur Fuites d'activité dans la barre latérale AHAT.
  4. Vérifier : AHAT listera com.android.memorylab.LeakedActivity comme ayant fait l'objet d'une fuite, car son champ mDestroyed est défini sur "true" (indiquant que le cycle de vie de l'activité est terminé), mais il est toujours accessible à partir d'une racine GC.

Page sur les fuites d'activité AHAT

Comparer des empreintes de la mémoire

Comparer deux vidages de tas est l'un des moyens les plus efficaces d'identifier les problèmes de mémoire. En comparant une empreinte de base "propre" à une empreinte prise après avoir effectué certaines actions, vous pouvez immédiatement voir quels objets se sont accumulés.

Exercice : identifier les fuites à l'aide de la comparaison

  1. Référence : lancez MemoryLab et effectuez une empreinte de la mémoire de référence :

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. Action : appuyez plusieurs fois sur Allocate Java Memory(10MB) (Allouer de la mémoire Java (10 Mo)) dans l'application.

  3. Final : prenez une deuxième empreinte de la mémoire :

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. Comparer : démarrez AHAT avec le deuxième vidage comme principal et le premier comme référence :

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. Présentation de l'analyse : la page Présentation inclut désormais une colonne Δ (delta). Vous devriez constater un delta positif important pour le tas app, ce qui indique une croissance significative de la mémoire.

Présentation de AHAT avec Delta

  1. Afficher plus de détails : cliquez sur rooté dans le menu. Cette page affiche les objets accessibles à partir des racines GC, triés par taille conservée. Vous verrez MainActivity en haut de l'écran, avec un grand delta positif.

Vue AHAT avec delta

Enregistrer les traces de pile d'allocation

Alors que Exemple de chemin à partir de la racine GC vous indique pourquoi un objet est toujours actif, il ne vous indique pas comment il a été créé. Les traces de pile d'allocation fournissent la ligne de code exacte qui a alloué un objet.

Concept et compromis : l'enregistrement de la trace de la pile de chaque allocation est coûteux en termes de calcul et consomme beaucoup de mémoire. Dans une grande application de production, cela peut rendre l'application presque inutilisable. Toutefois, MemoryLab est une application suffisamment petite pour que nous puissions activer ce suivi en toute sécurité afin d'identifier la source des allocations.

Exercice : Identifier la source des tableaux d'octets

  1. Commencez par le suivi : forcez l'arrêt de MemoryLab et redémarrez-le avec l'indicateur --track-allocation. Augmentez la profondeur de pile par défaut pour capturer plus de contexte.

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. Action : appuyez plusieurs fois sur Allouer de la mémoire Java(10 Mo).

  3. Dump : prenez une empreinte de la mémoire et extrayez-la.

  4. Analyser : ouvrez le fichier dump dans AHAT. Accédez à une grande instance byte[]. (par exemple, inspecter MainActivity → mJavaAllocations (ArrayList) → elementData (Object[]) → élément de tableau [0]).

  5. Vérifiez : dans la vue de l'instance, consultez la section Site d'allocation. La trace de la pile complète menant à MainActivity.allocateJava s'affiche.

Site d'attribution AHAT

Chasse aux chaînes en double et à l'inflation de l'hydratation

Même lorsqu'une application ne présente aucune fuite de racine de récupération de mémoire classique, son tas de mémoire Java actif peut être gonflé par des milliers d'instances java.lang.String en double créées lors de la désérialisation JSON, Protobuf, Cursor ou de la base de données Room. Les clés, chaînes d'état, libellés de catégorie ou URL répétés sont souvent attribués à nouveau à chaque réponse du réseau ou requête de base de données. Dans les grandes applications de flux, de messagerie et de contenu, les chaînes en double représentent généralement entre 30% et 60% de la mémoire String en direct.

Pour inspecter les chaînes en double dans AHAT :

  1. Ouvrez la page Attributions et filtrez les résultats par java.lang.String.
  2. Lorsque vous comparez deux vidages de tas avec --baseline, vérifiez si le nombre d'instances java.lang.String et le nombre total d'octets augmentent de manière disproportionnée après l'hydratation d'un flux ou le chargement d'un cache local.
  3. Parcourez le tableau des instances java.lang.String (trié par taille ou valeur) pour identifier les valeurs de chaîne identiques conservées dans plusieurs objets de modèle en mémoire.
  • Solution : Évitez d'appeler String.intern() de manière indiscriminée sur des entrées utilisateur ou réseau arbitraires, car la table interne du runtime est globale et peut entraîner des conflits de verrouillage ou conserver des chaînes plus longtemps que nécessaire. Au lieu de cela, dédupliquez les chaînes de domaine à haute fréquence lors de la désérialisation à l'aide d'un cache de déduplication à portée limitée (tel qu'un LruCache<String, String> dans votre analyseur ou adaptateur), ou représentez des ensembles de valeurs fixes sous forme d'énumérations ou de constantes entières.

Analyser la dynamique de la mémoire Java (profil combiné)

Pour obtenir une image complète du comportement de la mémoire d'une application, vous pouvez combiner les compteurs de mémoire, l'activité des threads et le profilage de l'allocation basé sur la pile d'appels dans une seule trace Perfetto. Cela vous permet de corréler les métriques de mémoire à l'échelle du système (comme la taille RSS et du tas) avec des sites d'exécution et d'allocation de code spécifiques.

Nous allons utiliser une configuration combinée qui permet :

  • Compteurs de mémoire (linux.process_stats) : interroge le RSS et d'autres métriques de mémoire.
  • ATrace (catégories dalvik, memory et sched) : capture les états des threads et les événements GC.
  • Heapprofd (android.heapprofd) : cible les tas com.android.art (Java) et libc.malloc (natif) avec des vidages continus toutes les cinq secondes.

Exercice : analyse combinée de la mémoire

Dans cet exercice, nous allons exécuter l'application MemoryLab et effectuer une séquence d'opérations de mémoire pour observer différents modèles dans la trace :

  1. Baseline : état inactif.
  2. Churn Java : allocations temporaires immédiatement collectées par le garbage collector.
  3. Allocation Java persistante : allocation d'objets Java qui restent en mémoire.
  4. Allocation de bitmap : allocation d'éléments graphiques volumineux (qui se trouvent dans le tas natif/la mémoire graphique).
  5. Récupération : libération de toutes les ressources allouées.

1. Lancer et préparer

  1. Forcez l'arrêt et redémarrez l'application pour vous assurer qu'elle est dans un état propre :

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. Démarrer le traçage et déclencher la séquence

Nous allons commencer une trace de 40 secondes et déclencher les événements de mémoire à l'aide des commandes am broadcast.

  1. Démarrer la trace :

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 131072
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "linux.process_stats"
            target_buffer: 0
            process_stats_config {
                scan_all_processes_on_start: true
                proc_stats_poll_ms: 100
            }
        }
    }
    data_sources: {
        config {
            name: "linux.ftrace"
            target_buffer: 0
            ftrace_config {
                ftrace_events: "sched/sched_switch"
                ftrace_events: "task/task_newtask"
                ftrace_events: "task/task_rename"
                ftrace_events: "ftrace/print"
                atrace_categories: "dalvik"
                atrace_categories: "am"
                atrace_categories: "res"
                atrace_categories: "memory"
                atrace_categories: "sched"
                atrace_apps: "com.android.memorylab"
            }
        }
    }
    data_sources: {
        config {
            name: "android.heapprofd"
            target_buffer: 0
            heapprofd_config {
                sampling_interval_bytes: 4096
                process_cmdline: "com.android.memorylab"
                heaps: "libc.malloc"
                heaps: "com.android.art"
                shmem_size_bytes: 8388608
                block_client: true
                continuous_dump_config {
                    dump_phase_ms: 1000
                    dump_interval_ms: 5000
                }
            }
        }
    }
    duration_ms: 40000
    EOF
    
  2. Déclenchez la séquence (exécutez ces commandes dans le terminal hôte pendant l'exécution de la trace, en respectant le timing suggéré) :

    # Wait ~5s for trace initialization, then start Java churn:
    adb shell am broadcast -a com.android.memorylab.CHURN_JAVA
    
    # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory:
    adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA
    
    # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics):
    adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP
    
    # Wait ~10s (at 30s mark), free everything:
    adb shell am broadcast -a com.android.memorylab.FREE_ALL
    
  3. Autre méthode (outil CLI) : Vous pouvez également démarrer le profil à l'aide du script heap_profile directement, en ciblant à la fois les tas Java et natifs avec des vidages continus :

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. Analyser la trace combinée

Ouvrez le java_memory.perfetto-trace collecté dans l'interface utilisateur de Perfetto.

Pistes clés dans Perfetto

Avant d'analyser la timeline, repérez les pistes essentielles pour le processus com.android.memorylab :

  1. mem.rss.anon (RSS anonyme) : se trouve dans la section Mémoire du processus. Cette piste mesure la mémoire physique (RAM) allouée au processus par l'OS. Il représente l'espace mémoire utilisé réel.
  2. Heap size (KB) : également dans la section Mémoire. Il s'agit d'un compteur spécifique à Dalvik/ART représentant l'espace d'adressage virtuel réservé au tas de mémoire Java. Elle reflète la limite de tas interne de la VM, qui fluctue à mesure que les objets sont alloués et que le GC s'exécute.
  3. HeapTaskDaemon : se trouve dans la liste des threads sous le processus. Il s'agit du thread d'arrière-plan dans lequel le récupérateur de mémoire ART effectue la majeure partie de son travail. L'activité ici indique les pass GC actifs.
  4. Vidéos de vidage de l'allocation continue (heapprofd) : affichées sous forme de segments colorés le long de la chronologie supérieure. Chaque tranche représente une durée. Cliquer sur une seule tranche ou sélectionner une plage horaire vous permet d'inspecter le graphique en flammes (dans le volet inférieur) pour com.android.art (allocations Java) ou libc.malloc (allocations natives) afin de voir ce qui a été alloué pendant cette période.

Analyse des phases chronologiques

Examinons la trace de manière chronologique pour voir comment ces pistes interagissent pendant chaque phase de l'exercice.

Phase 1 : référence (0 s à 5 s)
  • Que se passe-t-il ? L'application est inactive et attend des commandes.
  • État du suivi :
    • mem.rss.anon : ligne plate au niveau de référence (généralement entre 60 et 80 Mo selon l'appareil).
    • Heap size (KB) : ligne plate correspondant à l'allocation initiale du tas Java.
    • HeapTaskDaemon : Inactif (aucune tranche n'indique l'exécution).
    • Vider les allocations : affiche les allocations de référence minimales.

Interface utilisateur de Perfetto affichant la référence de la phase 1

Phase 2 : Churn d'allocation Java (5 à 15 s)
  • Que se passe-t-il ? : le AllocationChurnThread est démarré, allouant et supprimant à plusieurs reprises des tableaux de 1 Mo.
  • État du suivi :
    • Heap size (KB) : affiche un motif en dents de scie rapide. La taille du tas de mémoire augmente à mesure que les allocations s'accumulent et diminue fortement lorsque la récupération de mémoire s'exécute.
    • HeapTaskDaemon : affiche une activité quasi constante, avec des tranches d'exécution qui s'alignent parfaitement sur les baisses de la dent de scie Heap size.
    • mem.rss.anon : suit l'activité du tas Java.
    • Vidéos de vidage de l'allocation du tas Java : la sélection de tranches dans cette piste affiche les allocations du tas com.android.art.

Interface utilisateur Perfetto montrant le taux de désabonnement de la phase 2 Les exemples d'allocation révèlent que AllocationChurnThread est l'allocateur principal, toutes les allocations partageant la même pile d'appels qui pointe vers le lambda à l'intérieur de MainActivity.java.

Interface utilisateur de Perfetto affichant les allocations Java de la phase 2

Phase 3 : Allocation Java persistante (15 à 20 s)
  • Ce qui se passe : nous allouons 10 Mo d'objets Java et conservons une référence à ces objets dans mJavaAllocations.
  • État du suivi :
    • Heap size (KB) : la référence des étapes en dents de scie augmente d'environ 10 Mo.
    • mem.rss.anon : la mémoire augmente d'environ 10 Mo, car l'OS doit sauvegarder cette allocation persistante avec de nouvelles pages physiques.
    • Vidéos de vidage de l'allocation du tas Java : la sélection de tranches dans cette piste affiche les allocations du tas com.android.art.
    • Vider l'allocation (graphique en flammes) : l'inspection du tas com.android.art pour le vidage effectué dans cette fenêtre montre un nouveau chemin d'allocation à partir de MainActivity.allocateJava contribuant à la taille retenue.

Interface utilisateur de Perfetto montrant l'allocation Java persistante de la phase 3 Sélectionnez un exemple d'allocation qui couvre une durée chevauchant l'augmentation de 10 Mo pour l'allocation persistante. Vous devriez voir les piles d'appels d'allocation diverger vers deux sites différents. L'un est responsable du même churn d'allocation de courte durée que nous avons vu précédemment, et l'autre de la nouvelle allocation de longue durée.

Interface utilisateur de Perfetto affichant les allocations Java de la phase 3

Phase 4 : allocation de bitmap (20e à 30e seconde)
  • Que se passe-t-il ? Nous allouons également 20 Mo de bitmaps.
  • État du suivi :
    • Heap size (KB) : identique à avant.
    • mem.rss.anon : affiche une augmentation significative d'environ 20 Mo, ce qui correspond aux allocations natives pour les données de pixels Bitmap.
    • Vider l'allocation (graphique en flammes) : cette fois, concentrez-vous sur les tranches du tas libc.malloc (natif).

Interface utilisateur de Perfetto affichant la phase 4 de l'allocation de bitmaps Les piles d'appels d'allocation native révèlent l'allocation Bitmap provenant des bibliothèques graphiques natives. Il s'agit d'un bon cas d'utilisation pour le suivi de l'allocation native, car vous ne verrez pas ces allocations Bitmap dans le tas Java.

Interface utilisateur de Perfetto montrant les allocations natives de la phase 4

Phase 5 : récupération (30e à 40e secondes)
  • Ce qui se passe : nous déclenchons FREE_ALL, ce qui efface les références à toutes les allocations et bitmaps Java persistants, suivi d'un System.gc() explicite.
  • État du suivi :
    • Heap size (KB) : redescend au niveau de référence.
    • mem.rss.anon : redescend, montrant l'OS récupérant les pages physiques.
    • HeapTaskDaemon : affiche une dernière rafale d'activité lors du traitement de la récupération de mémoire.

Interface utilisateur Perfetto affichant la phase 5 de récupération

Surveillance des erreurs OOM historiques (ApplicationExitInfo)

Il est très utile de détecter un LMK au moment où il se produit pour le débogage actif, mais pour la télémétrie sur le terrain, vous pouvez utiliser l'API ApplicationExitInfo. Cela permet à votre application de découvrir pourquoi elle a été arrêtée lors d'une session précédente.

ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
    ApplicationExitInfo info = exitReasons.get(0);
    if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
        // App was killed by the system Low Memory Killer
    }
}

Bonnes pratiques

  1. Baseline First : prenez toujours une empreinte de la mémoire "de référence" après l'initialisation de l'application, mais avant d'effectuer l'action que vous testez.
  2. Utilisez la page "Activity Leaks" (Fuites d'activité) d'AHAT : AHAT inclut une page Activity Leaks dédiée qui identifie automatiquement les instances d'activité qui ont été détruites, mais qui sont toujours conservées en mémoire. C'est souvent le moyen le plus rapide de trouver les fuites courantes.
  3. Vérifier le chemin d'accès aux racines GC : pour tout objet divulgué, utilisez la vue Chemin d'accès à partir de la racine dans AHAT pour comprendre exactement quelle référence le maintient en vie (par exemple, un champ statique, un thread de longue durée ou un écouteur enregistré).

← Outils | ↑ Haut | Bitmaps →