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:

bash Rekam profil CPU 60 detik
jcmd <pid> JFR.start settings=profile filename=cpu.jfr duration=60s
jfr print --events jdk.ExecutionSample cpu.jfr
text Output nyata jfr print --events jdk.CPULoad (nilai berubah antar rekaman)
jdk.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:

text
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 penuh

Mari kita telaah baris-baris kuncinya:

  1. Baris 18-35 (versiA dan versiB): kedua alur eksekusi dinyatakan sebagai daftar Frame — nama method dan jumlah satuan kerjanya. Versi A memuat hitungPajak empat kali (7 satuan tiap panggilan); versi B cukup sekali ditambah tiga hitungan cache 1 satuan.
  2. Baris 62-74 (profilkan): penghitung tick berjalan per satuan kerja; setiap kelipatan INTERVAL, satu sampel dicatat ke method pemilik satuan tersebut lewat merge. Inilah loop sampling profiler versi mini — hanya penghitung, tanpa jam.
  3. 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.
  4. Baris 55-59 (baris akhir): versi B memangkas pekerjaan 37% dan menurunkan porsi sampel hitungPajak dari 63,6% ke 28,6% — dua angka berbeda karena sampel hanyalah taksiran statistik dari satuan kerja yang sesungguhnya.

Membaca Hasil dengan Kepala Dingin #

AspekImplikasi pada hasil
Interval 10 ms, durasi 60 detikmaksimal ±6.000 sampel; porsi di bawah ~0,5% tidak terbedakan dari derau
Safepoint biassampel hanya bisa diambil saat thread berada di safepoint; loop panjang tanpa safepoint bisa kurang terwakili
Thread tidur / menunggu I/Otidak 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ProfilerSampling.java

Unduh berkas
java
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());
        }
    }
}