Tampilan
Kenapa penting
Aplikasi yang lancar di laptop bisa mati di production dengan OutOfMemoryError, tiba-tiba lambat setiap beberapa menit, atau dimatikan Kubernetes tanpa jejak di log. Di situasi seperti itu, menebak-nebak dan menaikkan memori secara acak hanya membuang waktu. Backend engineer yang baik tahu bagaimana JVM membagi memori, apa yang dilakukan garbage collector, dan alat apa yang sudah tersedia di JDK — jcmd, thread dump, heap dump, GC log, dan Java Flight Recorder — untuk mengumpulkan bukti sebelum memperbaiki. Bab ini memberimu peta memori JVM dan langkah diagnosis yang bisa kamu ulangi saat insiden.
Konsep inti
Area memori JVM
| Area | Isi | Error bila habis |
|---|---|---|
| Heap | Semua objek (new), array, isi koleksi | OutOfMemoryError: Java heap space |
| Stack | Frame method per thread: variabel lokal, parameter | StackOverflowError (rekursi terlalu dalam) |
| Metaspace | Metadata class yang dimuat (di luar heap, memori native) | OutOfMemoryError: Metaspace |
| Native lain | Thread, buffer I/O langsung, kode hasil JIT, GC | Proses dimatikan OS / container |
Setiap thread punya stack sendiri, sedangkan heap dibagi semua thread — itulah alasan data bersama perlu sinkronisasi seperti di bab sebelumnya. Variabel lokal Order order ada di stack, tetapi objek Order yang dirujuknya ada di heap.
Garbage collector: G1 dan ZGC
Garbage collector (GC) membuang objek yang tidak lagi bisa dijangkau dari "akar" (variabel lokal aktif, field static, thread). Objek yang masih dirujuk tidak akan pernah dibuang, sebesar apa pun ukurannya. Sebagian besar objek mati muda (DTO request, string sementara), jadi GC modern membagi heap menjadi generasi muda dan tua.
| GC | Karakter | Kapan dipakai |
|---|---|---|
| G1 (default) | Seimbang antara throughput dan jeda; target jeda default 200 ms | Hampir semua layanan backend |
| ZGC (generasional) | Jeda sangat singkat (umumnya di bawah 1 ms) hampir tanpa peduli ukuran heap | Heap besar, layanan sensitif latensi p99 |
Di Java 25, ZGC selalu generasional (mode non-generasional sudah dihapus). Aktifkan dengan -XX:+UseZGC. Mulailah dengan G1; pindah ke ZGC hanya bila GC log membuktikan jeda GC mengganggu latensi.
Ukuran heap di container
-Xmx menetapkan batas heap secara absolut. Di container, cara yang lebih aman adalah persentase dari memory limit container, karena JVM membaca limit cgroup:
bash
java -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version | grep -E " MaxHeapSize| MaxRAMPercentage"text
size_t MaxHeapSize = 12348030976 {product} {ergonomic}
double MaxRAMPercentage = 75.000000 {product} {command line}Di mesin dengan RAM sekitar 16 GB, heap maksimum menjadi sekitar 11,5 GiB; di container dengan limit 1 GiB, heap menjadi sekitar 768 MiB. Sisakan 25% untuk metaspace, stack thread, dan memori native. Jangan menyetel heap sama dengan limit container — proses akan dimatikan kernel (OOMKilled) sebelum JVM sempat melempar OutOfMemoryError.
OutOfMemoryError dan heap dump
Program berikut meniru bug klasik: "cache" static tanpa batas.
java
import java.util.HashMap;
import java.util.Map;
public class LeakDemo {
record Session(String userId, byte[] payload) {}
// "Cache" tanpa batas dan tanpa TTL — tumbuh selamanya
static final Map<String, Session> SESSIONS = new HashMap<>();
public static void main(String[] args) {
long i = 0;
while (true) {
String id = "req-" + i++;
SESSIONS.put(id, new Session(id, new byte[10_000]));
if (i % 1_000 == 0) {
System.out.println("Sesi tersimpan: " + i);
}
}
}
}Jalankan dengan heap kecil dan minta JVM menulis heap dump saat OOME:
bash
javac -d out LeakDemo.java
java -cp out -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=leak.hprof LeakDemotext
Sesi tersimpan: 6000
java.lang.OutOfMemoryError: Java heap space
Dumping heap to leak.hprof ...
Heap dump file created [69748934 bytes in 0.050 secs]
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"Baris terakhir muncul karena bahkan mencetak stack trace pun butuh memori yang sudah habis — tanda heap benar-benar penuh. File leak.hprof (sekitar ukuran heap) dibuka di alat analisis heap seperti Eclipse MAT atau VisualVM; cari dominator terbesar dan kamu akan melihat HashMap milik LeakDemo.SESSIONS menahan hampir seluruh heap. Di aplikasi yang masih hidup, kamu bisa mengambil heap dump kapan saja dengan jcmd <pid> GC.heap_dump /tmp/app.hprof.
Heap dump berisi data sensitif
Heap dump memuat semua objek di memori: token, password, data pelanggan. Perlakukan seperti database production — jangan dikirim lewat chat atau disimpan di tempat publik.
GC log
GC log adalah cara termurah melihat kesehatan memori. Aktifkan dengan -Xlog:gc (atau -Xlog:gc*:file=gc.log untuk detail ke file):
bash
java -cp out -Xmx64m -Xlog:gc LeakDemotext
[0.002s][info][gc] Using G1
[0.038s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 35M->39M(64M) 7.911ms
[0.040s][info][gc] GC(1) Pause Young (Concurrent Start) (G1 Evacuation Pause) 46M->42M(64M) 1.903ms
[0.040s][info][gc] GC(2) Concurrent Mark Cycle
[0.042s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 48M->48M(64M) 0.936ms
[0.043s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 52M->53M(64M) 0.569msBaca 46M->42M(64M) 1.903ms sebagai: heap terpakai 46 MB sebelum GC, 42 MB sesudahnya, kapasitas 64 MB, jeda 1,9 ms. Pola sehat: angka sesudah GC naik-turun di kisaran stabil. Pola leak: angka sesudah GC terus naik sampai mendekati kapasitas, seperti di atas.
jcmd dan thread dump
jcmd adalah pisau lipat diagnosis JDK. Tanpa argumen ia menampilkan proses Java yang berjalan; dengan PID ia mengirim perintah ke JVM tersebut. Program berikut meniru checkout lambat — semua thread antre di satu lock sementara satu thread memanggil "API gudang" yang lambat:
java
public class SlowCheckout {
private static final Object INVENTORY_LOCK = new Object();
static void reserveStock(String orderId) {
synchronized (INVENTORY_LOCK) { // semua checkout antre di satu lock
try {
Thread.sleep(5_000); // pura-pura memanggil API gudang yang lambat
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 3; i++) {
String orderId = "ORD-" + i;
Thread.ofPlatform().name("checkout-" + i).start(() -> {
while (true) {
reserveStock(orderId);
}
});
}
Thread.currentThread().join(); // tetap hidup sampai dihentikan
}
}bash
jcmd # daftar proses Java → misalnya "977925 SlowCheckout"
jcmd 977925 Thread.print # thread dump (sama dengan jstack 977925)
jcmd 977925 GC.heap_info # ringkasan heapPotongan thread dump nyata:
text
"checkout-1" #25 [977947] prio=5 os_prio=0 cpu=0.38ms elapsed=1.90s tid=0x00007fa94c15e130 nid=977947 waiting on condition [0x00007fa92c3b4000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleepNanos0(java.base@25.0.4.1/Native Method)
at java.lang.Thread.sleepNanos(java.base@25.0.4.1/Thread.java:509)
at java.lang.Thread.sleep(java.base@25.0.4.1/Thread.java:540)
--
"checkout-2" #26 [977948] prio=5 os_prio=0 cpu=0.16ms elapsed=1.90s tid=0x00007fa94c15f3f0 nid=977948 waiting for monitor entry [0x00007fa92c2b3000]
java.lang.Thread.State: BLOCKED (on object monitor)
at SlowCheckout.reserveStock(SlowCheckout.java:8)
- waiting to lock <0x000000071a017dd8> (a java.lang.Object)
at SlowCheckout.lambda$main$0(SlowCheckout.java:20)checkout-1 memegang lock sambil tidur (I/O lambat), checkout-2 dan checkout-3 berstatus BLOCKED menunggu lock yang sama. Perbaikannya: jangan melakukan I/O lambat di dalam synchronized. Ambil beberapa thread dump berselang 5–10 detik; thread yang selalu berada di baris yang sama adalah tersangka utama. Bila ada deadlock, Thread.print menuliskannya secara eksplisit di bagian akhir (Found one Java-level deadlock).
Java Flight Recorder dan JDK Mission Control
Thread dump adalah foto; Java Flight Recorder (JFR) adalah rekaman video. JFR merekam event dari dalam JVM — sampel CPU per method, alokasi, contention lock, GC, I/O file dan socket — dengan overhead rendah sehingga aman dinyalakan di production.
bash
jcmd 977925 JFR.start duration=60s filename=checkout.jfr name=checkouttext
977925:
Started recording 1. The result will be written to:
/path/ke/checkout.jfrBisa juga sejak start: java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jar. Rangkum isinya di terminal dengan jfr summary checkout.jfr atau jfr print --events jdk.GarbageCollection checkout.jfr, lalu buka di JDK Mission Control (JMC) — aplikasi terpisah yang diunduh sendiri — untuk melihat method paling panas, thread yang paling lama terblokir, dan objek yang paling banyak dialokasi.
Contoh di dunia backend
Insiden: endpoint POST /orders di layanan pembayaran melambat dari 80 ms ke 4 detik setiap sore, dan sesekali pod di-restart. Langkah diagnosis yang terstruktur:
| Langkah | Tindakan | Pertanyaan yang dijawab |
|---|---|---|
| 1 | Cek event pod: OOMKilled atau OutOfMemoryError di log? | Masalah memori JVM atau memori container? |
| 2 | Baca GC log: heap setelah GC naik terus? Jeda GC panjang? | Leak atau GC kewalahan? |
| 3 | Ambil 3 thread dump berselang 10 detik | Thread menunggu lock, DB, atau API luar? |
| 4 | Rekam JFR 2–5 menit saat lambat | Method mana yang panas, lock mana yang macet? |
| 5 | Bila leak: heap dump → cari dominator terbesar | Objek apa yang menahan memori, dirujuk siapa? |
| 6 | Perbaiki satu hal, ukur ulang dengan metrik yang sama | Apakah perbaikan benar-benar berdampak? |
Konfigurasi JVM yang wajar untuk container layanan ini:
bash
# Potongan entrypoint container
java \
-XX:MaxRAMPercentage=75 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps \
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10m \
-jar payment-service.jarKebocoran memori yang paling sering ditemui di layanan Spring Boot:
Mapstaticatau field singleton yang dipakai sebagai cache tanpa batas dan tanpa TTL — gunakan Caffeine atau Redis dengan batas ukuran seperti di bab sebelumnya.ThreadLocalyang tidak di-remove()di thread pool, sehingga data request lama tertahan di thread yang dipakai ulang.- Listener atau callback yang didaftarkan tetapi tidak pernah dilepas.
- Memuat hasil query besar sekaligus ke
List(misalnya ekspor 2 juta baris) alih-alih memprosesnya per halaman.
Pertanyaan interview
1. Apa beda heap, stack, dan metaspace?
Heap menyimpan semua objek dan dibagi semua thread; dibersihkan oleh GC. Stack dimiliki tiap thread dan menyimpan frame method beserta variabel lokal; habis karena rekursi terlalu dalam (StackOverflowError). Metaspace menyimpan metadata class di memori native; bisa habis bila aplikasi terus memuat class baru, misalnya karena class loader yang bocor.
2. Bagaimana kamu mendiagnosis aplikasi Java yang tiba-tiba lambat di production?
Kumpulkan bukti sebelum mengubah apa pun: metrik latensi dan CPU, GC log untuk melihat jeda dan tren heap, beberapa thread dump berselang beberapa detik untuk melihat di mana thread menunggu, lalu rekaman JFR saat lambat untuk melihat method panas dan contention. Setelah penyebab ditemukan, perbaiki satu hal dan ukur ulang.
3. Kenapa -XX:MaxRAMPercentage lebih disarankan daripada -Xmx di container?
Persentase dihitung dari memory limit container sehingga konfigurasi tetap benar saat limit diubah, dan otomatis menyisakan ruang untuk memori non-heap (metaspace, stack thread, buffer native, JIT). -Xmx yang terlalu dekat atau melebihi limit membuat container di-OOMKill tanpa OutOfMemoryError dan tanpa heap dump.
Jebakan umum
- Langsung menaikkan
-Xmxsaat OOME — bila ada leak, itu hanya menunda crash dan membuat heap dump lebih besar. - Heap sama dengan limit container — proses di-OOMKill kernel tanpa jejak di log aplikasi.
- Mengambil satu thread dump saja — satu foto tidak membedakan thread yang macet dari yang kebetulan lewat.
- Lupa menyiapkan
-XX:+HeapDumpOnOutOfMemoryErrordan lokasi dump yang bisa ditulis — saat insiden terjadi, buktinya sudah hilang bersama pod. - Ganti GC atau tuning flag tanpa data — mulai dari G1 default, ubah hanya bila GC log membuktikan masalahnya.
- Membagikan heap dump sembarangan — isinya bisa memuat data pribadi dan secret.
Ringkasan
- Objek ada di heap (dibagi semua thread), variabel lokal di stack per thread, metadata class di metaspace.
- GC hanya membuang objek yang tidak lagi dirujuk; G1 adalah default yang tepat, ZGC generasional untuk latensi p99 ketat.
- Di container pakai
-XX:MaxRAMPercentagedan sisakan ruang untuk memori non-heap. - Siapkan
-XX:+HeapDumpOnOutOfMemoryErrordan GC log sejak awal; heap setelah GC yang terus naik menandakan leak. jcmduntuk thread dump, heap info, heap dump, dan JFR; JFR + JMC menunjukkan method panas dan lock yang macet.