Memory Leak di Java

Menemukan kebocoran memori yang tetap mungkin terjadi lewat collections static, listener, dan cache.

Daftar isi

Garbage collection membebaskan pengembang dari pengelolaan memori manual, tetapi tidak menghapus kemungkinan memory leak. Objek yang sudah tidak berguna tetap tidak dapat dikumpulkan selama masih ada referensi kuat yang menjangkaunya dari GC root. Topik ini membuktikan tiga pola kebocoran yang paling sering terjadi, yaitu collections static yang hanya bertambah, listener yang tidak dilepas, dan cache tanpa batas, beserta cara memutus rantai referensinya.

Kebocoran memori di atas GC#

Kebocoran memori di Java bukan memori yang hilang, melainkan memori yang tertahan. GC hanya membuang objek yang tidak terjangkau dari root, sehingga objek yang masih dirujuk, meski sudah tidak dipakai, tetap tinggal sampai proses berakhir. Sumber kebocoran biasanya tidak tampak sebagai satu variabel besar, melainkan sebagai struktur kecil yang menahan banyak objek, misalnya satu map static yang memuat ribuan entri sesi lama.

Gejalanya bergerak perlahan. Penggunaan heap naik tanpa pernah turun ke level semula setelah GC berjalan, full GC makin sering karena ruang makin tipis, dan akhirnya OutOfMemoryError muncul pada jam atau minggu berjalan. Pada aplikasi berumur panjang seperti server, pola ini lebih berbahaya daripada crash yang langsung terlihat, karena penggunanya mengalaminya sebagai pelambatan bertahap.

Diagnosisnya bertumpu pada perbandingan. Grafik heap setelah GC yang lantainya terus naik adalah sinyal pertama. Langkah berikutnya adalah heap dump, yaitu rekaman isi heap. Rekaman ini menunjukkan objek mana yang memegang memori paling banyak beserta jalur referensinya menuju root, sehingga pertanyaan diagnosis menjadi sederhana: objek apa yang tumbuh dan siapa yang merujuknya. Tiga bagian berikut memperlihatkan pola yang menyumbang sebagian besar jawaban atas pertanyaan itu.

Collections static yang hanya bertambah#

Field static hidup selama kelasnya dimuat, jadi collections yang ditampung di sana tidak pernah dikosongkan secara alami. Program berikut menyimulasikan aplikasi yang mencatat setiap permintaan ke list static tanpa pernah menghapus entri lamanya.

java UjiKebocoranStatic.java
import java.util.ArrayList;
import java.util.List;

public class UjiKebocoranStatic {

    static List<String> logKunjungan = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 1; i <= 5; i++) {
            logKunjungan.add("kunjungan-" + i);
            System.out.printf("permintaan %d: log menampung %d entri%n", i, logKunjungan.size());
        }
        System.out.printf("ukuran log setelah program selesai: %d entri%n", logKunjungan.size());
    }
}
text Keluaran
permintaan 1: log menampung 1 entri
permintaan 2: log menampung 2 entri
permintaan 3: log menampung 3 entri
permintaan 4: log menampung 4 entri
permintaan 5: log menampung 5 entri
ukuran log setelah program selesai: 5 entri

Keluaran memperlihatkan log yang hanya tumbuh. Pada program pendek pola ini terlihat jinak, tetapi pada server yang menerima ribuan permintaan per jam, list yang sama menumpuk seluruh riwayatnya sampai proses berakhir. Pola ini sering menyamar sebagai fitur yang sah, sebab log pengunjung, statistik penggunaan, dan daftar permintaan terakhir memang wajar disimpan, sehingga masalahnya bukan menyimpan data, melainkan menyimpannya tanpa batas waktu dan tanpa batas ukuran. GC tidak dapat menolong karena setiap entri masih terjangkau dari field static yang menjadi GC root. Perbaikannya berbentuk tiga pilihan, yaitu menghapus entri yang sudah kedaluwarsa, membatasi ukuran struktur data, atau mengganti field static dengan objek yang umurnya terikat pada satu permintaan.

Listener dan callback yang tidak dilepas#

Aplikasi berantarmuka dan sistem berbasis event mendaftarkan listener ke pendaftar bersama, dan setiap pendaftaran adalah satu referensi kuat yang disimpan oleh pendaftar. Bila komponen ditutup tanpa melepas pendaftarannya, pendaftar tetap menjaga objek itu tetap hidup tanpa pernah memanggilnya lagi.

java UjiListener.java
import java.util.ArrayList;
import java.util.List;

public class UjiListener {

    public static void main(String[] args) {
        List<Object> daftarListener = new ArrayList<>();

        Object panelA = new Object();
        Object panelB = new Object();
        Object panelC = new Object();
        daftarListener.add(panelA);
        daftarListener.add(panelB);
        daftarListener.add(panelC);

        panelA = null;
        panelC = null;

        System.gc();
        System.out.printf("setelah GC: %d listener tetap terjaga oleh daftar%n", daftarListener.size());
    }
}
text Keluaran
setelah GC: 3 listener tetap terjaga oleh daftar

Panel A dan panel C sudah tidak dirujuk oleh main, tetapi keduanya tetap hidup karena daftar pendaftar masih menunjuknya, dan System.gc() tidak mengubah apa pun. Pada aplikasi nyata, satu panel bisa membawa ratusan objek turunannya, sehingga satu pendaftaran yang terlupakan berarti satu kelompok objek yang tidak pernah dikumpulkan. Callback yang dititipkan ke library lain menyimpan masalah yang sama, yaitu pihak penerima menahan referensi ke objek pengirim sampai ada yang melepasnya. Disiplinnya sederhana, yaitu setiap pendaftaran harus punya pasangan pelepasan yang dipanggil saat komponen ditutup. Bila pelepasan sulit dijamin, pendaftar dapat menyimpan listener dalam bentuk referensi lemah agar GC berhak membuangnya, dengan konsekuensi listener bisa hilang lebih dulu dari dugaan.

Cache tanpa batas sampai OutOfMemoryError#

Cache yang tidak punya batas ukuran adalah kebocoran yang menyamar sebagai optimasi. Setiap objek yang masuk menambah beban heap, dan GC tidak boleh membuangnya karena cache menahan referensi kuat. Program berikut mengisi list static dengan blok data 100 MB tanpa henti sampai heapnya habis.

java UjiTumpukanHabis.java
import java.util.ArrayList;
import java.util.List;

public class UjiTumpukanHabis {

    static List<int[]> cache = new ArrayList<>();

    public static void main(String[] args) {
        int blok = 0;
        try {
            while (true) {
                cache.add(new int[25_000_000]);
                blok++;
            }
        } catch (OutOfMemoryError e) {
            System.out.printf("berhenti setelah %d blok, error: %s%n", blok, e.getMessage());
        }
    }
}
text Keluaran
berhenti setelah 10 blok, error: Java heap space

Programnya berhenti di angka sepuluh karena sepuluh blok 100 MB memenuhi heap yang tersedia. Pada mesin dengan RAM lebih besar, angka ini berubah, tetapi akhirnya tetap sama, yaitu OutOfMemoryError meski GC telah berjalan penuh. Error itu tidak berarti GC gagal, melainkan menegaskan bahwa semua blok masih dianggap terpakai menurut aturan reachability. Cache yang sehat selalu punya batas, misalnya menggusur entri yang paling lama tidak dipakai lewat LinkedHashMap dengan removeEldestEntry, atau menyimpan kuncinya lewat WeakHashMap agar entrinya mengikuti umur kunci.

Kesalahan umum#

  • Memakai ThreadLocal di thread pool tanpa memanggil remove(). Thread di pool tidak pernah mati, sehingga nilai yang tertinggal menumpuk per tugas dan tertahan seumur pool.
  • Memakai kunci dengan equals dan hashCode yang berubah setelah entri masuk map. Entri tidak pernah ditemukan lagi, tetapi tetap menempati heap sampai mapnya dibuang.
  • Mendaftarkan listener tanpa menulis pelepasannya. Pendaftar menyimpan referensi kuat, sehingga komponen yang sudah ditutup tetap hidup tanpa pernah dipakai.
  • Menganggap cache kecil aman dibiarkan berhari-hari. Kebocorannya tumbuh melalui entri baru yang terus masuk, bukan melalui entri yang sudah ada, sehingga terlihat lambat tetapi pasti.

Ringkasan#

  • Kebocoran memori di Java berarti objek sudah mati secara logis tetapi masih terjangkau dari GC root.
  • Collections static, pendaftar listener, dan cache tanpa batas adalah tiga pola kebocoran yang paling sering muncul.
  • OutOfMemoryError tetap mungkin terjadi meski GC berjalan, karena aturannya reachability, bukan kegunaan.
  • Perbaikannya selalu berbentuk memutus referensi, yaitu pelepasan eksplisit, batas ukuran, atau referensi lemah.

Referensi#