TungDaDev's Blog

Java Memory Model (JMM): Giải mã Happens-Before, Volatile và CAS tận gốc rễ phần cứng

Photo 1591799264318 7e6ef8ddb7ea?auto=format&fit=crop&w=1600&q=80
Published on
/7 mins read/

Lập trình đa luồng (Concurrency) trong Java khó không phải vì cú pháp, mà vì thế giới phần cứng bên dưới chạy theo những quy luật hoàn toàn khác với cách chúng ta đọc code tuần tự từ trên xuống dưới.

1. Bí ẩn của đoạn code "chạy mãi không dừng"

Hãy xem đoạn mã Java cực kỳ đơn giản sau:

public class VisibilityDemo {
    private static boolean stop = false;
 
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            int count = 0;
            while (!stop) {
                count++;
            }
            System.out.println("Worker kết thúc với count: " + count);
        });
 
        worker.start();
        Thread.sleep(100);
 
        stop = true; // Main thread đổi biến thành true
        System.out.println("Main thread đã set stop = true");
        worker.join();
    }
}

Nếu bạn chạy chương trình này ở chế độ -server (mặc định trên JVM 64-bit), chương trình sẽ không bao giờ kết thúc! Main thread đã in dòng "Main thread đã set stop = true", nhưng luồng worker vẫn tiếp tục quay vòng vô tận trong vòng lặp while (!stop).

Vì sao?

Bản chất phần cứng:

  1. CPU Caches & Memory Visibility: Mỗi lõi CPU có bộ nhớ đệm riêng siêu nhanh (L1/L2 Cache). Luồng worker đọc giá trị stop một lần vào Cache/Register, và JIT Compiler nhận thấy trong thân vòng lặp không có gì thay đổi biến stop, nó tự động tối ưu thành while (true) count++; (Hoisting).
  2. Khi main thread ghi stop = true, giá trị này có thể chỉ nằm trong Write Buffer của Core 1 hoặc RAM mà chưa hề được làm tươi (flush/invalidate) vào Cache của Core 2.

Để lập trình viên không phải viết assembly riêng cho từng loại CPU (x86, ARM, RISC-V), Java định nghĩa ra Java Memory Model (JMM).


2. Java Memory Model (JMM) là gì?

JMM (được chuẩn hóa lại trong JSR-133) là một bản đặc tả trừu tượng quy định: Khi nào một hành động ghi (Write) của một luồng được đảm bảo là nhìn thấy (Visible) bởi hành động đọc (Read) của một luồng khác.

JMM tạo ra sự cân bằng hoàn hảo giữa:

  • Tự do cho phần cứng & Compiler: Cho phép CPU sắp xếp lại lệnh (Instruction Reordering), dùng cache, vectorization để đạt tốc độ cao nhất.
  • Tính khả đoán cho Lập trình viên: Cung cấp các công cụ tường minh (volatile, synchronized, final, VarHandle) để khóa các giới hạn an toàn.

3. Nguyên tắc vàng: Quan hệ Happens-Before

Nếu hành động A happens-before hành động B, thì mọi kết quả và thay đổi dữ liệu của A đều được đảm bảo nhìn thấy bởi B, và thứ tự thực thi của A luôn đứng trước B.

Các quy tắc Happens-Before cốt lõi mà mọi Senior Java Dev phải nằm lòng:

  1. Program Order Rule: Trong cùng một luồng duy nhất, mỗi hành động đứng trước xảy ra trước mọi hành động đứng sau theo thứ tự mã nguồn.
  2. Volatile Variable Rule: Việc ghi (Write) vào một biến volatile luôn happens-before mọi hành động đọc (Read) tiếp theo của chính biến đó.
  3. Monitor Lock Rule: Việc mở khóa (unlock) một monitor / block synchronized luôn happens-before mọi hành động khóa (lock) tiếp theo trên cùng monitor đó.
  4. Thread Start Rule: Lời gọi thread.start() luôn happens-before bất kỳ câu lệnh nào bên trong luồng con đó.
  5. Thread Join Rule: Mọi câu lệnh trong luồng con luôn happens-before thời điểm câu lệnh thread.join() của luồng cha hoàn thành.
  6. Transitivity (Tính bắc cầu): Nếu A \prec B và B \prec C, thì chắc chắn A \prec C.

4. volatile hoạt động ở mức vi xử lý như thế nào?

Khi ta khai báo private static volatile boolean stop = false;, JVM sẽ sinh ra các mã lệnh máy đặc biệt gọi là Memory Barrier (Fence):

Hai bảo chứng của volatile:

  1. Tính hiển thị (Visibility): Giá trị ghi vào lập tức được flush ra bộ nhớ dùng chung, và các lõi khác bị ép nạp lại giá trị mới nhất.
  2. Chống Reordering: Trình biên dịch và CPU bị cấm di chuyển các lệnh đọc/ghi xung quanh rào cản volatile.

Lưu ý cốt tử: volatile KHÔNG đảm bảo tính nguyên tử (Atomicity)! Biến volatile int count; count++ vẫn bị Race Condition vì count++ gồm 3 bước riêng biệt: Read, Modify, Write.


5. Nguyên tử hóa không khóa: Compare-And-Swap (CAS)

Để giải quyết bài toán Atomicity mà không phải trả giá đắt cho việc lock luồng bằng synchronized, các lớp như AtomicInteger, AtomicReference sử dụng lệnh phần cứng CAS (Compare-And-Swap).

Đoạn mã mã máy của lệnh CAS trên chip Intel x86 là lock cmpxchg:

  • Lệnh này khóa bus bộ nhớ hoặc cache line tại cấp độ phần cứng chỉ trong vài chu kỳ CPU.
  • Nếu giá trị trong RAM vẫn đúng bằng expected, nó đổi thành update.
  • Nếu có luồng khác chen ngang đổi giá trị, CAS trả về false, và Java sẽ lặp lại vòng xoay (Spin lock) cho đến khi thành công.
// Cách AtomicInteger tăng giá trị trong JDK:
public final int incrementAndGet() {
    return U.getAndAddInt(this, VALUE, 1) + 1;
}
// Bên dưới sử dụng VarHandle hoặc Unsafe gọi trực tiếp lệnh CPU cmpxchg

6. Tổng kết bảng so sánh công cụ Concurrency trong Java

Cơ chếTính hiển thị (Visibility)Tính nguyên tử (Atomicity)Khóa luồng (Thread Blocking)Chi phí hiệu năng
Biến thông thườngKhông bảo đảmKhông (ngoại trừ 32-bit types)KhôngSiêu nhanh
volatileCó (100%)KhôngKhôngRất nhẹ (vài chu kỳ CPU)
AtomicInteger (CAS)CóCóKhông (Lock-Free Spin)Rất nhẹ khi ít tranh chấp
ReentrantLock / synchronizedCóCóCó (Treo luồng qua OS)Nặng hơn nếu tranh chấp cao

Nắm vững Java Memory Model và quan hệ Happens-Before là lằn ranh phân biệt giữa một lập trình viên Java nghiệp dư và một kỹ sư hạ tầng backend thực thụ. Bạn sẽ không còn hoang mang trước những bug "lúc chạy được lúc không" trên môi trường Production đa lõi!


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.