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:

PolaMekanisme KebocoranSolusi
Cache statisobject atau companion object menyimpan referensi ke instance besar tanpa pernah dibersihkanPakai WeakHashMap atau bounded cache (LRU)
Listener tidak di-unregisterEventBus, observer, atau callback menyimpan lambda yang menangkap instanceSelalu panggil unregister di finally/.use
ThreadLocal tidak dibersihkanThreadLocal tetap memegang nilai walaupun task sudah selesairemove() di akhir task; gunakan try-finally
Inner class non-staticAnonymous class menahan referensi implicit ke outer classPakai 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.

kotlin
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:

text
=== 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:

  1. Pola 1 (CacheStatis): object di Kotlin dikompilasi menjadi singleton statis di JVM. Setiap data yang dimasukkan ke map tetap ada selama process hidup. Setelah lima kali simpan, total mencapai 5.120 byte; setelah hapus semua, kembali ke 0 byte. Tanpa hapus, memori akan terus tumbuh.
  2. Pola 2 (EventBus): listener lambda disimpan dalam MutableList. Selama listener masih terdaftar, instance yang ditangkap lambda (mis. Activity atau Service di Android) tidak bisa di-GC. Solusinya: selalu panggil keluar(listener) saat objek tidak lagi aktif.
  3. Pola 3 (WeakReference): cache yang memakai WeakReference tidak menahan objek secara kuat. Setelah target = null, objek String menjadi kandidat GC walaupun cache masih memegang weak reference. Status cache tidak deterministik dan tidak boleh dipakai sebagai sumber kebenaran.
  4. Pola 4 (LRU cache): LinkedHashMap dengan accessOrder = true plus override removeEldestEntry memberi cache LRU sederhana. Saat size > 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:

bash
# 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 WeakHashMap untuk cache yang key-nya berupa instance objek.
  • Untuk resource, pakai AutoCloseable + .use agar cleanup deterministik.
  • Hindari ThreadLocal di pool thread yang panjang; pastikan remove() dipanggil di akhir task.
  • Tambahkan unit test dengan WeakReference untuk 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.