หน่วยความจำรั่ว (memory leak) — สถานการณ์ที่แอปพลิเคชันไม่ปล่อยหน่วยความจำที่ถูกครอบครองโดยวัตถุที่ไม่จำเป็นอีกต่อไป ในการพัฒนามือถือ สิ่งนี้สำคัญอย่างยิ่ง: heap ที่จำกัดและการไม่มี swap นำไปสู่ OutOfMemoryError และแอปพลิเคชันขัดข้อง ตามข้อมูลของ Purdue University (2022) 35% ของแอป Android ใน Google Play มีหน่วยความจำรั่วอย่างน้อยหนึ่งรายการ มาดูสถานการณ์ทั่วไป เครื่องมือวินิจฉัย และวิธีการแก้ไขกัน
ประเด็นสำคัญ
หน่วยความจำรั่ว — สถานการณ์ที่หน่วยความจำที่ถูกจัดสรรไม่ถูกส่งคืนให้ระบบหลังจากที่วัตถุไม่จำเป็นต่อโปรแกรมอีกต่อไป ตัวเก็บขยะถือว่าวัตถุดังกล่าวมีชีวิตอยู่เนื่องจากมีห่วงโซ่การอ้างอิงที่ทำงานอยู่จาก GC Root ชี้ไปที่มัน
ใน Java/Kotlin ตัวเก็บขยะทำงานโดยอัตโนมัติ แต่ไม่สามารถระบุได้ว่าวัตถุไม่จำเป็นตามตรรกะหากมีการอ้างอิงทางเทคนิคไปยังวัตถุนั้น นักพัฒนาต้องตัดการเชื่อมต่อที่ไม่จำเป็นอย่างชัดเจน ใน Swift/Objective-C ARC นับการอ้างอิงโดยอัตโนมัติ แต่ retain cycles ปิดกั้นไม่ให้ตัวนับถึงศูนย์
อันตรายหลักของการรั่วไหลคือ ผลสะสม การรั่วไหลแต่ละครั้งใช้หน่วยความจำจำนวนเล็กน้อย แต่เมื่อมีการเปลี่ยนหน้าจอซ้ำ ๆ (หมุนหน้าจอ เปิด/ปิด Activity) การรั่วไหลจะสะสมจนกว่าขีดจำกัด heap จะหมด
การรั่วไหล — วัตถุไม่สามารถเข้าถึงได้โดยโค้ดแต่ไม่ได้ถูกลบโดย GC การพองตัว — วัตถุจำเป็นตามตรรกะแต่ถูกเก็บไว้ในปริมาณที่มากเกินไป ตัวอย่างการพองตัว: แคชรูปภาพ 100 MB โดยมีชุดทำงาน 30 MB ปัญหาทั้งสองนำไปสู่ OOM แต่สาเหตุและวิธีการรักษาต่างกัน
ART (Android Runtime) ใช้การเก็บขยะแบบรุ่นต่อรุ่นพร้อมการบีบอัดพร้อมกัน หน่วยความจำแบ่งออกเป็นรุ่นอายุน้อย (Young) รุ่นแก่ (Old) และวัตถุขนาดใหญ่ (Large) วัตถุที่รอดชีวิตจากหลายรอบ GC จะถูกย้ายไปยังรุ่น Old ซึ่งการเก็บเกิดขึ้นน้อยกว่า — ซึ่งทำให้รอบปกติเร็วขึ้น
GC จะเริ่มเมื่อ heap ถึงเกณฑ์การครอบครองที่กำหนด (โดยปกติ 75-85%) ระหว่าง GC เธรดทั้งหมดของแอปพลิเคชันจะถูกหยุดชั่วคราว (STW — Stop The World) ยิ่งมีวัตถุที่มีชีวิตมากเท่าไร การหยุดชั่วคราวก็ยิ่งนานขึ้นเท่านั้น การรั่วไหลเพิ่มจำนวนวัตถุที่มีชีวิต ทำให้การหยุดชั่วคราวของ GC ยาวนานขึ้น
ตัวเก็บขยะระบุวัตถุที่มีชีวิตโดยการเดินกราฟจาก GC Roots: ฟิลด์สแตติก ตัวแปรสแต็กของเธรดที่ทำงานอยู่ การอ้างอิง JNI วัตถุใด ๆ ที่สามารถเข้าถึงได้ผ่านการอ้างอิงจากรากเหล่านี้ถือว่ามีชีวิตอยู่ — แม้ว่านักพัฒนาจะรู้ว่ามันไม่จำเป็นอีกต่อไป
// ตัวอย่าง: คอลเลกชันสแตติกเป็น GC Root — การรั่วไหลถาวร
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference ไม่ป้องกัน GC — พฤติกรรมที่ถูกต้อง
}
}
WeakReference แก้ปัญหา: GC ไม่สนใจการอ้างอิงแบบอ่อนเมื่อระบุวัตถุที่มีชีวิต หากเหลือเพียงการอ้างอิงแบบอ่อนไปยังวัตถุ มันจะถูกเก็บในรอบ GC ที่ใกล้ที่สุด
Activity Context — สถานการณ์การรั่วไหลที่แพร่หลายที่สุดใน Android หากซิงเกิลตัน ฟิลด์สแตติก หรือบริการที่มีอายุยาวนานเก็บการอ้างอิงไปยัง Activity Context ทั้ง Activity ที่มี Views ทั้งหมดจะไม่สามารถถูกเก็บโดย GC ได้ วิธีแก้ไข: ใช้ Application Context สำหรับวัตถุที่มีอายุยาวนาน
Handler และข้อความที่ส่ง — Handler.postDelayed(runnable, delay) วางข้อความในคิว Main Looper หาก Activity ถูกทำลายก่อนที่การหน่วงเวลาจะหมด ข้อความยังคงอยู่ในคิวและรักษาการอ้างอิงผ่าน Runnable → คลาสนิรนาม → คลาสภายนอก (Activity)
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // จำเป็น: ล้างคิว
super.onPause()
}
}
คลาสภายใน — คลาสภายในที่ไม่ใช่สแตติกมีการอ้างอิงโดยนัยไปยังอินสแตนซ์ของคลาสภายนอก หากคลาสภายนอกเป็น Activity และคลาสภายในถูกส่งออกไปภายนอก (เช่น ไปยัง RecyclerView.Adapter) Activity จะไม่สามารถถูกเก็บได้
Android Studio Memory Profiler — เครื่องมือในตัวสำหรับตรวจสอบ heap แบบเรียลไทม์ แสดงกราฟหน่วยความจำที่ใช้ จำนวนการจัดสรร และวัตถุตามประเภท สามารถบันทึก heap dump และส่งออกในรูปแบบ HPROF สำหรับการวิเคราะห์ใน MAT
Eclipse MAT (Memory Analyzer Tool) — โปรแกรมวิเคราะห์ heap dump บนเดสก์ท็อป สร้างรายงาน Leak Suspects โดยอัตโนมัติ ซึ่งเน้นวัตถุที่มี retained size มากที่สุดและแนะนำ GC root chain ที่เป็นไปได้สำหรับวัตถุที่น่าสงสัยแต่ละรายการ
Xcode Memory Graph Debugger — สำหรับ iOS หยุดแอปพลิเคชันชั่วคราวและแสดงกราฟวัตถุ Retain cycles จะถูกเน้นด้วยสีแดง คุณสามารถคลิกที่วัตถุใดก็ได้เพื่อดู retain count และการอ้างอิง
| เครื่องมือ | ความสามารถ | ความซับซ้อน |
|---|---|---|
| Memory Profiler | กราฟเรียลไทม์, heap dump, การติดตามการจัดสรรวัตถุ | ต่ำ |
| Eclipse MAT | Dominator tree, Leak Suspects, คำสั่ง OQL | ปานกลาง |
| LeakCanary | การตรวจจับอัตโนมัติ, ร่องรอยการรั่วไหลในการแจ้งเตือน | น้อยที่สุด |
| Xcode Memory Graph | กราฟภาพ retain cycles, รายการวัตถุที่มีชีวิต | ต่ำ |
ตาม Uber Engineering Blog การรวมการสร้างโปรไฟล์หน่วยความจำอัตโนมัติ (LeakCanary + การวิเคราะห์ heap dump) ในไปป์ไลน์ CI/CD ช่วยลดเหตุการณ์ที่เกี่ยวข้องกับหน่วยความจำในการผลิตลง 60% ภายใน 3 เดือน
เปลี่ยน Context — หากวัตถุมีอายุยืนกว่า Activity ให้ใช้ applicationContext วัตถุที่มีอายุยาวนานทั้งหมด (ซิงเกิลตัน, รีโพซิทอรี, ตัวช่วยฐานข้อมูล) ควรได้รับ Application Context ไม่ใช่ Activity Context ข้อยกเว้น: ส่วนประกอบ UI ที่ต้องการเข้าถึงธีมหรือทรัพยากรเฉพาะของ Activity
ส่วนประกอบที่รับรู้วงจรชีวิต — การใช้ LifecycleObserver, DefaultLifecycleObserver หรือส่วนขยายแบบรีแอคทีฟจะยกเลิกการสมัครรับข้อมูลโดยอัตโนมัติที่ onDestroy Android Jetpack มี lifecycleScope และ viewModelScope ซึ่งถูกทำความสะอาดโดยเหตุการณ์วงจรชีวิตที่เกี่ยวข้อง
คลาสภายในแบบสแตติก — หากคลาสภายในไม่จำเป็นต้องเข้าถึงฟิลด์ของคลาสภายนอก ให้ทำให้เป็น static คลาสภายในแบบสแตติกไม่มีการอ้างอิงโดยนัยไปยังคลาสภายนอก หากต้องการเข้าถึง ให้ใช้ WeakReference สำหรับการอ้างอิงที่ชัดเจน
class MyActivity : AppCompatActivity() {
// ❌ คลาสภายในที่ไม่ใช่สแตติก — การอ้างอิงโดยนัยไปยัง MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ คลาสภายในแบบสแตติก — ไม่มีการอ้างอิงโดยนัย
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
ใน iOS ใช้รายการดักจับ: [weak self] ใน closures ที่อาจมีอายุยืนกว่าผู้สร้าง สำหรับตัวแทน ใช้การอ้างอิงแบบอ่อน (weak var delegate) สำหรับ closures ที่รับประกันว่าจะถูกเรียกเฉพาะในช่วงอายุของ self สามารถใช้ [unowned self] ได้ แต่ต้องระมัดระวัง — การเข้าถึงวัตถุที่ถูกยกเลิกการจัดสรรจะทำให้เกิดการขัดข้อง
คำถามที่พบบ่อย
ใน Android ทำการเปลี่ยนหน้าจอหลายครั้ง (Activity A → B → A → B) และตรวจสอบ adb shell dumpsys meminfo package_name หาก Total PSS เพิ่มขึ้นอย่างต่อเนื่องและไม่กลับไปยังค่าเดิม — มีการรั่วไหล ใน iOS เช่นเดียวกัน: ใช้ Debug Memory Graph ใน Xcode เพื่อตรวจสอบด้วยภาพ
ได้ หาก CoroutineScope ไม่ถูกยกเลิกเมื่อส่วนประกอบถูกทำลาย Coroutine ที่เริ่มต้นใน GlobalScope จะยังคงทำงานต่อไปแม้หลังจาก finish() ของ Activity วิธีแก้ไข: ใช้ viewModelScope (ยกเลิกใน onCleared) หรือ lifecycleScope (ยกเลิกใน onDestroy) สำหรับขอบเขตที่กำหนดเอง ให้สร้างขอบเขตที่รับรู้วงจรชีวิตผ่าน LifecycleOwner
Bitmap เก็บข้อมูลพิกเซลใน native heap ไม่ใช่ Java heap ซึ่งหมายความว่า Java GC ไม่เห็นขนาดจริงของ Bitmap หากไม่เรียก recycle() บน Bitmap หรือไม่ทำให้การอ้างอิงเป็น null หน่วยความจำ native จะไม่ถูกปล่อย ใช้ BitmapFactory กับ inSampleSize เพื่อโหลดสำเนาที่ลดขนาดลง และ Glide/Coil สำหรับการจัดการแคชอัตโนมัติ
ฟิลด์สแตติก — คือ GC Root มันมีชีวิตอยู่ตราบเท่าที่คลาสยังถูกโหลด (ใน Android — ตราบเท่าที่ Process ยังมีชีวิตอยู่) หากฟิลด์สแตติกอ้างอิงถึง Activity, Bitmap, View หรือวัตถุหนักอื่น ๆ วัตถุนั้นจะไม่มีวันถูกเก็บโดย GC ฟิลด์สแตติกคือการอ้างอิงนิรันดร์ วิธีแก้ไข: เก็บเฉพาะ WeakReference หรือทำให้ฟิลด์สแตติกเป็น null ใน onDestroy
ARC ปล่อยวัตถุโดยอัตโนมัติเมื่อจำนวนการอ้างอิงแบบแข็งลดลงเป็นศูนย์ retain cycle เป็นวิธีเดียวที่จะรั่วไหลภายใต้ ARC ใช้ weak เสมอสำหรับการอ้างอิง parent→child ที่ child อาจมีอายุยืนกว่า parent (ตัวแทน, data source) สำหรับ closures ใช้รายการดักจับ [weak self] และตรวจสอบ self ว่าเป็น nil หรือไม่ภายใน closure
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ