๐ Topik: Garbage Collection di Produksi โ Pause, Throughput, dan Cara Tuning
Runtime ยท level 3 ยท sesi 05:00 Deep Learning
๐ฏ Tujuan Belajar
Setelah sesi ini kamu bisa ambil 4 keputusan konkret:
- Pilih collector (G1 / ZGC / Parallel / Go GC / JVM default) berdasarkan SLA latency vs throughput โ bukan berdasarkan "kata orang ZGC paling cepat".
- Tentukan ukuran heap dan memory limit container supaya GC tidak thrash dan pod tidak
OOMKilled. - Baca GC log dan tahu mana angka yang harus diperbaiki: allocation rate, live set, atau pause budget.
- Tahu kapan tidak melakukan tuning sama sekali โ karena bottleneck-nya bukan GC.
๐ง Penjelasan Inti
Masalah yang diselesaikan
Manual memory management (malloc/free) punya dua failure mode: use-after-free dan leak. GC menukar kedua bug itu dengan nondeterminism โ runtime boleh berhenti kapan saja untuk mengurus memori.
Jadi GC bukan "fitur gratis". GC adalah pajak: kamu bayar CPU + latency untuk menghindari bug memori. Tuning GC = negosiasi siapa yang bayar pajak itu, dan kapan.
Tiga besaran yang selalu diperdagangkan
| Besaran | Definisi | Siapa yang peduli |
|---|---|---|
| Throughput | % waktu CPU dipakai aplikasi, bukan GC | Batch job, ETL, analytics |
| Pause / latency | Lama aplikasi berhenti total (STW) + tail latency dari barrier | API request, trading, realtime |
| Footprint | Memori ekstra yang dipakai GC (headroom, metadata, copy space) | Container, biaya cloud |
Ini hukum kekekalan, bukan preferensi. Collector apa pun cuma memindahkan biaya antar ketiganya. Tidak ada collector yang menang di ketiganya โ kalau ada yang mengklaim begitu, dia mengukur workload yang salah.
โ๏ธ Cara Kerja Internal
1. Tri-color mark-sweep (fondasi semua collector modern)
white = belum dikunjungi (kandidat sampah)
grey = sudah ditemukan, anak-anaknya belum diperiksa
black = sudah diperiksa selesai
Alur: root (stack, register, global) โ grey โ telusuri pointer โ black. Sisa putih = unreachable = boleh dibebaskan.
Masalah: kalau aplikasi (mutator) jalan bersamaan dengan marker, mutator bisa memindahkan pointer dari objek hitam ke objek putih yang sudah dilewati marker โ objek hidup di-sweep โ crash. Solusinya write barrier.
2. Write barrier โ harga yang dibayar di setiap penulisan pointer
| Barrier | Mekanisme | Dipakai |
|---|---|---|
| Dijkstra (incremental update) | Tandai objek baru sebagai grey saat pointer ditulis | G1 (SATB varian), ZGC |
| Yuasa (snapshot-at-the-beginning) | Simpan pointer lama sebelum ditimpa | G1 SATB, Shenandoah |
| Brooks forwarding pointer | Setiap objek punya field indirect | Tidak dipakai produksi (mahal) |
Konsekuensi arsitektural yang sering dilupakan: barrier ini jalan di setiap store pointer. Artinya:
- Kode yang banyak menulis pointer (map besar, linked list, graph traversal) membayar barrier cost yang tidak muncul di profiler aplikasi โ muncul sebagai "GC overhead" atau "runtime overhead".
- -XX:+UseCompressedOops / compact object headers mengubah biaya ini karena pointer 32-bit lebih murah di-cache.
Ini alasan ZGC/Shenandoah bisa punya pause <1ms: mereka memindahkan biaya dari pause ke barrier + concurrent CPU. Bukan menghilangkan biaya.
3. Generational hypothesis โ kenapa "generasi" ada
Fakta empiris (weak generational hypothesis): mayoritas objek mati muda. Objek yang bertahan beberapa siklus cenderung bertahan lama.
Karena itu heap dibagi young (eden + survivor) dan old. Minor GC cuma menyapu young โ cepat, karena live set-nya kecil. Ini yang membuat GC modern bisa efisien: biaya proporsional terhadap objek hidup, bukan objek mati.
Kapan generational JANGAN dipakai: live set besar dan seragam (cache in-memory 20GB, long-lived session store). Di sini objek "muda" sedikit, promosi tinggi, dan generational justru menambah overhead bookkeeping. Ini alasan ZGC non-generational masih eksis sebagai opsi sebelum JDK 23, dan alasan Go sempat tanpa generasi selama bertahun-tahun.
4. Pacing โ siapa yang memutuskan "kapan GC mulai"
Di Go: GC dipicu oleh heap growth target, bukan timer.
target_heap = live_heap_after_last_GC ร (1 + GOGC/100)
GOGC=100 (default) = GC saat heap tumbuh 2ร dari live set. GOMEMLIMIT (Go 1.19+) menambahkan batas keras berbasis memori total, bukan hanya heap.
Ini penting: Go GC tidak punya generasi (sampai Green Tea, dan Green Tea pun bukan generational โ dia concurrent mark dengan pemindaian berbasis span/vektor). Jadi model mental "young/old" dari JVM tidak transfer ke Go.
5. Green Tea (Go 1.25, eksperimental) โ apa yang sebenarnya diubah
Fakta terverifikasi:
- Tersedia di Go 1.25 via GOEXPERIMENT=greenteagc saat build.
- Blog resmi Go (29 Okt 2025): banyak workload turun ~10% waktu di GC, sebagian sampai 40%. Rencana jadi default di Go 1.26.
- Mekanisme: pemindaian berbasis span + vektor (AVX-512), bukan lagi chasing pointer objek-per-objek acak. Lokalitas memori jadi jauh lebih baik โ objek dalam satu span diperiksa berurutan.
- Sudah dipakai di produksi Google (klaim blog), tapi ada laporan pihak ketiga yang tidak melihat perbaikan (Dolt, Sep 2025) โ artinya benefit sangat workload-dependent.
Pelajaran arsitektural: Green Tea menang karena layout memori, bukan algoritma baru. Ini bukti bahwa "GC lambat" sering akar masalahnya di alokasi tersebar (pointer chasing), bukan di GC-nya. Fix yang lebih murah: kurangi pointer indirection, pakai slice of struct bukan slice of pointer.
6. JVM landscape (status per JDK 25 LTS, rilis 16 Sep 2025)
| Collector | Pause | Throughput | Kapan dipakai |
|---|---|---|---|
| Serial | Tinggi (proporsional heap) | Bagus di heap kecil | Container <2 core, heap <1GB |
| Parallel | Tinggi, tapi total GC time paling rendah | Terbaik | Batch, offline, heap besar toleran pause |
| G1 (default) | Terkontrol via MaxGCPauseMillis (soft, bukan jaminan) |
Bagus | Default web service |
| ZGC | Sub-milidetik (target <1ms), concurrent | Sedikit lebih rendah | Heap sangat besar, SLA latency ketat |
| Shenandoah | Rendah, concurrent | Mirip ZGC | Alternatif, generational sejak JDK 25 (JEP 521) |
Catatan status yang sering salah dikutip:
- JDK 23 (JEP 474): -XX:+UseZGC otomatis generational. ZGC bukan default JVM โ G1 tetap default.
- JDK 25 (JEP 519): Compact Object Headers jadi product feature, tapi belum default โ harus -XX:+UseCompactObjectHeaders. Klaim pengurangan heap 10โ20%.
- JEP 534 menjadikannya default, ditargetkan JDK 27 (belum LTS, belum rilis stabil per Okt 2025).
Ini bukan detail kosmetik. Compact headers memotong header 96โ64 bit โ objek lebih kecil โ allocation rate turun dalam byte โ GC lebih jarang. Efeknya ke GC sama besarnya dengan tuning flag, tapi gratis.
๐๏ธ Model Mental: Satu Rumus
GC frequency โ allocation_rate / (heap_size โ live_set)
GC cost_per_cycle โ live_set ร cost_per_byte (mark)
+ allocation_rate ร cost_per_byte (sweep/alloc)
Konsekuensi langsung:
| Gejala | Akar masalah | Fix benar | Fix salah |
|---|---|---|---|
| GC sering, pause pendek | Allocation rate tinggi | Kurangi alokasi (escape analysis, reuse buffer) | Naikkan heap |
| GC jarang, pause panjang | Live set besar | Kurangi live set / ganti collector concurrent | Naikkan heap |
| GC sering DAN pause panjang | Live set โ heap | Heap terlalu kecil untuk working set | Tuning flag |
| Pause pendek tapi CPU 100% | Concurrent GC + barrier | Naikkan core / ganti collector | Naikkan heap |
Aturan tuning: perbaiki allocation rate dulu, flag terakhir. 80% masalah GC di produksi selesai dengan menghapus alokasi di hot path, bukan dengan -XX: magic.
โ๏ธ Trade-off & Kapan JANGAN Dipakai
Kapan JANGAN pakai ZGC / Shenandoah
- Throughput-bound workload (batch ETL, kompilasi, image processing). ZGC membayar barrier + concurrent thread; di workload yang CPU-nya sudah 95% utilized, ini kerugian bersih. Parallel GC menang di sini.
- Heap kecil (<2โ4GB) dengan live set kecil. G1 atau bahkan Serial sudah cukup. ZGC menambah kompleksitas operasional tanpa benefit terukur.
- Tim belum punya GC log monitoring. Collector concurrent memindahkan masalah dari "pause jelas" ke "latency tersebar" โ tanpa observability, kamu buta.
Kapan JANGAN pakai object pool / sync.Pool
Pooling adalah tuning yang paling sering salah. Pool menukar allocation cost dengan: - Kontensi (mutex/atomic di hot path โ bisa lebih mahal dari alokasi) - Memori yang tidak pernah kembali (pool = live set permanen โ GC makin mahal) - Bug state (objek kotor dipakai ulang โ bug paling sulit di-repro)
Pakai pool hanya kalau: objek besar (ratusan KB+), alokasi terbukti di profil, dan GC pressure terukur. Untuk objek kecil, allocator modern (TLAB di JVM, per-P mcache di Go) sudah hampir secepat stack allocation.
Kapan JANGAN tuning sama sekali
Kalau p99 latency didominasi network call / DB query / lock contention, GC tuning tidak akan mengubah apa pun. Ukur dulu: berapa % wall time di GC? Kalau <5%, berhenti. Ini bukan GC problem.
๐ญ Contoh Produksi & Failure Mode
Failure 1 โ OOMKilled padahal heap aman
Skenario: JVM -Xmx4g di container limit 4Gi. Pod kena OOMKilled berulang, tidak ada OutOfMemoryError di log.
Sebab: total RSS โ heap. RSS = heap + metaspace + code cache + thread stack + direct/native buffer + memori GC sendiri (G1 remembered set, ZGC multi-mapped heap bisa 3ร virtual). ZGC di JDK modern pakai multi-mapped memory โ virtual size jauh lebih besar dari RSS, dan RSS naik saat live set naik.
Fix benar: container limit = Xmx + 25โ30% headroom (minimum +512MB). Pakai -XX:MaxRAMPercentage=70 supaya JVM baca cgroup limit, bukan RAM host. Jangan pakai -Xmx hardcode di cluster heterogen.
Failure 2 โ GC thrashing karena live set โ heap
Skenario: Service dengan cache in-memory 3.5GB, heap 4GB. Latency naik dari 50ms ke 2s tiap beberapa menit.
Sebab: setelah mark, sisa ruang untuk alokasi sangat kecil โ GC jalan lagi hampir langsung โ full GC loop. CPU 100% di GC, throughput aplikasi ~0.
Fix: live set harus โค 50โ60% heap. Kalau cache butuh 3.5GB, heap minimal 6โ7GB โ atau pindahkan cache ke off-heap (Caffeine dengan maximumWeight + softValues, Redis, atau native memory). Naikkan heap tanpa memindahkan cache = menunda masalah, bukan menyelesaikannya.
Failure 3 โ Go: GOGC=off untuk "menghilangkan GC pause"
Skenario: Tim set GOGC=off karena GC "bikin latency spike". Service jalan 6 jam lalu OOM.
Sebab: GOGC=off bukan mematikan GC โ dia membuat GC hanya jalan saat GOMEMLIMIT tercapai. Setelah itu GC jalan terus-menerus (setiap kali alokasi menyentuh limit), dengan mark yang jauh lebih mahal karena live set besar. Pause individual mungkin pendek, tapi total CPU di GC meledak.
Fix: GOMEMLIMIT = 80โ90% container limit, GOGC tetap 100 atau diturunkan ke 50 kalau butuh footprint lebih kecil. Jangan pernah GOGC=off tanpa GOMEMLIMIT.
Failure 4 โ MaxGCPauseMillis dipercaya sebagai jaminan
Skenario: Set -XX:MaxGCPauseMillis=10, p99 masih 800ms.
Sebab: flag itu soft goal, bukan SLA. G1 akan memilih young size lebih kecil untuk mengejar target, tapi kalau ada humongous allocation (objek >50% region) atau full GC karena fragmentation, target dilewati. Selain itu, flag ini mengatur pause, bukan tail latency โ latensi dari write barrier, safepoint, dan concurrent cycle tidak tercakup.
Fix: ukur pause dan time-to-safepoint secara terpisah. Kalau p99 buruk tanpa pause buruk, masalahnya safepoint (loop panjang tanpa poll) atau barrier, bukan collector.
Failure 5 โ Latency spike dari safepoint, bukan GC
Thread JVM harus berhenti di safepoint untuk STW. Kalau ada loop komputasi panjang tanpa safepoint poll (JIT sudah hilangkan poll karena counted loop), satu thread bisa menahan STW jauh melebihi target GC.
Gejala khas: GC log bilang pause 3ms, tapi aplikasi freeze 400ms. Cek -Xlog:safepoint. Ini bukan masalah GC โ tuning GC tidak akan menyembuhkannya.
โ ๏ธ Miskonsepsi Umum
| Miskonsepsi | Koreksi |
|---|---|
| "ZGC = no pause" | ZGC punya STW fase root scan, sub-ms tapi bukan nol. Dan biayanya pindah ke throughput + footprint. |
| "Naikkan heap = GC lebih jarang = lebih cepat" | GC lebih jarang, tapi tiap cycle lebih mahal (mark proporsional live set) dan cache locality turun. Heap besar bisa memperburuk p99. |
| "GC log cuma buat debugging" | GC log adalah metrik produksi. Tanpa itu, semua tuning adalah tebakan. JVM: -Xlog:gc*:file=...:time,uptime,level,tags. Go: GODEBUG=gctrace=1. |
| "Go tidak punya generational GC karena belum perlu" | Alasan sebenarnya: Go mengoptimalkan latency + simplicity, dan generational butuh barrier lebih mahal yang bertentangan dengan goal sub-ms pause. Green Tea memperbaiki lokality scanning, bukan menambah generasi. |
| "Allocation di Go murah, jadi tidak masalah" | Murah per objek, tapi total allocation rate tetap penggerak utama frekuensi GC. []byte 1MB per request ร 10k rps = 10GB/s allocation โ GC tidak akan pernah mengejar. |
| "Escape analysis menyelamatkan semua" | EA di Go/JVM punya batas (interface, closure, ukuran). Selalu verifikasi dengan -gcflags=-m atau profiler, jangan asumsikan. |
| "Compact object headers = optimasi mikro" | 96โ64 bit = ~33% header reduction, 10โ20% heap total. Efek langsung ke allocation rate dan cache miss. Ini tuning gratis terbesar di JDK 25. |
โ Cek Pemahaman
-
Service Go punya p99 latency 300ms,
GODEBUG=gctrace=1menunjukkan GC ~2ร/detik dengan pause 400ยตs. Apakah GC penyebab p99? Kalau tidak, ke mana kamu lihat berikutnya โ dan metrik apa yang membuktikannya? -
Kamu punya batch job 8 jam yang memproses 500GB data, SLA-nya "selesai sebelum 09:00", tidak ada user. Collector mana yang kamu pilih, dan flag apa yang tidak kamu set? Jelaskan biayanya.
-
Cache in-memory 12GB di heap 16GB, G1, full GC tiap ~20 menit dengan pause 4 detik. Sebutkan dua solusi dengan trade-off berbeda (satu di level heap, satu di level arsitektur) dan kapan masing-masing lebih tepat.
๐ Poin Kunci
- GC = pajak. Tuning = memilih siapa bayar dan kapan. Throughput โ latency โ footprint, tidak ada yang gratis.
- Allocation rate dan live set adalah dua variabel yang menentukan segalanya. Flag hanya menggeser kurva.
- Concurrent collector (ZGC/Shenandoah) memindahkan biaya dari pause ke barrier + CPU. Buruk untuk workload yang sudah CPU-bound.
MaxGCPauseMillisadalah soft goal. Safepoint dan humongous allocation bisa melewatinya tanpa terlihat di GC log.- Container: limit โ heap. Butuh headroom 25โ30% untuk metaspace, stack, native, dan metadata GC.
- Live set harus โค50โ60% heap. Di atas itu, thrashing bukan soal "kurang tuning" โ itu soal sizing/arsitektur.
- Di Go:
GOGC+GOMEMLIMITadalah dua tuas.GOGC=offtanpaGOMEMLIMIT= bom waktu. - Perbaiki layout memori dan allocation rate sebelum menyentuh flag. Green Tea menang karena locality, bukan sihir.
โก๏ธ Eksplorasi Lanjut
- Baca GC log nyata dari service-mu sendiri minggu ini: JVM
-Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags, GoGODEBUG=gctrace=1. Hitung rasioGC time / wall time. Kalau <5%, tutup topik ini dan kejar bottleneck lain. - Allocation profiling: Go
go tool pprof -alloc_space, JVM JFRjdk.ObjectAllocationSample. Cari top-3 allocation site di hot path โ biasanya satufmt.Sprintfatau[]bytebuffer. - Topik berikutnya yang nyambung: escape analysis & TLAB/mcache allocator (kenapa alokasi kecil hampir gratis), lalu backpressure (karena GC thrashing dan queue buildup punya gejala p99 yang identik โ penting bisa membedakan).
- Eksperimen terukur: jalankan benchmark sederhana dengan
GOGC50/100/200 dan-Xmx2g/4g/8g, plot p50/p99/throughput. Satu jam eksperimen ini mengajarkan lebih banyak dari sepuluh artikel.
Sumber terverifikasi: - Go Blog, "The Green Tea Garbage Collector" (29 Okt 2025) โ https://go.dev/blog/greenteagc - Dolt, "We tried Go's experimental Green Tea GC and it didn't help" (26 Sep 2025) โ laporan kontra, benefit workload-dependent - JEP 474: ZGC Generational Mode by Default (JDK 23) โ https://openjdk.org/jeps/474 - JEP 519: Compact Object Headers (JDK 25, product feature, off by default) - JEP 534: Compact Object Headers by Default (target JDK 27) โ https://openjdk.org/jeps/534 - JEP 521: Generational Shenandoah (JDK 25) - Inside.java, "What's New in Java 25 in 2 Minutes" (17 Okt 2025) โ klaim reduksi heap 10โ20%
Catatan keyakinan: mekanisme internal GC (tri-color, barrier, pacing) = DIKETAHUI, stabil sejak lama. Status versi JDK/Go = DIKETAHUI per rilis yang dikutip. Angka persentase peningkatan = DIINFERENSI dari benchmark vendor/pihak ketiga, bukan jaminan untuk workload-mu โ selalu ukur sendiri.