โหมดมืด
บทที่ 19 — Performance + Profiling (JFR, async-profiler, JMH, Flame Graph)
📓 โซนขั้นสูง—ข้ามได้ (บท 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 ข้อ
- อย่า optimize อะไรที่ยังไม่ได้วัด — quote ดังของ Donald Knuth นักวิทยาการคอมพิวเตอร์ตำนาน ผู้เขียนหนังสือ The Art of Computer Programming: "การ optimize ก่อนเวลาอันควรคือต้นตอของความเลวร้ายทั้งปวง" (ต้นฉบับภาษาอังกฤษ: "Premature optimization is the root of all evil")
- 80/20 rule — 80% ของเวลาที่ช้า มาจาก 20% ของ code
- วัด end-to-end ก่อน micro — ถ้า user รอ 5 sec แต่คุณ optimize function จาก 1ms → 0.1ms = สูญเปล่า
- เทียบของชนิดเดียวกัน (อย่าเอาคนละเงื่อนไขมาเทียบ — เช่น 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.flags2.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.String2.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 ได้ ส่วน
profilesettings ที่เก็บข้อมูลละเอียดกว่าอาจดันขึ้นไป 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=profilesettings:
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 Internals — GC, JIT, ClassLoader
- Environment — system info
- Event Browser — raw events
Tab สำคัญที่ต้องดู
- Method Profiling — top method ที่ใช้ CPU
- GC — pause time, frequency, generation size
- Memory Allocation — class ไหน alloc เยอะ
- Lock Instances — contention อยู่ที่ไหน
- Socket I/O / File I/O — slow I/O
- 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 ทุกอย่างในครั้งเดียว:
bashmvn 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.jarOutput:
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/secMode.AverageTime— ns/opMode.SampleTime— distribution + percentileMode.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:
jstack <pid>→ thread dumptop -H -p <pid>→ หา TID ที่ใช้ CPU สูง (คำสั่งนี้ใช้บน Linux เท่านั้น — บน Windows ใช้ Process Explorer หรือ JMC แทน)- แปลง TID (decimal = เลขฐาน 10) → hex (เลขฐาน 16) เช่น TID 12345 →
0x3039— PowerShell:[Convert]::ToString(12345,16), bash:printf "%x\n" 12345, หรือใช้python -c "print(hex(12345))"ถ้ามี Python ก็ได้ - หาใน thread dump ที่
nid=0x<hex>→ เห็น stack trace - → รู้ว่า 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 regionConcurrent Cycle— mark phase ใน backgroundPause 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 summaryOutput:
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();
}
}- รัน app พร้อม
spring.jpa.show-sql=trueในapplication.properties - JFR
jcmd <pid> JFR.start name=t1 duration=30s filename=/tmp/t.jfr - curl
/users10 ครั้ง - ดู SQL log ใน console — นับ query ต่อ request (จะเห็น 1 findAll + 10 lazy load = 11 query → N+1) (หรือเปิด JMC → Java Application → Method Profiling เพื่อดู hot method)
📝 JMC ไม่มี tab "Database Queries" ในตัว — การดู SQL query ต้องใช้
spring.jpa.show-sql=trueหรือเครื่องมือ APM ที่มี JDBC instrumentation เพิ่มเติม
- เห็น 11 query สำหรับ 10 user (1 findAll + 10 lazy load) → N+1
แก้: JOIN FETCH → SQL 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/secLab 3: หา hot method ด้วย async-profiler
bash
./profiler.sh -d 30 -f /tmp/cpu.html <pid>→ เปิด /tmp/cpu.html → หา plateau (แท่งกว้าง ๆ ที่ด้านบนสุด) → "ใครใช้ CPU เยอะ"
Part 12: Checkpoint
- ความต่างระหว่าง latency กับ throughput?
- ทำไม
System.nanoTime()วัด benchmark ไม่ถูก? - JFR ทำไม overhead ต่ำพอใช้ใน production?
- Flame graph อ่านยังไง? กว้าง = อะไร? สูง = อะไร?
@Benchmarkใน JMH ต้องการBlackholeตอนไหน?- Allocation rate สูงทำไมเป็นปัญหา?
- NMT ใช้ debug อะไร?
- ถ้า CPU 100% stuck — debug step คืออะไร?
- P99 latency ต่างกับ P50 ทำไมสำคัญ?
- 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)