Skip to content

บทที่ 16 — JVM Internals + Memory Model + Garbage Collection (ลึก)

← บทที่ 15 | สารบัญ | บทที่ 17: JDBC + Connection Pool →

📓 โซนขั้นสูง—ข้ามได้ (บท 11-19) บทนี้เป็น บทที่ลึกที่สุดและศัพท์เทคนิคหนักที่สุดของเล่ม — เจาะระบบภายในของ JVM ระดับ senior มือใหม่ข้ามไปก่อนได้แน่นอน ไม่จำเป็นต่อการเขียน Java ให้ทำงาน ค่อยกลับมาตอนต้องแก้ปัญหา memory/ความเร็วของ app จริงในงาน หรือเตรียมสัมภาษณ์ระดับสูง บรรทัด "บทนี้คือบังคับ" ข้างล่างหมายถึง "บังคับสำหรับคนที่อยากเป็น senior" ไม่ใช่บังคับสำหรับมือใหม่

ศัพท์ย่อที่จะเจอ (เลื่อนกลับมาดูได้ตลอด):

ย่อขยาย / ความหมาย
JVMเครื่องเสมือนที่รัน Java
bytecodeโค้ดกลางที่ JVM อ่าน
JITJust-In-Time — compile bytecode → machine code ขณะรัน
AOTAhead-Of-Time — compile เป็น native ก่อน run
GCGarbage Collector — เก็บกวาดหน่วยความจำที่ไม่ใช้แล้ว
STWStop-the-World — หยุดทุก thread ชั่วคราวระหว่าง GC
CASCompare-And-Swap — atomic operation (ทำสำเร็จทีเดียวโดยไม่ถูกแทรกกลาง) สำหรับการเข้าถึงข้อมูลร่วมกันระหว่าง thread โดยไม่ต้องล็อก (lock-free)
OOP / oopsOrdinary Object Pointer — การชี้ object ใน heap (compressed = ย่อเป็น 32-bit) (ไม่ใช่ OOP = Object-Oriented Programming นะ — คนละความหมายกัน ในบทนี้ oops = pointer ภายใน JVM)
heapบริเวณ memory เก็บ object
stackmemory เก็บการเรียก method
G1 / ZGCอัลกอริทึม GC สองตัวที่นิยม
JMMJava Memory Model — กฎ memory ระหว่างเธรด
DCLDouble-Checked Locking — pattern lazy init แบบ thread-safe
JEPJDK Enhancement Proposal — ข้อเสนอ feature ใหม่ของ Java
NMTNative Memory Tracking — เครื่องมือติดตาม memory นอก heap
JFRJava Flight Recorder — profiler built-in
JMHJava Microbenchmark Harness — เครื่องมือวัดความเร็วของ code ขนาดเล็ก (micro = เล็กมาก, harness = โครงเครื่องมือ)
MATMemory Analyzer Tool (Eclipse)
PermGenPermanent Generation — heap region เก่าก่อน Java 8 ที่เก็บ class metadata (ถูกแทนที่ด้วย Metaspace)
OOMOutOfMemoryError — หน่วยความจำเต็ม
K8sKubernetes (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

มันไม่ใช่แค่ "รันโปรแกรม" — มันคือ:

  1. Class Loader หา HelloWorld.class → load bytecode เข้า memory
  2. Bytecode Verifier ตรวจว่า bytecode ปลอดภัย (ไม่มี stack overflow, type confusion)
  3. JIT Compiler แปลง bytecode → machine code (เริ่ม interpret ก่อน, compile ทีหลังถ้า hot = ถูกเรียกบ่อย — รายละเอียดใน Part 7)
  4. Execution Engine รันโค้ดบน CPU จริง
  5. Garbage Collector ทำงาน background — เก็บ object ที่ไม่ใช้แล้ว
  6. 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ผู้สร้างจุดเด่น
HotSpotOracle / OpenJDKมาตรฐาน, ใช้กันมากที่สุด
GraalVMOracle Labscompile เป็น native image ได้ (start เร็ว)
OpenJ9Eclipse / IBMใช้ memory น้อย (เหมาะ container)
Azul Zing/PrimeAzul SystemsGC pause ต่ำมาก (พิเศษ enterprise)
Amazon CorrettoAWSฟรี LTS distribution ของ OpenJDK
Eclipse TemurinAdoptiumของฟรี 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, ireturni หมายถึง 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 พื้นที่หลัก:

  1. Heap — object ที่เราสร้างด้วย new อยู่ที่นี่ → GC จัดการตรงนี้
  2. Stack — local variable, parameter, frame ของ method call → ของแต่ละ thread แยกกัน
  3. Metaspace — class definition, method bytecode, constant pool
  4. 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 ก้อนหลัก:

  1. Direct ByteBuffer — buffer ที่จองหน่วยความจำโดยตรงนอก heap สำหรับ I/O ความเร็วสูง (Netty = framework เครือข่าย, Kafka client = client ของระบบ message queue — ใช้เยอะ)
  2. JIT Code Cache — machine code ที่ JIT compile แล้ว
  3. Thread Stack — รวมขนาดทุก thread × stack size
  4. 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 ทำ:

  1. หาที่ว่างใน Eden
  2. จองพื้นที่เท่าขนาด object (header + field)
  3. set header bits (mark word, class pointer)
  4. clear field (default value: int=0, ref=null)
  5. รัน constructor
  6. คืน 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 เต็ม:

  1. STW (Stop-the-World) — หยุดทุก thread ใน app
  2. GC traverse จาก GC Roots (static field, local var ของ thread ที่ active, JNI ref) — หา object ที่ "ยังถูกอ้างอิง"
  3. Live object ใน Eden → copy ไป Survivor S0
  4. Eden → clear ทั้งหมด (เร็ว — แค่ reset pointer)
  5. 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 — เป็นหัวข้อขั้นสูง ยังไม่ต้องรู้ตอนนี้) แทน synchronized

lock 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ใช้เมื่อThroughputPauseHeap size
Serial GC1 thread, small appต่ำสูง<100MB
Parallel GC (default ก่อน Java 9)batch job, throughput-firstสูงสูงใหญ่
G1 GC (default ตั้งแต่ Java 9)server appกลางต่ำ-กลางกลาง-ใหญ่
ZGClow-latency, huge heapกลางต่ำมาก (< 10 ms pre-Java 21; < 1 ms Java 21+ Generational ZGC — ดู Part 4.4)ใหญ่มาก (TB)
Shenandoahlow-latency (Red Hat)กลางต่ำมากใหญ่
Epsilonno-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      # ขนาด region

ZGC (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
-Xmx16g

Java 15+ → ZGC ใช้ใน production ได้ทั่วไป Java 21+ → Generational ZGC (เร็วกว่าเดิม)

Shenandoah (Red Hat)

คล้าย ZGC แต่ใช้ Brooks pointer (pointer พิเศษที่ชี้ไปยัง copy ของ object ที่กำลังย้าย ทำให้ relocate ได้ขณะ app รัน) + concurrent compact

Flag:

bash
-XX:+UseShenandoahGC

Epsilon (Java 11+)

"No-op GC" — ไม่ทำอะไรเลย เมื่อ heap เต็มก็ OOM

ใช้ทำอะไร? — benchmark (วัด allocation rate โดยไม่มี noise จาก GC)

bash
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC

4.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 รอบที่ 0
  • Pause Young (Normal) — minor GC, STW
  • G1 Evacuation Pause — algorithm ที่ใช้
  • 50M->5M — heap ก่อน → หลัง (45MB freed)
  • (512M) — total heap
  • 12.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 log

5.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 รับประกัน:

  1. Atomicity — operation ที่เกิดทีเดียว (ไม่ถูกแทรก)
  2. Visibility — thread อื่นเห็นการเปลี่ยน
  3. Ordering — code execute ตามลำดับ (compiler/CPU อาจ reorder)

6.3 happens-before

ความสัมพันธ์ที่บอกว่า "A เกิดก่อน B และ B เห็นผลของ A"

มี happens-before เมื่อ:

  • คำสั่งใน thread เดียวกัน เรียงตามลำดับ
  • synchronized release → acquire ของ lock เดียวกัน
  • volatile write → read
  • Thread.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 optimization

Method ที่เรียกบ่อย ๆ → ขึ้น 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)
  • synchronized block ยาว (จะ pin carrier thread)
    • Java 21: ยังต้อง refactor synchronizedReentrantLock เพื่อไม่ pin
    • Java 24+: JEP 491 แก้ปัญหา synchronized pinning แล้ว — ไม่ต้อง refactor อีกต่อไป (การ refactor ยังเป็น good practice แต่ไม่ใช่ requirement บน Java 24+)

💡 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 สาเหตุที่พบบ่อย

  1. Static collection — เอา object ใส่ static Map แล้วไม่ลบ
  2. Listener / callback — register แล้วไม่ unregister
  3. ThreadLocal — ไม่ remove ทำให้ object ติด thread pool
  4. ClassLoader leak — web container reload แล้ว class เก่ายังอยู่
  5. Cache ไม่จำกัดขนาดHashMap ที่ใส่ไม่หยุด → ใช้ LinkedHashMap (LRU = Least Recently Used — ลบ entry ที่ไม่ได้ใช้นานที่สุดก่อน) หรือ Caffeine (library cache ยอดนิยม — ต้องเพิ่ม dependency com.github.ben-manes.caffeine:caffeine ใน pom.xml/build.gradle)

9.3 Tool ที่ใช้

📌 ก่อนใช้ tool เหล่านี้ — หา PID ของ app ก่อน

PID (Process ID — หมายเลขประจำตัวของ process ที่ OS ออกให้) คือตัวเลขที่ต้องใส่แทน <pid> ในทุกคำสั่งด้านล่าง

วิธีหา PID:

bash
jcmd          # รัน 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 tracking

jstat

bash
jstat -gc <pid> 1000      # GC stats ทุก 1 วินาที

Column:

  • S0C, S1C, S0U, S1U — Survivor capacity / used
  • EC, EU — Eden capacity / used
  • OC, OU — Old capacity / used
  • MC, MU — Metaspace
  • YGC, YGCT — Young GC count / total time
  • FGC, 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 ขั้นตอน

  1. Loading — อ่าน .class จาก disk/jar/network → byte array
  2. Linking
    • Verify — ตรวจ bytecode ปลอดภัย
    • Prepare — จอง memory ของ static field (set default value)
    • Resolve — แทน symbolic reference เป็น direct reference
  3. 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 21opt-in: -XX:+UseZGC -XX:+ZGenerational
Java 23generational เป็น default ของ -XX:+UseZGC
Java 24non-generational deprecated
Java 25non-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:+UseStringDeduplication

11.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.jar

start 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.jar

12.3 Batch job (throughput-first)

bash
java \
  -XX:+UseParallelGC \
  -Xms4g -Xmx4g \
  -XX:+AlwaysPreTouch \
  -jar batch.jar

Part 13: Checkpoint สำคัญ

ตอบให้ได้ก่อนข้ามบท:

  1. Heap ต่างจาก Stack ยังไง? Object เก็บไว้ที่ไหน?
  2. Generational Hypothesis คืออะไร? ทำไม Young Gen แบ่งเป็น Eden + Survivor 2 ตัว?
  3. G1 GC ต่างจาก Parallel GC ยังไง?
  4. ZGC ใช้เมื่อไหร่? trade-off?
  5. ความต่างระหว่าง volatile, synchronized, AtomicInteger?
  6. happens-before คืออะไร?
  7. JIT level 0-4 หมายถึงอะไร? warm-up เป็นปัญหายังไง?
  8. Virtual Thread ต่างจาก Platform Thread? เหมาะกับ workload แบบไหน?
  9. Metaspace ต่างจาก Heap? อะไรอยู่ที่ Metaspace?
  10. ถ้าเจอ 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