Benchmark menjawab “berapa cepat”, profiler menjawab “kenapa lambat”. CPU profiling adalah disiplin menemukan method yang benar-benar menghabiskan siklus prosesor — bukan method yang terasa mahal. Alat utamanya, sampling profiler, bekerja dengan prinsip statistik yang elegan: alih-alih mencatat setiap eksekusi, profiler memotret isi call stack pada interval tetap, lalu menghitung method mana yang paling sering muncul di puncak foto-foto itu. Chapter ini membedah mekanismenya, menjalankannya lewat JFR bawaan JDK, dan menunjukkan bagaimana profil membenarkan (atau membantah) asumsi optimasi kita.
Cara Kerja Sampling #
Sampling profiler bangun setiap interval — misalnya setiap 10 milidetik — lalu menelusuri stack seluruh thread Java dan mencatat method terdalam yang sedang berjalan. Sebuah method yang menghabiskan 40% waktu CPU akan muncul di sekitar 40% sampel; tidak ada pengukuran per method yang dipasang, tidak ada kode yang diubah. Biaya profiling sebanding jumlah sampel, bukan jumlah eksekusi method, sehingga method yang dipanggil sejuta kali per detik tidak lebih mahal diprofil daripada yang dipanggil sepuluh kali.
Dua istilah penting dalam membaca hasilnya: porsi eksklusif menghitung sampel di mana method itu sendiri sedang bekerja, sedangkan porsi inklusif menggabungkan seluruh sampel di subtree panggilannya — persis model yang kita hitung pada Chapter Profiling Aplikasi Java. Pendekatan lawas instrumentation — menyisipkan penghitung ke setiap method — menghasilkan angka eksak tetapi mengubah program yang diukur, dan justru karena itu pendekatan tersebut ditinggalkan untuk profiling CPU.
Sampling dengan JFR #
Cara termudah di JDK 25 tanpa memasang apa pun: nyalakan rekaman berprofil profile, lalu baca event jdk.ExecutionSample:
jcmd <pid> JFR.start settings=profile filename=cpu.jfr duration=60s
jfr print --events jdk.ExecutionSample cpu.jfrjdk.CPULoad {
startTime = 04:24:16.834 (2026-10-16)
jvmUser = 0.00%
jvmSystem = 0.00%
machineTotal = 0.00%
}Event jdk.CPULoad di atas adalah foto beban prosesor per detik dari rekaman uji kita yang sesungguhnya — pasangan kerjanya, jdk.ExecutionSample, membawa satu foto call stack lengkap dengan nomor baris pada setiap eventnya. Tumpukan ribuan event seperti itu diolah menjadi histogram per method atau menjadi flame graph — diagram batang bertingkat yang lebarnya sebanding jumlah sampel. Detail perekaman dan pembacaan lengkapnya ada pada Chapter JFR dan JDK Mission Control; satu fakta penting untuk CPU: thread yang sedang tidur tidak menghasilkan sampel CPU sama sekali — pada rekaman uji terhadap proses yang mayoritas waktunya Thread.sleep, jfr summary melaporkan jdk.ExecutionSample 0. Profil CPU kosong adalah petunjuk: aplikasi menunggu, bukan berhitung — dan itu masalah kelas berbeda.
Dua Versi Kode di Bawah Sampler #
Mari kita lihat sampler bekerja pada dua versi fungsi cetak laporan — versi A menghitung ulang pajak per laporan, versi B menyimpan hasil hitung pertama di cache. Program berikut memodelkan keduanya sebagai urutan satuan kerja:
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Simulasi sampling profiler pada dua versi kode: sebelum dan sesudah
* hasil hitungPajak di-cache. Sampler mencatat method terdalam setiap
* INTERVAL satuan kerja; seluruh angka berupa penghitung operasi.
*/
public class ProfilerSampling {
static final int INTERVAL = 4; // 1 sampel tiap 4 satuan kerja
record Frame(String method, int unit) {}
public static void main(String[] args) {
// Versi A: hitungPajak dikerjakan ulang untuk setiap laporan
List<Frame> versiA = new ArrayList<>();
versiA.add(new Frame("muatData", 6));
for (int l = 1; l <= 4; l++) {
versiA.add(new Frame("formatBaris", 2));
versiA.add(new Frame("hitungPajak", 7));
}
versiA.add(new Frame("simpanLaporan", 4));
// Versi B: hitungPajak dihitung sekali, laporan berikutnya kena cache
List<Frame> versiB = new ArrayList<>();
versiB.add(new Frame("muatData", 6));
versiB.add(new Frame("hitungPajak", 7));
for (int l = 1; l <= 4; l++) {
versiB.add(new Frame("formatBaris", 2));
versiB.add(new Frame("hitungPajak (cache)", 1));
}
versiB.add(new Frame("simpanLaporan", 4));
Map<String, Integer> sampelA = profilkan(versiA);
Map<String, Integer> sampelB = profilkan(versiB);
int totalA = totalUnit(versiA);
int totalB = totalUnit(versiB);
System.out.println("Interval sampling = " + INTERVAL + " satuan kerja");
System.out.println();
System.out.println("Versi A (tanpa cache): total " + totalA + " satuan, "
+ jumlahSampel(sampelA) + " sampel");
cetakTabel(sampelA);
cetakBar(sampelA, jumlahSampel(sampelA));
System.out.println();
System.out.println("Versi B (dengan cache): total " + totalB + " satuan, "
+ jumlahSampel(sampelB) + " sampel");
cetakTabel(sampelB);
cetakBar(sampelB, jumlahSampel(sampelB));
System.out.println();
double porsiA = 100.0 * sampelA.getOrDefault("hitungPajak", 0) / jumlahSampel(sampelA);
double porsiB = 100.0 * sampelB.getOrDefault("hitungPajak", 0) / jumlahSampel(sampelB);
System.out.printf("Pekerjaan turun %.1f%%, porsi sampel hitungPajak turun dari "
+ "%.1f%% menjadi %.1f%%%n", 100.0 * (totalA - totalB) / totalA, porsiA, porsiB);
System.out.println("Sampel adalah taksiran; satuan kerja adalah kebenaran penuh");
}
static Map<String, Integer> profilkan(List<Frame> alur) {
Map<String, Integer> sampel = new LinkedHashMap<>();
int tick = 0; // sesi profiling berdiri sendiri, penghitung mulai dari 0
for (Frame f : alur) {
for (int u = 0; u < f.unit(); u++) {
tick++;
if (tick % INTERVAL == 0) {
sampel.merge(f.method(), 1, Integer::sum);
}
}
}
return sampel;
}
static int totalUnit(List<Frame> alur) {
int t = 0;
for (Frame f : alur) {
t += f.unit();
}
return t;
}
static int jumlahSampel(Map<String, Integer> sampel) {
int n = 0;
for (int v : sampel.values()) {
n += v;
}
return n;
}
static void cetakTabel(Map<String, Integer> sampel) {
for (var e : sampel.entrySet()) {
System.out.printf(" %-19s %2d sampel%n", e.getKey(), e.getValue());
}
}
// Flame graph mini: lebar batang sebanding jumlah sampel
static void cetakBar(Map<String, Integer> sampel, int totalSampel) {
System.out.println(" flame graph mini:");
for (var e : sampel.entrySet()) {
int lebar = Math.round(20.0f * e.getValue() / totalSampel);
System.out.println(" " + "#".repeat(lebar) + " " + e.getKey());
}
}
}Output yang Dihasilkan:
Interval sampling = 4 satuan kerja
Versi A (tanpa cache): total 46 satuan, 11 sampel
muatData 1 sampel
formatBaris 2 sampel
hitungPajak 7 sampel
simpanLaporan 1 sampel
flame graph mini:
## muatData
#### formatBaris
############# hitungPajak
## simpanLaporan
Versi B (dengan cache): total 29 satuan, 7 sampel
muatData 1 sampel
hitungPajak 2 sampel
hitungPajak (cache) 1 sampel
formatBaris 2 sampel
simpanLaporan 1 sampel
flame graph mini:
### muatData
###### hitungPajak
### hitungPajak (cache)
###### formatBaris
### simpanLaporan
Pekerjaan turun 37.0%, porsi sampel hitungPajak turun dari 63.6% menjadi 28.6%
Sampel adalah taksiran; satuan kerja adalah kebenaran penuhMari kita telaah baris-baris kuncinya:
- Baris 18-35 (
versiAdanversiB): kedua alur eksekusi dinyatakan sebagai daftarFrame— nama method dan jumlah satuan kerjanya. Versi A memuathitungPajakempat kali (7 satuan tiap panggilan); versi B cukup sekali ditambah tiga hitungan cache 1 satuan. - Baris 62-74 (
profilkan): penghitungtickberjalan per satuan kerja; setiap kelipatanINTERVAL, satu sampel dicatat ke method pemilik satuan tersebut lewatmerge. Inilah loop sampling profiler versi mini — hanya penghitung, tanpa jam. - Baris 98-105 (
cetakBar): lebar batang#dihitung sebanding porsi sampel, meniru cara flame graph menyandikan beban: semakin lebar, semakin banyak sampel jatuh ke method itu. - Baris 55-59 (baris akhir): versi B memangkas pekerjaan 37% dan menurunkan porsi sampel
hitungPajakdari 63,6% ke 28,6% — dua angka berbeda karena sampel hanyalah taksiran statistik dari satuan kerja yang sesungguhnya.
Membaca Hasil dengan Kepala Dingin #
| Aspek | Implikasi pada hasil |
|---|---|
| Interval 10 ms, durasi 60 detik | maksimal ±6.000 sampel; porsi di bawah ~0,5% tidak terbedakan dari derau |
| Safepoint bias | sampel hanya bisa diambil saat thread berada di safepoint; loop panjang tanpa safepoint bisa kurang terwakili |
| Thread tidur / menunggu I/O | tidak menghasilkan sampel CPU; gunakan profil kelas lain untuk mendiagnosisnya |
CPU hanyalah satu sumber daya. Ketika aplikasi lambat bukan karena berhitung, melainkan karena heap menipis atau memori tak kunjung kembali, kita pindah ke profil memori — pokok bahasan chapter berikutnya.
Referensi #
- Oracle: jfr — JDK 25 Tool Reference
- Oracle: package jdk.jfr (Java SE 25)
- Oracle: Troubleshooting — Diagnostic Tools
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ProfilerSampling.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Simulasi sampling profiler pada dua versi kode: sebelum dan sesudah
* hasil hitungPajak di-cache. Sampler mencatat method terdalam setiap
* INTERVAL satuan kerja; seluruh angka berupa penghitung operasi.
*/
public class ProfilerSampling {
static final int INTERVAL = 4; // 1 sampel tiap 4 satuan kerja
record Frame(String method, int unit) {}
public static void main(String[] args) {
// Versi A: hitungPajak dikerjakan ulang untuk setiap laporan
List<Frame> versiA = new ArrayList<>();
versiA.add(new Frame("muatData", 6));
for (int l = 1; l <= 4; l++) {
versiA.add(new Frame("formatBaris", 2));
versiA.add(new Frame("hitungPajak", 7));
}
versiA.add(new Frame("simpanLaporan", 4));
// Versi B: hitungPajak dihitung sekali, laporan berikutnya kena cache
List<Frame> versiB = new ArrayList<>();
versiB.add(new Frame("muatData", 6));
versiB.add(new Frame("hitungPajak", 7));
for (int l = 1; l <= 4; l++) {
versiB.add(new Frame("formatBaris", 2));
versiB.add(new Frame("hitungPajak (cache)", 1));
}
versiB.add(new Frame("simpanLaporan", 4));
Map<String, Integer> sampelA = profilkan(versiA);
Map<String, Integer> sampelB = profilkan(versiB);
int totalA = totalUnit(versiA);
int totalB = totalUnit(versiB);
System.out.println("Interval sampling = " + INTERVAL + " satuan kerja");
System.out.println();
System.out.println("Versi A (tanpa cache): total " + totalA + " satuan, "
+ jumlahSampel(sampelA) + " sampel");
cetakTabel(sampelA);
cetakBar(sampelA, jumlahSampel(sampelA));
System.out.println();
System.out.println("Versi B (dengan cache): total " + totalB + " satuan, "
+ jumlahSampel(sampelB) + " sampel");
cetakTabel(sampelB);
cetakBar(sampelB, jumlahSampel(sampelB));
System.out.println();
double porsiA = 100.0 * sampelA.getOrDefault("hitungPajak", 0) / jumlahSampel(sampelA);
double porsiB = 100.0 * sampelB.getOrDefault("hitungPajak", 0) / jumlahSampel(sampelB);
System.out.printf("Pekerjaan turun %.1f%%, porsi sampel hitungPajak turun dari "
+ "%.1f%% menjadi %.1f%%%n", 100.0 * (totalA - totalB) / totalA, porsiA, porsiB);
System.out.println("Sampel adalah taksiran; satuan kerja adalah kebenaran penuh");
}
static Map<String, Integer> profilkan(List<Frame> alur) {
Map<String, Integer> sampel = new LinkedHashMap<>();
int tick = 0; // sesi profiling berdiri sendiri, penghitung mulai dari 0
for (Frame f : alur) {
for (int u = 0; u < f.unit(); u++) {
tick++;
if (tick % INTERVAL == 0) {
sampel.merge(f.method(), 1, Integer::sum);
}
}
}
return sampel;
}
static int totalUnit(List<Frame> alur) {
int t = 0;
for (Frame f : alur) {
t += f.unit();
}
return t;
}
static int jumlahSampel(Map<String, Integer> sampel) {
int n = 0;
for (int v : sampel.values()) {
n += v;
}
return n;
}
static void cetakTabel(Map<String, Integer> sampel) {
for (var e : sampel.entrySet()) {
System.out.printf(" %-19s %2d sampel%n", e.getKey(), e.getValue());
}
}
// Flame graph mini: lebar batang sebanding jumlah sampel
static void cetakBar(Map<String, Integer> sampel, int totalSampel) {
System.out.println(" flame graph mini:");
for (var e : sampel.entrySet()) {
int lebar = Math.round(20.0f * e.getValue() / totalSampel);
System.out.println(" " + "#".repeat(lebar) + " " + e.getKey());
}
}
}