Skip to content

บทที่ 19 — Performance + Profiling (JFR, async-profiler, JMH, Flame Graph)

← บทที่ 18: Reflection | สารบัญ

📓 โซนขั้นสูง—ข้ามได้ (บท 11-19) บทนี้เป็นบทปิดท้ายโซนอ้างอิง สอนการวัดและจูนประสิทธิภาพ (performance) ระดับมืออาชีพ มือใหม่ข้ามไปก่อนได้ ค่อยกลับมาตอน app ทำงานช้าจริงในงานแล้วต้องหาสาเหตุ ตอนหัดเขียนโปรแกรมยังไม่ต้องใช้เครื่องมือพวกนี้

ศัพท์ย่อที่จะเจอ: profiling (โปรไฟลิง = การวัดว่าโปรแกรมใช้เวลา/หน่วยความจำไปกับส่วนไหน), JFR (Java Flight Recorder = กล่องดำในตัว JVM ที่บันทึกการทำงานไว้วิเคราะห์), async-profiler (เครื่องมือ profiling ภายนอกที่นิยม), flame graph (เฟลมกราฟ = กราฟรูปเปลวไฟแสดงว่าฟังก์ชันไหนกินเวลามากสุด), JMH (Java Microbenchmark Harness = เครื่องมือวัดความเร็วโค้ดชิ้นเล็กอย่างแม่นยำ), heap dump (ภาพสแน็ปช็อตของหน่วยความจำ heap ไว้หา memory leak), jcmd/jstack/jstat (คำสั่งบรรทัดคำสั่งสำหรับส่อง JVM ที่กำลังรันอยู่), GC (Garbage Collector = ตัวเก็บกวาดหน่วยความจำ), latency/throughput (เวลาต่อหนึ่งงาน / จำนวนงานต่อวินาที)

บทสุดท้ายของหมวด Java — สอน "การวัดและแก้ performance" แบบมืออาชีพ

เมื่อ app ช้า — อย่าเดา:

  • "น่าจะเป็นเพราะ DB"
  • "น่าจะ N+1"
  • "ลองเพิ่ม cache ดูสิ"

→ ถ้าไม่วัด → แก้ผิดที่ + เสียเวลา + เพิ่ม complexity เปล่า ๆ

บทนี้สอนเครื่องมือที่ วัดให้รู้จริง:

  • JFR (Java Flight Recorder) — profiler ในตัว JVM
  • async-profiler — flame graph ที่ใช้กันในวงการ
  • JMH (Java Microbenchmark Harness) — benchmark ที่ถูกต้อง
  • JMC, jstack, jcmd — ของพื้นฐาน
  • Heap dump analysis — Eclipse MAT, VisualVM

ใช้เวลา 3-4 ชั่วโมง


Part 1: Performance Mindset

1.1 กฎ 4 ข้อ

  1. อย่า optimize อะไรที่ยังไม่ได้วัด — quote ดังของ Donald Knuth นักวิทยาการคอมพิวเตอร์ตำนาน ผู้เขียนหนังสือ The Art of Computer Programming: "การ optimize ก่อนเวลาอันควรคือต้นตอของความเลวร้ายทั้งปวง" (ต้นฉบับภาษาอังกฤษ: "Premature optimization is the root of all evil")
  2. 80/20 rule — 80% ของเวลาที่ช้า มาจาก 20% ของ code
  3. วัด end-to-end ก่อน micro — ถ้า user รอ 5 sec แต่คุณ optimize function จาก 1ms → 0.1ms = สูญเปล่า
  4. เทียบของชนิดเดียวกัน (อย่าเอาคนละเงื่อนไขมาเทียบ — เช่น JVM ต่างรุ่น, data ต่างชุด, เครื่องต่างสเปก) — benchmark ต้อง same JVM, same warm-up, same data (ภาษาอังกฤษเรียกสำนวนนี้ว่า "apples to apples" หรือภาษาไทยพูดว่า "เอาของชนิดเดียวกันมาเทียบกัน ไม่ใช่เอาส้มไปเทียบกับมะนาว")

1.2 Performance Vocabulary

คำความหมาย
Latency (เวลาต่องาน)เวลาที่ 1 operation ใช้ (ms)
Throughput (อัตรารับงาน)จำนวน operation ต่อหน่วยเวลา (req/s)
P50 / P95 / P99 (เปอร์เซ็นไทล์)percentile ของ latency (P99 = 99% ของ request เร็วกว่าค่านี้ หรือพูดได้ว่า 1% ที่ช้าที่สุด)
Tail latency (หางช้า)ของช้าสุด — มักสำคัญกว่า median
CPU-bound (ติด CPU)คอมพิวต์เยอะ (math, parsing)
I/O-bound (ติด I/O)รอ disk/network
Allocation rate (อัตราการจอง memory)bytes/sec ที่จองใน heap
GC overhead (ภาระจาก GC)% ของเวลาที่ JVM ใช้ทำ GC
Hot method (เมท็อดร้อน)method ที่ใช้เวลาเยอะที่สุด

1.3 Performance Pyramid — ลำดับที่ควรเช็ค

แก้จากบนลงล่าง — micro-optimization (เช่น + เป็น StringBuilder) ส่วนใหญ่ไม่มีผล


Part 2: เครื่องมือพื้นฐานที่มากับ JDK

📝 หมายเหตุผู้ใช้ Windows: คำสั่ง jcmd, jstack, jstat ใช้ได้บน Windows ตามปกติ แต่ path ให้ใช้แบบ Windows (เช่น C:\temp\heap.hprof แทน /tmp/heap.hprof)

ส่วนคำสั่งต่อไปนี้ ใช้ไม่ได้บน Windows โดยตรง:

  • top -H และ tar — คำสั่งของ Linux
  • ./profiler.sh และ async-profiler ทั้งตัว — ไม่รองรับ Windows

ทางแก้มี 3 ทาง เลือกอย่างใดอย่างหนึ่ง:

  • ใช้ JFR + JMC แทน (ครอบคลุมเนื้อหาส่วนใหญ่ในบทนี้ได้)
  • รันใน WSL (Windows Subsystem for Linux = ตัวรัน Linux ภายใน Windows โดยไม่ต้องมีเครื่องแยก ดาวน์โหลดจาก Microsoft Store)
  • รันใน Docker (โปรแกรมรัน container Linux บน Windows ดาวน์โหลดที่ docker.com)

ถ้าไม่คุ้นทั้ง WSL และ Docker ข้าม Part 4 ไปก่อนแล้วใช้แค่ JFR+JMC ในส่วนอื่นได้เลย

📝 หาเลข PID ก่อนใช้คำสั่งใน Part 2: คำสั่งส่วนใหญ่ในส่วนนี้ต้องใช้ Process ID (PID — หมายเลขประจำโปรเซส) ของ Java app ที่กำลังรันอยู่ วิธีหา PID: รัน jcmd โดยไม่ต้องใส่อะไรต่อ — มันจะแสดงรายการโปรเซส Java ทั้งหมดพร้อมหมายเลข PID เช่น 12345 com.example.Main แล้วนำหมายเลข (เช่น 12345) ไปแทน <pid> ในคำสั่งต่าง ๆ บน Windows เปิด Task Manager → Details tab แล้วหา java.exe ก็ได้เช่นกัน

2.1 jcmd — Swiss Army Knife (เครื่องมือสารพัดประโยชน์ในตัวเดียว)

jcmd เป็นเครื่องมือ "มีดพับสวิส" (มีดเอนกประสงค์ในเล่มเดียว — รวมกรรไกร, ตะไบ, ไขควง, ที่เปิดขวด ฯลฯ ในด้ามเดียว) ที่มากับ JDK — สั่งงาน JVM ที่รันอยู่ได้สารพัด: ดู/dump heap, thread dump, สั่ง JFR, ดู native memory และ flag ต่าง ๆ ผ่าน pid เดียว เป็นตัวแรกที่ควรหยิบมาใช้ตอน debug production:

bash
jcmd                                  # แสดงรายการโปรเซส Java ที่รันอยู่
jcmd <pid> help                       # แสดงรายการคำสั่งที่ใช้ได้

# heap (หน่วยความจำ heap)
jcmd <pid> GC.heap_info
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jcmd <pid> GC.run                     # บังคับให้ GC ทำงาน (อย่าใช้ใน production!)

# thread (เธรด)
jcmd <pid> Thread.print               # dump สถานะ thread ทั้งหมด

# JFR (บันทึกการทำงานของ JVM)
jcmd <pid> JFR.start name=myrec duration=60s filename=/tmp/r.jfr
jcmd <pid> JFR.dump name=myrec
jcmd <pid> JFR.stop name=myrec

# native memory (หน่วยความจำนอก heap)
jcmd <pid> VM.native_memory summary

# system properties + flags (ค่าตั้งและ flag ของ JVM)
jcmd <pid> VM.system_properties
jcmd <pid> VM.flags

2.2 jstack — Thread Dump

bash
jstack <pid> > threads.txt

ดู:

  • thread ทุกตัวกำลังทำอะไร
  • มี deadlock ไหม?
  • มี thread block ที่อะไร?

ตัวอย่างเจอ deadlock:

text
Found one Java-level deadlock:       ← พบ deadlock 1 จุด
=============================
"thread-1":
  waiting to lock monitor 0x...      ← รออยู่เพื่อล็อก monitor (object lock)
  (object 0x..., a Object),
  which is held by "thread-2"        ← ซึ่งถูกล็อกโดย thread-2 อยู่แล้ว
"thread-2":
  waiting to lock monitor 0x...      ← thread-2 ก็รอล็อก object อีกตัว
  (object 0x..., a Object),
  which is held by "thread-1"        ← ซึ่งถูกล็อกโดย thread-1 → วนไม่หลุด

2.3 jstat — GC Stats

bash
jstat -gc <pid> 1000          # ทุก 1 วินาที
jstat -gcutil <pid> 1000      # ใช้เปอร์เซ็นต์

Output ตัวอย่าง:

text
S0     S1     E      O      M     CCS    YGC   YGCT    FGC  FGCT
0.00   78.0   45.2   33.1   97.0  93.0   42    0.812   0    0.000

ความหมาย column: S0/S1 = Survivor 0/1 (พื้นที่รับ object ที่รอด Young GC), E = Eden (พื้นที่จอง object ใหม่), O = Old (พื้นที่ object อายุยาว), M = Metaspace (ข้อมูล class), CCS = Compressed Class Space (ส่วนหนึ่งของ Metaspace), YGC = จำนวน Young GC, YGCT = เวลา Young GC รวม, FGC = จำนวน Full GC, FGCT = เวลา Full GC รวม

แปล:

  • E (Eden) 45% — กำลัง alloc
  • O (Old) 33% — เพิ่มขึ้นเรื่อย ๆ = ต้องจับตา
  • YGC 42 ครั้ง, total 0.812s — สบาย
  • FGC 0 — ไม่มี Full GC = ดี

2.4 jmap (บาง option ย้ายไปอยู่ใต้ jcmd แล้ว)

jmap -histo แสดง "histogram" ของ object ใน heap — class ไหนมีกี่ instance กินกี่ไบต์ ช่วยหา memory leak ได้เร็ว (jmap ตัว binary ยังไม่ deprecated อย่างเป็นทางการ แต่ option ส่วนใหญ่ปัจจุบันแนะนำใช้ผ่าน jcmd แทน — ดูเพิ่มเติมที่บทที่ 16 Part 9.3):

bash
jmap -histo <pid> | head -20      # top 20 class ที่กิน memory

# ตัวอย่าง output
 num     #instances         #bytes  class name
   1:        450891       21642768  byte[]
   2:        450012       10800288  java.util.HashMap$Node
   3:        180400        8659200  java.lang.String

2.5 JConsole / VisualVM (GUI)

ถ้าชอบดูแบบกราฟ JDK มีเครื่องมือ GUI — jconsole (มากับ JDK) และ VisualVM (โหลดแยก) แสดง heap, thread, CPU แบบ real-time เหมาะกับการสำรวจเบื้องต้นหรือ demo:

bash
jconsole         # GUI ที่มากับ JDK
jvisualvm        # ต้องดาวน์โหลดแยกจาก https://visualvm.github.io (ถูกแยกออกจาก JDK ตั้งแต่ Java 9)
                 # และต้อง add ไปยัง PATH เองก่อนจึงจะใช้คำสั่งนี้ได้

ดูแบบกราฟ real-time:

  • Heap usage
  • Thread count
  • CPU
  • Class loaded
  • GC

ใช้สำหรับ dev/staging — ใน production (สภาพแวดล้อมที่ใช้งานจริง) ใช้ Micrometer (library เก็บ metric ของ Java app) + Grafana (dashboard แสดงกราฟ)


Part 3: JFR — Java Flight Recorder

3.1 ทำไม JFR

  • Built-in และ free ตั้งแต่ Java 11 — JFR มีใน Oracle JDK มาตั้งแต่ Java 7 แต่ต้องใช้ commercial license จนถึง Java 8 (เฉพาะ Oracle JDK เท่านั้น — OpenJDK 8 ไม่มี JFR) ตั้งแต่ Java 11 เปิดเป็น open-source ฟรีทั้ง Oracle JDK และ OpenJDK-based distributions
  • Overhead ต่ำมาก (< 1% ด้วย default settings — ใช้ใน production ได้ ส่วน profile settings ที่เก็บข้อมูลละเอียดกว่าอาจดันขึ้นไป 3-5% ขึ้นกับ workload วัดบนเครื่องตัวเองก่อนใช้กับ production)
  • บันทึก: GC event, allocation, lock contention, I/O, thread, exception
  • View ด้วย JDK Mission Control (JMC) GUI

3.2 Record

Continuous (always-on, ระดับต่ำ)

bash
java -XX:StartFlightRecording=filename=/var/log/jfr.jfr,maxsize=200m,maxage=24h \
     -jar app.jar

→ บันทึกตลอดเวลา, rotate, ใช้ได้ตอน incident ย้อนหลัง

On-demand (สอบสวนเฉพาะกิจ)

bash
jcmd <pid> JFR.start name=rec1 duration=60s filename=/tmp/rec.jfr settings=profile

settings:

  • default — overhead ต่ำสุด (production)
  • profile — เก็บข้อมูลเยอะกว่า (debugging)

3.3 View ด้วย JMC

Download JMC: https://www.oracle.com/java/technologies/jdk-mission-control.html

เปิด .jfr → เห็น tab:

  • Java Application — overview, top method (hot)
  • JVM InternalsGC, JIT, ClassLoader
  • Environment — system info
  • Event Browser — raw events

Tab สำคัญที่ต้องดู

  1. Method Profiling — top method ที่ใช้ CPU
  2. GC — pause time, frequency, generation size
  3. Memory Allocation — class ไหน alloc เยอะ
  4. Lock Instances — contention อยู่ที่ไหน
  5. Socket I/O / File I/O — slow I/O
  6. Exception — exception ที่เกิดบ่อย (เผลอ throw ใน loop?)

3.4 อ่าน JFR programmatically

ไฟล์ JFR (Java Flight Recorder) ไม่ได้ดูแค่ใน GUI — เราอ่านด้วยโค้ดได้ผ่าน RecordingFile วน event ทีละตัว เพื่อทำ custom analysis หรือ alert อัตโนมัติ (เช่นแจ้งเมื่อ GC pause นานผิดปกติ):

java
// หมายเหตุ: "rec.jfr" เป็น path แบบ relative — resolve จาก working directory ของ JVM ตอนรัน
// ในทางปฏิบัติแนะนำใช้ absolute path เช่น:
//   Path.of("/tmp/rec.jfr")           (Linux/macOS)
//   Path.of("C:/temp/rec.jfr")        (Windows)
//   Path.of(System.getProperty("user.home"), "rec.jfr")   (หา home dir อัตโนมัติ)
try (RecordingFile file = new RecordingFile(Path.of("rec.jfr"))) {
    while (file.hasMoreEvents()) {
        RecordedEvent event = file.readEvent();
        if (event.getEventType().getName().equals("jdk.GarbageCollection")) {
            System.out.println(event.getStartTime() + " : " + event.getDuration());
        }
    }
}

ใช้ทำ custom analysis / alert

3.5 Custom JFR event

นอกจาก event มาตรฐาน เราสร้าง JFR event ของตัวเองได้ (extends Event) เพื่อบันทึกเหตุการณ์ทางธุรกิจ (เช่น "order processed") ลงในไฟล์เดียวกับ profiling data — ดูร่วมกันใน JMC ได้:

java
@Name("com.example.OrderProcessed")
@Label("Order Processed")
@Category("Business")
public class OrderEvent extends Event {
    @Label("Order ID")
    long orderId;
    @Label("Amount")
    double amount;
}

// ใน code:
OrderEvent e = new OrderEvent();
e.begin();
e.orderId = 123;
e.amount = 99.50;
processOrder();
e.commit();

→ event ของคุณอยู่ใน .jfr → JMC แสดงให้ดู


Part 4: async-profiler — Flame Graph

4.1 ทำไมต้องมี async-profiler

JFR รุ่นเก่า (ก่อน JDK 16) ใช้ "safepoint sampling" — safepoint คือจุดหยุดปลอดภัยที่ JVM หยุด thread ทั้งหมดได้ (เช่น ตอน GC) จึงมองไม่เห็นโค้ดที่รันระหว่าง safepoint ทำให้ผลเอียง (bias = ไม่ครบถ้วน) ตั้งแต่ JDK 16-17+ JFR method profiling ปรับมาใช้ async/timer-based sampling แล้ว จึงไม่ได้ถูก safepoint จำกัดเหมือนเดิม

📝 ส่วนนี้เป็น advanced สำหรับคนที่ต้องการเข้าใจว่า profiler ทำงานอย่างไรเบื้องหลัง — ถ้าต้องการแค่ใช้ async-profiler ข้ามไปดูคำสั่งใน section ถัดไปได้เลย

async-profiler ยังคงได้เปรียบในบางกรณี เพราะใช้ AsyncGetCallTrace (API ของ JVM สำหรับดู call stack) ร่วมกับ perf ของ Linux — perf คือเครื่องมือที่อ่านค่าจาก PMU (Performance Monitoring Unit = ตัวนับระดับ hardware ใน CPU) ได้โดยตรง จึงทำให้ async-profiler เห็น native frame, kernel frame และมี overhead ต่ำในบาง workload

async-profiler รองรับ Linux (ผ่าน perf/AsyncGetCallTrace) และ macOS — Windows รองรับบางส่วน ดู documentation ที่ GitHub สำหรับ platform-specific setup

4.2 Install

เริ่มใช้ async-profiler ด้วยการดาวน์โหลดและแตกไฟล์ — เป็น tool แยกต่างหาก (ไม่มากับ JDK) ที่ใช้ผ่าน script profiler.sh:

Download: https://github.com/async-profiler/async-profiler

bash
tar xf async-profiler-*.tar.gz
cd async-profiler-*

4.3 Profile CPU (60s) → flame graph

bash
./profiler.sh -d 60 -f /tmp/profile.html <pid>

-f *.html → ได้ไฟล์ HTML flame graph เปิดใน browser (ไม่ใช่ SVG)

📝 async-profiler รุ่น 3.x ขึ้นไปใช้คำสั่ง asprof แทน profiler.sh (option เหมือนกัน) — ถ้าโหลดรุ่นใหม่แล้วหา profiler.sh ไม่เจอ ให้ใช้ asprof แทน

ตัวอย่าง flame graph:

text
[main]
 └── [run()]
      ├── [processOrder()]              ← width = CPU time
      │    ├── [validateOrder()]
      │    └── [saveToDb()]
      │         └── [executeQuery()]    ← ลึกสุด = stack ลึก
      └── [logResult()]

อ่าน:

  • กว้าง = ใช้ CPU เยอะ
  • สูง = stack ลึก
  • หา plateau (แท่งกว้าง ๆ ที่ด้านบนสุด) = hotspot

4.4 Profile allocation

นอกจาก CPU async-profiler ยัง profile การจอง memory (allocation) ได้ — เปลี่ยน event เป็น -e alloc เพื่อหา method ที่จอง object เยอะ ซึ่งเป็นต้นเหตุที่ GC ทำงานหนัก:

bash
./profiler.sh -e alloc -d 60 -f /tmp/alloc.html <pid>

เห็น method ไหน allocate เยอะ → กิน GC

4.5 Profile lock

ในแอป multi-thread บางทีช้าเพราะ thread แย่งกัน lock (contention) — profile ด้วย -e lock เพื่อหาว่า lock ตัวไหนเป็นคอขวด:

bash
./profiler.sh -e lock -d 60 -f /tmp/lock.html <pid>

เห็น lock ที่ contention สูง

4.6 Profile cache miss / branch miss (CPU level)

สำหรับการจูนระดับลึกสุด async-profiler เข้าถึงตัวนับของ CPU ได้ (ผ่าน perf) — profile cache miss / branch miss เพื่อหาโค้ดที่ไม่เป็นมิตรกับ CPU cache (ต้องมีสิทธิ์ root):

bash
./profiler.sh -e cache-misses -d 60 -f /tmp/cache.html <pid>
./profiler.sh -e branch-misses -d 60 -f /tmp/branch.html <pid>

(ต้อง root + perf_event_paranoid ตั้ง)

4.7 Continuous mode + JFR output

async-profiler รันยาว ๆ เก็บหลาย event พร้อมกัน (cpu + alloc) แล้ว output เป็นไฟล์ JFR เพื่อเปิดใน JMC ได้ — เหมาะกับการเก็บข้อมูลต่อเนื่องใน production:

bash
./profiler.sh -d 600 -e cpu,alloc -o jfr -f /tmp/all.jfr <pid>

→ output เป็น .jfr → เปิด JMC


Part 5: JMH — Microbenchmark ที่ถูกต้อง

5.1 ทำไมไม่ใช้ System.nanoTime() วัด

java
// ❌ ผิด!
long t0 = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    expensive();
}
long t1 = System.nanoTime();
System.out.println("avg: " + (t1 - t0) / 1_000_000 + " ns");

ปัญหา:

  • JIT ไม่ทันอุ่นเครื่อง — รันครั้งแรก JVM ยังไม่ได้ compile โค้ดไปถึงระดับ C2 (ตัว compiler ระดับสูงสุดของ JVM HotSpot) ทำให้โค้ดรันช้ากว่าที่ควรจะเป็น ผลวัดจึงสูงเกินจริง
  • Dead code elimination (การกำจัดโค้ดที่ผลลัพธ์ไม่ถูกใช้) — ถ้า expensive() คืนค่าที่ไม่มีใครใช้ต่อ JIT จะตัดโค้ดนั้นทิ้ง ทำให้วัดเวลาได้ 0 ทั้งที่จริง ๆ โค้ดหายไปแล้ว!
  • Loop unrolling (JIT คลาย loop ออกเป็นคำสั่งตรง ๆ เพื่อประสิทธิภาพ) — ทำให้ผลการวัด loop ไม่ตรงกับการรันจริงในโปรแกรม
  • No warmup — ตอนเริ่มรัน JVM ยังทำ GC และ ClassLoading อยู่ ทำให้ผลช่วงแรกเบี้ยว
  • No isolation — process อื่นในเครื่องก็กระทบผลวัดได้ เช่น background update, antivirus scan

5.2 JMH แก้ทุกข้อข้างบน

การวัดเวลาเองด้วย System.nanoTime() ให้ผลผิดเพราะ JIT warm-up, dead-code elimination ฯลฯ — JMH (Java Microbenchmark Harness) คือเครื่องมือทางการที่จัดการปัญหาเหล่านี้ให้ครบ เพิ่ม dependency แล้วเขียน benchmark ด้วย annotation:

📝 ทำไมถึงต้อง maven-shade-plugin: JMH ต้องถูกแพ็คเกจรวมกับโค้ด benchmark เป็น JAR ไฟล์เดียวที่รันได้ด้วยตัวเอง (เรียกว่า "fat jar" หรือ "uber jar") maven-shade-plugin ทำหน้าที่นี้โดยอัตโนมัติเมื่อรัน mvn packageไม่ต้องเข้าใจรายละเอียด XML ทุกบรรทัด แค่ copy configuration นี้ไว้ใน pom.xml ครั้งเดียวแล้วใช้ได้เลย

pom.xml:

xml
<!-- ตรวจ version ล่าสุดที่ https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-core -->
<!-- หมายเหตุ: JMH 1.37 เขียนไว้ณ เวลาที่จัดทำเอกสาร — สำหรับ Java 23+ ให้ตรวจสอบว่า version -->
<!-- ที่ใช้รองรับ JDK รุ่นนั้นด้วย เพราะการเปลี่ยน module system อาจกระทบบาง version -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
</dependency>

<!-- จำเป็น! maven-shade-plugin สร้าง fat jar (benchmarks.jar) สำหรับ java -jar -->
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.5.1</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals><goal>shade</goal></goals>
                    <configuration>
                        <finalName>benchmarks</finalName>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>org.openjdk.jmh.Main</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

📝 ทางเลือก: ใช้ JMH Maven archetype เพื่อสร้าง project พร้อม config ทุกอย่างในครั้งเดียว:

bash
mvn archetype:generate \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DarchetypeVersion=1.37

5.3 Benchmark แรก

java
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class StringBench {

    String a = "hello";
    String b = "world";

    @Benchmark
    public String concatPlus() {
        return a + b;
    }

    @Benchmark
    public String concatBuilder() {
        return new StringBuilder(a).append(b).toString();
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

📝 main() ในคลาสนี้ไม่จำเป็นต้องมี — ใส่ไว้เผื่อสะดวกตอนรันจาก IDE โดยตรง (คลิกขวา → Run) ส่วนคำสั่ง java -jar target/benchmarks.jar ด้านล่างจะรันผ่าน manifest main-class ที่ maven-shade-plugin ตั้งไว้ (org.openjdk.jmh.Main) แทน — ทั้งสองทางเรียก JMH ตัวเดียวกัน ไม่ขัดแย้งกัน

Run:

bash
mvn clean install
java -jar target/benchmarks.jar

Output:

text
Benchmark                       Mode  Cnt   Score   Error  Units
StringBench.concatBuilder       avgt   20  18.45 ± 0.41  ns/op
StringBench.concatPlus          avgt   20  17.92 ± 0.38  ns/op

→ จะเห็นว่า "concat กับ +" ไม่ต่างกัน เพราะตั้งแต่ Java 9 เป็นต้นมา compiler (javac) แปลง + เป็นกลไก string concat ที่ optimize แล้วตั้งแต่ตอน compile (ผ่าน StringConcatFactory — กลไกภายในที่ไม่ต้องเข้าใจรายละเอียด รู้แค่ว่า + ไม่ได้ช้ากว่า StringBuilder อีกต่อไปในกรณีง่าย ๆ แบบนี้) ไม่ใช่ JIT ตอน runtime

⚠️ แต่ข้อยกเว้นสำคัญ: ผลนี้ใช้ได้กับ concat แบบครั้งเดียว (compile ครั้งเดียว) เท่านั้น — ถ้า concat ด้วย + ใน loop แต่ละรอบยัง call กลไก StringConcatFactory ใหม่ทุกครั้งอยู่ดี ยังต้องใช้ StringBuilder ตัวเดียวแล้ว .append() ซ้ำ ๆ (ดูตัวอย่างใน Part 6.1) — อย่าสรุปว่า + ใน loop โอเคเสมอ

5.4 Mode ที่สำคัญ

JMH วัดได้หลายแบบตามสิ่งที่สนใจ — throughput (จำนวนครั้งต่อวินาที), average time (เวลาเฉลี่ยต่อครั้ง), หรือ distribution/percentile เลือก mode ให้ตรงกับคำถาม:

  • Mode.Throughput — ops/sec
  • Mode.AverageTime — ns/op
  • Mode.SampleTime — distribution + percentile
  • Mode.SingleShotTime — รันครั้งเดียว (เผื่อวัด cold start)

5.5 Blackhole — กัน dead-code elimination

กับดักของ benchmark: ถ้าผลลัพธ์ไม่ถูกใช้ JIT จะ "ตัดทิ้ง" โค้ดนั้น (dead-code elimination) ทำให้วัดได้ 0 — Blackhole.consume() หลอก JIT ว่าค่าถูกใช้แล้ว เพื่อให้โค้ดที่ต้องการวัดทำงานจริง:

java
@Benchmark
public void doWork(Blackhole bh) {
    int result = expensive();
    bh.consume(result);          // หลอก JIT ว่า result ถูกนำไปใช้จริง
                                 // ป้องกัน dead-code elimination (การกำจัดโค้ดที่ผลลัพธ์ไม่ถูกใช้)
}

5.6 Profile ใน JMH

bash
java -jar benchmarks.jar -prof gc           # GC profiler
java -jar benchmarks.jar -prof async:libPath=/path/to/libasyncProfiler.so  # async-profiler (ต้องโหลด async-profiler มาก่อน — ดู Part 4.2)
java -jar benchmarks.jar -prof perf         # Linux perf
java -jar benchmarks.jar -prof jfr          # JFR

→ ได้ result + GC stat + flame graph ทันทีจาก JMH


Part 6: ปัญหายอดฮิต + วิธีแก้

6.1 Allocation Rate สูง → GC เยอะ

อาการ:

  • jstat YGC เยอะ + รายงาน "GC took 20% of CPU"
  • Allocation rate > 1 GB/sec ใน profiler

สาเหตุ:

  • Auto-boxing ใน loop (Integer แทน int)
  • String concat ใน loop
  • Stream / lambda allocate excessive
  • ใช้ collection ผิด (ArrayList vs primitive array)

แก้:

java
// ❌ allocate ทุก iteration
for (int i = 0; i < 1_000_000; i++) {
    String s = "value " + i;       // alloc String
    map.put(i, s);                  // auto-box int → Integer
}

// ✅ ใช้ primitive map (eclipse-collections / fastutil)
// หมายเหตุ: Int2ObjectMap / Int2ObjectOpenHashMap มาจาก library fastutil (it.unimi.dsi:fastutil)
// ต้องเพิ่ม dependency ใน pom.xml ก่อน — ไม่มีใน JDK standard:
//
// <dependency>
//     <groupId>it.unimi.dsi</groupId>
//     <artifactId>fastutil</artifactId>
//     <version>8.5.13</version>  <!-- ตรวจ version ล่าสุดที่ mvnrepository.com -->
// </dependency>
Int2ObjectMap<String> map = new Int2ObjectOpenHashMap<>();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1_000_000; i++) {
    sb.setLength(0);
    sb.append("value ").append(i);
    map.put(i, sb.toString());
}

6.2 Lock Contention

อาการ: thread block เยอะ, throughput ไม่ขึ้นกับ thread count

ดู: async-profiler -e lock หรือ JMC "Lock Instances"

แก้:

  • ใช้ ConcurrentHashMap แทน synchronized HashMap
  • ใช้ AtomicInteger / AtomicReference แทน synchronized
  • ใช้ ReentrantReadWriteLock หรือ StampedLock (lock ประสิทธิภาพสูงมี optimistic read — อ่านโดยไม่ lock จริงแล้วตรวจทีหลัง เร็วมากสำหรับ read-heavy) สำหรับ read-heavy
  • ลด lock granularity (lock แต่ละ key แยก)

6.3 N+1 Query

อาการ: app เร็วตอน 1 user, ช้าตอน production. DB CPU สูง

ดู: ดู SQL log + count query per request

แก้ (ใน Hibernate):

java
// ❌ N+1
List<User> users = userRepo.findAll();
for (User u : users) {
    u.getOrders().size();          // query 1 ครั้งต่อ user!
}

// ✅ fetch join
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();

6.4 Slow JIT Warm-up

อาการ: first request ช้า 1-2 sec, ที่เหลือเร็ว

แก้:

  • CDS / AppCDS — share class data ระหว่าง JVM
  • JIT directive-XX:CompileCommand (สั่ง directive ให้ JIT เช่น บังคับ/ห้าม compile method ที่ระบุ)
  • Tiered compilation (default แล้ว — Java 8+)
  • Native Image (GraalVM) — start ~50ms (ดูบท Spring Boot 15)

6.5 CPU 100% Stuck

ขั้นตอน debug:

  1. jstack <pid> → thread dump
  2. top -H -p <pid> → หา TID ที่ใช้ CPU สูง (คำสั่งนี้ใช้บน Linux เท่านั้น — บน Windows ใช้ Process Explorer หรือ JMC แทน)
  3. แปลง TID (decimal = เลขฐาน 10) → hex (เลขฐาน 16) เช่น TID 12345 → 0x3039 — PowerShell: [Convert]::ToString(12345,16), bash: printf "%x\n" 12345, หรือใช้ python -c "print(hex(12345))" ถ้ามี Python ก็ได้
  4. หาใน thread dump ที่ nid=0x<hex> → เห็น stack trace
  5. → รู้ว่า method ไหนค้าง

หรือใช้ async-profiler 30s → flame graph

6.6 Memory Leak

memory leak เป็นปัญหา performance ที่ค่อย ๆ กิน heap จนแอปช้า/ล่ม — วิธีหาคือ heap dump + วิเคราะห์ด้วย Eclipse MAT (รายละเอียดเต็มอยู่บทที่ 16 Part 9):

ดูบท 16 Part 9 — heap dump + Eclipse MAT


Part 7: Tuning Strategy

7.1 SLO-based tuning

ตั้งเป้าก่อน:

  • P99 latency < 200ms
  • Throughput > 1000 req/s
  • Error rate < 0.1%

ถ้าตอนนี้:

  • P99 = 800ms → ต้อง optimize
  • P99 = 100ms → อย่าแตะ (waste of time)

"Performance is a feature with no value above the SLO" (ประสิทธิภาพที่เกิน SLO ที่ตั้งไว้ไม่มีคุณค่าเพิ่ม — optimize เกินเป้าคือเสียเวลาเปล่า)

7.2 Process

text
1. Baseline       → วัด current performance ด้วย load test (k6, Gatling, JMeter)
2. Identify       → profile หา bottleneck (JFR + async-profiler)
3. Hypothesize    → "ปัญหาน่าจะมาจาก X"
4. Test           → เปลี่ยน → re-measure
5. Verify         → ถ้าดีขึ้น keep, ไม่ → revert
6. Iterate

อย่าเปลี่ยนหลายอย่างพร้อมกัน — รู้ไม่ได้ว่าอันไหนช่วย

7.3 Production Telemetry

การจูน performance ที่ดีต้องมีข้อมูลจาก production จริง — ติดตั้งระบบ telemetry (Micrometer→Prometheus→Grafana สำหรับ metric, OpenTelemetry สำหรับ tracing) เพื่อเห็น GC pause, latency, DB pool ตลอดเวลา:

ติดตั้ง:

  • Micrometer → Prometheus → Grafana
  • OpenTelemetry → Jaeger/Tempo (tracing = การติดตาม request ว่าผ่านไปแต่ละ service อย่างไร เหมาะกับระบบ microservices — Jaeger คือเครื่องมือ tracing open-source จาก Uber เหมาะกับ self-host; Tempo คือเครื่องมือ tracing ของ Grafana ที่เก็บข้อมูลประหยัดกว่าตอน scale ใหญ่; ทั้งคู่ทำงานร่วมกับ OpenTelemetry ได้เหมือนกัน เลือกตัวใดก็ได้)
  • JFR continuous → ส่งไป S3/storage

ดู:

  • JVM metric: jvm.gc.pause, jvm.memory.used, jvm.threads.live
  • App metric: HTTP latency histogram, DB pool active/idle
  • DB metric: query latency, connection wait

Part 8: Garbage Collection Tuning (ลึกกว่าบท 16)

8.1 G1 Tuning

bash
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200          # target pause
-XX:G1HeapRegionSize=16m          # ขนาด region
-XX:InitiatingHeapOccupancyPercent=45  # เริ่ม mixed GC เมื่อใช้ 45%
-XX:G1ReservePercent=15           # เผื่อ to-space ใน young

เครื่องมือดูว่า G1 ทำอะไร

bash
java -Xlog:gc*=debug:file=gc.log:time,uptime,level,tags ...

อ่าน:

  • Pause Young (Normal) — minor GC ปกติ
  • Pause Young (Mixed) — collect young + บาง old region
  • Concurrent Cycle — mark phase ใน background
  • Pause Remark / Cleanup — สิ้นสุด concurrent

ถ้า Mixed GC > 300ms บ่อย → IHOP (InitiatingHeapOccupancyPercent — flag ด้านบน) ต่ำเกิน หรือ heap เล็กเกิน

8.2 ZGC Tuning

ZGC ออกแบบมาให้ pause ต่ำมาก (< 1ms) และจูนน้อย — ส่วนใหญ่ทำงาน self-adaptive ตั้งแค่ heap size และ SoftMaxHeapSize (เป้าหมายที่ลดได้ตอน idle) ก็พอ:

bash
-XX:+UseZGC -XX:+ZGenerational     # Java 21+ (Generational ZGC)
-Xmx8g                              # heap (จริง ๆ ZGC จัดการ self-adaptive)
-XX:SoftMaxHeapSize=6g             # target heap (ลดได้ตอน idle)

📝 Generational ZGC timeline:

  • Java 21: Generational ZGC เป็น experimental ต้องเปิด flag -XX:+ZGenerational เอง
  • Java 23+: กลายเป็น default แทน non-generational ZGC — ไม่ต้องใส่ flag -XX:+ZGenerational ก็ได้
  • Java 25: non-generational ZGC ถูกนำออกทั้งหมด (JEP 490/491) — ไม่มีให้ใช้แล้ว flag -XX:+ZGenerational เป็น no-op (ไม่มีผล)

สรุปสำหรับ Java 25: ใช้แค่ -XX:+UseZGC โดยไม่ต้องใส่ -XX:+ZGenerational เพราะมี Generational ZGC เป็นตัวเดียวอยู่แล้ว

ZGC tuning น้อย — ส่วนใหญ่ทำงานเอง

8.3 ดู GC overhead

"GC overhead" คือสัดส่วนเวลาที่ใช้ทำ GC เทียบกับเวลาทั้งหมด — เป็นตัวชี้วัดว่า GC เป็นปัญหาไหม ค่าควร < 5% ถ้าเกิน 10% แสดงว่า heap เล็กเกินหรือ allocate มากเกิน ต้องแก้:

text
GC overhead = total GC time / total wall clock time

ค่าควรอยู่ < 5%. ถ้า > 10% = ปัญหา

จาก gc.log:

bash
grep "Pause" gc.log | awk '{sum += $NF} END {print sum}'   # ดูยอดรวม

Part 9: Native Memory Tracking (NMT)

9.1 เปิด

bash
java -XX:NativeMemoryTracking=summary -jar app.jar

ดู:

bash
jcmd <pid> VM.native_memory summary

Output:

text
Total: reserved=2G, committed=1.5G
-                  Java Heap (reserved=1G, committed=1G)
-                     Class (reserved=128M, committed=120M)
-                    Thread (reserved=80M, committed=80M)
-                      Code (reserved=240M, committed=120M)
-                        GC (reserved=200M, committed=160M)

reserved = จอง address space ไว้แต่ยังไม่ใช้จริง / committed = จองและใช้จริงแล้ว (OS จัดสรร physical memory ให้แล้ว) — ถ้า committed สูงมากใกล้ limit = ต้องเพิ่ม memory

ใช้ดู:

  • ทำไม container OOMKilled (Out-Of-Memory Kill = container ถูก OS หยุดเพราะกิน memory เกิน limit) แม้ Xmx ไม่เต็ม → อาจ native memory leak (Direct ByteBuffer = buffer ที่อยู่นอก Java heap โดยตรง ไม่นับรวมใน Xmx)
  • Thread count สูง → ใช้ memory เยอะ

9.2 Detail mode

ถ้า summary ยังไม่พอ NMT มี detail mode ที่แสดงว่าหน่วยความจำ native ถูกจองจากจุดไหนในโค้ด (call site) — ช่วยตามหา native memory leak ที่ลึกกว่าระดับ category:

bash
java -XX:NativeMemoryTracking=detail -jar app.jar
jcmd <pid> VM.native_memory detail

→ stack trace ของแต่ละ allocation


Part 10: Production Performance Checklist

text
☐ ตั้ง SLO ชัดเจน (latency P99, throughput, error rate)
☐ ติดตั้ง Micrometer + Prometheus + Grafana
☐ ติดตั้ง distributed tracing (OpenTelemetry)
☐ JFR continuous recording (200MB max, 24h max age)
☐ GC log enabled (file rotation)
☐ Heap dump on OOM (-XX:+HeapDumpOnOutOfMemoryError)
☐ ตั้ง alert ที่:
  - GC pause > 500ms
  - GC overhead > 10%
  - Heap > 80%
  - Thread count > limit
  - DB pool wait > 100ms
  - HTTP P99 > SLO
☐ Load test เป็นระยะ (k6, Gatling)
☐ Capacity plan (req/s ต่อ pod)
☐ Cold start measure (สำคัญถ้า scale up/down บ่อย)

Part 11: Lab — ทำจริง

Lab 1: เจอ N+1 ด้วย JFR

⚠️ Prerequisites: Lab นี้ต้องการโปรเจกต์ Spring Boot ที่มี spring-boot-starter-data-jpa และ database (H2 หรือ PostgreSQL) และ Lombok ที่ configure แล้ว — กลับมาทำ lab นี้หลังจากเรียน Spring Boot บทที่ 1-2 แล้ว ถ้ายังไม่มีโปรเจกต์ Spring Boot ข้ามไป Lab 2 และ Lab 3 ก่อนได้เลย

java
@Entity
@Data  // Lombok: สร้าง getter/setter/toString/equals/hashCode ให้อัตโนมัติ
class User {
    @Id Long id;
    String name;
    // ต้องใส่ mappedBy ชี้ไป field ใน Order ที่เป็น @ManyToOne — ไม่งั้น JPA จะ
    // เข้าใจว่าเป็น unidirectional แล้วสร้าง join table ให้ (หรือ validation fail)
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    List<Order> orders;
}

@Entity
@Data  // Lombok: สร้าง getter/setter ให้อัตโนมัติ — controller ใช้ u.getName()/u.getOrders()
class Order {
    @Id Long id;
    @ManyToOne User user;
    double amount;
}

record UserDto(String name, int orderCount) {}

interface UserRepository extends JpaRepository<User, Long> {}

@RestController
@RequiredArgsConstructor
class UserController {
    private final UserRepository userRepo;

    @GetMapping("/users")
    public List<UserDto> all() {
        return userRepo.findAll().stream()
            .map(u -> new UserDto(u.getName(), u.getOrders().size()))
            .toList();
    }
}
  1. รัน app พร้อม spring.jpa.show-sql=true ใน application.properties
  2. JFR jcmd <pid> JFR.start name=t1 duration=30s filename=/tmp/t.jfr
  3. curl /users 10 ครั้ง
  4. ดู SQL log ใน console — นับ query ต่อ request (จะเห็น 1 findAll + 10 lazy load = 11 query → N+1) (หรือเปิด JMC → Java ApplicationMethod Profiling เพื่อดู hot method)

📝 JMC ไม่มี tab "Database Queries" ในตัว — การดู SQL query ต้องใช้ spring.jpa.show-sql=true หรือเครื่องมือ APM ที่มี JDBC instrumentation เพิ่มเติม

  1. เห็น 11 query สำหรับ 10 user (1 findAll + 10 lazy load) → N+1

แก้: JOIN FETCHSQL log เห็น 1 query

Lab 2: ลด allocation rate

ฝึก profile allocation แล้วลดการจอง object — เทียบโค้ด 2 เวอร์ชัน วัด allocation rate ด้วย JMH -prof gc แล้วดูว่าการลด allocation ช่วยลดภาระ GC อย่างไร:

java
// version A (alloc เยอะ)
List<String> upper(List<String> in) {
    return in.stream()
        .map(String::toUpperCase)
        .collect(Collectors.toList());
}

// version B (in-place)
void upperInPlace(String[] arr) {
    for (int i = 0; i < arr.length; i++) {
        arr[i] = arr[i].toUpperCase();
    }
}

JMH benchmark + -prof gc:

text
Benchmark            Mode  Cnt  Score   Units  ·gc.alloc.rate
upper                avgt   20  150     ns/op       8 MB/sec
upperInPlace         avgt   20   90     ns/op       2 MB/sec

Lab 3: หา hot method ด้วย async-profiler

bash
./profiler.sh -d 30 -f /tmp/cpu.html <pid>

→ เปิด /tmp/cpu.html → หา plateau (แท่งกว้าง ๆ ที่ด้านบนสุด) → "ใครใช้ CPU เยอะ"


Part 12: Checkpoint

  1. ความต่างระหว่าง latency กับ throughput?
  2. ทำไม System.nanoTime() วัด benchmark ไม่ถูก?
  3. JFR ทำไม overhead ต่ำพอใช้ใน production?
  4. Flame graph อ่านยังไง? กว้าง = อะไร? สูง = อะไร?
  5. @Benchmark ใน JMH ต้องการ Blackhole ตอนไหน?
  6. Allocation rate สูงทำไมเป็นปัญหา?
  7. NMT ใช้ debug อะไร?
  8. ถ้า CPU 100% stuck — debug step คืออะไร?
  9. P99 latency ต่างกับ P50 ทำไมสำคัญ?
  10. SLO-based tuning หมายถึงอะไร?

Part 13: สรุปบทนี้

  • อย่า optimize โดยไม่วัด — ใช้เครื่องมือเสมอ
  • jcmd / jstack / jstat = ของพื้นฐาน — ทุกคนต้องใช้เป็น
  • JFR = profiler in-JVM ที่ใช้ใน production ได้
  • async-profiler = flame graph แม่นกว่า + เห็น native + lock + alloc
  • JMH = วิธีเดียวที่ benchmark Java ได้ถูก (warmup, fork, deadcode protection)
  • Performance pyramid: algorithm → DB → cache → concurrency → I/O → JIT/GC → memory → micro
  • SLO-first: ตั้งเป้าก่อน optimize. ดีกว่า SLO = อย่าแตะ
  • Production: JFR continuous + Micrometer + alert + load test

จบหมวด Java — บทถัดไปเข้าหมวด Spring Boot ที่จะลึกเพิ่ม (Reactive, Caching, Messaging, Batch, GraphQL, Native Image)


← บทที่ 18 | สารบัญ