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.
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.OomSampleActivityconserve 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 enfantbyte[]. - Plusieurs instances d'activité en direct : la sélection du frame
OomSampleActivityaffiche Self Count: 8. Même si une seule instance deOomSampleActivitydoit 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
OomSampleActivitydans le graphique de type "flamme" montre la chaîne de référence qui maintient ces instances actives :com.example.sampleapp.StatsReportercontient unjava.util.concurrent.CopyOnWriteArrayList(sauvegardé parjava.lang.Object[]), qui référence directement les huit instancesOomSampleActivity.
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 :
- Chaque instance
OomSampleActivityalloue un tamponbyte[]de 30 Mio (heavyDataen Kotlin,mHeavyDataen Java) et s'enregistre en tant qu'écouteur sur le singletonStatsReporterdansonCreate. OomSampleActivityn'appelle jamaisunregisterListenerlorsque le système le détruit. Étant donné queStatsReportersurvit à 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 tableauxbyte[]de 30 Mio.- 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 dansStatsReporter. Après sept rotations (8 instances × 30 Mio ≈ 240 Mio), le tas se remplit et l'application plante avec une erreurOutOfMemoryError.
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 :
- 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/. - Ouvrez
OomSampleActivityFixedsur 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. Déclenchez une empreinte de la mémoire Java à la demande (
profiling_type1) à l'aide de la commande shellcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappExtrayez le profil conservé de
/data/misc/perfetto-traces/profiling/et ouvrez-le dans l'UI de Perfetto.

OomSampleActivityFixed.Comme le montre la figure 2, après plusieurs rotations de l'écran :
com.example.sampleapp.OomSampleActivityFixeda 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
OutOfMemoryErrordans votre outil de rapport d'erreur en tant que métrique proxy dans les différentes versions pour confirmer que les plantages impliquantOomSampleActivitydiminuent. - Inspectez les nouveaux instantanés de tas
TRIGGER_TYPE_OOMcollectés à partir de la version mise à jour pour vérifier queStatsReporterne conserve plus les instancesOomSampleActivitydétruites.