Alokasi objek di Java memang murah — pointer geser di dalam TLAB (Thread Local Allocation Buffer), tanpa daftar ruang kosong — tetapi tagihan dibayar di belakang: setiap kali young gen penuh, minor GC menyapu jutaan objek yang baru saja diciptakan. Aplikasi yang menciptakan 200 MB objek per menit memaksa GC bekerja empat kali lebih sering daripada aplikasi yang hanya menciptakan 50 MB, sekalipun keduanya menghasilkan pekerjaan yang sama. Allocation profiling melacak dari mana byte-byte itu datang, dan escape analysis menjelaskan mengapa sebagian alokasi tidak pernah benar-benar terjadi di mesin hasil kompilasi C2.
Dari Alokasi ke Frekuensi GC #
Mekanismenya berlapis. Thread mengalokasikan objek di TLAB miliknya — begitu TLAB habis, thread meminta TLAB baru dan penghitung alokasi JVM mencatat byte yang lolos. Ketika young gen terisi penuh, minor GC menyapu seluruh area: objek yang sudah mati dibuang, objek yang masih hidup dipindahkan. Kuncinya: biaya minor GC tidak sebanding jumlah objek hidup melainkan laju alokasi — mayoritas objek memang mati muda, tetapi tetap harus dialokasikan, tetap mengisi young gen, dan tetap memicu penyapuan. Menurunkan laju alokasi adalah cara paling langsung menurunkan frekuensi GC.
Profil Alokasi dengan JFR #
Rekaman JFR profil mengambil sampel alokasi beserta stack trace penciptanya:
jfr print --events jdk.ObjectAllocationSample cpu.jfrjdk.ObjectAllocationSample {
startTime = 04:24:15.788 (2026-10-16)
objectClass = java.lang.Integer (classLoader = bootstrap)
weight = 5.0 GB
eventThread = "main" (javaThreadId = 3)
stackTrace = [
java.lang.Integer.valueOf(int) line: 1006
ProsesSibuk.main(String[]) line: 15
]
}Rekaman uji diambil dari program sederhana yang terus-menerus menyisipkan Integer ke sebuah map di dalam perulangan. Satu event menyebut class, bobot alokasi, thread, dan baris kode yang memicu alokasi — contoh di atas langsung menunjuk boxing Integer di Integer.valueOf, persis sumber tekanan yang dibahas pada tabel di bawah. Informasi semacam itu cukup untuk mengurutkan penyumbang terbesar tanpa alat pihak ketiga.
Pembukuan Alokasi dengan Wrapper #
Prinsip pembukuan ini bisa kita tiru dengan penyadap sederhana. Program berikut membandingkan dua versi pemrosesan pesanan: versi pertama mengalokasikan objek per pesanan, versi kedua memakai akumulator primitif dan StringBuilder yang dipakai ulang, lalu menghitung tekanan GC secara matematis:
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Pembukuan alokasi model dengan penyadap sederhana: setiap penciptaan
* objek dicatat per jenis, lalu tekanan GC dihitung dari matematika murni
* (total byte / ukuran young gen) — tanpa statistik GC sungguhan.
*/
public class TekananAlokasi {
// Pengganti profil alokasi: pencatat alokasi per jenis objek
static final class Penyadap {
long totalByte = 0;
long totalObjek = 0;
final Map<String, long[]> rincian = new LinkedHashMap<>(); // [objek, byte]
void catat(String jenis, int ukuranPerObjek, int banyak) {
totalObjek += banyak;
totalByte += (long) ukuranPerObjek * banyak;
long[] v = rincian.computeIfAbsent(jenis, k -> new long[2]);
v[0] += banyak;
v[1] += (long) ukuranPerObjek * banyak;
}
}
public static void main(String[] args) {
int pesanan = 2_000_000;
long youngGen = 64L * 1024 * 1024; // model young gen 64 MB
// Versi 1: tiap pesanan membuat objek baru — boxing, penggabungan
// string, dan iterator loop-for di atas List<Integer>.
Penyadap versi1 = new Penyadap();
for (int i = 0; i < pesanan; i++) {
versi1.catat("Integer (boxing)", 16, 1);
versi1.catat("String (penggabungan)", 24, 2);
versi1.catat("Iterator + lainnya", 40, 1);
}
// Versi 2: akumulator primitif + StringBuilder dipakai ulang.
Penyadap versi2 = new Penyadap();
System.out.println("== Rincian alokasi versi 1 ==");
for (Map.Entry<String, long[]> e : versi1.rincian.entrySet()) {
System.out.printf(" %-26s %,12d objek %,13d byte%n",
e.getKey(), e.getValue()[0], e.getValue()[1]);
}
System.out.printf(" %-26s %,12d objek %,13d byte%n",
"TOTAL", versi1.totalObjek, versi1.totalByte);
System.out.println();
System.out.println("== Rincian alokasi versi 2 ==");
System.out.println(" nol alokasi per pesanan (akumulator int, StringBuilder dipakai ulang)");
System.out.println();
System.out.printf("Young gen model = %,d byte%n", youngGen);
System.out.printf("Estimasi minor GC versi 1 = %d kali%n", versi1.totalByte / youngGen);
System.out.println("Estimasi minor GC versi 2 = 0 kali");
System.out.println();
System.out.println("Versi 2 bukan sekadar hemat:");
System.out.println("tekanan alokasi = byte yang harus dipungut, bukan jumlah objek hidup");
}
}Output yang Dihasilkan:
== Rincian alokasi versi 1 ==
Integer (boxing) 2,000,000 objek 32,000,000 byte
String (penggabungan) 4,000,000 objek 96,000,000 byte
Iterator + lainnya 2,000,000 objek 80,000,000 byte
TOTAL 8,000,000 objek 208,000,000 byte
== Rincian alokasi versi 2 ==
nol alokasi per pesanan (akumulator int, StringBuilder dipakai ulang)
Young gen model = 67,108,864 byte
Estimasi minor GC versi 1 = 3 kali
Estimasi minor GC versi 2 = 0 kali
Versi 2 bukan sekadar hemat:
tekanan alokasi = byte yang harus dipungut, bukan jumlah objek hidupMari kita telaah bagian-bagian kuncinya:
- Baris 12-24 (
Penyadap): setiap panggilancatatmenambahkan objek dan byte ke total serta ke rincian per jenis dalamLinkedHashMap— struktur yang kita pilih agar urutan cetak mengikuti urutan pencatatan. Inilah versi mini dari histogram alokasi profiler. - Baris 33-37 (versi 1): empat alokasi per pesanan dimodelkan dengan ukuran tetap: satu
Integerhasil boxing (16 byte), duaStringhasil penggabungan (24 byte masing-masing), dan satuIteratordari perulangan atasList<Integer>(40 byte). Angka model ini meniru bentuk asli di HotSpot — cukup dekat untuk berhitung, tanpa mengklaim presisi byte. - Baris 39-40 (versi 2): penyadap kedua sengaja dibiarkan kosong — akumulator
inthidup di stack danStringBuilderdialokasikan sekali lalu dipakai ulang, sehingga tidak ada byte per pesanan yang perlu dicatat. - Baris 53-55 (matematika GC): 208.000.000 byte dibagi young gen 64 MB menghasilkan estimasi tiga minor GC untuk versi 1 dan nol untuk versi 2 — perhitungan murni atas angka model, bukan pengukuran runtime.
Escape Analysis di Balik Layar #
Ada lapisan penghematan yang tidak terlihat dari kode sumber: JIT C2 menganalisis apakah objek lolos (escape) dari method yang menciptakannya. Objek yang tidak pernah disimpan ke field, tidak dilempar keluar, dan tidak masuk struktur data dapat di-scalar replace — field-nya ditaruh di register dan alokasinya dibatalkan seluruhnya:
long total = 0;
for (int i = 0; i < 1_000_000; i++) {
int[] pasangan = {i, i + 1}; // tidak lolos dari loop
total += pasangan[0] + pasangan[1];
}Sebaliknya, objek yang disimpan ke List, dikembalikan sebagai nilai, atau dipegang lambda pasti lolos — dan itulah alokasi yang pembukuannya layak diperangi. Escape analysis aktif secara bawaan pada C2; pekerjaan kita adalah memastikan kebiasaan kode tidak memaksakan lolosnya objek yang seharusnya bersifat lokal.
| Sumber tekanan umum | Perbaikan struktural |
|---|---|
Stream<Integer> dan Map<Integer, ...> | IntStream primitif, kunci int/long |
| Penggabungan string di dalam perulangan | satu StringBuilder dipakai ulang |
| DTO baru per elemen hanya untuk dibaca | baca field langsung atau gunakan record sementara |
substring/split berlebihan saat parsing | indexOf + charAt, atau split dengan batas |
Tekanan alokasi yang sudah serendah mungkin menuntaskan setengah pekerjaan; separuh sisanya ada di sisi pemungutnya. Chapter berikutnya masuk ke garbage collection tuning: memilih collector dan menghitung ukuran heap sesuai SLA.
Referensi #
- Oracle: Java Virtual Machine Technology Overview
- Oracle: package jdk.jfr (Java SE 25)
- Oracle: jfr — JDK 25 Tool Reference
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
TekananAlokasi.java
Unduh berkasimport java.util.LinkedHashMap;
import java.util.Map;
/**
* Pembukuan alokasi model dengan penyadap sederhana: setiap penciptaan
* objek dicatat per jenis, lalu tekanan GC dihitung dari matematika murni
* (total byte / ukuran young gen) — tanpa statistik GC sungguhan.
*/
public class TekananAlokasi {
// Pengganti profil alokasi: pencatat alokasi per jenis objek
static final class Penyadap {
long totalByte = 0;
long totalObjek = 0;
final Map<String, long[]> rincian = new LinkedHashMap<>(); // [objek, byte]
void catat(String jenis, int ukuranPerObjek, int banyak) {
totalObjek += banyak;
totalByte += (long) ukuranPerObjek * banyak;
long[] v = rincian.computeIfAbsent(jenis, k -> new long[2]);
v[0] += banyak;
v[1] += (long) ukuranPerObjek * banyak;
}
}
public static void main(String[] args) {
int pesanan = 2_000_000;
long youngGen = 64L * 1024 * 1024; // model young gen 64 MB
// Versi 1: tiap pesanan membuat objek baru — boxing, penggabungan
// string, dan iterator loop-for di atas List<Integer>.
Penyadap versi1 = new Penyadap();
for (int i = 0; i < pesanan; i++) {
versi1.catat("Integer (boxing)", 16, 1);
versi1.catat("String (penggabungan)", 24, 2);
versi1.catat("Iterator + lainnya", 40, 1);
}
// Versi 2: akumulator primitif + StringBuilder dipakai ulang.
Penyadap versi2 = new Penyadap();
System.out.println("== Rincian alokasi versi 1 ==");
for (Map.Entry<String, long[]> e : versi1.rincian.entrySet()) {
System.out.printf(" %-26s %,12d objek %,13d byte%n",
e.getKey(), e.getValue()[0], e.getValue()[1]);
}
System.out.printf(" %-26s %,12d objek %,13d byte%n",
"TOTAL", versi1.totalObjek, versi1.totalByte);
System.out.println();
System.out.println("== Rincian alokasi versi 2 ==");
System.out.println(" nol alokasi per pesanan (akumulator int, StringBuilder dipakai ulang)");
System.out.println();
System.out.printf("Young gen model = %,d byte%n", youngGen);
System.out.printf("Estimasi minor GC versi 1 = %d kali%n", versi1.totalByte / youngGen);
System.out.println("Estimasi minor GC versi 2 = 0 kali");
System.out.println();
System.out.println("Versi 2 bukan sekadar hemat:");
System.out.println("tekanan alokasi = byte yang harus dipungut, bukan jumlah objek hidup");
}
}