กรณีศึกษา: แก้ไขข้อบกพร่องของข้อขัดข้อง OOM

ข้อขัดข้องเนื่องจากหน่วยความจำไม่เพียงพอ (OutOfMemoryError) เกิดขึ้นเมื่อแอปใช้ฮีป Java ที่จัดสรรไว้จนหมดและไม่สามารถจัดสรรหน่วยความจำใหม่ได้ สแต็กเทรซจากรายงานข้อขัดข้องของ OutOfMemoryError ไม่ค่อยเปิดเผยสาเหตุของปัญหาเนื่องจากแสดงเฉพาะการเรียกการจัดสรรที่ล้มเหลวเมื่อหน่วยความจำหมด ไม่ใช่ออบเจ็กต์ที่เก็บส่วนที่เหลือของฮีป

ProfilingManager ช่วยให้คุณบันทึกฮีปดัมป์ของ Java จากอุปกรณ์ในภาคสนามและในเครื่องได้ เพื่อให้คุณตรวจสอบกราฟการเก็บรักษาออบเจ็กต์ใน Perfetto UI และระบุสิ่งที่ใช้หน่วยความจำอยู่ได้

สถานการณ์

สมมติว่าเครื่องมือรายงานข้อขัดข้องแจ้งว่ามีคลัสเตอร์ข้อขัดข้อง OutOfMemoryError ที่เพิ่มขึ้นในเวอร์ชันที่ใช้งานจริงเมื่อผู้ใช้โต้ตอบกับ OomSampleActivity

หากต้องการตรวจสอบ คุณต้องลงทะเบียนทริกเกอร์ TRIGGER_TYPE_OOM กับ ProfilingManager เพื่อให้ระบบบันทึก Java ฮีปดัมป์ โดยอัตโนมัติเมื่อเกิด OutOfMemoryError

บันทึกฮีปดัมป์

ใน Android 17 (ระดับ API 37) ขึ้นไป หากต้องการบันทึกฮีปดัมป์เมื่อเกิดข้อขัดข้อง OutOfMemoryError ให้กำหนดค่าการสร้างโปรไฟล์ตามทริกเกอร์ด้วย ProfilingTrigger.TRIGGER_TYPE_OOM เมื่อแอปแสดง OutOfMemoryError ที่ไม่ได้รับการจัดการ ระบบจะเริ่มบันทึก PROFILING_TYPE_JAVA_HEAP_DUMP ใหม่โดยอัตโนมัติ

เนื่องจากกระบวนการของแอปจะสิ้นสุดลงเมื่อเกิดข้อขัดข้อง ระบบจึงบันทึกฮีปดัมพ์ในเบื้องหลังให้เสร็จสิ้นและจัดคิวผลลัพธ์ (ดูวงจรทริกเกอร์) ลงทะเบียน Listener ผลลัพธ์ส่วนกลางในเบื้องหลัง Executor หลังจากที่แอปเริ่มต้นเสร็จสมบูรณ์ (เพื่อไม่ให้แอปเริ่มทำงานช้าลง) โดยใช้ registerForAllProfilingResults เพื่อให้แอปได้รับ ProfilingResult ที่อยู่ในคิว ในครั้งถัดไปที่ผู้ใช้เปิดแอป registerForAllProfilingResults ต้องเรียกใช้ทุกครั้งที่แอปเริ่มต้น ซึ่งต่างจาก addProfilingTriggers (ซึ่งจะยังคงอยู่แม้จะรีสตาร์ทแอป และต้องเรียกใช้เพียงครั้งเดียวหรือเมื่ออัปเดตทริกเกอร์) ดังนี้

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

ถอดรหัสโปรไฟล์

หลังจาก OutOfMemoryError ทริกเกอร์การบันทึกและ Callback ได้รับ ProfilingResult ในการเปิดแอปครั้งถัดไป ให้อัปโหลดไฟล์โปรไฟล์ไปยังเซิร์ฟเวอร์หรือดึงไฟล์ไปยังคอมพิวเตอร์สำหรับการพัฒนาซอฟต์แวร์ด้วย adb หากคุณบันทึกโปรไฟล์จากบิลด์ที่เผยแพร่ซึ่งใช้การลดขนาดโค้ดและการปรับให้ยากต่อการอ่าน (Obfuscation) ของ R8 ให้ถอดรหัสซอร์สโค้ดที่สร้างความสับสนแบบออฟไลน์ในเวิร์กสเตชันหรือเซิร์ฟเวอร์ก่อนการวิเคราะห์ หากไม่มีการถอดรหัสซอร์สโค้ดที่สร้างความสับสน R8 จะปรับให้ยากต่อการอ่าน (Obfuscate) ชื่อคลาส, ฟิลด์ และเมธอดของ Java และ Kotlin ในฮีปดัมป์ดิบ (เช่น เปลี่ยนชื่อคลาสต้นฉบับเป็น a.b)

ใช้คำสั่ง trace_processor bundle ของ Perfetto กับแฟล็ก --proguard-map เพื่อรวมแพ็กเกจโปรไฟล์ที่บันทึกไว้กับไฟล์ R8 mapping.txt ที่สร้างขึ้นสำหรับบิลด์ที่เผยแพร่ที่แน่นอนนั้น

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

คำสั่งนี้จะสร้าง deobfuscated_oom.tar ซึ่งเป็นไฟล์ที่สมบูรณ์ซึ่งรวมโปรไฟล์ต้นฉบับเข้ากับการแมปการถอดรหัสซอร์สโค้ดที่สร้างความสับสน เมื่อคุณเปิดที่เก็บถาวรนี้ใน Perfetto UI หรือค้นหาด้วย trace_processor Perfetto จะกู้คืนชื่อคลาส ฟิลด์ และเมธอดเดิมทั้งหมดโดยอัตโนมัติ ดูข้อมูลเพิ่มเติมได้ที่เอกสารประกอบการแทนที่ด้วยสัญลักษณ์และการถอดรหัสซอร์สโค้ดที่สร้างความสับสนของ Perfetto

วิเคราะห์ฮีปดัมป์

คุณสามารถตรวจสอบฮีปดัมป์ที่ถอดรหัสแล้วด้วยตนเองใน Perfetto UI ตามขั้นตอนต่อไปนี้ หรือตรวจสอบ OOM โดยอัตโนมัติด้วย AI Agent

เปิดดัมพ์ฮีปที่ถอดรหัสแล้วใน Perfetto UI แล้วเลือก Heapdump Explorer ในแถบนำทางด้านซ้าย ในแท็บกราฟเปลวไฟที่มีการวัดตั้งค่าเป็นขนาดออบเจ็กต์ ลำดับชั้นการเก็บรักษาในรูปที่ 1 จะโดดเด่นขึ้นมาทันที

กราฟ Flame ของ Heapdump Explorer ใน Perfetto UI แสดง StatsReporter ที่เก็บรักษา OomSampleActivity ไว้ 8 อินสแตนซ์ รวมเป็น 240.28 MiB
รูปที่ 1 กราฟ Flame ของฮีปดัมป์แสดงอินสแตนซ์ที่เก็บรักษาไว้ 8 รายการของ OomSampleActivity

การตรวจสอบกราฟ Flame ในรูปที่ 1 เผยให้เห็นผลลัพธ์ที่สำคัญหลายประการ ดังนี้

  • การเก็บรักษาหน่วยความจำจำนวนมาก: จากฮีปรวม 254.79 MiB (root) com.example.sampleapp.OomSampleActivity จะเก็บรักษาขนาดสะสมไว้ที่ 240.28 MiB (94.31% ของฮีปแอปทั้งหมด) ซึ่งส่วนใหญ่อยู่ในอาร์เรย์ byte[] ย่อย
  • อินสแตนซ์กิจกรรมสดหลายรายการ: การเลือกเฟรม OomSampleActivity จะแสดง Self Count: 8 แม้ว่าควรมี OomSampleActivity เพียงอินสแตนซ์เดียวบนหน้าจอ ณ เวลาหนึ่งๆ แต่อินสแตนซ์ที่แยกกัน 8 รายการจะทำงานในหน่วยความจำพร้อมกัน
  • เส้นทางออบเจ็กต์ที่เก็บรักษา: การอ่านเฟรมหลักของ OomSampleActivity ในกราฟ Flame แสดงเชนการอ้างอิงที่เก็บอินสแตนซ์เหล่านั้นไว้ โดย com.example.sampleapp.StatsReporter เก็บ java.util.concurrent.CopyOnWriteArrayList (ซึ่งได้รับการสนับสนุนโดย java.lang.Object[]) ซึ่งอ้างอิงอินสแตนซ์ OomSampleActivity ทั้ง 8 รายการโดยตรง

ระบุสาเหตุของปัญหาในโค้ด

เมื่อเส้นทางการเก็บรักษาชี้จาก StatsReporter ไปยัง OomSampleActivity ให้ตรวจสอบทั้ง 2 คลาสในฐานของโค้ด

StatsReporter เป็นซิงเกิลตันที่เก็บรักษารายการของ Callback StatsListener และโพสต์การอัปเดตเป็นระยะๆ จากเทรดการโพลในเบื้องหลัง

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

ถัดไป ให้ตรวจสอบ 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);
    }
}

โค้ดยืนยันสาเหตุที่ StatsReporter เก็บ OomSampleActivity ไว้ 8 อินสแตนซ์ ดังนี้

  1. อินสแตนซ์ OomSampleActivity แต่ละรายการจะจัดสรรบัฟเฟอร์ byte[] ขนาด 30 MiB (heavyData ใน Kotlin, mHeavyData ใน Java) และลงทะเบียนตัวเองเป็น Listener ใน Singleton StatsReporter ใน onCreate
  2. OomSampleActivity จะไม่เรียกใช้ unregisterListener เมื่อระบบทำลายออบเจ็กต์ เนื่องจาก StatsReporter มีอายุการใช้งานนานกว่ากิจกรรมและเก็บการอ้างอิงที่เข้มงวดไปยัง Listener ที่ลงทะเบียนไว้ทุกรายการ ตัวรวบรวมขยะจึงไม่สามารถเรียกคืนอินสแตนซ์ที่ถูกทำลายหรืออาร์เรย์ byte[] ขนาด 30 MiB ได้
  3. ทุกครั้งที่ระบบทำลายและสร้าง OomSampleActivity ขึ้นมาใหม่ เช่น ระหว่างการหมุนหน้าจอในวงจรกิจกรรมของ Android อินสแตนซ์ขนาด 30 MiB อีกรายการจะรั่วไหลใน StatsReporter หลังจากหมุน 7 ครั้ง (8 อินสแตนซ์ × 30 MiB ≈ 240 MiB) ฮีปจะเต็มและแอปขัดข้องพร้อมข้อผิดพลาด OutOfMemoryError

แก้ไขการรั่วไหล

หากต้องการแก้ไขหน่วยความจำรั่วไหล ให้ลบล้าง onDestroy ในกิจกรรมและเรียกใช้ StatsReporter.getInstance().unregisterListener(this) เพื่อให้ Singleton ปล่อยการอ้างอิงเมื่อระบบทำลายกิจกรรม

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

ทำให้การตรวจสอบ OOM เป็นอัตโนมัติด้วย AI Agent

แทนที่จะตรวจสอบกราฟ Flame และติดตามการอ้างอิงในไฟล์ต่างๆ ด้วยตนเอง คุณสามารถใช้เอเจนต์การเขียนโค้ด AI ที่กำหนดค่าด้วยทักษะ Perfetto AI เพื่อวิเคราะห์ฮีปดัมพ์และเชื่อมโยงกับฐานของโค้ดได้ ดังนี้

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.

Agent จะค้นหาฮีปดัมพ์ด้วย trace_processor ติดตามเส้นทาง Dominator จาก StatsReporter ไปยังอินสแตนซ์ OomSampleActivity ที่รั่วไหล และตรวจสอบทั้ง 2 คลาสในฐานของโค้ด โดยไม่ต้องมีการแทรกแซงด้วยตนเอง ระบบจะสรุปผลแบบเดียวกันว่า OomSampleActivity ลงทะเบียน Listener ใน onCreate โดยไม่ยกเลิกการลงทะเบียน และสร้างแพตช์ที่เพิ่ม unregisterListener(this) ใน onDestroy (รายงานต่อไปนี้สร้างขึ้นจากการบันทึกแยกต่างหากซึ่งมีอินสแตนซ์ที่เก็บไว้ 7 รายการ ณ เวลาที่เกิดข้อขัดข้อง)

# 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.

ยืนยันการแก้ไข

ยืนยันว่าการรั่วไหลได้รับการแก้ไขแล้ว ไม่ว่าจะในอุปกรณ์ทดสอบหรือในเวอร์ชันที่ใช้งานจริง

หากปัญหานี้เกิดขึ้นซ้ำในเครื่อง

เนื่องจาก TRIGGER_TYPE_OOM จะบันทึกฮีปดัมพ์เมื่อเกิด OutOfMemoryError เท่านั้น การแก้ไขที่สำเร็จจะป้องกันไม่ให้ทริกเกอร์เริ่มทำงาน หากต้องการตรวจสอบฮีปและยืนยันว่าการรั่วไหลหายไปแล้ว ให้หมุนหน้าจอแล้วบันทึกฮีปดัมป์ Java แบบออนดีมานด์จากบรรทัดคำสั่ง โดยทำดังนี้

  1. ปิดใช้การจำกัดอัตราคำขอ (rate_limiter.disabled true) และเก็บผลลัพธ์ชั่วคราว (delete_temporary_results.disabled true) เพื่อให้ระบบเก็บการบันทึกบรรทัดคำสั่งไว้ใน /data/misc/perfetto-traces/profiling/
  2. เปิด OomSampleActivityFixed ในอุปกรณ์ทดสอบ หมุนหน้าจออย่างน้อย 8 ครั้งเพื่อยืนยันว่าแอปไม่ขัดข้องอีกต่อไป และเปิดแอปไว้ในเบื้องหน้า
  3. เรียกใช้ฮีปดัมป์ของ Java แบบออนดีมานด์ (profiling_type 1) โดยใช้คำสั่ง Shell cmd profiling_service request-profiling ดังนี้

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. ดึงโปรไฟล์ที่เก็บไว้จาก /data/misc/perfetto-traces/profiling/ และเปิดใน Perfetto UI

กราฟ Flame ของ Heapdump Explorer ใน Perfetto UI หลังจากแก้ไขแล้ว ซึ่งแสดงอินสแตนซ์เดียวของ OomSampleActivityFixed ที่คงขนาด 30.01 MiB ไว้
รูปที่ 2 กราฟ Flame ของฮีปดัมพ์หลังจากการแก้ไขแสดงอินสแตนซ์เดียวของ OomSampleActivityFixed

ดังที่แสดงในรูปที่ 2 หลังจากหมุนหน้าจอหลายครั้ง

  • com.example.sampleapp.OomSampleActivityFixed มีจำนวนตัวเองเป็น 1 แทนที่จะสะสมอินสแตนซ์ในการหมุนแต่ละครั้ง
  • ขนาดที่คงไว้สะสมยังคงคงที่ที่ 30.01 MiB (จากฮีปทั้งหมด 44.27 MiB) ซึ่งยืนยันว่าตัวเก็บขยะจะเรียกคืนอินสแตนซ์กิจกรรมก่อนหน้า

หากปัญหาเกิดขึ้นซ้ำได้ยากในเครื่อง

เมื่อการนําทางหลายหน้าจอที่ซับซ้อน เซสชันที่ใช้เวลานาน หรือข้อมูลผู้ใช้ที่เฉพาะเจาะจงทำให้เกิด OutOfMemoryError ที่คุณไม่สามารถจำลองได้อย่างน่าเชื่อถือในอุปกรณ์ทดสอบ ให้ตรวจสอบการแก้ไขในเวอร์ชันที่ใช้งานจริงหลังจากการเปิดตัว โดยทำดังนี้

  • ตรวจสอบคลัสเตอร์ข้อขัดข้อง OutOfMemoryError ในเครื่องมือรายงานข้อขัดข้องเป็นเมตริกพร็อกซีในรุ่นต่างๆ เพื่อยืนยันว่าข้อขัดข้องที่เกี่ยวข้องกับ OomSampleActivity ลดลง
  • ตรวจสอบฮีปดัมพ์ TRIGGER_TYPE_OOM ใหม่ที่รวบรวมจากรุ่นที่อัปเดตเพื่อยืนยันว่า StatsReporter ไม่เก็บอินสแตนซ์ OomSampleActivity ที่ถูกทำลายไว้อีกต่อไป