Bỏ qua đến nội dung
Quay lại

Java Hoạt Động Thực Sự Như Thế Nào: Bên Trong JVM Từ Bytecode Đến Garbage Collection

Đã đăng:  at  10:00 SA

Có lẽ ngày nào bạn cũng viết những dòng code như thế này:

public static void main(String[] args) {
    User user = new User("Hung");
    process(user);
}

Thoạt nhìn, mọi thứ trông thật đơn giản. Tạo một đối tượng. Truyền nó vào một phương thức. Xong.

Nhưng đây chính là nơi JVM trở nên thú vị. Giữa lúc bạn nhấn Enter và lúc đoạn code này thực sự chạy trên CPU, cả một cỗ máy bắt đầu hoạt động: một trình biên dịch sinh ra các chỉ thị trung gian, một runtime environment xác minh, nạp và liên kết các class, một interpreter âm thầm chạy code của bạn trong khi profiling nó, một just-in-time compiler viết lại code thành chỉ thị native, bộ nhớ được cấp phát từ những vùng bạn không bao giờ nhìn thấy, và cuối cùng, một garbage collector quyết định — theo lịch trình riêng của nó — khi nào đối tượng User được phép chết.

Bài viết này là một chuyến đi thực địa vào bên trong cỗ máy đó. Chúng ta sẽ theo dõi một chương trình nhỏ từ mã nguồn đến CPU, trả lời một câu hỏi ở mỗi bước:

Điều gì thực sự xảy ra từ lúc Java code bắt đầu chạy cho đến khi các đối tượng được tạo, sử dụng, và cuối cùng được dọn dẹp?

Bạn đã biết cú pháp Java. Bài viết này bàn về thứ đang chạy bên dưới nó.

1. Bức Tranh Lớn — Điều Gì Xảy Ra Khi Bạn Chạy Một Chương Trình Java?

Hành trình trong một sơ đồ

  .java source

      │  javac (trình biên dịch)

  .class file  ────  bytecode: các chỉ thị độc lập với nền tảng

      │  java <Main>  (launcher khởi động JVM)

  ┌────────────────────────── JVM ──────────────────────────┐
  │  Class loading  →  Bytecode verification  →  Linking   │
  │                                                         │
  │  Execution engine:                                      │
  │    Interpreter ──(phát hiện hot method)──► JIT compiler │
  │        │                                   │            │
  │        └─────────► native machine code ◄────┘            │
  │                                                         │
  │  Runtime services: GC  ·  Threads  ·  Exceptions  · ... │
  └──────────────────────────┬──────────────────────────────┘


                           CPU

Java bytecode thực chất là gì

Khi bạn chạy javac Main.java, trình biên dịch không tạo ra machine code cho bộ xử lý x86 hay ARM của laptop bạn. Nó tạo ra một file .class chứa bytecode — một tập lệnh được định nghĩa bởi Java Virtual Machine Specification, không phải bởi bất kỳ phần cứng thật nào.

Mỗi chỉ thị bytecode dài đúng một byte (do đó có tên gọi), và chỉ có vài trăm chỉ thị. Những chỉ thị điển hình:

Bạn có thể xem bytecode của bất kỳ class nào đã biên dịch bằng một công cụ bạn đã có sẵn:

javap -c Main

Phần sau của bài viết này chúng ta sẽ đọc đúng bytecode của chương trình ví dụ. Đây là lý do đầu tiên Java “chạy ở mọi nơi”: trình biên dịch nhắm đến một cỗ máy hư cấu, và nhiệm vụ của JVM trên mỗi nền tảng là biến cỗ máy hư cấu đó thành hiện thực.

Vì sao Java có thể chạy trên nhiều hệ điều hành khác nhau

“Write once, run anywhere” không phải phép màu — đó là một sự ủy quyền. Java code được biên dịch một lần, thành bytecode. JVM là môi trường thực thi chuyển bytecode đó thành chỉ thị native của hệ điều hành và CPU mà nó đang chạy: macOS cần một bản dựng JVM, Linux một bản khác, Windows một bản khác nữa — nhưng tất cả đều chạy đúng một file .class giống hệt nhau.

Nói cách khác, JVM là lớp tương thích. Chương trình của bạn không bao giờ nói chuyện trực tiếp với hệ điều hành về bộ nhớ, thread hay thực thi code; nó nói chuyện với JVM, và JVM nói chuyện với hệ điều hành. Thứ có tính di động không phải là chương trình — mà là hợp đồng runtime.

Class loading ở mức tổng quan

Trước khi bất kỳ code nào chạy, JVM phải tìm và nạp các class cần thiết. Việc này do các class loader đảm nhiệm, được tổ chức theo thứ bậc:

                     ┌──────────────────────────────┐
                     │  Bootstrap class loader      │  các class lõi của JDK
                     │  (java.base, java.lang, ...) │  (java/lang/String, ...)
                     └──────────────┬───────────────┘
                                    │  parent
                     ┌──────────────▼───────────────┐
                     │  Platform class loader        │  các module JDK
                     └──────────────┬───────────────┘
                                    │  parent
                     ┌──────────────▼───────────────┐
                     │  Application class loader     │  các class của bạn trên
                     │  (classpath)                  │  classpath
                     └──────────────────────────────┘

Khi cần một class, quá trình nạp của JVM yêu cầu class loader của nó, và class loader bình thường sẽ ủy quyền lên parent trước — một mô hình đảm bảo chẳng hạn java.lang.String luôn đến từ JDK và không bao giờ bị class trên classpath thay thế.

Khi tìm thấy file class, nó được verify (JVM kiểm tra bytecode có đúng cấu trúc và type-safe — đó là lý do bytecode rác không thể làm sập JVM theo cách machine code rác có thể làm sập hệ điều hành), sau đó link, rồi — chỉ khi được dùng thực sự lần đầu — được initialize (các đoạn static initializer chạy). Class được nạp một cách lười biếng: lúc khởi động, JVM chỉ nạp những gì cần.

Interpreter vs JIT compiler — và vì sao warm-up lại quan trọng

Giờ phương thức main đã được nạp và sẵn sàng thực thi. Nhưng thực thi bằng cách nào? Có hai chiến lược:

HotSpot hiện đại làm cả hai, theo từng bậc. Một phương thức bắt đầu được interpret. Trong lúc interpret, JVM âm thầm profiling nó: nó được gọi bao nhiêu lần, nhánh nào được rẽ, kiểu dữ liệu nào thực sự truyền vào tham số? Sau đó profile được dùng để tối ưu hóa và inline tốt hơn, rồi phương thức được biên dịch dần sang native code — đầu tiên bởi C1 (trình biên dịch “client”: biên dịch nhanh, tối ưu khiêm tốn), và nếu vẫn còn hot, bởi C2 (trình biên dịch “server”: biên dịch chậm, tối ưu mạnh tay).

Đây là lý do các ứng dụng Java chạy lâu có thể nhanh dần sau warm-up: request đầu tiên bị interpret, request thứ triệu đang chạy native code được tối ưu rất sâu, dựa trên dữ liệu thu thập từ chính workload thật của bạn. Đây cũng là lý do micro-benchmark không có bước warm-up vô nghĩa, và bạn không bao giờ nên đánh giá hiệu năng của một API Java chỉ bằng một lần gọi lạnh.

2. Một Phương Thức Được Gọi — Điều Gì Thực Sự Xảy Ra?

Đến lúc lần theo process(user).

Khi JVM khởi động main, nó tạo một thread cho main. Mỗi thread trong JVM có một JVM stack riêng — cấu trúc LIFO nơi mỗi lần gọi phương thức chiếm một stack frame. Khi main gọi process, JVM đẩy một frame mới lên stack. Khi process trả về, frame bị pop ra.

    Thread "main"
┌─────────────────────────────────────┐
│  JVM stack                         │
│  ┌───────────────────────────────┐  │
│  │ frame của process(...)        │  │  ← được đẩy lên khi gọi process
│  │   local variables             │  │
│  │   operand stack (rỗng)        │  │
│  │   return address / frame data │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │ frame của main(...)           │  │  ← đã có sẵn
│  │   args        (ô 0)           │  │
│  │   user        (ô 1)           │  │  ← chứa tham chiếu đến User
│  │   operand stack               │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

Về mặt khái niệm, một frame chứa ba thứ (bố cục chính xác là chi tiết triển khai):

Gọi process với frame đang nằm trên cùng:

  1. Caller push các tham số lên operand stack của nó.
  2. JVM nhận ra invokestatic process, tạo một frame mới cho process, và copy các tham số vào các ô local của callee.
  3. Thân phương thức chạy — cũng theo cách đó, mỗi chỉ thị tác động lên operand stack riêng của frame.
  4. Khi gặp return, giá trị trả về được push lên operand stack của caller, và frame của callee bị vứt đi.

Đệ quy và StackOverflowError

Vì mỗi lần gọi cần một frame, đệ quy sâu làm stack của thread phình ra hết frame này đến frame khác — các frame không “tái sử dụng” gì cả. Stack của thread có kích thước cố định (trong HotSpot, thường 512 KB đến 1 MB cho mỗi thread; cấu hình bằng -Xss). Khi đệ quy làm cạn kiệt nó, JVM ném StackOverflowError — không phải vì bạn hết heap, mà vì bạn hết stack. Đây cũng là lý do thuật toán đệ quy trên đầu vào lớn (cây sâu, danh sách lớn) thất bại ở chỗ một ngăn xếp tường minh trên heap lại thành công.

Sự đơn giản hóa quá mức bạn cần gỡ bỏ

Giáo trình thường dạy:

“Primitive nằm trên stack. Đối tượng nằm trên heap.”

Đây là một mô hình tinh thần hữu ích, nhưng không đầy đủ — và trong một số trường hợp, sai rõ ràng.

Điều đúng:

Điều sai, hoặc ít nhất là không được đảm bảo:

Vậy mô hình “primitive = stack, object = heap” giải thích runtime khái niệm chứ không phải thực thi thật, và thực thi thật được phép — và thường xuyên — làm một điều gì đó thông minh hơn.

3. Điều Gì Thực Sự Xảy Ra Khi Bạn Viết new User("Hung")?

Dòng này xứng đáng được điều tra riêng. Xem bytecode trước — javap -c trên một class đã biên dịch chứa dòng đó sẽ hiện:

 0: new           #2     // class User
 3: dup
 4: ldc           #3     // String "Hung"
 6: invokespecial #4     // Method User."<init>":(Ljava/lang/String;)V
 9: astore_1             // lưu tham chiếu vào ô local variable 1

Đọc ngược lại từ ngôn ngữ bậc cao:

  1. new — cấp phát bộ nhớ cho đối tượng. Chưa có gì trong đối tượng được khởi tạo; JVM chỉ dành chỗ và chuẩn bị nó (trong HotSpot điều này thường là: lấy một vùng bộ nhớ đã zero từ thread-local allocation buffer — TLAB, nơi các thread cấp phát nóng chỉ cần nudge con trỏ thay vì tranh chấp toàn cục — và gắn một object header).
  2. dup — nhân đôi tham chiếu, vì lời gọi constructor sẽ tiêu thụ một bản, nhưng chúng ta cần bản còn lại để lưu vào biến cục bộ sau đó.
  3. ldc "Hung" — nạp hằng số string lên operand stack.
  4. invokespecial User.<init> — gọi constructor, tiêu thụ tham chiếu và tham số.
  5. astore_1 — lưu tham chiếu còn lại vào ô local variable 1: user = ...;.

Toàn bộ vòng đời của đối tượng hiện ra trong sáu chỉ thị bytecode.

Từng bước: máy làm gì

Bước 1 — new: bộ nhớ được cấp phát. “Heap” là một khái niệm; cách triển khai cấp phát từ một vùng do garbage collector quản lý. HotSpot cấp cho mỗi thread một TLAB — một lát riêng của vùng young generation. Cấp phát chỉ là đẩy con trỏ về phía trước, rẻ đến mức sánh ngang stack allocation (và thực tế nó trở thành y hệt stack allocation sau escape analysis, xem Phần 9).

Bước 2 — Bộ nhớ được khởi tạo về giá trị mặc định. Vùng vừa cấp phát được zero hóa. Do đó, field int đọc ra 0, reference đọc ra null, boolean đọc ra falsetrước khi bất kỳ constructor nào chạy. Đây là một phần của ngôn ngữ đảm bảo: field luôn có giá trị mặc định, ngay cả khi không có constructor nào gán cho chúng.

Bước 3 — Object header được đặt vào. Trong HotSpot, mọi đối tượng bắt đầu bằng một object header, nơi lưu (về mặt khái niệm, không nhất thiết đúng dạng vật lý này): một mark word (hash code nhận dạng, tuổi GC, trạng thái khóa — đây là cách synchronized và các khóa nhỏ tái sử dụng header) và một klass pointer liên kết đối tượng với metadata của class để JVM biết kiểu của nó khi dispatch virtual call hay cast. Các field nằm sau đó ở các offset được tính ở runtime (HotSpot sắp xếp field để giảm padding; bố cục là chi tiết triển khai, và có thể được nén với compressed oops, nơi reference được lưu dưới dạng offset 32-bit vào một địa chỉ cơ sở thay vì con trỏ 64-bit đầy đủ).

Bước 4 — Field được khởi tạo. Đầu tiên là giá trị mặc định do zero hóa (bước 2), kế đến là các field initializer tường minh (private String name = "default"), rồi đối với một Usersuper() được gọi ngầm, và cuối cùng thân constructor bạn viết chạy: this.name = "Hung";.

Bước 5 — Tham chiếu được gán cho user. Biến cục bộ user — ô 1 của frame main — giờ giữ một tham chiếu đến đối tượng.

Sơ đồ giải tỏa hầu hết những nhầm lẫn

   Thread "main" — JVM stack                    Heap
┌──────────────────────────────┐     ┌──────────────────────────────────────┐
│  main frame                  │     │                                      │
│  ┌────────────────────────┐  │     │   ┌───────────────────────────┐      │
│  │ args      (ô 0)        │  │     │   │  User object              │      │
│  │ user ──────────────────┼──┼─────┼──►│  ┌─────────────────────┐  │      │
│  └────────────────────────┘  │     │   │  │ object header       │  │      │
│                             │     │   │  │  mark word           │  │      │
│  Ô này chứa một REFERENCE   │     │   │  │  klass pointer ──────┼──┼───┐  │
│  trỏ tới một đối tượng      │     │   │  ├─────────────────────┤  │   │  │
│  trên heap.                 │     │   │  │ name  ──────────────┼──┼─┐ │  │
└──────────────────────────────┘     │   └───────────────────────┘  │ │ │  │
                                     │                     ┌────────┘ │ │  │
                                     │                     │ ┌─────────┘ │  │
                                     │   ┌─────────────────▼─▼──────────┐  │
                                     │   │  String "Hung" object        │  │
                                     │   │  (char[] value, ...)         │  │
                                     │   └──────────────────────────────┘  │
                                     └──────────────────────────────────────┘

Điểm mấu chốt: biến cục bộ và đối tượng là hai thứ khác nhau. user là một ô nhỏ trong frame trên stack của thread. Đối tượng User là một vùng trong bộ nhớ dùng chung do GC quản lý, có thể tiếp cận thông qua user. Chính sự phân biệt đó giải thích reference, tính reachability của GC, và hầu hết các nhầm lẫn “đối tượng còn sống hay không” ngay sau đây.

4. Khi Nào Một Đối Tượng Thực Sự Chết?

Bây giờ đến động tác tiếp theo của lập trình viên:

User user = new User("Hung");
user = null;

Đối tượng có bị xóa ngay lập tức không?

Không. Gán null cho một reference không làm gì với đối tượng cả. Nó chỉ gỡ một đường dẫn trỏ tới đối tượng — ô user. Bản thân đối tượng vẫn nằm nguyên trong heap memory, nguyên vẹn, bị phớt lờ. Không gì quét nó, không gì giải phóng nó, không gì phá vỡ tham chiếu của nó tới string "Hung".

Java không có delete. Java không giải phóng bộ nhớ khi gán. Bộ nhớ của Java được thu hồi bởi một garbage collector chạy theo lịch riêng, về sau, và hàng loạt — chứ không phải vào lúc bạn ngừng quan tâm đến một đối tượng.

Reachability — khái niệm thực sự

GC không hỏi “biến này có được gán null không?” Nó hỏi: “Đối tượng này có reachable không?”

Đồ thị hoạt động như sau:

GC Roots                                       Heap
─────────                                      ─────────────────────
(biến cục bộ trên stack,          ┌───────┐
 static field,                    │  objA │ ◄──── vẫn reachable
 JNI reference, ...)              └───────┘
      │         │                     ▲
      │         └─────────────────────┘


┌───────────┐      ┌───────────┐
│  objB     │───►  │  objC     │   reachable — qua objB
└───────────┘      └───────────┘


┌───────────┐      ┌───────────┐
│  objD     │◄─────│  objE     │   cả hai UNREACHABLE — không có
└───────────┘      └───────────┘   đường nào từ GC Root

GC Roots là những điểm neo cố định của đồ thị đối tượng — nơi tính sống bắt đầu trong góc nhìn của garbage collector: biến cục bộ trong các frame đang hoạt động, static field của các class đã nạp, register giữ reference trong code đã biên dịch, JNI handle, và vài loại khác.

Bắt đầu từ mọi root, collector đi theo reference một cách bắc cầu (transitively). Mọi đối tượng chạm tới được gọi là reachable — còn sống, giữ bộ nhớ. Mọi đối tượng không bao giờ được chạm tới là unreachable — và unreachable chính là định nghĩa của “chết” trong Java.

user = null của chúng ta làm cho User trở nên unreachable: trước đó user (một root) trỏ tới nó; giờ không còn đường nào từ root tới nó. Nhưng “unreachable” chỉ có nghĩa là đủ điều kiện. Collector có thể thu hồi nó trong chu kỳ tiếp theo — hoặc một chu kỳ xa xôi hơn.

Vì sao garbage collection mang tính bất định

Kết luận: đừng bao giờ viết code phụ thuộc vào thời điểm hủy diệt. Java không có bảo đảm destructor (nó có finalizeCleaner, nhưng cả hai đều không nên được dùng cho logic vòng đời). Nếu bạn cần dọn dẹp xác định — đóng file, giải phóng kết nối — hãy làm tường minh bằng try-with-resources, và để việc quản lý bộ nhớ nằm trong tay GC.

5. Garbage Collection Thực Sự Suy Nghĩ Như Thế Nào

Trước khi nhìn vào các collector cụ thể, hãy hiểu ba thao tác trừu tượng mà mọi tracing collector thực hiện:

  1. Mark — đi từ GC Roots, bắc cầu, và đánh dấu mọi đối tượng reachable. Việc này xác định một cách tường minh tập hợp còn sống; mọi thứ khác mặc nhiên là rác.
  2. Sweep — đi qua bộ nhớ và thu hồi các đối tượng chưa được đánh dấu, nối các khối trống lại thành một free list.
  3. Compact — trượt các đối tượng còn sống sát lại nhau để loại bỏ phân mảnh: khoảng chết giữa các đối tượng sống sụp đổ. (Biến thể: copying collection copy các đối tượng sống sang một vùng mới, để lại vùng cũ hoàn toàn trống — vừa thu gom vừa compact trong một động tác.)

Marking là tracing: nó suy luận về toàn bộ đồ thị đối tượng, không quan tâm tham chiếu nào đang trỏ vào bạn. Đúng đặc tính này — tracing từ gốc — khiến GC của Java về bản chất khác với reference counting (chúng ta sẽ thấy vì sao trong Phần 6).

Generational collection — vì sao đối tượng trẻ quan trọng

Kinh nghiệm cho thấy hầu hết đối tượng chết trẻ: một StringBuilder tạm thời, một BigDecimal trong vòng lặp, một DTO theo request. Điều này đúng đến mức nó có tên gọi: weak generational hypothesis. Các generational collector khai thác điều đó bằng cách chia không gian quản lý thành các vùng với workload rất khác nhau:

   Young generation (thay máu liên tục)         Old generation (sống lâu)

┌─────────┬─────────┬─────────┐          ┌────────────────────────────┐
│  Eden   │ Surv 0  │ Surv 1  │          │      Old generation        │
│ (mới    │ (tuổi 1)│ (tuổi   │          │  những đối tượng sống sót   │
│  sinh)  │         │ 2+)     │          │  qua các chu kỳ trẻ được   │
│         │         │         │          │  promoted lên đây          │
└─────────┴─────────┴─────────┘          └────────────────────────────┘
     │                                        ▲
     └── minor cycle copy survivor ───────────┘

Đối tượng được sinh ra trong Eden. Khi Eden đầy, một chu kỳ young-only GC (thường gọi là minor GC) chạy: nó copy những đối tượng sống sót vào một survivor space (kèm bộ đếm tuổi — ghi chú chúng đã sống sót qua bao nhiêu chu kỳ), và cuối cùng promoted những survivor già nhất lên old generation. Young collection rẻ hầu hết Eden đã chết — copy vài survivor rồi vứt phần còn lại rẻ hơn nhiều so với quét toàn bộ heap.

Old generation phình chậm hơn, nên bị thu gom ít hơn, nhưng mỗi lần thu gom lại nặng nề hơn. Cẩn thận với thuật ngữ ở đây: “full GC” và “major GC” có nghĩa khác nhau ở từng collector. Trong G1 chẳng hạn, bạn có young-only cycle, mixed cycle (young + một phần old region), và một full GC tường minh (thường là triệu chứng các chiến lược khác đã thất bại); ZGC và Shenandoah làm cho collection của old generation chạy concurrent chính là để giảm các sự kiện stop-the-world này. Khi đọc GC log, luôn tra cứu các thuật ngữ theo tài liệu của chính collector đó.

Collector hiện đại — đánh đổi, không phải phép màu

CollectorÝ tưởng cốt lõiĐánh đổi chủ đạo
G1 (mặc định từ Java 9)Heap theo region; đáp ứng mục tiêu pause time bằng cách thu gom tăng dần trong các lát pause nhỏ cố địnhPause dự đoán được trên heap lớn, nhưng tốn nhiều CPU và header hơn
ZGC (từ JDK 15/16, old-gen từ 21)Gần như tỷ lệ thuận với kích thước tập hợp sống, collection mostly concurrent; pause hầu như không tăng theo kích thước heapPhức tạp — mỗi lần đọc phải trả một chút chi phí color/barrier, footprint lớn hơn
ShenandoahHoàn toàn concurrent evacuation — ngay cả di chuyển cũng chạy song song với thread ứng dụngTương tự ZGC: chi phí barrier và CPU để đổi lấy latency

Vấn đề không phải là học thuộc thuật toán. Vấn đề là: mọi collector là một phép dung hòa kỹ thuật giữa pause time, throughput, footprintđộ phức tạp. Chọn một cái là quyết định ở cấp hệ thống, dựa vào kích thước heap, mục tiêu latency và ngân sách CPU của bạn — chứ không phải một flag bạn bật vì một bài blog nói vậy.

Sơ đồ — phễu young/old

 Cấp phát ──► Eden ──► young-only GC ──► Survivor space (tuổi++) ──► Old gen
 (TLAB)        │            │                                    │
               └─► chết     └─► chết                             └─► mixed/big collection
              (đa số đối tượng chết ở đây)      (đối tượng sống lâu nằm ở đây)

Vì sao bạn nên quan tâm khi viết code? Bởi vì young generation kỳ vọng bạn tạo rác và chỉ phạt bạn một cách nhẹ nhàng; nơi đắt đỏ là old generation. Ngược đãi old-gen (tạo liên tục các cấu trúc dài hơi khổng lồ, làm rò rỉ vào đó, hoặc nhồi những đối tượng chết non vào đó) chính là cách các ứng dụng rơi vào vòng lặp full GC tốn kém — sự cố production kinh điển mang tên “GC thrashing”.

6. Trường Hợp Đặc Biệt: Tham Chiếu Vòng Không Phải Là Memory Leak

Hãy xem trường hợp “bất khả thi” kinh điển:

class A { B b; }
class B { A a; }

void leakCheck() {
    A a = new A();
    B b = new B();
    a.b = b;
    b.a = a;          // A ↔ B giờ trỏ vào nhau
    a = null;
    b = null;         // không root nào trỏ tới... hay là vẫn có?
}

Truyền thuyết dai dẳng: hai đối tượng trỏ vào nhau thì không bao giờ được GC thu hồi.

Điều đó sai. Cả hai đối tượng đều chết.

Trước a = null, b = null:            Sau:

ROOT ──► A ──► B              ROOT      A ◄──► B
          ▲   │                          (unreachable)
          └───┘

Cả hai đối tượng từng reachable từ root qua các biến cục bộ. Khoảnh khắc cả hai biến cục bộ bị xóa, không còn đường nào từ bất kỳ GC Root nào tới A hoặc B. A → BB → A chỉ là các cạnh giữa những mảnh rác — không cái nào trỏ tới nơi collector quan tâm. Pha mark đơn giản là không bao giờ chạm tới chúng, và cả hai được thu hồi, dù có vòng hay không.

Vì sao reference counting sai ở chỗ này — và tracing thì không

Các ngôn ngữ như Python thời kỳ đầu hay Objective-C dùng reference counting: mỗi đối tượng giữ một bộ đếm số tham chiếu tới nó; khi bộ đếm về 0, nó bị giải phóng ngay lập tức. Giờ xét A ↔ B: giảm bộ đếm của A xuống, nó đi từ 2 xuống 1 chứ không phải 0 — vì B vẫn tham chiếu nó. Không cái nào chạm mốc 0. Cặp đôi đó rò rỉ vĩnh viễn, trừ khi gắn thêm cơ chế phát hiện vòng phức tạp.

Collector của Java không đếm tham chiếu. Nó tracing: bắt đầu từ root, đi qua đồ thị, thu hồi mọi thứ không được ghé thăm. Câu hỏi “Ai đang tham chiếu tôi?” không bao giờ được hỏi; chỉ có “Tôi có reachable từ root không?” mới có ý nghĩa. Đó chính xác là lý do vòng tham chiếu được thu hồi tự động — kể cả các vòng thực tế hơn nhiều như một collection cha tham chiếu đến phần tử con và bị chính phần tử con tham chiếu ngược, xuất hiện trong mọi domain model thật.

Đây cũng là lý do các kiểu reference yếu (WeakReference, WeakHashMap) tồn tại và hoạt động hợp lý: reachability — chứ không phải đếm — mới là ngôn ngữ của mô hình bộ nhớ Java.

7. Java Có GC, Vậy Vì Sao Vẫn Có Memory Leak?

Nếu GC thu hồi mọi thứ unreachable, làm sao một chương trình Java có thể phình heap cho đến khi chết?

Bởi vì có một khác biệt sâu sắc giữa:

Garbage collection là một máy dò reachability, không phải máy dò sự cần thiết. Bất cứ thứ gì bạn vô tình giữ reachable sẽ không bao giờ bị thu hồi — dù nó rõ ràng vô dụng đến đâu. Một memory leak trong Java thường có nghĩa là chương trình vô tình giữ các đối tượng reachable.

Góc nhìn của collector về một leak kinh điển

public class UserCache {
    private static final Map<String, User> USERS = new HashMap<>();

    public static void remember(User user) {
        USERS.put(user.getId(), user);      // mọi user được yêu cầu, mãi mãi
    }
}

Nhìn khách quan: map là static — một GC Root. Mọi User đưa vào đó là reachable ngay từ bản chất. Nếu cache không có chính sách eviction, heap phình to cho đến OutOfMemoryError. GC đang làm việc hoàn hảo: mỗi user được lưu, ở một thời điểm nào đó, có thể là chính đáng. Bug nằm ở chỗ không có gì quyết định rằng chúng đã xong việc.

Các biến thể thực tế của cùng một căn bệnh:

1. Cache không có giới hạn / map key theo dữ liệu request

Map<String, Result> resultsByRequestId = new HashMap<>();
// mỗi request đến thêm một entry; entry không bao giờ bị xóa

2. Event listener không bao giờ được gỡ

someService.addListener(myListener);   // caller quên removeListener(...)

Nếu someService sống lâu (một singleton), nó giữ một listener được tham chiếu mạnh mãi mãi — cùng với mọi thứ mà listener đó tham chiếu (thường là toàn bộ đồ thị đối tượng bao quanh: một controller hay consumer từng có scope request).

3. Dùng sai ThreadLocal với thread pool

private static final ThreadLocal<BigData> PER_THREAD = new ThreadLocal<>();

void handle(Request r) {
    PER_THREAD.set(expensiveData());
    // ... không bao giờ PER_THREAD.remove()
}

Trong một web server, “thread” là có pool và sống lâu. Giá trị ThreadLocal của mỗi pool thread tính là reachable từ một GC Root (các thread được pool tham chiếu, và giá trị ThreadLocal treo trên thread của chúng). Đặt cái này vào pool 200 thread mà không remove() và bạn giữ tới 200 bản expensiveData — vĩnh viễn. (Tệ hơn: ngay cả khi bạn gán ThreadLocal của mình về null, cái entry vẫn có thể tồn tại cùng giá trị của nó cho đến khi cái key yếu của entry bị xóa.)

4. Reference sống lâu, sống quá cả nhu cầu sử dụng

private Model model;   // được gán từ một request lớn, dùng một lần, rồi giữ
                       // đến hết đời của bean sống lâu này

Một field, tưởng vô hại, có thể ghim cả một đồ thị đối tượng khổng lồ.

Cái mô hình, trong một dòng

Một memory leak của Java hầu như không bao giờ là lỗi của GC — đó là khi chương trình vô tình giữ các đối tượng reachable.

Nếu các đối tượng bị giữ phải sống sót hợp pháp hơn nguồn dữ liệu của chúng, hãy dùng đúng công cụ: WeakHashMap, WeakReference/ReferenceQueue, cache có eviction (CacheBuilder, Caffeine) — bất cứ thứ gì biến một cái ghim strong không thể hủy thành một cái có thể hủy. Và khi một heap dump cho thấy hàng gigabyte “một HashMap to đùng”, giải pháp là chính sách evictionkỷ luật sở hữu, chứ không phải một -Xmx to hơn.

8. Trường Hợp Đặc Biệt: OutOfMemoryError Không Phải Lúc Nào Cũng Là Heap Đầy

OutOfMemoryError là một chiếc mũ đội vừa nhiều cái đầu. Nó được ném — ở nhiều phiên bản của cùng một Error — mỗi khi một tài nguyên nào đó JVM cần không thể lấy được. Chỉ một số phiên bản liên quan đến heap bạn đã nâng bằng -Xmx. Tăng -Xmx mù quáng cho mọi OOME là phản xạ kinh điển: giải quyết một vấn đề và che giấu những vấn đề khác.

Thông điệp ngoại lệCái gì thực sự cạn kiệtNguyên nhân điển hình
Java heap spaceHeap (giới hạn bởi -Xmx)Rò rỉ đối tượng, collection khổng lồ, cấp phát quá sức thật sự
MetaspaceVùng class metadata — native memory, giới hạn bởi -XX:MaxMetaspaceSizeSinh class động không kiểm soát: reflection, proxy, bytecode generation, biên dịch JSP/scripting
Direct buffer memoryCác direct (off-heap) buffer, tính vào -XX:MaxDirectMemorySizeCác consumer ByteBuffer.allocateDirect (NIO, netty, compression) không bao giờ release
Unable to create new native threadThread ở cấp hệ điều hành — giới hạn tiến trình, ulimit -u, giới hạn pid cgroup, hoặc chính không gian địa chỉ của tiến trìnhTạo thread cho từng tác vụ (không có pool), virtual thread pinning mất kiểm soát, giới hạn của hệ điều hành

Chú ý cái mô hình: trong bốn cái, chỉ cái đầu tiên sống trong heap. Các cái còn lại nằm hoàn toàn bên ngoài:

Tư duy gỡ rối thực dụng

Khi OOME ập đến, hãy cưỡng lại phản xạ. Đi theo bậc thang này:

  1. Xác định vùng bộ nhớ nào cạn kiệt — đọc thông điệp (Java heap space so với Metaspace so với Direct buffer memory so với Unable to create new native thread). Mỗi cái chỉ một hệ thống con và một núm -XX khác nhau; một -Xmx không dịch chuyển được cái nào khác.
  2. Hiểu cái gì đang cấp phát vùng bộ nhớ đó. Theo dấu nơi cấp phát bằng profiler: heap — object dump; metaspace — log nạp class (-Xlog:class+load); direct — stack cấp phát buffer; thread — đếm thread (jstack / jcmd Thread.print).
  3. Kiểm tra xem đối tượng bị giữ chứ không chỉ bị cấp phát. Chụp một heap dump (jmap -dump:live, hoặc -XX:+HeapDumpOnOutOfMemoryError) và kiểm tra tập hợp bị giữ: có phải một HashMap đang ôm mấy megabyte dữ liệu dùng một lần? Một ThreadLocal cho mỗi pool thread? Công cụ: jcmd GC.heap_dump, jvisualvm/jconsole, Eclipse MAT hoặc tính năng leak suspect tích hợp.
  4. Kiểm tra phía native khi heap vẫn ổn. Nếu RSS cứ tăng mà heap ổn định: direct buffer, rò rỉ class loader (Metaspace), JNI, hoặc thread. HotSpot có Native Memory Tracking (-XX:NativeMemoryTracking=summary, jcmd VM.native_memory) chính xác cho việc này.
  5. Chỉ sau đó mới cân nhắc cấu hình. Tăng -Xmx là một nước đi về dung lượng. Nếu bạn đang giữ dữ liệu đáng lẽ không nên giữ, nước đi đúng là dừng giữ nó; nếu bạn đang sinh class, nước đi đúng là unload hoặc tái sử dụng; nếu bạn tạo thread cho từng tác vụ, nước đi đúng là dùng pool (và trên JDK 21+, virtual thread cho công việc I/O-bound, không tốn một native thread cho mỗi tác vụ).

Tóm một câu: thông điệp OOME cho bạn biết hệ thống bộ nhớ nào thất bại; cách sửa đúng là sửa hệ thống đó, chứ không phải cái mà bạn đã tinh chỉnh.

9. Khi JVM Thông Minh Hơn Bạn Nghĩ

Quay lại chủ đề cấm kỵ của Phần 2 — stack vs heap — vì bây giờ JVM làm một điều thực sự bất ngờ:

public int calculate() {
    Point point = new Point(10, 20);
    return point.x + point.y;
}

Đối tượng Point này có luôn luôn cần tồn tại như một đối tượng heap thật không?

Thoạt nhìn, “tất nhiên”: new là cấp phát. Nhưng đây chính là nơi JVM trở nên thú vị.

Trình biên dịch C2 của HotSpot thực hiện escape analysis: nó phân tích liệu point có thể “thoát” khỏi phương thức — tham chiếu của nó có được lưu vào field không? có được truyền cho phương thức khác không? có được trả về không? có được công bố cho thread khác không? Nếu không có điều nào xảy ra, đối tượng là non-escaping — vô hình với mọi thứ khác, và do đó danh tính của nó chỉ là một ảo ảnh mà ngôn ngữ ép chúng ta phải tin.

Một khi JVM biết point không thể thoát, nó có thể áp dụng:

calculate() sau khi viết lại có hiệu quả trở thành:

public int calculate() {
    int x = 10, y = 20;        // field được nâng lên thành biến cục bộ
    return x + y;              // không còn cấp phát nào cả
}

Vậy: đôi khi — khi JVM chứng minh được đối tượng không bao giờ thoát — new Point(10, 20) không tạo ra bất kỳ heap allocation nào, không object header, không TLAB bump, không gì hiện ra trước mắt collector nào. Ngữ nghĩa ngôn ngữ vẫn nguyên vẹn, bởi vì không ai có thể quan sát được sự khác biệt (không identity thoát ra, không đồng bộ hóa, không quan sát identity-hash).

Những cảnh báo quan trọng

Đây là lời giải sâu cho câu đố ở Phần 2: mô hình khái niệm nói “đối tượng sống trên heap”, và cỗ máy có thể hợp pháp thay heap bằng register mỗi khi ngữ nghĩa cho phép. Đừng viết code phụ thuộc vào việc cấp phát xảy ra (“tôi có thể phát hiện cấp phát trong một vòng lặp bằng cách…”), và đừng ngạc nhiên khi một micro-benchmark báo zero allocation với code trông như đang cấp phát.

Điều này cũng giải thích vì sao thêm một escape hữu hình — ví dụ lưu point vào một field “phòng khi cần debug” — có thể làm thay đổi đo lường được hành vi cấp phát: bạn vừa đẩy một đối tượng non-escaping vào thế giới escaping.

10. Các Trường Hợp Đặc Biệt Của Java Khiến Lập Trình Viên Bất Ngờ

JVM giấu cỗ máy của nó sau ngữ nghĩa ngôn ngữ — và thỉnh thoảng cỗ máy đó tung ra một cái bóng khiến người ta vấp ngã. Đây là bốn chỗ nổi tiếng nhất.

10.1 Integer cache

Integer a = 100;
Integer b = 100;
System.out.println(a == b);      // in ra ?

Integer c = 200;
Integer d = 200;
System.out.println(c == d);      // in ra ?

Hầu hết lập trình viên dự đoán true / false, và nhầm ngược ở cả hai đầu — kết quả thật là true cho 100false cho 200.

Cơ chế: Integer a = 100 là autoboxing — viết tắt của Integer.valueOf(100). Và valueOf thì có cache: JDK cache các instance Integer cho một khoảng nhỏ (mặc định -128..127), trả về cùng một đối tượng cho các lần gọi lặp lại trong khoảng đó. Vậy a == b so hai reference trỏ đến một instance được cache dùng chung — true. Ở 200, cache trượt, hai đối tượng Integer riêng biệt được tạo ra, và == so reference — false. (Khoảng này có thể tinh chỉnh: -XX:AutoBoxCacheMax=... mở rộng nó.)

Bài học kép ở đây: == trên kiểu boxed so identity, không bao giờ so giá trị; và cache khiến hành vi của chúng phụ thuộc vào giá trị theo cách trái ngược trực giác. Luôn so số bằng equals (hoặc intValue()), luôn unbox trước khi == — và nhớ rằng thủ thuật tương tự tồn tại cho Boolean và (cache nhỏ hơn) các kiểu boxed khác.

10.2 NPE khi unbox null

Integer value = null;
int result = value;        // điều gì xảy ra?

int result = value là unboxing — được biên dịch thành value.intValue(). Gọi một phương thức trên null ném NullPointerException. Vậy dòng này luôn ném NPE, dù trông “hiển nhiên là 0”. JDK hiện đại thậm chí nêu tên thủ phạm: “Cannot invoke ‘java.lang.Integer.intValue()’ because ‘value’ is null” (thông điệp hữu ích từ JDK 14+). Kết bài: bất kỳ dữ liệu nào truyền vào API kiểu primitive qua ranh giới autoboxing đều mang theo một lời gọi phương thức ẩn có thể thất bại — hãy validate trước khi unbox, đừng bao giờ cho rằng nullability biến mất tại lớp parse từ tầng lưu trữ.

10.3 Hai cuộc đời của "hello"

String a = "hello";
String b = "hello";
System.out.println(a == b);        // true

String c = new String("hello");
System.out.println(a == c);        // false

"hello" với tư cách literal là một compile-time constant: javac intern nó — đưa nó vào string pool, một sổ bộ của JVM mà mọi class đều chia sẻ. ab nạp cùng một instance được pool hóa, nên ==true. new String("hello") chẳng dính dáng gì đến pooling: thông thường nó tạo ra một đối tượng mới (và bản copy trong pool trở nên thừa). Nên ==false.

Sự tinh tế sâu hơn khi string được dựng lên:

Quy tắc ngón tay cái, mỗi cái một dòng: so nội dung bằng equals, luôn luôn; literal được pool hóa, new thì không; không bao giờ tin == trên string trừ khi bạn cố ý dựa vào pooling của string intern’d/compile-time; và nhớ pool nằm trong heap (từ Java 7) — hàng nghìn chuỗi intern() độc nhất cũng ngốn heap.

10.4 finally có thể thay đổi kết quả

static int probe() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
// probe() trả về ...?

Câu trả lời — 2 — khiến người ta vỗ trán trong code review mỗi năm. JVM biên dịch một khối finally vào mọi đường thoát của try: đường bình thường, và từng đường exception handler. Lệnh return trong finally thực thi sau khi biểu thức return 1 đã được tính nhưng trước khi phương thức thực sự trả về — và một return được thực thi là một return thắng cuộc, âm thầm vứt bỏ giá trị 1 đang chờ sẵn.

Thiệt hại thực tế: một hàm trông như trả về giá trị dự định lại trả về thứ khối dọn dẹp quyết định; tệ hơn, return trong finally nuốt chửng exception ném ra trong try (cơ chế tương tự cũng áp dụng cho catch). Dọn dẹp thuộc về finally, nhưng return từ finally xứng đáng sự nghi ngờ như một dòng catch (Exception e) {} trống trơn.

Mỗi cái bẫy trong bốn cái bẫy này không phải “Java ngu ngốc” — mỗi cái là runtime trung thành thực thi một hợp đồng mà cú pháp nguồn khiến bạn dễ đọc sai (identity được cache, lời gọi phương thức ẩn, các instance chính tắc được intern, cách điều khiển luồng được biên dịch). Đúng như chủ đề của cả bài viết này: code bạn viết là một bản mô tả, và cách cỗ máy đọc bản mô tả đó mới là thực tại đang chạy.

Mô Hình Tinh Thần Thực Sự Giúp Ích Cho Bạn

Java code có vẻ đơn giản, nhưng mỗi lời gọi phương thức, cấp phát đối tượng, tham chiếu và tối ưu hóa đều xảy ra bên trong một hệ thống runtime phức tạp. Hiểu hệ thống đó giúp lập trình viên gỡ lỗi sự cố production, điều tra memory leak, tìm hiểu vấn đề hiệu năng, và viết Java hay hơn.

Mô hình tinh thần, trong một bức ảnh:

 Java source của bạn                        Thực tại của JVM
 ────────────────                           ─────────────────
 javac ──► bytecode ──► loader ──► interpreter ──► JIT native code
                            │   v   │           ──────────────
                         frames / TLAB / mark-sweep / inlining /
                         escape analysis / generations / barriers

                                        hành vi quan sát được:
                                        kết quả đúng, code nóng dần,
                                        rác được thu hồi

Không có gì là phép màu ở đây. Mỗi mảnh là một quyết định kỹ thuật do các compiler và runtime thật đưa ra — và mỗi mảnh đều có thể kiểm tra, đo lường và tinh chỉnh (javap, jcmd, GC log, heap dump, các flag -XX mà bạn hiểu trước khi bật). Khi production phình to, hay một heap dump cho thấy gigabyte của một HashMap, hay một benchmark bỗng cấp phát 0 byte, bạn giờ đã biết nên hỏi câu hỏi ở tầng nào.

Key Takeaways

Các Quan Niệm Sai Phổ Biến

Quan niệm saiThực tế
Đối tượng luôn luôn đơn giản là “được lưu trên heap”Mô hình khái niệm hữu ích, nhưng hành vi JVM thật có thể bao gồm các tối ưu hóa
user = null giải phóng bộ nhớ ngay lập tứcGC chạy độc lập và về sau
Vòng tham chiếu luôn rò rỉ bộ nhớTracing GC có thể thu hồi các vòng unreachable
GC ngăn chặn memory leakCác đối tượng reachable-nhưng-không-dùng vẫn có thể rò rỉ
OutOfMemoryError luôn nghĩa là tăng -XmxCác vùng bộ nhớ JVM/native khác nhau có thể cạn kiệt
Tạo đối tượng cục bộ luôn luôn là heap allocationJVM tối ưu hóa có thể loại bỏ hoặc biến đổi cấp phát

Lần tới khi bạn viết User user = new User("Hung"), hãy nhớ: đó không phải một bước — đó là một cái bắt tay giữa code của bạn, một cỗ máy hư cấu, và một runtime rất thật, rất thông minh đã tối ưu hóa những chương trình như thế này từ năm 1995.


Chia sẻ bài viết này trên:

Bài tiếp theo
Java Threads: Từ Thread Pool đến Virtual Threads