Bahasa dengan garbage collector tidak otomatis bebas dari kebocoran memori. Sebuah objek tetap dianggap hidup selama masih ada referensi aktif yang menunjuk kepadanya, walaupun secara logika objek tersebut sudah tidak dipakai. Kondisi ini disebut retained memory dan menjadi akar penyebab memory leak pada aplikasi Kotlin/JVM. Pada chapter ini kita akan mengenal pola umum kebocoran, mendemonstrasikannya dengan kode, serta membahas cara mendeteksi dan mencegahnya.
Penyebab Umum Memory Leak #
Empat pola berikut paling sering ditemui pada aplikasi Kotlin/JVM produksi:
| Pola | Mekanisme Kebocoran | Solusi |
|---|---|---|
| Cache statis | object atau companion object menyimpan referensi ke instance besar tanpa pernah dibersihkan | Pakai WeakHashMap atau bounded cache (LRU) |
| Listener tidak di-unregister | EventBus, observer, atau callback menyimpan lambda yang menangkap instance | Selalu panggil unregister di finally/.use |
| ThreadLocal tidak dibersihkan | ThreadLocal tetap memegang nilai walaupun task sudah selesai | remove() di akhir task; gunakan try-finally |
| Inner class non-static | Anonymous class menahan referensi implicit ke outer class | Pakai static inner class atau lambda tanpa capture |
Demonstrasi Pola Leak #
Kode berikut memperlihatkan tiga pola umum dan solusinya secara berurutan. Pola keempat (bounded cache) menunjukkan praktik yang aman sebagai pengganti cache tak terbatas.
import java.lang.ref.WeakReference
// Simulasi pola kebocoran memori yang umum ditemui di aplikasi Kotlin/JVM
object CacheStatis {
val data = mutableMapOf<String, ByteArray>()
fun simpan(kunci: String, ukuran: Int) {
data[kunci] = ByteArray(ukuran)
}
fun hapus(kunci: String) {
data.remove(kunci)
}
fun ukuranTotal(): Int = data.values.sumOf { it.size }
}
// Pola listener yang tidak dihapus
class EventBus {
private val listeners = mutableListOf<() -> Unit>()
fun daftar(listener: () -> Unit) {
listeners.add(listener)
}
fun keluar(listener: () -> Unit) {
listeners.remove(listener)
}
fun jumlahListener(): Int = listeners.size
}
fun main() {
println("=== Demonstrasi Pola Memory Leak ===")
// 1. Cache statis yang tidak dibersihkan
println()
println("Pola 1: Cache statis yang tidak dibersihkan")
repeat(5) { i ->
CacheStatis.simpan("item-$i", ukuran = 1024)
}
println(" Total cache setelah 5 simpan: ${CacheStatis.ukuranTotal()} byte")
// Solusi: hapus ketika tidak dipakai
repeat(5) { i -> CacheStatis.hapus("item-$i") }
println(" Total cache setelah hapus semua: ${CacheStatis.ukuranTotal()} byte")
// 2. Listener yang tidak dihapus
println()
println("Pola 2: Listener yang tidak dihapus dari EventBus")
val bus = EventBus()
val pendengar1 = { println(" [listener1] event diterima") }
val pendengar2 = { println(" [listener2] event diterima") }
bus.daftar(pendengar1)
bus.daftar(pendengar2)
println(" Jumlah listener terdaftar: ${bus.jumlahListener()}")
// Jika listener tidak pernah di-unregister, referensi terhadap
// instance pendengar tertahan dalam list EventBus
bus.keluar(pendengar1)
println(" Setelah keluar(pendengar1): ${bus.jumlahListener()} listener tersisa")
// 3. WeakReference sebagai solusi cache
println()
println("Pola 3: WeakReference sebagai solusi cache ringan")
val cacheLemah = mutableMapOf<String, WeakReference<String>>()
var target: String? = "nilai-besar-c8d9e0"
cacheLemah["kunci"] = WeakReference(target!!)
println(" cache.get() sebelum null: ${cacheLemah["kunci"]?.get()}")
target = null
// cacheLemah["kunci"] mungkin null setelah GC, tidak bocor
println(" Setelah target=null, cache tidak bocor (GC bisa membebaskan)")
println(" Status cache.get() tergantung pada kapan GC berjalan (tidak deterministik)")
// 4. Solusi: gunakan peta weak-key atau bounded cache
println()
println("Pola 4: Bounded cache (mis. LRU dengan ukuran maksimum)")
val lruCache = object : LinkedHashMap<String, String>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
return size > 3
}
}
listOf("a" to "1", "b" to "2", "c" to "3", "d" to "4", "e" to "5").forEach {
lruCache[it.first] = it.second
}
println(" LRU cache setelah 5 entry, ukuran: ${lruCache.size}")
println(" Isi: ${lruCache.keys}")
}Output yang Dihasilkan:
=== Demonstrasi Pola Memory Leak ===
Pola 1: Cache statis yang tidak dibersihkan
Total cache setelah 5 simpan: 5120 byte
Total cache setelah hapus semua: 0 byte
Pola 2: Listener yang tidak dihapus dari EventBus
Jumlah listener terdaftar: 2
Setelah keluar(pendengar1): 1 listener tersisa
Pola 3: WeakReference sebagai solusi cache ringan
cache.get() sebelum null: nilai-besar-c8d9e0
Setelah target=null, cache tidak bocor (GC bisa membebaskan)
Status cache.get() tergantung pada kapan GC berjalan (tidak deterministik)
Pola 4: Bounded cache (mis. LRU dengan ukuran maksimum)
LRU cache setelah 5 entry, ukuran: 3
Isi: [c, d, e]Mari kita telaah kode di atas:
- Pola 1 (CacheStatis):
objectdi Kotlin dikompilasi menjadi singleton statis di JVM. Setiap data yang dimasukkan ke map tetap ada selama process hidup. Setelah lima kalisimpan, total mencapai 5.120 byte; setelahhapussemua, kembali ke 0 byte. Tanpahapus, memori akan terus tumbuh. - Pola 2 (EventBus): listener lambda disimpan dalam
MutableList. Selama listener masih terdaftar, instance yang ditangkap lambda (mis.ActivityatauServicedi Android) tidak bisa di-GC. Solusinya: selalu panggilkeluar(listener)saat objek tidak lagi aktif. - Pola 3 (WeakReference): cache yang memakai WeakReference tidak menahan objek secara kuat. Setelah
target = null, objekStringmenjadi kandidat GC walaupun cache masih memegang weak reference. Status cache tidak deterministik dan tidak boleh dipakai sebagai sumber kebenaran. - Pola 4 (LRU cache):
LinkedHashMapdenganaccessOrder = trueplus overrideremoveEldestEntrymemberi cache LRU sederhana. Saatsize > 3, entry tertua otomatis dihapus — mencegah pertumbuhan tak terbatas.
Mendeteksi dengan Heap Dump #
Saat kebocoran sudah terjadi, kita perlu memeriksa apa yang menahan memori. Heap dump adalah snapshot lengkap isi heap pada suatu saat. Alat yang umum dipakai:
# Memaksa heap dump saat OutOfMemoryError terjadi
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-jar aplikasi.jar
# Heap dump manual via jcmd (butuh PID proses)
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof
# Analisis dengan Eclipse MAT (Memory Analyzer Tool)
# https://www.eclipse.org/mat/Setelah dump diunduh, buka dengan Eclipse MAT dan jalankan Leak Suspects Report. MAT akan menandai objek dengan retained size terbesar beserta rantai referensi yang menahannya.
Pencegahan #
Pencegahan lebih murah daripada investigasi. Praktik berikut membatasi risiko kebocoran:
- Gunakan
WeakHashMapuntuk cache yang key-nya berupa instance objek. - Untuk resource, pakai
AutoCloseable+.useagar cleanup deterministik. - Hindari
ThreadLocaldi pool thread yang panjang; pastikanremove()dipanggil di akhir task. - Tambahkan unit test dengan
WeakReferenceuntuk memverifikasi objek benar-benar dapat di-GC.
Referensi Lanjutan #
- JVM Guide — panduan resmi HotSpot JVM Java SE 21 untuk memahami retensi heap
- JetBrains Profiler — panduan profiler JetBrains untuk mendeteksi memory leak di aplikasi Kotlin/JVM
Penutup #
Memory leak pada JVM jarang terlihat saat pengembangan, tetapi muncul sebagai degradasi performa atau OutOfMemoryError di produksi. Dengan mengenal pola umum dan rutin memeriksa heap dump, kita dapat menjaga aplikasi tetap sehat. Selanjutnya, kita akan membahas opsi-opsi konfigurasi JVM yang memengaruhi ukuran heap dan perilaku GC.