Étude de cas : Déboguer un plantage OOM

Les plantages de type "Mémoire insuffisante" (OutOfMemoryError) se produisent lorsque votre application épuise le tas de mémoire Java qui lui est alloué et ne peut pas effectuer une nouvelle allocation de mémoire. Les traces de la pile des rapports d'erreur OutOfMemoryError révèlent rarement la cause principale, car elles n'indiquent que l'appel d'allocation qui a échoué lorsque la mémoire était saturée, et non les objets qui conservent le reste du tas de mémoire.

ProfilingManager vous permet de capturer des empreintes de la mémoire Java à partir d'appareils sur le terrain et localement afin d'inspecter le graphique de conservation des objets dans l'UI Perfetto et d'identifier précisément ce qui encombre la mémoire.

Scénario

Supposons que votre outil de rapport d'erreur signale un nombre croissant de plantages OutOfMemoryError en production lorsque les utilisateurs interagissent avec OomSampleActivity.

Pour examiner le problème, vous enregistrez un déclencheur TRIGGER_TYPE_OOM avec ProfilingManager afin que le système capture automatiquement une empreinte de la mémoire Java lorsqu'une erreur OutOfMemoryError se produit.

Capturer l'empreinte de la mémoire

Sur Android 17 (niveau d'API 37) et versions ultérieures, pour capturer une empreinte de la mémoire lorsqu'un plantage OutOfMemoryError se produit, configurez le profilage basé sur un déclencheur avec ProfilingTrigger.TRIGGER_TYPE_OOM. Lorsque votre application génère une exception OutOfMemoryError non gérée, le système lance automatiquement une nouvelle capture PROFILING_TYPE_JAVA_HEAP_DUMP.

Étant donné que le processus de l'application se termine lorsque le plantage se produit, le système termine l'enregistrement de l'empreinte de la mémoire en arrière-plan et met le résultat en file d'attente (voir Cycle de vie du déclencheur). Enregistrez un écouteur de résultats global sur un Executor en arrière-plan une fois le démarrage de l'application terminé (pour ne pas ralentir le démarrage de l'application) à l'aide de registerForAllProfilingResults afin que votre application reçoive le ProfilingResult en file d'attente la prochaine fois que l'utilisateur la lancera. Contrairement à addProfilingTriggers (qui persiste lors des redémarrages de l'application et ne doit être appelé qu'une seule fois ou lors de la mise à jour des déclencheurs), registerForAllProfilingResults doit être appelé à chaque démarrage de l'application :

Kotlin

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerOomTrigger(context: Context) {
    val profilingManager =
        context.getSystemService(ProfilingManager::class.java) ?: return
    val callbackExecutor: Executor = Executors.newSingleThreadExecutor()

    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (
            profilingResult.errorCode == ProfilingResult.ERROR_NONE &&
            profilingResult.triggerType == ProfilingTrigger.TRIGGER_TYPE_OOM
        ) {
            val heapDumpPath = profilingResult.resultFilePath
            // Schedule a background job to compress and upload heapDumpPath
        }
    }

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback)

    val oomTrigger = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
        .build()
    profilingManager.addProfilingTriggers(listOf(oomTrigger))
}

Java

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
public static void registerOomTrigger(Context context) {
    ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class);
    if (profilingManager == null) return;
    Executor callbackExecutor = Executors.newSingleThreadExecutor();

    Consumer<ProfilingResult> resultCallback =
        profilingResult -> {
            if (profilingResult.getErrorCode() == ProfilingResult.ERROR_NONE
                && profilingResult.getTriggerType() == ProfilingTrigger.TRIGGER_TYPE_OOM) {
                String heapDumpPath = profilingResult.getResultFilePath();
                // Schedule a background job to compress and upload heapDumpPath
            }
        };

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback);

    ProfilingTrigger oomTrigger =
        new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
            .build();
    profilingManager.addProfilingTriggers(Collections.singletonList(oomTrigger));
}

Désobscurcir le profil

Une fois qu'un OutOfMemoryError déclenche une capture et que votre rappel reçoit le ProfilingResult au prochain lancement de l'application, importez le fichier de profil sur votre serveur ou transférez-le vers votre ordinateur de développement avec adb. Si vous avez capturé le profil à partir d'un build de release qui utilise la minification et l'obscurcissement de code R8, désobscurcissez-le hors connexion sur votre station de travail ou votre serveur avant l'analyse. Sans désobscurcissement, R8 obscurcit les noms de classe, de champ et de méthode Java et Kotlin dans l'empreinte de la mémoire brute (par exemple, en transformant les noms de classe d'origine en a.b).

Utilisez la commande trace_processor bundle de Perfetto avec l'option --proguard-map pour empaqueter le profil capturé avec le fichier R8 mapping.txt généré pour cette version exacte :

trace_processor bundle \
  --proguard-map /path/to/mapping.txt \
  profile_trigger-type-7_2026-05-06-14-12-40-748_uid-10316.perfetto-java-heap-dump \
  deobfuscated_oom.tar

Cette commande génère deobfuscated_oom.tar, une archive enrichie qui regroupe le profil d'origine et ses mappages de désobscurcissement. Lorsque vous ouvrez cette archive dans l'interface utilisateur de Perfetto ou que vous l'interrogez avec trace_processor, Perfetto restaure automatiquement tous les noms d'origine des classes, des champs et des méthodes. Pour en savoir plus, consultez la documentation Perfetto sur la symbolisation et le désobscurcissement.

Analyser l'empreinte de la mémoire

Vous pouvez inspecter manuellement l'empreinte de la mémoire désobscurcie dans l'UI Perfetto, comme indiqué dans les étapes suivantes, ou automatiser l'analyse des erreurs de mémoire insuffisante avec un agent IA.

Ouvrez l'empreinte de la mémoire désobscurcie dans l'interface utilisateur de Perfetto, puis sélectionnez Heapdump Explorer (Explorateur d'empreintes de la mémoire) dans la barre de navigation de gauche. Dans l'onglet Flamegraph avec l'option Mesure définie sur Taille de l'objet, la hiérarchie de conservation de la figure 1 se distingue immédiatement.

Graphique de type &quot;flamme&quot; de l'explorateur de vidage de tas de l'UI Perfetto montrant que StatsReporter conserve huit instances d'OomSampleActivity, pour un total de 240,28 Mio.
Figure 1. Graphique de type "flamme" de l'empreinte de la mémoire montrant huit instances conservées de OomSampleActivity.

L'inspection du graphique de type "flamme" de la figure 1 révèle plusieurs résultats clés :

  • Rétention massive de mémoire : sur un tas total de 254,79 Mio (root), com.example.sampleapp.OomSampleActivity conserve une taille cumulative de 240,28 Mio (94,31 % de l'ensemble du tas de l'application), dont la majeure partie se trouve dans un tableau enfant byte[].
  • Plusieurs instances d'activité en direct : la sélection du frame OomSampleActivity affiche Self Count: 8. Même si une seule instance de OomSampleActivity doit exister à l'écran à un moment donné, huit instances distinctes sont actives simultanément en mémoire.
  • Chemin d'accès de l'objet de conservation : la lecture des frames parents de OomSampleActivity dans le graphique de type "flamme" montre la chaîne de référence qui maintient ces instances actives : com.example.sampleapp.StatsReporter contient un java.util.concurrent.CopyOnWriteArrayList (sauvegardé par java.lang.Object[]), qui référence directement les huit instances OomSampleActivity.

Identifier la cause principale dans le code

Le chemin de conservation pointant de StatsReporter vers OomSampleActivity, inspectez les deux classes de votre codebase.

StatsReporter est un singleton qui gère une liste de rappels StatsListener et publie régulièrement des mises à jour à partir d'un thread d'interrogation en arrière-plan :

Kotlin

class StatsReporter private constructor() {
    fun interface StatsListener {
        fun onReportStats(stats: String)
    }

    private val listeners: MutableList<StatsListener> = CopyOnWriteArrayList()
    private var isReporting = false

    init {
        startReporting()
    }

    fun registerListener(listener: StatsListener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener)
        }
    }

    fun unregisterListener(listener: StatsListener) {
        listeners.remove(listener)
    }

    private fun startReporting() {
        if (isReporting) return
        isReporting = true

        Thread({
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000)
                } catch (e: InterruptedException) {
                    Thread.currentThread().interrupt()
                    break
                }

                for (listener in listeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread")
                }
            }
        }, "StatsReporterPollingThread").start()
    }

    companion object {
        private var instance: StatsReporter? = null

        @JvmStatic
        fun getInstance(): StatsReporter {
            return instance ?: StatsReporter().also { instance = it }
        }
    }
}

Java

public class StatsReporter {
    public interface StatsListener {
        void onReportStats(String stats);
    }

    private static StatsReporter sInstance;

    private final List<StatsListener> mListeners = new CopyOnWriteArrayList<>();
    private boolean mIsReporting = false;

    private StatsReporter() {
        startReporting();
    }

    public static StatsReporter getInstance() {
        if (sInstance == null) {
            sInstance = new StatsReporter();
        }
        return sInstance;
    }

    public void registerListener(StatsListener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    public void unregisterListener(StatsListener listener) {
        mListeners.remove(listener);
    }

    private void startReporting() {
        if (mIsReporting) return;
        mIsReporting = true;

        new Thread(() -> {
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }

                for (StatsListener listener : mListeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread");
                }
            }
        }, "StatsReporterPollingThread").start();
    }
}

Ensuite, inspectez OomSampleActivity :

Kotlin

class OomSampleActivity : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        StatsReporter.getInstance().registerListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleActivity"
    }
}

Java

public class OomSampleActivity extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleActivity";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

Le code confirme pourquoi StatsReporter conserve huit instances de OomSampleActivity :

  1. Chaque instance OomSampleActivity alloue un tampon byte[] de 30 Mio (heavyData en Kotlin, mHeavyData en Java) et s'enregistre en tant qu'écouteur sur le singleton StatsReporter dans onCreate.
  2. OomSampleActivity n'appelle jamais unregisterListener lorsque le système le détruit. Étant donné que StatsReporter survit à l'activité et conserve une référence forte à chaque écouteur enregistré, le récupérateur de mémoire ne peut pas récupérer les instances détruites ni leurs tableaux byte[] de 30 Mio.
  3. Chaque fois que le système détruit et recrée OomSampleActivity, par exemple lors d'une rotation de l'écran dans le cycle de vie d'une activité Android, une autre instance de 30 Mio fuit dans StatsReporter. Après sept rotations (8 instances × 30 Mio ≈ 240 Mio), le tas se remplit et l'application plante avec une erreur OutOfMemoryError.

Corriger la fuite

Pour corriger la fuite de mémoire, remplacez onDestroy dans l'activité et appelez StatsReporter.getInstance().unregisterListener(this) afin que le singleton libère sa référence lorsque le système détruit l'activité :

Kotlin

class OomSampleActivityFixed : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // FIX: Unregister the listener to prevent the memory leak
        // when the Activity is destroyed (for example, during screen rotation).
        StatsReporter.getInstance().unregisterListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleFixed"
    }
}

Java

public class OomSampleActivityFixed extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleFixed";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // FIX: Properly unregister the listener to prevent the memory leak
        // when the Activity is destroyed (e.g., during screen rotation).
        StatsReporter.getInstance().unregisterListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

Automatiser l'investigation des erreurs de mémoire insuffisante avec un agent IA

Au lieu d'inspecter manuellement le graphique de type "flamme" et de tracer les références dans les fichiers, vous pouvez utiliser un agent de codage IA configuré avec la compétence Perfetto AI pour analyser l'empreinte de la mémoire et la mettre en corrélation avec votre codebase :

Analyze the Perfetto OOM heap dump at </path/to/profile>, which corresponds to
the codebase located at </path/to/codebase> using the Perfetto AI skill and find
the root cause of the OutOfMemoryError.

L'agent interroge l'empreinte de la mémoire avec trace_processor, trace le chemin du dominateur de StatsReporter aux instances OomSampleActivity ayant fuité et inspecte les deux classes dans votre codebase. Sans intervention manuelle, il arrive à la même conclusion, à savoir que OomSampleActivity enregistre un écouteur dans onCreate sans le désenregistrer, et génère un correctif ajoutant unregisterListener(this) dans onDestroy (le rapport suivant a été généré à partir d'une capture distincte qui comportait sept instances conservées au moment du plantage) :

# Perfetto OOM Heap Dump Analysis Report

## 1. Orienting Context & First-Pass Triage

| Property | Value |
| :--- | :--- |
| **Process Name** | `com.example.sampleapp` |
| **Sample Timestamp (`graph_sample_ts`)** | `3433221241585` |
| **Leaf Retained Class** | `byte[]` (7 instances) |
| **Leaf Self Size** | `220,201,044` bytes (~210 MiB) |

### Dominator Tree Retention Path

Querying the `android.memory.heap_graph.dominator_tree` standard library module identifies the following largest dominated class path from GC root to leaf allocation:

    [ROOT_JAVA_FRAME] com.example.sampleapp.StatsReporter [1]
      -> java.util.concurrent.CopyOnWriteArrayList [1]
      -> java.lang.Object[] [1]
      -> com.example.sampleapp.OomSampleActivity [7]
      -> byte[] [7] (220,201,044 bytes)
## 2. Root Cause Analysis

> **Important:** The `OutOfMemoryError` is caused by an **Activity listener leak**: `OomSampleActivity` (`app/src/main/java/com/example/sampleapp/OomSampleActivity.java#L8-L27`) registers itself as a listener on the long-lived `StatsReporter` (`app/src/main/java/com/example/sampleapp/StatsReporter.java#L6-L61`) singleton in `onCreate()`, but never unregisters itself in `onDestroy()`.

### Step-by-Step Retention Mechanism

1. **Per-Activity 30 MiB Allocation:**
   Each instance of `OomSampleActivity` (`OomSampleActivity.java#L11-L12`) allocates a 30 MiB byte array (`30 * 1024 * 1024 = 31,457,280` bytes):
   ```java
   // OomSampleActivity.java:12
   private final byte[] mHeavyData = new byte[30 * 1024 * 1024];
   ```

2. **Strong Reference Registration in `onCreate()`:**
   When created, `OomSampleActivity.onCreate()` (`OomSampleActivity.java#L15-L20`) registers `this` with `StatsReporter` (`StatsReporter.java#L29-L33`):
   ```java
   // OomSampleActivity.java:19
   StatsReporter.getInstance().registerListener(this);
   ```

3. **GC Root Pinning via Background Thread & Singleton:**
   `StatsReporter` (`StatsReporter.java#L11-L60`) is both a static singleton (`sInstance`) and the owner of an active background thread (`StatsReporterPollingThread`, which holds a `[ROOT_JAVA_FRAME]` GC root reference while sleeping/looping in `startReporting()`). It stores listeners in `mListeners` (`CopyOnWriteArrayList<StatsListener>`).

4. **Leak Accumulation Across Activity Recreations:**
   Because `OomSampleActivity` does not override `onDestroy()` to call `StatsReporter.unregisterListener(this)`, every destroyed instance of `OomSampleActivity` remains strongly reachable in `mListeners`. After 7 Activity creations (7 * 30 MiB = 220,200,960 bytes plus array headers = `220,201,044` bytes), the Java heap is exhausted.

---

## 3. Actionable Implementation Plan

### 1. Unregister the Listener in `OomSampleActivity.onDestroy()`

Update `OomSampleActivity.java` to unregister the listener when the `Activity` is destroyed (matching the fix in `OomSampleActivityFixed.java`):

```diff
--- a/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
+++ b/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
@@ -19,4 +19,10 @@
         StatsReporter.getInstance().registerListener(this);
     }

+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        StatsReporter.getInstance().unregisterListener(this);
+    }
+
     @Override
```

### 2. Optional Architectural Hardening in `StatsReporter`

* **Lifecycle-Aware Registration:** Have `StatsReporter.registerListener()` accept a `LifecycleOwner` and attach a `DefaultLifecycleObserver` that automatically invokes `unregisterListener()` on `ON_DESTROY`.
* **Weak References:** Store `WeakReference<StatsListener>` entries in `StatsReporter` and evict cleared references during iteration so forgotten `unregisterListener()` calls cannot retain entire `Activity` hierarchies.

Vérifier la correction

Vérifiez que la fuite est résolue, soit localement sur un appareil de test, soit en production.

Si le problème est reproductible localement

Étant donné que TRIGGER_TYPE_OOM ne capture une empreinte de la mémoire que lorsqu'une OutOfMemoryError se produit, une correction réussie empêche le déclencheur de se déclencher. Pour inspecter le tas de mémoire et confirmer que la fuite a disparu, faites pivoter l'écran, puis capturez une empreinte de la mémoire Java à la demande à partir de la ligne de commande :

  1. Désactivez le limiteur de débit (rate_limiter.disabled true) et conservez les résultats temporaires (delete_temporary_results.disabled true) pour que le système conserve la capture de la ligne de commande dans /data/misc/perfetto-traces/profiling/.
  2. Ouvrez OomSampleActivityFixed sur votre appareil de test, faites pivoter l'écran au moins huit fois pour vérifier que l'application ne plante plus, et gardez-la au premier plan.
  3. Déclenchez une empreinte de la mémoire Java à la demande (profiling_type 1) à l'aide de la commande shell cmd profiling_service request-profiling :

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Extrayez le profil conservé de /data/misc/perfetto-traces/profiling/ et ouvrez-le dans l'UI de Perfetto.

Graphique de type &quot;flamme&quot; de l'explorateur d'empreintes de la mémoire de l'UI Perfetto après la correction, montrant une seule instance d'OomSampleActivityFixed conservant 30,01 Mio.
Figure 2. Graphique de type "flamme" de l'empreinte de la mémoire après la correction, montrant une seule instance de OomSampleActivityFixed.

Comme le montre la figure 2, après plusieurs rotations de l'écran :

  • com.example.sampleapp.OomSampleActivityFixed a un nombre d'instances de 1 au lieu d'accumuler une instance à chaque rotation.
  • Sa taille conservée cumulative reste stable à 30,01 Mio (sur un tas total de 44,27 Mio), ce qui confirme que le récupérateur de mémoire récupère les instances d'activité précédentes.

Si le problème est difficile à reproduire localement

Lorsqu'une navigation complexe sur plusieurs écrans, des sessions de longue durée ou des données utilisateur spécifiques provoquent une OutOfMemoryError que vous ne pouvez pas reproduire de manière fiable sur un appareil de test, validez le correctif en production après le déploiement :

  • Surveillez le cluster de plantages OutOfMemoryError dans votre outil de rapport d'erreur en tant que métrique proxy dans les différentes versions pour confirmer que les plantages impliquant OomSampleActivity diminuent.
  • Inspectez les nouveaux instantanés de tas TRIGGER_TYPE_OOM collectés à partir de la version mise à jour pour vérifier que StatsReporter ne conserve plus les instances OomSampleActivity détruites.