ภาพลวงตาของ Garbage Collector: มีคนช่วยเก็บขยะ แล้วทำไมแรมยังเต็มจน Server ดับ?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที
สาย C หรือ C++ มักจะมองชาว Java / Kotlin ด้วยสายตาอิจฉาปนหมั่นไส้ เพราะไม่ต้องมานั่งพิมพ์ malloc() หรือจำว่าต้อง free() คืนแรมตอนไหนให้ปวดกบาล
เราโตมากับคำสอนที่ว่า "เขียนๆ ไปเหอะ JVM มี Garbage Collector (GC) คอยเก็บกวาดขยะให้อัตโนมัติ สบายจะตาย"
แต่ถ้ามันแสนดีขนาดนั้น... ทำไมตอนระบบรันไปสัก 3 วัน กราฟแรมถึงค่อยๆ ไต่ขึ้นเป็นบันไดสวรรค์ แล้วจบลงด้วยหายนะระดับชาติ:
🧹 GC ไม่ใช่คนฉลาดรู้ใจ แต่เป็น "พนักงานทำความสะอาดตามกฎ"
นิยามคำว่า "ขยะ" ของมนุษย์เราคือ "ของที่เราไม่ได้ใช้แล้ว"
แต่นิยามคำว่า "ขยะ" ของ Garbage Collector คือ "ของที่ไม่มีทางเอื้อมถึง (Unreachable)"
ลองจินตนาการว่าคุณจ้างแม่บ้านมาทำความสะอาดห้อง กฎเหล็กของแม่บ้านคือ:
"กล่องอะไรก็ตามที่มีเชือกผูกติดอยู่กับเสากลางบ้าน แม่บ้านจะไม่กล้าทิ้งเด็ดขาด เพราะถือว่าเจ้าของบ้านยังหวงอยู่"
และ "เสากลางบ้าน" ในโลกของ Java ก็คือสิ่งที่เรียกว่า GC Roots (เช่น ตัวแปร Static, Thread ที่กำลังทำงานอยู่ หรือตัวแปร Local ใน Call Stack ปัจจุบัน)
👻 ฆาตกรเงียบ: ผูกเชือกทิ้งไว้โดยไม่รู้ตัว
แล้ว Memory Leak เกิดขึ้นได้ยังไง? ส่วนใหญ่ไม่ได้เกิดจากท่าลึกลับ แต่เกิดจากความชุ่ยแบบเนียนๆ สองสามท่านี้:
1. Static Collection ที่ไม่มีวันหยุดโต
object UserCache {
val cache = mutableMapOf<String, BigUserData>()
}
fun onUserLogin(user: BigUserData) {
UserCache.cache[user.id] = user // ใส่เข้าเรื่อยๆ แต่ไม่เคยลบออก!
}
ตัวแปร Static จะมีชีวิตอยู่เท่ากับอายุของ JVM เชือกเส้นนี้จึงไม่มีวันขาด! ยิ่งมีคนล็อกอิน แรมก็ยิ่งบวม จนสุดท้าย GC ต้องยอมแพ้
2. Listener และ Callback ที่ Register แล้วไม่ยอม Unregister
คุณสร้าง Object ชั่วคราวขึ้นมาทำงาน 3 วินาที แต่เผลอเอาไปผูกไว้กับ Event Publisher กลางของระบบ... ผลคือตัวแม่ยังถือ Reference ตัวลูกไว้ Object ลูกก็เลยตายไม่ได้ ต้องลอยค้างอยู่ใน Heap Memory ไปตลอดกาล
💡 5-Min Takeaway
Garbage Collector ช่วยปกป้องเราจากการ ลืมคืนแรม แต่ไม่สามารถปกป้องเราจากการ ลืมตัดสายสัมพันธ์ (Reference) ได้ อย่าปล่อยให้ Object ชั่วคราวไปผูกติดกับ Object อายุยืนโดยไม่จำเป็น ถ้าผูกเชือกไว้ เมื่อเลิกใช้ ต้องตัดเชือกทิ้งเสมอ