หน่วยความจำรั่ว (Memory Leak) คือสถานการณ์ที่แอปพลิเคชันเก็บการอ้างอิงไปยังออบเจ็กต์ที่ไม่ได้ต้องการอีกต่อไป ทำให้ตัวเก็บขยะ (GC) ไม่สามารถปลดปล่อยหน่วยความจำที่ถูกครอบครองได้ ตามข้อมูลของ LeakCanary แม้ในแอปพลิเคชันที่เขียนอย่างดีก็พบการรั่ว 3–5 ครั้งต่อ 10,000 บรรทัดของโค้ด แต่ละครั้ง ที่รั่วจะค่อยๆ ลดหน่วยความจำที่พร้อมใช้งาน นำไปสู่ความช้าและ OutOfMemoryError
ประเด็นสำคัญ
หน่วยความจำรั่ว (Memory Leak) คือสถานการณ์ที่ออบเจ็กต์ยังคงสามารถเข้าถึงได้ผ่านห่วงโซ่ของการอ้างอิงแบบแข็ง (Strong Reference) แม้ว่ามันจะไม่จำเป็นต่อแอปพลิเคชันในเชิงตรรกะอีกต่อไป ตัวเก็บขยะ (GC) ถือว่าออบเจ็กต์ดังกล่าวยังมีชีวิตและไม่ปลดปล่อยหน่วยความจำที่มันครอบครอง ส่งผลให้หน่วยความจำฮีปที่พร้อมใช้งานลดลงอย่างต่อเนื่องและความถี่ของการหยุด GC เพิ่มขึ้น
แตกต่างจาก ภาษาที่มีการจัดการหน่วยความจำด้วยตนเอง (C, C++) ใน Java/Kotlin การรั่วไม่ใช่ free() ที่ถูกลืม แต่เป็นการอ้างอิงที่ถูกลืม ตราบใดที่มี strong reference จาก GC Root ไปยังออบเจ็กต์ที่รั่ว GC จะถือว่ามันจำเป็น GC Root ทั่วไป: ฟิลด์สแตติก, เธรดที่ทำงานอยู่, สแต็กการเรียก, การอ้างอิงทั่วโลก JNI
อันตราย ของการรั่วคือผลสะสม การรั่ว 100 KB หนึ่งครั้งไม่สังเกตเห็น แต่การรั่ว 100 ครั้งครอบครอง 10 MB และแอปพลิเคชันเริ่มช้าลงเนื่องจาก GC บ่อยครั้ง มวลวิกฤตของการรั่วนำไปสู่ OutOfMemoryError และแอปพลิเคชันขัดข้อง อาการของการรั่ว: การเติบโตของการใช้หน่วยความจำอย่างต่อเนื่องบนกราฟ Profiler, การหยุด GC บ่อยครั้งด้วย STW (Stop The World) และประสิทธิภาพ UI ที่ลดลง
ห้าประเภท ของการรั่วครอบคลุม 95% ของกรณีในการพัฒนาแอปมือถือ แต่ละประเภทมีสาเหตุและรูปแบบโค้ดเฉพาะของตัวเอง
การรั่ว ที่รู้จักกันดีที่สุดใน Android คือการเก็บการอ้างอิงแบบสแตติกไปยัง Activity หรือ Context โค้ดทั่วไป: ฟิลด์ Activity แบบสแตติกที่ไม่ถูกทำให้เป็น null ใน onDestroy() ตราบใดที่ฟิลด์สแตติกยังมีชีวิต ทั้ง Activity ที่มีทรี View ซึ่งอาจครอบครอง 1–10 MB ก็ยังมีชีวิตอยู่ นี่คือการรั่วแบบคลาสสิกที่ LeakCanary พบเป็นอันดับแรก
วิธีแก้: อย่าเก็บ Activity หรือ Context ในฟิลด์สแตติกเด็ดขาด ใช้ Application Context สำหรับซิงเกิลตันที่มีอายุยืนกว่า Activity หากต้องการการอ้างอิงไปยัง Activity ให้ใช้ WeakReference<Activity>
object MySingleton {
private var weakActivity: WeakReference<Activity>? = null
fun attach(activity: Activity) {
weakActivity = WeakReference(activity)
}
}
คลาส นิรนาม และคลาสภายในที่ไม่ใช่สแตติกเก็บการอ้างอิงไปยังคลาสที่ครอบครองโดยนัย Runnable ที่ส่งไปยัง Handler ซึ่งทำงานหลังจาก onDestroy() จะยึด Activity ทั้งตัว callback Retrofit ที่จับ Activity ก็ทำเช่นเดียวกัน นี่เป็นการรั่วที่ร้ายกาจที่สุด — การอ้างอิงโดยนัยไม่ปรากฏในโค้ด
นิพจน์ object และแลมบ์ดาใน Kotlin ก็จับการอ้างอิงไปยังคลาสภายนอกเช่นกัน ทำให้คลาสภายในเป็นสแตติก (หรือระดับบนสุดใน Kotlin) และส่งการอ้างอิงภายนอกผ่าน WeakReference สำหรับแลมบ์ดา ให้ใช้แนวทาง Lifecycle-aware กับ viewLifecycleOwner
การสมัคร รับบริการระบบโดยไม่ยกเลิกเป็นการรั่วโดยตรง SensorManager, LocationManager, NotificationListener ที่ลงทะเบียนใน onResume() โดยไม่เรียก unregister ใน onPause() จะยึด Activity เช่นเดียวกัน: RxJava Disposable ที่ไม่ได้เพิ่มใน CompositeDisposable และ coroutine ที่เริ่มผ่าน GlobalScope
ใช้คอมโพเนนต์ Lifecycle-aware: observe() กับ LifecycleOwner จะยกเลิกการสมัครโดยอัตโนมัติเมื่อ onDestroy() สำหรับ RxJava — viewLifecycleOwner.lifecycle.addObserver กับ DisposableObserver สำหรับ coroutine — lifecycleScope.launch() ผูกกับวงจรชีวิต
// การยกเลิกการสมัครอัตโนมัติผ่าน Lifecycle
viewModel.userData.observe(viewLifecycleOwner) { data ->
updateUI(data)
}
// coroutine กับ lifecycleScope
lifecycleScope.launch {
viewModel.loadData().collect { render(it) }
}
Bitmap ครอบครองหน่วยความจำฮีปจำนวนมาก: หนึ่งบิตแมป FullHD คือ 1920 × 1080 × 4 ไบต์ = 8.3 MB หากสร้าง Bitmap สำหรับแต่ละรายการในรายการและไม่เรียก recycle() เมื่อซ่อน หน่วยความจำจะหมดอย่างรวดเร็ว ใน Android เวอร์ชันเก่า (ก่อน 3.0) Bitmap ถูกเก็บในหน่วยความจำเนทีฟ แต่ในเวอร์ชันสมัยใหม่อยู่ในฮีป Dalvik/ART และ GC สามารถปลดปล่อยได้ก็ต่อเมื่อไม่มี strong reference
ใช้ Glide หรือ Coil สำหรับโหลดรูปภาพ — ไลบรารีเหล่านี้จัดการแคชและการรีไซเคิลโดยอัตโนมัติ หากทำงานกับ Bitmap โดยตรง ให้เรียก bitmap.recycle() สำหรับรูปภาพขนาดใหญ่ที่ไม่ได้แสดงอีกต่อไป และใช้ inSampleSize เพื่อโหลดสำเนาที่ลดขนาด
Fragment มีสองวงจรชีวิต: ของ Fragment เองและของ View หลังจาก onDestroyView() ทรี View ถูกทำลาย แต่ Fragment เองอาจยังคงอยู่ในหน่วยความจำหากมีการอ้างอิงภายนอก ข้อผิดพลาดทั่วไปคือการเก็บการอ้างอิงไปยัง Fragment ในอะแดปเตอร์ ViewPager หรือในกราฟการนำทางที่ไม่ถูกล้างเมื่อถูกทำลาย
อย่าเก็บ การอ้างอิงไปยัง Fragment ในฟิลด์ของออบเจ็กต์ที่มีอายุยืนนาน ใช้ childFragmentManager สำหรับ fragment ที่ซ้อนกันและ observe() กับ LifecycleOwner สำหรับการถ่ายโอนข้อมูลระหว่างกัน ViewPager2 แก้ปัญหานี้ในระดับ API: FragmentTransactionAdapter จัดการวงจรชีวิตอย่างถูกต้อง
การตรวจจับ การรั่วต้องตรวจสอบสองข้อเท็จจริง: หน่วยความจำไม่กลับมาหลังจากอายุขัยที่คาดหวังและจำนวนออบเจ็กต์บางประเภทเพิ่มขึ้นโดยไม่ลดลง กระบวนการวินิจฉัยประกอบด้วยสามขั้นตอน
ขั้นตอนแรก — การตรวจสอบด้วยภาพผ่าน Memory Profiler ใน Android Studio เปิดแท็บ Memory ดำเนินการเป้าหมาย (เปิดและปิดหน้าจอ) กด GC (Garbage Collection) และดูว่าหน่วยความจำกลับสู่ระดับเริ่มต้นหรือไม่ หากหลังจาก 3–4 รอบการเปิด-ปิด หน่วยความจำเพิ่มขึ้นอย่างต่อเนื่อง — มีการรั่ว
ขั้นตอนที่สอง — การทำ Heap Dump ใน Memory Profiler กด Dump Java Heap เปิดไฟล์ .hprof ที่ได้ใน Android Studio: คุณจะเห็นออบเจ็กต์ทั้งหมดในฮีปพร้อมขนาดและการอ้างอิง ค้นหาคลาสที่จำนวนควรเป็นศูนย์หลังจากปิดหน้าจอ ตัวอย่างเช่น MainActivity ที่มีจำนวน 2 หลังจากปิด — เป็นการรั่วที่ชัดเจน
ขั้นตอนที่สาม — การวิเคราะห์ Retained Size และ GC Root ใน Android Studio วิเคราะห์ Retained Size: หน่วยความจำเท่าใดจะถูกปลดปล่อยหากคุณลบออบเจ็กต์นี้ เส้นทางจาก GC Root ไปยังออบเจ็กต์แสดงสิ่งที่ยึดมันไว้: Static field → HashMap → Activity — และคุณเห็นจุดที่รั่ว แผง Reference แสดงผู้ถือออบเจ็กต์ทั้งหมด
สี่เครื่องมือ ครอบคลุมการค้นหาการรั่วตั้งแต่การตรวจจับอัตโนมัติจนถึงการวิเคราะห์ Heap Dump เชิงลึก
| เครื่องมือ | วิธีการ | รูปแบบผลลัพธ์ |
|---|---|---|
| LeakCanary | การตรวจสอบอัตโนมัติ | Heap Dump + stack trace การรั่ว |
| Android Memory Profiler | การตรวจสอบด้วยตนเอง | กราฟหน่วยความจำ + Heap Dump |
| MAT (Eclipse) | การวิเคราะห์เชิงลึก | รายงาน Dominator Tree + เส้นทาง GC Root |
| Perfetto | การติดตามทั้งระบบ | ไทม์ไลน์ + หน่วยความจำเนทีฟ |
LeakCanary เป็นสิ่งที่ต้องมีสำหรับโปรเจกต์ Android ใดๆ มันตรวจจับการรั่วโดยอัตโนมัติเมื่อสิ้นสุดวงจรชีวิต Activity/Fragment และแสดงตำแหน่งการรั่วที่แน่นอนพร้อม stack trace การผสานรวม: หนึ่งบรรทัดใน build.gradle LeakCanary 2.x ไม่ต้องการการเริ่มต้นด้วยตนเอง — มันลงทะเบียน Application Watcher โดยอัตโนมัติ
การป้องกัน การรั่วถูกสร้างไว้ในกระบวนการพัฒนาผ่านชุดกฎและเครื่องมือที่ตรวจสอบโค้ดในทุกขั้นตอน
อย่าเก็บการอ้างอิงไปยัง Activity, Fragment หรือ View เด็ดขาด ในฟิลด์สแตติก, ซิงเกิลตัน หรือออบเจ็กต์ที่มีอายุยืนนาน หากเลี่ยงการอ้างอิงไม่ได้ ให้ใช้ WeakReference หรือเก็บข้อมูลผ่าน ViewModel ซึ่งมีอายุตรงตามที่ต้องการและไม่ยึด View โดยตรง
ViewModel และ LiveData จาก Android Architecture Components แก้ปัญหาวงจรชีวิตในระดับสถาปัตยกรรม ViewModel อยู่รอดจากการหมุนหน้าจอและไม่มีการอ้างอิงไปยัง View LiveData ยกเลิกการสมัคร observer โดยอัตโนมัติเมื่อ onDestroy() ใช้สิ่งเหล่านี้แทนการสมัครรับบริการระบบด้วยตนเอง
ในการตรวจสอบโค้ด ให้ใส่ใจกับ: ฟิลด์สแตติกที่มีประเภท Context/View, คลาสนิรนาม, แลมบ์ดาที่จับ Activity, การสมัครด้วยตนเอง, RxJava disposable ที่ไม่มี composite, การเก็บ Fragment ผ่าน Bundle ใน Kotlin ให้ตรวจสอบ coroutine เพิ่มเติมด้วย launch โดยไม่มีการผูกวงจรชีวิต
LeakCanary สามารถทำงานเป็นส่วนหนึ่งของไปป์ไลน์ทดสอบ: รันการทดสอบการยอมรับกับ LeakCanary และทำให้ build ล้มเหลวหากพบการรั่ว สิ่งนี้ป้องกันไม่ให้การรั่วถึงระบบการผลิต เสริมการตรวจสอบด้วยกฎ StaticFieldLeak ของ Android Lint — มันค้นหาการรั่วที่อาจเกิดขึ้นในระดับการวิเคราะห์แบบสแตติก
// LeakCanary ในการทดสอบ
class LeakTest {
@Test
fun activityShouldNotLeak() {
ActivityScenario.launch(MainActivity::class.java)
.close()
LeakAssertions.assertNoLeak() // ล้มเหลวหากมีการรั่ว
}
}
คำถามที่พบบ่อย
การรั่ว เป็นสาเหตุ และ OutOfMemoryError เป็นผลลัพธ์ การรั่วครั้งเดียวไม่นำไปสู่ OOM แต่การสะสมของการรั่วหลายสิบครั้งทำให้ฮีปหมด OOM เป็นข้อยกเว้นร้ายแรง ในขณะที่การรั่วเป็นรูปแบบที่นำไปสู่มันเมื่อเวลาผ่านไป
ผ่าน Android Memory Profiler: เปิดและปิดหน้าจอ 5 ครั้ง หลังจากปิดแต่ละครั้งให้เรียก GC หากหน่วยความจำไม่กลับสู่ระดับฐาน — มีการรั่ว ทำ Heap Dump และค้นหาคลาส Activity ที่จำนวนมากกว่า 0 หลังจากปิดในรายการ
บางส่วน Kotlin แก้ปัญหา null-safety แต่ไม่จัดการ strong references coroutine กับ lifecycleScope และ viewModelScope ป้องกันการรั่วจากงานพื้นหลัง ในขณะที่ sealed class และ data class ลดจำนวนสถานะที่นำไปสู่การรั่ว การป้องกันหลักคือรูปแบบสถาปัตยกรรม ไม่ใช่คุณสมบัติภาษา
LeakCanary บางครั้งให้ผลบวกปลอม: ออบเจ็กต์อาจถูกระบบยึดไว้ชั่วคราว (เช่น InputMethodManager ยึด View ล่าสุด) ตรวจสอบด้วยตนเอง: ถ้า Retained Size < 1 KB และ GC Root เป็นบริการระบบ แสดงว่าอาจเป็นผลบวกปลอม
ไม่ การรั่วเกิดขึ้นได้บนทุกแพลตฟอร์มที่มี GC: iOS (Swift/Objective-C), Flutter (Dart), เว็บเบราว์เซอร์ (JavaScript) กลไกเหมือนกัน — strong reference จาก GC Root บน iOS ARC จัดการหน่วยความจำโดยอัตโนมัติ แต่ retain cycle ระหว่างออบเจ็กต์สร้างการรั่วแบบเดียวกัน
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม