Keunggulan paling populer Java — garbage collector — sering disalahartikan menjadi jaminan “tidak mungkin kebocoran memori”. Kenyataannya kebocoran tetap terjadi, hanya bentuknya berbeda dari C: objek yang sudah tidak dipakai tetap terjangkau dari GC roots, sehingga GC dengan benar menganggapnya hidup dan tidak pernah memungutnya. Sepuluh kali lipat ulangan permintaan, dan heap pelan-pelan dipenuhi jejak tugas yang sudah selesai — sampai suatu hari OutOfMemoryError menghentikan layanan. Pada chapter ini kita bedah empat pola kebocoran paling umum, membuktikan dua pola dengan program, lalu melihat gejala dan jalur diagnosisnya.
Bocor di Tengah Automatic Memory Management #
Kriteria GC adalah reachability, bukan kegunaan. GC tidak tahu bahwa entri log usia tiga bulan tidak akan pernah dibaca lagi; selama masih ada referensi dari koleksi static yang hidup, entri itu resmi hidup. Kebocoran memori di Java didefinisikan begini: objek terus tertahan oleh referensi yang tidak lagi dibutuhkan program. Efeknya kumulatif — aplikasi berjalan normal berhari-hari, heap terisi perlahan, GC makin sering bekerja dan makin lama, sampai batas -Xmx terlampaui dan JVM melempar OutOfMemoryError: Java heap space — pesan persis ini kita jumpai pada mesin uji saat memprogram alokasi melebihi kapasitas.
Empat pola berikut menutup mayoritas kasus nyata. Semuanya berakar pada satu kesalahan yang sama: referensi hidup lebih lama daripada kebutuhannya.
| Pola | Mekanisme | Tempat lazim |
|---|---|---|
| Koleksi static | List/Map static hanya ditambah, tak pernah dibersihkan | cache sesi, riwayat audit |
| Listener tak dilepas | pendaftaran callback tanpa remove saat komponen mati | GUI, event bus |
ThreadLocal tanpa remove() | nilai menempel seumur thread | thread pool aplikasi server |
| Cache tanpa eviksi | HashMap “memo” tumbuh tanpa batas ukuran | hasil kalkulasi, data master |
Pola Pertama: Koleksi Static #
Koleksi yang dirujuk field static berumur sepanjang class dimuat — praktis seumur JVM. Setiap add tanpa kebijakan penghapusan adalah keputusan bahwa data itu hidup selamanya. Program berikut mensimulasikan cache riwayat yang salah arah: tiga putaran pengisian, masing-masing menambah 100 ribu entri tanpa pernah melepas satu pun:
import java.util.ArrayList;
import java.util.List;
public class SimulasiKebocoran {
// koleksi static: berumur sepanjang JVM, tidak pernah dipungut GC
static final List<String> riwayat = new ArrayList<>();
public static void main(String[] args) {
for (int putaran = 1; putaran <= 3; putaran++) {
for (int i = 0; i < 100_000; i++) {
riwayat.add("log-" + putaran + "-" + i);
}
System.out.println("putaran " + putaran + " selesai, riwayat.size() = " + riwayat.size());
}
System.out.println("elemen terakhir: " + riwayat.get(riwayat.size() - 1));
}
}Output yang Dihasilkan:
putaran 1 selesai, riwayat.size() = 100000
putaran 2 selesai, riwayat.size() = 200000
putaran 3 selesai, riwayat.size() = 300000
elemen terakhir: log-3-99999Tiga baris berikut membaca gejala pada output:
- Baris 5 (
static final List<String> riwayat): field static adalah GC root — selama classSimulasiKebocorandimuat, koleksi ini terjangkau, beserta seluruh isinya. GC tidak punya alasan memungut satu entri pun. - Baris 13 (
riwayat.size()yang terus naik): angka100000,200000,300000adalah gejala kebocoran dalam bentuk yang bisa diukur. Di dunia nyata tanda bacanya sama: metrik ukuran koleksi yang hanya naik tidak turun sepanjang umur aplikasi. - Skala masalahnya:
size()mencatat jumlah objek, bukan byte — 300 ribu string pendek ini masih jauh dari menghabiskan heap 1 GB. Ulangi pola yang sama dengan entri lebih besar dan siklus tanpa henti di aplikasi berumur panjang, danOutOfMemoryErrorhanya soal waktu.
Perbaikannya selalu berupa kebijakan umur data: rotasi dengan remove atau clear berkala, penggantian ArrayList dengan ArrayDeque berkapasitas terbatas, atau — untuk kebutuhan cache — struktur yang tahu cara memangkas dirinya sendiri sebagaimana kita bahas pada Chapter Strong, Weak, Soft, dan Phantom References.
Pola Kedua: ThreadLocal Tanpa remove #
ThreadLocal menyimpan nilai per-thread: thread berbeda melihat nilai berbeda, dan nilai bertahan selama thread hidup. Di application server, thread dipakai ulang untuk ratusan permintaan — nilai yang tidak dibersihkan bocor ke permintaan berikutnya dan menahan objek lebih lama dari kebutuhannya. Program berikut memperlihatkan daya rekatnya:
public class JejakThreadLocal {
static ThreadLocal<String> konteks = new ThreadLocal<>();
public static void main(String[] args) {
prosesPermintaan();
// variabel lokal sudah mati, tetapi nilai ThreadLocal masih menempel
System.out.println("setelah method selesai (tanpa remove) : " + konteks.get());
konteks.remove();
System.out.println("setelah remove() : " + konteks.get());
}
static void prosesPermintaan() {
konteks.set("permintaan-42");
System.out.println("di dalam method : " + konteks.get());
}
}Output yang Dihasilkan:
di dalam method : permintaan-42
setelah method selesai (tanpa remove) : permintaan-42
setelah remove() : nullDua baris output terpenting berbunyi demikian:
- Baris
setelah method selesai (tanpa remove) : permintaan-42: methodprosesPermintaansudah pulang — seluruh variabel lokalnya mati — tetapi nilaiThreadLocaltetap dapat dibaca. Nilai tersebut tersimpan di peta internal milik thread, bukan di frame method; seumur thread, seumur pula nilainya. - Baris
setelah remove() : null: satu barisremove()di akhir tugas menutup celah. Pola baku di thread pool: bloktry { ... } finally { konteks.remove(); }agar pembersihan tetap berjalan walau tugas melempar exception.
Pola Ketiga dan Keempat: Listener dan Cache #
Listener yang tidak dilepas adalah varian koleksi static dengan wajah berbeda: komponen mendaftarkan diri ke event bus atau PropertyChangeListener, lalu komponen itu dibongkar — tetapi registrasinya tetap memegang referensinya. Seluruh object graph komponen (beserta semua yang ditunjuknya) ikut tertahan. Perbaikannya simetris: setiap addXxxListener harus punya pasangan removeXxxListener yang pasti dipanggil pada siklus pembongkaran.
Cache tanpa eviksi menutup daftar: Map<Long, Hasil> memo yang mengisi diri dari database tumbuh mengikuti kardinalitas data sumber — jutaan baris master data berarti jutaan entri selamanya. Solusi sehatnya bukan menahan semua, melainkan memilih struktur yang mengerti tekanan memori: WeakHashMap untuk pemetaan kanonik, cache berbasis SoftReference yang rela melepas isinya menjelang kehabisan heap, atau cache pustaka dengan kebijakan ukuran maksimum — ketiganya dibahas tuntas pada Chapter References.
Gejala dan Jalur Diagnosis #
Kebocoran jarang diumumkan; gejalanya harus dibaca. Tiga sinyal klasiknya: penggunaan heap yang naik terus dalam grafik monitoring dan tidak turun setelah GC besar; frekuensi GC yang meningkat dari menit-jadian menjadi detik-jadian; dan akhirnya OutOfMemoryError: Java heap space disertai stack trace titik alokasi. Momen galat itu justru berharga — dengan bendera -XX:+HeapDumpOnOutOfMemoryError, JVM otomatis menuliskan heap dump ke berkas .hprof, potret seluruh heap saat kehabisan ruang.
Heap dibuka di alat analisis — Eclipse MAT atau JDK Mission Control — dan pertanyaan pembukanya selalu sama: dominator mana yang menahan porsi heap terbesar, dan rantai referensi apa yang menahannya dari GC root. Rantai itu hampir selalu berujung pada salah satu dari empat pola di atas; membaca rantai referensi adalah keterampilan yang akan kita latih dengan alat CLI pada Chapter jcmd, jstack, dan jmap dan diperdalam lewat rekaman JFR. Sebelum sampai ke alat, mari kita pahami dulu perjalanan objek di dalam heap — dari lahir di Eden hingga dipungut — pada Chapter Object Lifecycle di JVM.
Referensi #
- Oracle: Troubleshooting Java SE — OutOfMemoryError dan heap dump
- Oracle: API ThreadLocal (Java SE 25)
- Oracle: Garbage Collection Tuning Guide (JDK 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
JejakThreadLocal.java
Unduh berkaspublic class JejakThreadLocal {
static ThreadLocal<String> konteks = new ThreadLocal<>();
public static void main(String[] args) {
prosesPermintaan();
// variabel lokal sudah mati, tetapi nilai ThreadLocal masih menempel
System.out.println("setelah method selesai (tanpa remove) : " + konteks.get());
konteks.remove();
System.out.println("setelah remove() : " + konteks.get());
}
static void prosesPermintaan() {
konteks.set("permintaan-42");
System.out.println("di dalam method : " + konteks.get());
}
}
SimulasiKebocoran.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
public class SimulasiKebocoran {
// koleksi static: berumur sepanjang JVM, tidak pernah dipungut GC
static final List<String> riwayat = new ArrayList<>();
public static void main(String[] args) {
for (int putaran = 1; putaran <= 3; putaran++) {
for (int i = 0; i < 100_000; i++) {
riwayat.add("log-" + putaran + "-" + i);
}
System.out.println("putaran " + putaran + " selesai, riwayat.size() = " + riwayat.size());
}
System.out.println("elemen terakhir: " + riwayat.get(riwayat.size() - 1));
}
}