โหมดมืด
บทที่ 16 — JVM Internals + Memory Model + Garbage Collection (ลึก)
📓 โซนขั้นสูง—ข้ามได้ (บท 11-19) บทนี้เป็น บทที่ลึกที่สุดและศัพท์เทคนิคหนักที่สุดของเล่ม — เจาะระบบภายในของ JVM ระดับ senior มือใหม่ข้ามไปก่อนได้แน่นอน ไม่จำเป็นต่อการเขียน Java ให้ทำงาน ค่อยกลับมาตอนต้องแก้ปัญหา memory/ความเร็วของ app จริงในงาน หรือเตรียมสัมภาษณ์ระดับสูง บรรทัด "บทนี้คือบังคับ" ข้างล่างหมายถึง "บังคับสำหรับคนที่อยากเป็น senior" ไม่ใช่บังคับสำหรับมือใหม่
ศัพท์ย่อที่จะเจอ (เลื่อนกลับมาดูได้ตลอด):
ย่อ ขยาย / ความหมาย JVM เครื่องเสมือนที่รัน Java bytecode โค้ดกลางที่ JVM อ่าน JIT Just-In-Time — compile bytecode → machine code ขณะรัน AOT Ahead-Of-Time — compile เป็น native ก่อน run GC Garbage Collector — เก็บกวาดหน่วยความจำที่ไม่ใช้แล้ว STW Stop-the-World — หยุดทุก thread ชั่วคราวระหว่าง GC CAS Compare-And-Swap — atomic operation (ทำสำเร็จทีเดียวโดยไม่ถูกแทรกกลาง) สำหรับการเข้าถึงข้อมูลร่วมกันระหว่าง thread โดยไม่ต้องล็อก (lock-free) OOP / oops Ordinary Object Pointer — การชี้ object ใน heap (compressed = ย่อเป็น 32-bit) (ไม่ใช่ OOP = Object-Oriented Programming นะ — คนละความหมายกัน ในบทนี้ oops = pointer ภายใน JVM) heap บริเวณ memory เก็บ object stack memory เก็บการเรียก method G1 / ZGC อัลกอริทึม GC สองตัวที่นิยม JMM Java Memory Model — กฎ memory ระหว่างเธรด DCL Double-Checked Locking — pattern lazy init แบบ thread-safe JEP JDK Enhancement Proposal — ข้อเสนอ feature ใหม่ของ Java NMT Native Memory Tracking — เครื่องมือติดตาม memory นอก heap JFR Java Flight Recorder — profiler built-in JMH Java Microbenchmark Harness — เครื่องมือวัดความเร็วของ code ขนาดเล็ก (micro = เล็กมาก, harness = โครงเครื่องมือ) MAT Memory Analyzer Tool (Eclipse) PermGen Permanent Generation — heap region เก่าก่อน Java 8 ที่เก็บ class metadata (ถูกแทนที่ด้วย Metaspace) OOM OutOfMemoryError — หน่วยความจำเต็ม K8s Kubernetes (K-ubernete-s = K + 8 ตัวอักษรกลาง + s)
บทนี้คือ บทที่ใต้พรม — สิ่งที่ JVM ทำให้คุณโดยไม่บอก ถ้าคุณเขียน Java แค่ "ทำงานได้" ข้ามบทนี้ได้ แต่ถ้าอยาก:
- เข้าใจว่า OutOfMemoryError มันมาจากไหน
- รู้ว่าทำไม app เร็วช่วงแรก แล้ว ช้าลงเมื่อรันนาน ๆ
- ตั้ง JVM flag ให้เหมาะกับ production
- อ่าน heap dump หา memory leak ได้
- เข้าใจว่า virtual thread (Project Loom) มันต่างจาก OS thread ยังไง
- เป็น senior / staff engineer หรือผ่าน interview level สูง
→ บทนี้คือบังคับ
ใช้เวลา 3-5 ชั่วโมง อ่าน (ยาวมาก) แต่ลงทุนครั้งเดียวใช้ได้ทั้งชีวิตการเป็น Java dev
Part 1: JVM ทำอะไรกันแน่ ?
1.1 ทบทวน — เกิดอะไรขึ้นเมื่อรัน Java
ตอนคุณพิมพ์:
bash
java HelloWorldมันไม่ใช่แค่ "รันโปรแกรม" — มันคือ:
- Class Loader หา
HelloWorld.class→ load bytecode เข้า memory - Bytecode Verifier ตรวจว่า bytecode ปลอดภัย (ไม่มี stack overflow, type confusion)
- JIT Compiler แปลง bytecode → machine code (เริ่ม interpret ก่อน, compile ทีหลังถ้า hot = ถูกเรียกบ่อย — รายละเอียดใน Part 7)
- Execution Engine รันโค้ดบน CPU จริง
- Garbage Collector ทำงาน background — เก็บ object ที่ไม่ใช้แล้ว
- Memory Manager จัดการ heap, stack, metaspace
แต่ละขั้นมีรายละเอียดเยอะ — บทนี้เจาะลึก
💡 เปรียบเทียบกับชีวิตจริง: JVM = "ห้องครัวร้านอาหาร" ที่:
- Class Loader = คนหยิบสูตรอาหาร (bytecode = สูตร)
- JIT = พ่อครัวที่จำสูตรได้แล้วทำเร็วขึ้น
- GC = พนักงานทำความสะอาดที่เก็บจานสกปรกตลอด
- Heap = ตู้เย็นเก็บวัตถุดิบ
- Stack = โต๊ะที่กำลังทำอาหาร
1.2 JVM ≠ JRE ≠ JDK
ทบทวนคำที่สับสน:
| คำ | คือ |
|---|---|
| JVM (Java Virtual Machine) | "เครื่องเสมือน" ที่รัน bytecode |
| JRE (Java Runtime Environment) | JVM + standard library (Oracle หยุด distribute JRE แยกตั้งแต่ Java 11 — ดาวน์โหลด JDK แทน JRE เพราะ JDK รวม JRE อยู่แล้ว แต่ JRE distribution จาก vendor อื่น เช่น Azul ยังมีอยู่) |
| JDK (Java Development Kit) | JRE + เครื่องมือ dev (javac, jar, javadoc, jstack, ฯลฯ) |
JVM ไม่ใช่แค่ตัวเดียว — มี implementation หลายตัว:
| Implementation | ผู้สร้าง | จุดเด่น |
|---|---|---|
| HotSpot | Oracle / OpenJDK | มาตรฐาน, ใช้กันมากที่สุด |
| GraalVM | Oracle Labs | compile เป็น native image ได้ (start เร็ว) |
| OpenJ9 | Eclipse / IBM | ใช้ memory น้อย (เหมาะ container) |
| Azul Zing/Prime | Azul Systems | GC pause ต่ำมาก (พิเศษ enterprise) |
| Amazon Corretto | AWS | ฟรี LTS distribution ของ OpenJDK |
| Eclipse Temurin | Adoptium | ของฟรี community (ที่เราใช้บทที่ 01) |
Temurin, Corretto, Liberica, Zulu — ล้วน "build ของ OpenJDK" + แพตช์ (patches = การแก้ไข bug หรือปิดช่องโหว่ความปลอดภัยเพิ่มเติม) — เนื้อในเหมือนกัน
1.3 Bytecode: ภาษากลางที่ JVM เข้าใจ
ลองดู — เขียน Java:
java
public class Add {
public static int add(int a, int b) {
return a + b;
}
}javac Add.java แล้วดู bytecode:
bash
javap -c Addได้:
text
public static int add(int, int);
Code:
0: iload_0 // โหลด local variable 0 (a) ขึ้น stack
1: iload_1 // โหลด local variable 1 (b) ขึ้น stack
2: iadd // pop 2 ตัวบน stack มาบวก, push ผลลัพธ์กลับ
3: ireturn // pop ค่าจาก stack เป็น return valueสังเกต:
- JVM เป็น stack machine (เครื่องที่ใช้กองซ้อนข้อมูลในการคำนวณ) — operation ใช้ stack (ไม่ใช่ register-based แบบ x86 ซึ่ง register = ช่องเก็บค่าชั่วคราวใน CPU โดยตรง)
iload,iadd,ireturn—iหมายถึง integer. มีl(long),f(float),d(double),a(reference)- Bytecode = "assembly ของ JVM"
ทำไมต้องมี bytecode? — เพราะ JVM portable ข้าม OS/CPU. JVM x86 รัน bytecode ได้, JVM ARM ก็ได้. "Write once, run anywhere"
🛠️ Checkpoint 1.1 — ลองเขียน method ง่าย ๆ แล้วใช้
javap -cดู bytecode. ลองทาย operation แต่ละบรรทัดก่อนดูเฉลย
Part 2: Memory Layout — สมองของ JVM
ก่อนคุยเรื่อง GC — เราต้องรู้ก่อนว่า JVM แบ่ง memory ยังไง
2.1 ภาพรวม
มี 4 พื้นที่หลัก:
- Heap — object ที่เราสร้างด้วย
newอยู่ที่นี่ → GC จัดการตรงนี้ - Stack — local variable, parameter, frame ของ method call → ของแต่ละ thread แยกกัน
- Metaspace — class definition, method bytecode, constant pool
- Native memory — off-heap (Direct ByteBuffer, JIT code cache, native library)
2.2 Heap — ที่ object อยู่จริง
Heap แบ่งเป็น 2 generation หลัก:
Young Generation (อายุน้อย)
แบ่งย่อยอีก:
text
┌────────────────────────────────────────┐
│ Young Generation │
│ ┌──────────┐ ┌──────┐ ┌──────┐ │
│ │ Eden │ │ S0 │ │ S1 │ │
│ │ (ใหญ่) │ │ │ │ │ │
│ └──────────┘ └──────┘ └──────┘ │
└────────────────────────────────────────┘- Eden — object เกิดใหม่อยู่ที่นี่ (ใหญ่)
- S0, S1 (Survivor 0, 1) — object ที่รอด GC รอบแรกย้ายมา
Old Generation (Tenured)
text
┌────────────────────────────────────────┐
│ Old Generation │
│ object ที่รอด GC หลายรอบ │
└────────────────────────────────────────┘object ที่รอด GC ใน young gen หลายรอบ (ค่า default กำหนด สูงสุด 15 รอบ เป็นเพดาน — ไม่ใช่ตัวเลขที่ object ส่วนใหญ่ใช้จริง ในทางปฏิบัติ G1 มักเลื่อนขั้น object เร็วกว่านั้นถ้า survivor space เริ่มแน่น) → ย้ายมาที่ Old
ทำไมต้องแบ่งเป็น generation?
เพราะมี observation สำคัญ: object ส่วนใหญ่ "ตายเร็ว" (อายุสั้น) — ตัวอย่างเช่น variable ใน method, intermediate object ใน stream
→ ถ้าเราแยก young/old ออก เราจะ GC young บ่อย ๆ (เร็ว เพราะเล็ก) และ GC old ไม่บ่อย (ช้า แต่ object ใน old ส่วนใหญ่จะรอดอยู่ดี)
นี่คือ สมมติฐานรุ่น (Generational Hypothesis) — ว่า object ส่วนใหญ่มีอายุสั้น
2.3 Stack — ของแต่ละ thread
แต่ละ thread มี stack ของตัวเอง
text
Thread กำลังรัน getUser() ที่เรียก queryDb() ที่เรียก openConnection():
Stack of Thread 1
┌──────────────────────┐
│ openConnection() │ ← top (active)
│ - local url │
│ - local timeout │
├──────────────────────┤
│ queryDb() frame │
│ - local sql │
├──────────────────────┤
│ getUser() frame │
│ - local userId = 42 │
└──────────────────────┘แต่ละ frame เก็บ:
- Local variable
- Parameter
- Return address (กลับไป method เรียก)
- Reference ไป object ใน heap (object เองอยู่ heap)
Stack overflow = method call ซ้อนกันลึกเกินขนาด stack ที่จองไว้ (สำหรับ Platform Thread: default ~512KB-1MB ต่อ thread — ตัวเลขจริงขึ้นกับ OS/JVM version มาก เช็คของเครื่องตัวเองได้ด้วย -XX:+PrintFlagsFinal -version | grep ThreadStackSize — Virtual Thread มี stack ขนาดเล็กกว่ามาก)
java
// ตัวอย่าง stack overflow (เต็มรูปคลาส เพื่อให้ compile ได้)
public class StackDemo {
public static void main(String[] args) { recurse(); }
public static void recurse() {
recurse(); // ไม่มี base case
}
}
// → java.lang.StackOverflowErrorตั้งขนาด stack ด้วย -Xss:
bash
java -Xss2m HelloWorld # 2MB ต่อ thread⚠️ Pitfall มือใหม่:
- object ไม่ได้ อยู่ใน stack — object อยู่ใน heap เสมอ (ยกเว้น escape analysis optimize)
- reference ที่ชี้ไป object ต่างหากที่อยู่ใน stack (เป็น local variable)
- primitive type (int, long, double) ในฐานะ local variable อยู่ใน stack
2.4 Metaspace — ที่ class definition อยู่
ก่อน Java 8 ใช้ชื่อ PermGen (Permanent Generation) อยู่ใน heap → ตั้งขนาดยาก, OOM ใน PermGen เจอบ่อย
ตั้งแต่ Java 8 — เปลี่ยนเป็น Metaspace อยู่ใน native memory (off-heap) → ขยายอัตโนมัติ (จำกัดโดย -XX:MaxMetaspaceSize ถ้าตั้ง)
เก็บอะไรบ้าง?
- Class metadata (ชื่อ, field, method)
- Method bytecode
- Constant pool ของ class (symbolic reference, ค่าคงที่) — หมายเหตุ: string literal ตัวจริง (String pool) อยู่ใน heap ตั้งแต่ Java 7
- Reflection data
Metaspace OOM เจอตอน:
- Load class จำนวนมาก (เช่น dynamic class generation — Hibernate, AOP proxy)
- Memory leak ของ class loader (web app reload ใน Tomcat แล้วไม่ unload class เก่า)
2.5 Native Memory — ที่ไม่ใช่ heap
มี 4 ก้อนหลัก:
- Direct ByteBuffer — buffer ที่จองหน่วยความจำโดยตรงนอก heap สำหรับ I/O ความเร็วสูง (Netty = framework เครือข่าย, Kafka client = client ของระบบ message queue — ใช้เยอะ)
- JIT Code Cache — machine code ที่ JIT compile แล้ว
- Thread Stack — รวมขนาดทุก thread × stack size
- GC structures — internal data structure ของ GC
💡 คำเตือน:
-Xmxคุม heap เท่านั้น. Process JVM จริง ๆ ใช้ memory เกิน Xmx เสมอ (heap + metaspace + native = ใช้จริง)ใน container ถ้าตั้ง memory limit = Xmx เป๊ะ → จะโดน OOMKilled
Part 3: Object Lifecycle ในห้องของ Heap
มาตามดู object 1 ตัวตั้งแต่เกิดจนตาย
3.1 Step 1 — เกิดใน Eden
java
User user = new User("Alice");JVM ทำ:
- หาที่ว่างใน Eden
- จองพื้นที่เท่าขนาด object (header + field)
- set header bits (mark word, class pointer)
- clear field (default value: int=0, ref=null)
- รัน constructor
- คืน reference
ขนาด object header (64-bit HotSpot, ปี 2026):
🎯 สรุปตัวเลขก่อน — ตอบคำถาม "12 หรือ 16 bytes กันแน่":
สถานการณ์ ขนาด header Default (flag ย่อ pointer เปิดทั้งคู่ — เป็นค่าเริ่มต้นของ Spring Boot app ทั่วไป) 12 bytes ปิด flag ย่อ pointer ทั้งคู่ (หายาก) 16 bytes ตัวเลข 12 bytes นี้คือขนาด header เท่านั้น (mark word + klass pointer) — object จริงยังมี field ของตัวเองบวกเพิ่ม แล้วปัดขึ้นให้ลงตัว 8 bytes เสมอ (alignment) ดังนั้น object เล็ก ๆ ที่มี field น้อยจะเห็นขนาดจริงใน JOL (Java Object Layout —
jol: library สำหรับดู layout ของ object) มักเป็น 16 bytes — ตัวเลข 16 ตรงนี้คือ "ขนาด object ทั้งก้อนหลัง padding" ไม่ใช่ "ขนาด header" คนละความหมายกับ 16 bytes ในแถวบน (header ไม่ย่อ) อย่าสับสนสองคำนี้รายละเอียดที่มาของตัวเลข 12/16:
คำ ความหมาย Mark word ส่วนแรกของ header ขนาด 8 bytes เสมอ — เก็บข้อมูล lock, hash code, อายุ GC Klass pointer ส่วนที่สองของ header ชี้ไปยัง class metadata ใน Metaspace — 4 bytes ถ้าเปิด UseCompressedClassPointers(default), 8 bytes ถ้าปิดUseCompressedClassPointersflag JVM ที่ย่อ klass pointer จาก 8 bytes → 4 bytes (เปิด default) UseCompressedOopsflag JVM ที่ย่อ object reference (pointer ชี้ไป object อื่น ไม่ใช่ตัว header) จาก 8 bytes → 4 bytes (เปิด default เมื่อ heap ≤ 32GB) — ("oops" ตรงนี้ = Ordinary Object Pointer คนละความหมายกับ OOP = Object-Oriented Programming นะ) รวม header: mark word (8) + klass pointer (4 เมื่อย่อ) = 12 bytes — สอง flag ข้างบน ปกติเปิดคู่กัน เป็น default แต่เป็นคนละ flag กัน คนละหน้าที่ (คนละ pointer คนละที่)
💡 สำหรับมือใหม่: JVM เปิด flag เหล่านี้อัตโนมัติบน 64-bit — ไม่ต้องตั้งเอง เรียนไว้เพื่อเข้าใจตอน debug heap dump หรืออ่าน JVM performance guide
- Compact Object Headers (JEP 519, Java 25 stable): ลดเหลือ 12 bytes → 8 bytes (mark+klass รวมใน 8 bytes) — เปิดด้วย
-XX:+UseCompactObjectHeadersดู Part 11.2 (หมายเหตุ: ทำงานได้ดีที่สุดเมื่อ heap ≤ 32GB ซึ่งเป็นเงื่อนไขเดียวกับUseCompressedOops— ถ้า heap > 32GB ให้ทดสอบก่อน deploy)
text
User object in Eden (default: compressed oops):
┌─────────────────────────┐
│ Mark word (8 bytes) │ hash / lock / age / GC bits
├─────────────────────────┤
│ Klass ptr (4 bytes) │ shrink-compressed class pointer
├─────────────────────────┤
│ name reference (4-8 b) │ → "Alice" String อยู่อีกที่ใน heap
├─────────────────────────┤
│ age (4 bytes) │
├─────────────────────────┤
│ padding (เพื่อ align 8) │
└─────────────────────────┘3.2 Step 2 — Eden เต็ม → Minor GC
เมื่อ Eden เต็ม:
- STW (Stop-the-World) — หยุดทุก thread ใน app
- GC traverse จาก GC Roots (static field, local var ของ thread ที่ active, JNI ref) — หา object ที่ "ยังถูกอ้างอิง"
- Live object ใน Eden → copy ไป Survivor S0
- Eden → clear ทั้งหมด (เร็ว — แค่ reset pointer)
- Resume thread
→ การจัดสรร memory เร็วมาก เพราะ Eden เป็น bump pointer allocation (การจองที่ว่างโดยแค่เลื่อน pointer ไปข้างหน้าทีละนิด — เร็วมากเพราะไม่ต้องหาที่ว่างใน heap)
3.3 Step 3 — Survive หลายรอบ
object ที่รอด GC หลายรอบจะถูกย้ายไปมาระหว่าง Survivor space (S0↔S1) และนับอายุเพิ่มขึ้น พอแก่พอ (ผ่านเกณฑ์) ก็ถูกเลื่อนขั้น (promote) ไป Old generation แผนภาพด้านล่างแสดงการย้ายแต่ละรอบ:
text
รอบที่ 1: Eden full → live obj ไป S0
Eden S0 S1
┌──┐ ┌──┐ ┌──┐
│..│ │A │ │ │
└──┘ └──┘ └──┘
รอบที่ 2: Eden full → live obj จาก Eden + S0 ไป S1
┌──┐ ┌──┐ ┌──┐
│..│ │ │ │A │ A age = 2
└──┘ └──┘ └──┘
รอบที่ 3: → ไป S0
รอบที่ 4: → ไป S1
...
รอบที่ 15 (default): → ย้ายไป Old Gen (Tenured)ค่า 15 ตั้งด้วย -XX:MaxTenuringThreshold
3.4 Step 4 — ย้ายไป Old Gen
object ที่:
- Survive ผ่าน threshold (15 รอบ minor GC)
- หรือ "ใหญ่เกิน" Eden — allocate ตรงเข้า Old (Large Object Allocation)
→ เข้า Old Gen
Old Gen เต็ม → Major GC (Full GC) — STW นานกว่ามาก เพราะต้อง mark + sweep ทั้ง heap
3.5 Object header สำคัญยังไง
Mark word (8 bytes) เก็บ:
- Hash code (จาก
hashCode()) - Lock info (
synchronized) - Age (จำนวนรอบ survive)
- GC flags (mark bit)
นี่คือเหตุผลที่ synchronized ทำงานได้ — JVM ใช้ mark word เก็บสถานะ lock
⚠️ อัปเดตปี 2026: Biased locking ถูก deprecate + disabled by default ใน Java 15 (JEP 374) และถูกลบออกจาก source หลักใน Java 18 (กระบวนการเก็บกวาดต่อเนื่องในเวอร์ชันถัดมา) เพราะกระทบ throughput ของ workload สมัยใหม่ที่ใช้
java.util.concurrent.locks(= ชุด lock ระดับสูงกว่าsynchronizedเช่นReentrantLock— เป็นหัวข้อขั้นสูง ยังไม่ต้องรู้ตอนนี้) แทนsynchronizedlock progression ปัจจุบันคือ 2 ขั้น:
- thin lock (lock เบา ใช้ CAS ไม่บล็อก thread อื่นจริง) → heavyweight lock/monitor (lock หนัก บล็อก thread อื่นจริง ๆ ใช้เมื่อมีหลาย thread แย่งกัน)
ตำราเก่าหลายเล่มยังเขียน 3 stage (รวม biased) อยู่ — ใช้ Java 18+ ถือเป็น 2 stage
Part 4: Garbage Collection — เก็บขยะยังไง
4.1 หลักการพื้นฐาน — Reachability
object ตาย เมื่อ "ไม่มีใครชี้ถึง" จาก GC Roots
GC Roots ได้แก่:
- Local variable ใน active stack frame
- Static field ของ class ที่ load อยู่
- JNI reference
- Active thread object
- Synchronization monitor
💡 ความเข้าใจผิด: "Java ใช้ reference counting" — ผิด. Java ใช้ reachability (tracing GC). ภาษาที่ใช้ ref counting คือ Python, Swift, Objective-C — ปัญหาคือ "circular reference (การอ้างอิงวนไปมา — A ชี้ B, B ชี้ A ทำให้ทั้งคู่ตายยากแม้ไม่มีใครใช้แล้ว)" ตายยาก
4.2 Algorithm หลัก ๆ
Mark-Sweep
text
Mark: traverse จาก root → tag ทุก reachable obj
Sweep: scan heap → ลบทุก obj ที่ไม่ tagปัญหา: Fragmentation — หน่วยความจำที่ว่างกระจัดกระจายเป็นก้อนเล็ก ๆ ไม่ต่อเนื่องกัน ทำให้จองพื้นที่ก้อนใหญ่ไม่ได้แม้ว่ารวมกันแล้วพอ
Mark-Sweep-Compact
text
Mark + Sweep + Compact: ย้าย live obj มาชิดต้น heap → ลด fragmentationปัญหา: ช้า (ต้อง move + update reference)
Copying
text
แบ่ง heap เป็น 2 ฝั่ง From / To
Mark + Copy: copy live obj จาก From → To (compact ในตัว)
Swap: From ↔ Toใช้ใน Young Gen เพราะ live object น้อย — copy ใช้เวลาน้อยกว่า sweep เพราะ live object ใน Young Gen มีน้อย
Generational
ผสมข้างบน — แยก Young (copying) + Old (mark-sweep-compact)
4.3 GC Algorithm ใน JVM (Java 8+)
| GC | ใช้เมื่อ | Throughput | Pause | Heap size |
|---|---|---|---|---|
| Serial GC | 1 thread, small app | ต่ำ | สูง | <100MB |
| Parallel GC (default ก่อน Java 9) | batch job, throughput-first | สูง | สูง | ใหญ่ |
| G1 GC (default ตั้งแต่ Java 9) | server app | กลาง | ต่ำ-กลาง | กลาง-ใหญ่ |
| ZGC | low-latency, huge heap | กลาง | ต่ำมาก (< 10 ms pre-Java 21; < 1 ms Java 21+ Generational ZGC — ดู Part 4.4) | ใหญ่มาก (TB) |
| Shenandoah | low-latency (Red Hat) | กลาง | ต่ำมาก | ใหญ่ |
| Epsilon | no-op (test) | — | — | — |
Serial GC
- 1 thread ทำทั้งหมด
- เหมาะ: dev/test, embedded, < 100MB heap
- Flag:
-XX:+UseSerialGC
Parallel GC (เก่า default)
- หลาย thread ขนาน
- เหมาะ: batch / throughput งานหนัก
- Flag:
-XX:+UseParallelGC
G1 GC (default ตั้งแต่ Java 9)
แบ่ง heap เป็น region (1-32 MB ต่อ region):
text
┌─────────────────────────────────────────┐
│ Heap = หลาย region │
│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │
│ │E│ │E│ │S│ │O│ │O│ │H│ │ │ │ │ │O│ │
│ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ │
└─────────────────────────────────────────┘
E = Eden, S = Survivor, O = Old, H = Humongous, blank = freeแต่ละ region สามารถเป็น role ใดก็ได้ (Eden / Survivor / Old / Humongous)
G1 ทำ:
- Concurrent marking — mark ใน background
- Evacuation — copy live obj จาก region ที่ "garbage เยอะ" ไป region ใหม่ (STW แต่สั้น)
- Adaptive — เลือก region ที่จะ collect ตาม pause target
Flag:
bash
-XX:+UseG1GC # เปิด (default ตั้งแต่ Java 9)
-XX:MaxGCPauseMillis=200 # target pause สูงสุด (G1 จะพยายามทำตาม)
-XX:G1HeapRegionSize=16M # ขนาด regionZGC (Java 11+, production-ready 15+)
- Pause time ไม่ว่า heap ใหญ่แค่ไหน:
- Pre-Java 21 ZGC: sub-10 ms typical (มักได้น้อยกว่านี้)
- Java 21+ Generational ZGC: < 1 ms typical (sub-millisecond)
- ใช้ "colored pointers" (pointer ที่มี bit พิเศษบอกสถานะ GC) + load barrier (โค้ดเล็ก ๆ ที่รันทุกครั้งที่อ่าน object เพื่อตรวจสถานะ) — ทำให้ ZGC เก็บขยะได้ขณะ app ยังทำงานอยู่
- รองรับ heap จนถึง 16 TB
- Concurrent ทั้งหมด (mark + relocate ทำขณะ app run)
Flag:
bash
-XX:+UseZGC
-Xmx16gJava 15+ → ZGC ใช้ใน production ได้ทั่วไป Java 21+ → Generational ZGC (เร็วกว่าเดิม)
Shenandoah (Red Hat)
คล้าย ZGC แต่ใช้ Brooks pointer (pointer พิเศษที่ชี้ไปยัง copy ของ object ที่กำลังย้าย ทำให้ relocate ได้ขณะ app รัน) + concurrent compact
Flag:
bash
-XX:+UseShenandoahGCEpsilon (Java 11+)
"No-op GC" — ไม่ทำอะไรเลย เมื่อ heap เต็มก็ OOM
ใช้ทำอะไร? — benchmark (วัด allocation rate โดยไม่มี noise จาก GC)
bash
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC4.4 เลือก GC ยังไง
ใน production สมัยใหม่ (Java 25 LTS, ปี 2026) ส่วนใหญ่ใช้:
- G1 ถ้าทั่วไป (default ดีพอ)
- ZGC ถ้า latency-sensitive (เช่น real-time, low-latency trading)
🆕 Generational ZGC — timeline
จุดอ่อนของ ZGC เก่า: เก็บ object อายุน้อย/อายุมาก รวมกันทุกรอบ → cost สูงกับ workload ที่มี short-lived object เยอะ (เช่น web request)
Generational ZGC (JEP 439) แยก Young/Old gen เหมือน G1 — แต่ยังคง pause time < 1ms:
- Java 21: opt-in ด้วย
-XX:+UseZGC -XX:+ZGenerational - Java 23: generational เป็น default เมื่อใส่
-XX:+UseZGC(ไม่ต้อง+ZGenerationalแล้ว) - Java 24: non-generational ZGC ถูก deprecate (
-XX:-ZGenerationalยังใช้ได้ แต่ขึ้น warning) - Java 25: non-generational ZGC ถูก ลบทิ้ง —
-XX:+UseZGC= generational เท่านั้น - Throughput ดีขึ้นอย่างมีนัยสำคัญเทียบ ZGC เก่า (ตัวเลขขึ้นกับ workload — ดู benchmark ใน JEP/release notes ทางการ)
- Memory overhead ลดลง
Java 21: ต้องเปิด -XX:+ZGenerationalJava 23+: default แล้ว — ใช้ -XX:+UseZGC พอ ได้ generational อัตโนมัติ Java 24: deprecated non-generational ZGC Java 25: removed non-generational ZGC — +UseZGC = generational เสมอ
🆕 Compact Object Headers (Java 25 stable, JEP 519)
ลด object header จาก 12 bytes (compressed default) → 8 bytes สำหรับ 64-bit JVM — โดย pack mark word + klass pointer เข้าใน 8 bytes เดียว (ไม่ใช่ลดเหลือ 8 จาก 16 ตามที่หลายเอกสารเขียนผิด)
bash
-XX:+UseCompactObjectHeadersผลกระทบ:
- ลด heap footprint อย่างมีนัยสำคัญ สำหรับ app ที่มี small object เยอะ (string-heavy, list of records) (ตัวเลขขึ้นกับ workload — ดู JEP 519 release notes)
- cache friendly ขึ้น → throughput ดีขึ้น (ตัวเลขขึ้นกับ workload)
- Java 24: experimental → Java 25: product (stable)
💡 ใน Spring Boot app ทั่วไป — เปิด flag นี้ + Generational ZGC = ได้ฟรี ๆ ทั้ง latency + memory
4.5 GC Logging — มอง GC ทำงาน
ตั้งแต่ Java 9 syntax เปลี่ยน (รวม unified logging):
bash
java -Xlog:gc*:file=gc.log:time,uptime,level,tags \
-Xmx512m -Xms512m \
MyAppดู log:
text
[0.123s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
50M->5M(512M) 12.345msแปล:
GC(0)— GC รอบที่ 0Pause Young (Normal)— minor GC, STWG1 Evacuation Pause— algorithm ที่ใช้50M->5M— heap ก่อน → หลัง (45MB freed)(512M)— total heap12.345ms— pause time
Part 5: JVM Flags ที่ควรรู้
5.1 Memory
bash
-Xms2g # initial heap (เริ่มเลย — แนะนำให้ = Xmx)
-Xmx2g # max heap
-Xss512k # thread stack size
-XX:MaxMetaspaceSize=256m # cap metaspace (ไม่งั้นโตได้ไม่จำกัด)
-XX:+UseCompressedOops # ใช้ 4-byte reference (heap < 32GB) — default💡 best practice: ตั้ง
Xms = Xmxใน production
- ป้องกัน heap resize ตอน warm up (resize = pause)
- JVM จองหน่วยความจำตั้งแต่ start → predictable
- ตอน container ตั้ง memory limit จะ tune ง่าย
5.2 GC
flag กลุ่มนี้ใช้เลือกและปรับจูน garbage collector — เลือก algorithm (G1 เป็น default, ZGC/Shenandoah สำหรับ low-latency), ตั้งเป้า pause time, และเปิด GC log เพื่อวิเคราะห์:
bash
-XX:+UseG1GC # G1 (default Java 9+)
-XX:MaxGCPauseMillis=200 # target pause สูงสุด
-XX:ParallelGCThreads=8 # thread ที่ GC ใช้ตอน STW
-XX:ConcGCThreads=4 # thread ที่ทำ concurrent marking
-XX:+UseZGC # ZGC
-XX:+UseShenandoahGC # Shenandoah
-Xlog:gc*:file=gc.log # GC log5.3 OOM Handling
bash
-XX:+HeapDumpOnOutOfMemoryError # dump heap เมื่อ OOM
-XX:HeapDumpPath=/var/log/app-oom.hprof # ที่เก็บ dump
-XX:+ExitOnOutOfMemoryError # JVM exit ทันที (ดีกว่ารัน zombie)ใน container เคย OOMKill — ตั้ง 3 flag ข้างบนทุกครั้ง เพราะ heap dump ช่วยหา leak
5.4 Performance
flag กลุ่มนี้ช่วยรีดประสิทธิภาพเพิ่มในกรณีเฉพาะ — เช่น AlwaysPreTouch (จองหน่วยความจำจริงตอนสตาร์ทเพื่อลด page fault ภายหลัง), string deduplication และ tiered compilation ของ JIT:
bash
-XX:+TieredCompilation # JIT แบบเป็นชั้น (default)
-XX:+UseStringDeduplication # ลด duplicate String ใน heap (G1)
-XX:+AlwaysPreTouch # จอง memory จริงตอน start (ลด page fault)
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintInlining # ดูว่า method ไหน inline (debug)5.5 ใน Container (Docker / K8s)
ตั้งแต่ Java 10+ — JVM detect container memory limit อัตโนมัติ
bash
java -XshowSettings:vm # ดู max heap ที่ JVM เลือก
java -XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=75 # 75% ของ container limit⚠️ อย่าตั้ง Xmx เป็นค่าตายตัว เมื่อรันใน K8s ที่ memory request ปรับได้
ใช้ percentage flag — JVM จะคำนวณตาม
--memory-limitของ container
Part 6: Java Memory Model (JMM) — เรื่อง concurrency
6.1 ปัญหา: multi-thread + cache
CPU สมัยใหม่มีหน่วยความจำชั่วคราว (cache) หลายชั้น: L1 (เล็กที่สุด เร็วที่สุด อยู่ใกล้ core มากที่สุด), L2, L3 (ใหญ่กว่า ช้ากว่า แชร์ระหว่าง core ได้) — ยิ่งอยู่ใกล้ core ยิ่งเร็ว แต่เล็กกว่า:
text
┌───────────────────────────────────────────┐
│ Main Memory (RAM) │
└────────────┬──────────────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌──────┐ ┌──────┐
│ L3 │ │ L3 │
└──┬───┘ └──┬───┘
▼ ▼
┌──────┐ ┌──────┐
│ L2 │ │ L2 │
└──┬───┘ └──┬───┘
▼ ▼
┌──────┐ ┌──────┐
│ L1 │ │ L1 │
└──┬───┘ └──┬───┘
▼ ▼
CORE 1 CORE 2ถ้า thread 1 (core 1) เขียน x = 10 → อาจอยู่แค่ใน L1 cache ของ core 1 thread 2 (core 2) อ่าน x → ยังเห็นค่าเก่า
นี่คือ visibility problem
6.2 JMM กำหนด rule
Java Memory Model (JSR-133) บอกว่าเมื่อไหร่ thread หนึ่งจะ "เห็น" การเปลี่ยนแปลงของอีก thread
3 รับประกัน:
- Atomicity — operation ที่เกิดทีเดียว (ไม่ถูกแทรก)
- Visibility — thread อื่นเห็นการเปลี่ยน
- Ordering — code execute ตามลำดับ (compiler/CPU อาจ reorder)
6.3 happens-before
ความสัมพันธ์ที่บอกว่า "A เกิดก่อน B และ B เห็นผลของ A"
มี happens-before เมื่อ:
- คำสั่งใน thread เดียวกัน เรียงตามลำดับ
synchronizedrelease → acquire ของ lock เดียวกันvolatilewrite → readThread.start()→ first action ใน thread นั้น- thread.join() → action หลัง join
6.4 volatile
java
class Counter {
private volatile boolean running = true;
public void stop() { running = false; } // thread 1
public void run() {
while (running) { /* work */ } // thread 2
}
}ถ้าไม่ใส่ volatile — thread 2 อาจไม่เห็น running = false เลย → infinite loop
volatile รับประกัน:
- Write ไปถึง main memory ทันที
- Read อ่านจาก main memory
- Compiler/CPU ไม่ reorder
แต่ ไม่ รับประกัน atomicity — volatile int counter; counter++; ยังคง race condition (สภาวะที่ thread หลายตัวแย่งเขียนค่าพร้อมกันจนได้ผลไม่ถูกต้อง)
6.5 synchronized
java
synchronized (this) {
counter++; // atomic ภายใต้ lock เดียวกัน
}- ทำงานเหมือน volatile (visibility + ordering) + atomicity
- แต่ block thread อื่นที่จะ acquire lock เดียวกัน
6.6 Atomic*
java
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic + visibleใช้ CAS (compare-and-swap) ของ CPU — เร็วกว่า lock ตอน contention ต่ำ (contention = thread ไม่ค่อยแย่งกันใช้ resource เดียวกันพร้อมกัน)
Part 7: JIT Compilation — ทำให้ Java เร็วเท่า C
7.1 Interpreter vs Compiler
Java เริ่มด้วย interpret bytecode (ช้า — ทุก instruction ต้อง decode)
หลังจาก method ถูกเรียกบ่อย → JIT compile เป็น machine code (เร็ว 10-100x)
7.2 HotSpot — Tiered Compilation
HotSpot ใช้หลาย level:
text
Level 0: Interpreter
Level 1: C1 (Client) — quick compile, no profiling
Level 2: C1 + basic profiling
Level 3: C1 + full profiling (เพื่อตัดสินใจไป C2)
Level 4: C2 (Server) — slow compile, aggressive optimizationMethod ที่เรียกบ่อย ๆ → ขึ้น level ไปเรื่อย ๆ จนถึง C2 (optimized สุด)
7.3 Optimization ที่ JIT ทำ
Inlining
java
int foo() { return bar(); }
int bar() { return 42; }JIT inline:
java
int foo() { return 42; } // bar() หายไปลด method call overhead + เปิดทางให้ optimization อื่น
Escape Analysis
ถ้า object ไม่ escape ออกจาก method:
java
void render() {
Point p = new Point(1, 2); // ไม่ leak ออกนอก method
System.out.println(p.x);
}JIT อาจ stack allocate หรือ scalar replace (แทนที่จะสร้าง object จริง — JIT แตก field แต่ละตัวเป็น local variable ธรรมดาแทน ไม่ต้องใช้ heap เลย) → ไม่เสีย heap, ไม่เสีย GC
Loop Unrolling
java
for (int i = 0; i < 4; i++) sum += a[i];→
java
sum += a[0]; sum += a[1]; sum += a[2]; sum += a[3];ลด branch + เปิดทาง vectorize (ให้ CPU ประมวลผลข้อมูลหลายชิ้นพร้อมกันในคำสั่งเดียว)
Vectorization (SIMD)
JIT ใช้ SSE/AVX ของ CPU — บวก 4 int ใน 1 instruction
(SIMD = Single Instruction Multiple Data — 1 คำสั่ง ทำกับข้อมูลหลายชุดพร้อมกัน; SSE/AVX = ชุดคำสั่ง CPU ของ Intel/AMD สำหรับ SIMD)
Deoptimization
JIT optimize เผื่อ ว่า assumption (สมมติฐาน) บางอย่างจริง (เช่น method นี้ไม่ถูก override)
ตัวอย่าง: JIT เห็นว่า method ถูกเรียกจาก class A เท่านั้น จึง inline ไว้ — แต่ถ้า class B มา extend และ override → JIT ต้องทิ้ง optimization นั้น (deoptimize) แล้วคอมไพล์ใหม่กลับไป interpret + recompile
7.4 ดูว่า method ไหน hot
JIT จะ compile เฉพาะ method ที่ "ร้อน" (ถูกเรียกบ่อย) เป็น native code อยากรู้ว่ามี method ไหนบ้าง ใช้ flag -XX:+PrintCompilation ดูรายการ method ที่ถูก compile พร้อมเวลาและขนาด:
bash
java -XX:+PrintCompilation MyAppได้:
text
123 1 java.lang.String::hashCode (55 bytes)
456 3 s com.app.Foo::bar (12 bytes)- column 1: timestamp (ms)
- column 2: compile id
- column 3: compilation attributes/flags (เช่น
!= มี exception handler,s= synchronized,n= native) - column 4: tier level (1-4)
- column 5: method name
- column 6: bytecode size
ใช้ดูว่า hot method ของคุณคืออะไร
7.5 JIT warm-up
JIT ใช้เวลา "อุ่นเครื่อง" → app ช้ากว่า ใน 30 sec แรก, เร็วขึ้น หลังจากนั้น
นี่คือเหตุผลที่:
- Benchmark ต้อง warm-up ก่อน (ดูบท 19)
- App startup time + first request ช้า (ทางแก้คือ native image — ดูบทที่ 15 ของชุด Spring Boot)
Part 8: Concurrency Primitives ใน JVM
8.1 Thread — OS thread
java
Thread t = new Thread(() -> System.out.println("Hi"));
t.start();แต่ละ Platform Thread = 1 OS thread (1:1 mapping)
📝 คำว่า "Platform Thread" = Java Thread แบบเดิม (pre-Loom = ก่อนที่ Java จะมี Virtual Thread ใน Java 21) ที่ผูกกับ OS thread ตรง ๆ — แตกต่างจาก Virtual Thread (Java 21+) ซึ่งเป็น Java Thread เช่นกัน แต่ JVM จัดการเอง ไม่ใช่ 1:1 กับ OS thread (ดู ส่วน 8.3)
ปัญหา:
- OS thread หนัก (memory ~1MB stack, kernel resource)
- มีจำนวนจำกัด (~ พันถึงหมื่น)
- Block ระหว่างรอ I/O = thread ว่างกินที่
8.2 ExecutorService — pool
เนื่องจาก OS thread หนักและสร้างใหม่บ่อยไม่คุ้ม ExecutorService จึงใช้ "pool" ของ thread ที่สร้างไว้แล้วนำกลับมา reuse — submit งานเข้าไป pool จัดคิวให้ ลด overhead การสร้าง thread:
java
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> doWork());
pool.shutdown();- Reuse thread → reduce overhead
- จำกัด concurrency ได้
8.3 Virtual Threads (Project Loom, Java 21+)
java
Thread.startVirtualThread(() -> doWork());
// หรือ
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
pool.submit(() -> doWork());
}Virtual Thread = thread "เสมือน" — managed by JVM ไม่ใช่ OS:
- เบามาก (~few KB)
- สร้าง ล้านตัว ได้ใน 1 JVM
- ตอน block I/O — JVM unmount จาก carrier (real OS thread) → carrier ไปทำงานอื่น
- เหมาะกับ I/O-bound workload (HTTP server, DB call)
ไม่เหมาะกับ:
- CPU-bound (ใช้ ForkJoinPool หรือ Parallel Stream แทน)
- Native call ยาว (จะ pin carrier thread — pin = ตรึง virtual thread ไว้กับ OS thread จริง ทำให้เสีย benefit)
synchronizedblock ยาว (จะ pin carrier thread)- Java 21: ยังต้อง refactor
synchronized→ReentrantLockเพื่อไม่ pin - Java 24+: JEP 491 แก้ปัญหา
synchronizedpinning แล้ว — ไม่ต้อง refactor อีกต่อไป (การ refactor ยังเป็น good practice แต่ไม่ใช่ requirement บน Java 24+)
- Java 21: ยังต้อง refactor
💡 Game changer: code style เดิม (one thread per request, blocking I/O) ใช้ได้แล้วใน Java 21 — ไม่ต้องเขียน reactive/async แล้วก็ scale ได้ (ข้อแม้: virtual thread ช่วยเต็มที่กับงานที่ block รอ I/O เป็นหลัก — code แบบ CPU-bound หรือที่ใช้
synchronizedเยอะยังต้อง refactor ให้ได้ประโยชน์เต็มที่ ดู pitfall ด้านบน)
8.4 Structured Concurrency (Java 25 stable — JEP 505)
Structured Concurrency คือ pattern ที่บอกว่า task ย่อย (ลูก) จะต้องจบภายใน scope ของ task แม่เสมอ — ทำให้ manage task ง่ายและไม่รั่ว
⚠️ หมายเหตุ: API เปลี่ยนใน Java 25 (JEP 505 finalized) —
StructuredTaskScope.open()แทนnew StructuredTaskScope.ShutdownOnFailure()โค้ดด้านล่างใช้ API Java 25 ที่ stable แล้ว
java
import java.util.concurrent.StructuredTaskScope;
// สมมติมี method/record เหล่านี้ประกาศไว้แล้วในคลาสเดียวกัน (ตัวอย่างเพื่อสาธิต pattern เท่านั้น):
// User fetchUser() { ... } // ดึงข้อมูล user จาก DB/API
// Order fetchOrder(){ ... } // ดึงข้อมูล order จาก DB/API
// record Result(User user, Order order) {}
// Java 25+ API (JEP 505 stable)
// หมายเหตุ: ใส่ throws Exception ที่ method signature หรือ wrap ด้วย try-catch
// เพราะ scope.join() throws InterruptedException
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser());
var order = scope.fork(() -> fetchOrder());
scope.join();
return new Result(user.get(), order.get());
}ทำ parallel task แบบ scope-based — เลิก task พ่อ = task ลูกเลิกตาม
Part 9: Memory Leak — debug ยังไง
9.1 อาการ memory leak
- Heap ค่อย ๆ โต เรื่อย ๆ ไม่ลด
- Full GC บ่อย, ใช้เวลานาน, แต่ไม่ free memory
- ในที่สุด OutOfMemoryError: Java heap space
9.2 สาเหตุที่พบบ่อย
- Static collection — เอา object ใส่ static
Mapแล้วไม่ลบ - Listener / callback — register แล้วไม่ unregister
- ThreadLocal — ไม่ remove ทำให้ object ติด thread pool
- ClassLoader leak — web container reload แล้ว class เก่ายังอยู่
- Cache ไม่จำกัดขนาด —
HashMapที่ใส่ไม่หยุด → ใช้LinkedHashMap(LRU = Least Recently Used — ลบ entry ที่ไม่ได้ใช้นานที่สุดก่อน) หรือ Caffeine (library cache ยอดนิยม — ต้องเพิ่ม dependencycom.github.ben-manes.caffeine:caffeineใน pom.xml/build.gradle)
9.3 Tool ที่ใช้
📌 ก่อนใช้ tool เหล่านี้ — หา PID ของ app ก่อน
PID (Process ID — หมายเลขประจำตัวของ process ที่ OS ออกให้) คือตัวเลขที่ต้องใส่แทน
<pid>ในทุกคำสั่งด้านล่างวิธีหา PID:
bashjcmd # รัน jcmd ไม่มี argument → แสดงรายการ Java process ทั้งหมดพร้อม PID jps -l # เครื่องมืออีกตัวใน JDK — แสดง PID + ชื่อ class หลักบน Windows ยังดูได้ที่ Task Manager → แท็บ Details → ค้นหา
java.exeตัวอย่าง: ถ้า
jcmdแสดง12345 com.example.MyApp→ ใช้12345แทน<pid>
jcmd
bash
jcmd <pid> GC.heap_info # ดูสภาพ heap
jcmd <pid> GC.run # force GC (อย่าใช้ใน production!)
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jcmd <pid> Thread.print # thread dump
jcmd <pid> VM.native_memory summary # native memory trackingjstat
bash
jstat -gc <pid> 1000 # GC stats ทุก 1 วินาทีColumn:
S0C, S1C, S0U, S1U— Survivor capacity / usedEC, EU— Eden capacity / usedOC, OU— Old capacity / usedMC, MU— MetaspaceYGC, YGCT— Young GC count / total timeFGC, FGCT— Full GC count / total time
jmap (แนะนำใช้ jcmd แทน)
bash
jmap -histo <pid> # นับ object ตาม class
jmap -dump:live,file=heap.hprof <pid>📝 jmap ไม่ได้ deprecated officially ใน Java 25 — แต่ทีม OpenJDK แนะนำให้ใช้
jcmdแทนเพราะ option ครอบคลุมกว่า (jcmd <pid> GC.heap_dump,GC.class_histogramฯลฯ) และ jmap option บางตัวถูกย้ายไป jcmd
Eclipse MAT (Memory Analyzer Tool)
โปรแกรม GUI ฟรี — เปิด .hprof แล้ว:
- ดู Histogram — class ที่กิน memory สูงสุด
- ดู Dominator Tree — แผนภาพต้นไม้ที่แสดงว่า object ไหน "ยึด" memory เยอะที่สุด (ถ้าลบ object ที่อยู่บนสุดออก จะปลด memory ทั้ง subtree ได้)
- ดู Path to GC Roots — ทำไมยัง alive
- Leak Suspects report — วิเคราะห์อัตโนมัติหาจุดน่าสงสัย (rule-based)
JFR (Java Flight Recorder)
bash
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr MyApp→ เปิดด้วย JDK Mission Control (JMC) — โปรแกรม GUI ฟรีที่ bundled กับ JDK มาให้แล้ว ต่างจาก Eclipse MAT ตรงที่ดู profiling data แบบ timeline — เห็น GC, allocation hot spot, lock contention
(บท 19 จะลึกเรื่อง JFR + async-profiler)
9.4 ตัวอย่าง leak จริง
java
public class UserCache {
private static final Map<String, User> CACHE = new HashMap<>();
public User get(String id) {
return CACHE.computeIfAbsent(id, k -> loadFromDb(k));
}
}ปัญหา: CACHE ไม่มี TTL / size limit → user ไม่เคยลบ → memory leak
แก้:
java
private static final Cache<String, User> CACHE = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(Duration.ofMinutes(10))
.build();Part 10: Class Loading — ใต้พรมของ Spring/Hibernate
10.1 3 ขั้นตอน
- Loading — อ่าน
.classจาก disk/jar/network → byte array - Linking
- Verify — ตรวจ bytecode ปลอดภัย
- Prepare — จอง memory ของ static field (set default value)
- Resolve — แทน symbolic reference เป็น direct reference
- Initialization — รัน
<clinit>(static initializer) + assign static field
10.2 ClassLoader Hierarchy
Delegation model: เมื่อจะ load class — ถาม parent ก่อน. parent โหลดไม่ได้ค่อยลอง — เพื่อป้องกันไม่ให้ app override class มาตรฐาน (เช่น java.lang.String) ด้วย version ของตัวเอง ซึ่งจะทำให้ระบบไม่ปลอดภัย
10.3 ClassLoader Leak
ใน Tomcat/Spring Boot:
text
Web App v1 → ClassLoader_v1 (load class ของ webapp + Spring + Hibernate)
Reload → ควรปล่อย ClassLoader_v1
Web App v2 → ClassLoader_v2
แต่ถ้ามี static field ของ class ใน v1 ยังถูกอ้างอิงจากภายนอก (เช่น JDBC driver)
→ ClassLoader_v1 ไม่ถูก GC → Metaspace ค่อย ๆ โต → OOMวิธีแก้:
- ใช้ short-lived JVM (deploy ใหม่ทั้งหมดแทน hot reload — Docker / K8s)
- ระวัง JDBC driver, log framework, ThreadLocal
Part 11: Modern Improvements (Java 17 → 25)
11.1 ZGC Generational — timeline
(ดูรายละเอียดเต็มที่ Part 4.4) สรุปสั้น:
| Version | สถานะ ZGC |
|---|---|
| Java 21 | opt-in: -XX:+UseZGC -XX:+ZGenerational |
| Java 23 | generational เป็น default ของ -XX:+UseZGC |
| Java 24 | non-generational deprecated |
| Java 25 | non-generational ลบทิ้ง — +UseZGC = generational เสมอ |
bash
# Java 25 (ปี 2026):
-XX:+UseZGC # = Generational ZGC โดยอัตโนมัติ11.2 Compact Object Headers (Java 25 stable, JEP 519)
ทุก object ใน Java มี "header" เก็บ metadata — ฟีเจอร์นี้ pack mark word + klass pointer เข้าใน 8 bytes:
ลด header จาก 12 bytes (compressed default) → 8 bytes → ประหยัด heap ~10-20% สำหรับ object เล็ก ๆ
bash
-XX:+UseCompactObjectHeaders # Java 25 stable (JEP 519)หมายเหตุข้อมูลที่หลายเอกสารเขียนผิด: "ลดจาก 16 → 8" หรือ "header default 16 bytes" — ตัวเลข 16 รวม alignment padding แล้ว ส่วน header จริง ของ HotSpot 64-bit + compressed oops คือ 12 bytes ตัวที่ JEP 519 ลดคือ 12 → 8
11.3 String Deduplication
แอปจริงมักมี String ที่มีเนื้อหาซ้ำกันเต็ม heap — ฟีเจอร์นี้ให้ GC ตรวจเจอแล้วให้ String เหล่านั้นใช้ char[] ตัวเดียวกัน ประหยัดหน่วยความจำโดยไม่ต้องแก้โค้ด:
G1 + ZGC → JVM ตรวจ String ที่มี content เดียวกัน → share char[] array
bash
-XX:+UseStringDeduplication11.4 Virtual Threads + Pinning Detection
virtual thread (Java 21) เบามากแต่มีกับดัก "pinning" — ถ้าติด synchronized มันจะถูกตรึงกับ OS thread ทำให้เสียประโยชน์ flag นี้ช่วยตรวจหาจุดที่เกิด pinning เพื่อแก้ไข:
bash
-Djdk.tracePinnedThreads=fullดูว่า virtual thread ของคุณติด synchronized ตรงไหน
11.4.5 Class-Data Sharing (CDS / AppCDS) — start เร็วขึ้น
ปัญหา: ทุกครั้ง JVM start ต้อง load + verify class ทั้งหมด → ใช้เวลา 100-500ms (พัน class)
CDS = pre-process class metadata ไว้ใน "archive file" → JVM mmap (memory-mapped file — โหลดไฟล์เข้า memory โดยตรงโดยไม่ต้องอ่านทีละไบต์ เร็วกว่ามาก) เข้า memory ตอน start (ไม่ต้อง parse ใหม่)
Java built-in (system classes)
CDS เปิด default ตั้งแต่ Java 12 — java.base, java.util ฯลฯ ถูก pre-process ไว้ใน classes.jsa ที่อยู่ใน JDK
AppCDS — pre-process class ของ app เอง
bash
# Step 1: บันทึก class list ตอน run จริง
java -XX:DumpLoadedClassList=app.classlist -jar myapp.jar
# Step 2: สร้าง archive จาก list
# ⚠️ หมายเหตุ: app ต้อง exit เองหลัง startup ไม่งั้น step นี้จะค้างไม่จบ
# สำหรับ Spring Boot fat jar (ที่เป็น server จะไม่ exit เอง) ต้องบังคับให้ exit:
# วิธีที่ 1: เพิ่ม --spring.main.web-application-type=none + CommandLineRunner ที่เรียก System.exit(0)
# วิธีที่ 2: ใช้ Spring Boot Actuator endpoint /actuator/shutdown (ต้อง enable ก่อน)
# วิธีที่ 3: ใช้ -Xshare:dump พร้อม -XX:DumpLoadedClassList ใน dry-run profile แยกต่างหาก
java -XX:SharedClassListFile=app.classlist \
-XX:SharedArchiveFile=app.jsa \
-Xshare:dump -jar myapp.jar
# Step 3: run จริงด้วย archive
java -XX:SharedArchiveFile=app.jsa -jar myapp.jarผลลัพธ์: start time ลด 30-50% (Spring Boot app ~3s → 1.5s) (ตัวเลขขึ้นกับ app size และจำนวน class — ลองวัดเองสำหรับแต่ละ app หรือดู benchmark จาก Spring Boot team documentation)
AOT Class Loading & Linking (Java 24+, JEP 483)
ใหม่กว่า AppCDS — pre-link class + resolve method handle ก่อนด้วย:
bash
# Training run + create AOT cache
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar myapp.jar
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar myapp.jar
# Production run
java -XX:AOTCache=app.aot -jar myapp.jarstart time ลดเหลือ ~30% ของเดิม — ใกล้ native-image แต่ยังได้ JIT optimization ระยะยาว
💡 ใช้กับ serverless / k8s pod ที่ scale บ่อย — ลด cold start ได้มาก
11.5 Coordinated Restore at Checkpoint (CRaC, preview)
ปัญหาคลาสสิกของ Java คือ startup ช้า (JIT ต้องอุ่นเครื่อง) CRaC แก้โดย "ถ่ายภาพ" (snapshot) สถานะ JVM ที่อุ่นแล้วเก็บไว้ แล้ว restore กลับในไม่กี่ ms — ลด startup จากวินาทีเหลือหลักสิบ ms:
CRaC ทำงานโดย ถ่ายภาพสถานะทั้งหมดของ JVM (heap, thread, connection) ขณะที่ app อุ่นตัวแล้ว → บันทึกลง disk (snapshot) → ครั้งต่อไปที่ start ก็ restore สถานะนั้นกลับมาแทนการเริ่มจากศูนย์ → start time → ~50ms (vs 5 sec) — ตัวเลขขึ้นกับ workload (heap size, pre-warmed state) อย่างมาก
Part 12: Cheat Sheet สำหรับ Production
12.1 Spring Boot service ทั่วไป (Java 21, K8s)
ชุด JVM flag สำเร็จรูปสำหรับ Spring Boot service ทั่วไปที่รันบน Kubernetes — ใช้ G1GC, ตั้ง heap เป็นเปอร์เซ็นต์ของ RAM (สำคัญใน container), เปิด heap dump เมื่อ OOM และ GC log ก๊อปไปปรับใช้ได้เลย:
bash
java \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:MaxRAMPercentage=75 \
-XX:InitialRAMPercentage=75 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap.hprof \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=10m \
-jar app.jar
# หมายเหตุ: เคยเห็น `-Djava.security.egd=file:/dev/./urandom` (flag เร่งความเร็ว random number generator บน Linux) ในชุด flag เก่า ๆ
# ตั้งแต่ Java 11+ ไม่จำเป็น (SecureRandom ไม่ block บน Linux แล้ว) — ตัดออกได้12.2 Low-latency service (real-time, trading)
สำหรับบริการที่ต้องการ pause ต่ำสุด (real-time, trading) ใช้ชุด flag ต่างออกไป — ZGC (pause ต่ำมาก), AlwaysPreTouch + heap คงที่ (-Xms = -Xmx) เพื่อลดความแปรปรวนของ latency:
bash
# Java 21-22: ต้องระบุ ZGenerational ชัด ๆ
java \
-XX:+UseZGC -XX:+ZGenerational \
-Xms8g -Xmx8g \
-XX:+AlwaysPreTouch \
-XX:+HeapDumpOnOutOfMemoryError \
-jar app.jar
# Java 23+/25 (แนะนำ): ZGenerational เป็น default แล้ว ไม่ต้องระบุ
# หมายเหตุ: เลือกอย่างใดอย่างหนึ่งระหว่าง -Xms/-Xmx (heap คงที่) หรือ MaxRAMPercentage
# ไม่ควรใช้ทั้งสองพร้อมกัน เพราะ -Xmx จะ override MaxRAMPercentage
java \
-XX:+UseZGC \
-Xms8g -Xmx8g \
-XX:+AlwaysPreTouch \
-XX:+HeapDumpOnOutOfMemoryError \
-jar app.jar12.3 Batch job (throughput-first)
bash
java \
-XX:+UseParallelGC \
-Xms4g -Xmx4g \
-XX:+AlwaysPreTouch \
-jar batch.jarPart 13: Checkpoint สำคัญ
ตอบให้ได้ก่อนข้ามบท:
- Heap ต่างจาก Stack ยังไง? Object เก็บไว้ที่ไหน?
- Generational Hypothesis คืออะไร? ทำไม Young Gen แบ่งเป็น Eden + Survivor 2 ตัว?
- G1 GC ต่างจาก Parallel GC ยังไง?
- ZGC ใช้เมื่อไหร่? trade-off?
- ความต่างระหว่าง
volatile,synchronized,AtomicInteger? - happens-before คืออะไร?
- JIT level 0-4 หมายถึงอะไร? warm-up เป็นปัญหายังไง?
- Virtual Thread ต่างจาก Platform Thread? เหมาะกับ workload แบบไหน?
- Metaspace ต่างจาก Heap? อะไรอยู่ที่ Metaspace?
- ถ้าเจอ
OutOfMemoryError: Java heap spaceคุณจะทำอะไรเป็นขั้นตอนแรก?
Part 14: Lab — ลองด้วยตัวเอง
Lab 1: ดู GC log
java
public class GcDemo {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000_000; i++) {
byte[] b = new byte[1024]; // allocate 1KB
if (i % 10_000 == 0) Thread.sleep(10);
}
}
}bash
javac GcDemo.java
java -Xms64m -Xmx64m -Xlog:gc -XX:+UseG1GC GcDemoสังเกตจำนวน GC + pause time
ลองเปลี่ยน:
-XX:+UseSerialGC-XX:+UseParallelGC-XX:+UseZGC(ถ้า Java 15+)
→ เห็นพฤติกรรมต่างกัน
Lab 2: ทำ leak จริง
ฝึกสร้าง memory leak ของจริงด้วย static collection ที่เก็บ object ไว้เรื่อย ๆ ไม่ปล่อย — แล้วรันจน OutOfMemoryError เพื่อดู heap dump และฝึกหาสาเหตุ (static field คือต้นเหตุ leak ที่พบบ่อยสุด):
java
import java.util.ArrayList;
import java.util.List;
public class Leak {
static List<byte[]> bucket = new ArrayList<>();
public static void main(String[] args) {
while (true) {
bucket.add(new byte[1_000_000]); // 1MB
}
}
}bash
# Linux/macOS:
java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/leak.hprof Leak
# Windows: ใช้ path แบบ Windows หรือ relative path
# java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=leak.hprof Leak→ ได้ heap dump. เปิดใน Eclipse MAT — เห็น bucket กิน 99% memory
Lab 3: ดู JIT compile
java
public class JitDemo {
public static int sum(int n) {
int s = 0;
for (int i = 0; i < n; i++) s += i;
return s;
}
public static void main(String[] args) {
for (int i = 0; i < 100_000; i++) {
sum(1000);
}
}
}bash
java -XX:+PrintCompilation JitDemo→ เห็น sum ถูก compile ที่ level 3 → level 4
Part 15: สรุปบทนี้
- JVM = Class Loader + JIT + GC + Memory Manager ทำงานร่วมกัน
- Heap เก็บ object → GC จัดการ. Stack เก็บ local variable per thread
- Generational GC: Young (Eden + S0 + S1) → Old. Object ส่วนใหญ่ตายเร็ว
- G1 (default) balance pause/throughput. ZGC สำหรับ low-latency
- JMM กำหนด visibility/ordering ของ multi-thread.
volatile,synchronized,Atomicแต่ละแบบใช้คนละโจทย์ - JIT ทำให้ Java เร็วใกล้ C — interpret → C1 → C2 + escape analysis + inline + vectorize
- Virtual Thread (Java 21) เปลี่ยน Java เป็น "I/O-friendly" ที่ทุกคน scale ได้
- Memory leak หาด้วย heap dump + Eclipse MAT + JFR
- Production flag = G1 + max-ram-percentage + heap dump on OOM + GC log
บทต่อไป — เราจะลงไปดูชั้นที่อยู่ใต้ JPA: JDBC + Connection Pool — เพราะถ้าไม่เข้าใจตรงนี้ debug "connection leak" ใน Spring ไม่ออก
← บทที่ 15 | สารบัญ | บทที่ 17: JDBC + Connection Pool →
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-23