Skip to main content
ThinkNote

Detailed Analysis of the Java Memory Model (JMM)

Your NameYour Name
August 5, 2025
11 min read
Table of Contents

Phân tích chi tiết Java Memory Model (JMM)

Tác giả: GiaTK Đối tượng: Lập trình viên Java có kinh nghiệm Ngày tạo: 2025-08-05


1. Java Memory Model là gì?

Java Memory Model (JMM) là một đặc tả (specification) định nghĩa cách các luồng (threads) trong Java tương tác với bộ nhớ (memory). Nó không phải là một đối tượng hay cấu trúc dữ liệu cụ thể, mà là một mô hình trừu tượng đảm bảo rằng các hành vi của chương trình đa luồng là có thể dự đoán được trên nhiều kiến trúc phần cứng khác nhau.

Vấn đề JMM giải quyết

Trong các hệ thống hiện đại, để tối ưu hiệu năng, CPU và trình biên dịch (compiler) thường thực hiện các hành động sau:

  1. CPU Caching: Mỗi lõi CPU có các tầng bộ nhớ đệm (cache) riêng (L1, L2) nhanh hơn nhiều so với bộ nhớ chính (RAM). Các luồng chạy trên các lõi khác nhau có thể có các bản sao (copy) khác nhau của cùng một biến trong cache của chúng.
  2. Instruction Reordering: Cả trình biên dịch và CPU có thể sắp xếp lại thứ tự các chỉ thị (instructions) để tối ưu hóa việc thực thi, miễn là không làm thay đổi ngữ nghĩa của chương trình đơn luồng.

Những tối ưu hóa này gây ra hai vấn đề lớn trong môi trường đa luồng:

  • Visibility (Tính thấy được): Một luồng đã thay đổi giá trị của một biến, nhưng các luồng khác không "thấy" được sự thay đổi đó vì chúng vẫn đang đọc từ bản sao cũ trong cache của mình.
  • Race Conditions do Reordering: Việc sắp xếp lại chỉ thị có thể phá vỡ logic của các chương trình đa luồng vốn phụ thuộc vào một thứ tự thực thi nghiêm ngặt.

JMM ra đời để cung cấp một bộ quy tắc và đảm bảo, giúp lập trình viên có thể viết code đa luồng một cách chính xác và an toàn mà không cần phải lo lắng về sự phức tạp của phần cứng bên dưới.

2. Tương tác giữa Luồng và Bộ nhớ

Ta có thể hình dung mỗi luồng trong Java có một bộ nhớ làm việc riêng (Working Memory), tương ứng với CPU cache và registers. Mọi thao tác đọc/ghi của luồng đều diễn ra trên bộ nhớ làm việc này. Bộ nhớ chính (Main Memory), tương ứng với RAM, là nơi chứa trạng thái chia sẻ của tất cả các biến.

+----------+       +----------+
| Thread 1 |       | Thread 2 |
+----------+       +----------+
     |                    |
+----------------+   +----------------+
| Working Memory |   | Working Memory | (CPU Cache)
| (x=1, y=2)     |   | (x=0, y=2)     |
+----------------+   +----------------+
     |                    |
     +--------+   +--------+
              |   |
        +-----------------+
        |   Main Memory   | (RAM)
        |   (x=0, y=2)    |
        +-----------------+

Một luồng muốn thay đổi biến chia sẻ phải:

  1. Đọc giá trị từ Main Memory vào Working Memory.
  2. Thực hiện thao tác trên bản sao trong Working Memory.
  3. Ghi giá trị mới từ Working Memory trở lại Main Memory.

JMM định nghĩa khi nào và bằng cách nào các giá trị được truyền giữa Main Memory và Working Memory.

3. Các khái niệm chính

a. Atomicity (Tính nguyên tử)

Một thao tác được gọi là nguyên tử nếu nó diễn ra hoàn toàn hoặc không diễn ra chút nào, không thể bị chia cắt giữa chừng.

  • Trong Java, việc đọc và ghi các biến kiểu nguyên thủy (trừ longdouble) là nguyên tử.
  • Việc đọc và ghi longdouble không được đảm bảo là nguyên tử trên các hệ thống 32-bit, chúng có thể được thực hiện như hai thao tác 32-bit riêng biệt.
  • Phép toán count++ không phải là nguyên tử. Nó bao gồm 3 bước: đọc giá trị count, tăng giá trị, và ghi lại giá trị mới. Một luồng khác có thể xen vào giữa các bước này.

Ví dụ về Race Condition:

class Counter {
    private int count = 0;

    public void increment() {
        count++; // Not atomic
    }

    public int getCount() {
        return count;
    }
}

// Nếu hai luồng cùng gọi increment() khi count = 0, kết quả cuối cùng
// có thể là 1 thay vì 2, vì cả hai luồng có thể cùng đọc giá trị 0,
// cùng tăng lên 1 và cùng ghi lại giá trị 1.

b. Visibility (Tính thấy được)

Visibility đảm bảo rằng khi một luồng sửa đổi một biến chia sẻ, các luồng khác có thể thấy được sự thay đổi đó. Như đã đề cập, do CPU cache, vấn đề visibility rất phổ biến.

Ví dụ về Visibility Problem:

class TaskRunner {
    private static boolean running = true;

    public void run() {
        while (running) {
            // do some work...
        }
        System.out.println("Thread stopped.");
    }

    public static void stop() {
        running = false;
    }
}

// Một luồng gọi run(), luồng này có thể cache giá trị `running = true`.
// Khi một luồng khác gọi stop(), nó thay đổi `running` thành `false` trong Main Memory.
// Tuy nhiên, luồng đầu tiên có thể không bao giờ thấy sự thay đổi này và
// bị kẹt trong vòng lặp vô hạn.

c. Reordering (Sắp xếp lại thứ tự)

Trình biên dịch và CPU có thể sắp xếp lại các chỉ thị để tối ưu hóa.

Ví dụ về Reordering Problem:

class ReorderingExample {
    private int x = 0;
    private boolean initialized = false;

    public void writer() {
        x = 42;
        initialized = true;
    }

    public void reader() {
        if (initialized) {
            System.out.println(x); // Có thể in ra 0!
        }
    }
}

Trong phương thức writer, trình biên dịch có thể sắp xếp lại hai câu lệnh, gán initialized = true trước khi gán x = 42. Nếu luồng reader chạy vào thời điểm đó, nó sẽ thấy initializedtrue nhưng x vẫn là 0.

d. Happens-Before Relationship

Đây là khái niệm cốt lõi nhất của JMM, cung cấp sự đảm bảo về cả visibility và ordering. Quy tắc happens-before định nghĩa rằng nếu hành động A happens-before hành động B, thì kết quả của A sẽ được thấy bởi B, và A được đảm bảo thực thi trước B.

Các quy tắc happens-before chính:

  1. Program Order Rule: Trong một luồng, bất kỳ hành động nào xuất hiện trước trong code sẽ happens-before hành động xuất hiện sau.
  2. Monitor Lock Rule: Một lệnh unlock trên một monitor (khóa) sẽ happens-before mọi lệnh lock tiếp theo trên cùng monitor đó. Đây là cơ chế của synchronized.
  3. Volatile Variable Rule: Một lệnh ghi vào một biến volatile sẽ happens-before mọi lệnh đọc tiếp theo của cùng biến volatile đó.
  4. Thread Start Rule: Lệnh gọi thread.start() của một luồng happens-before bất kỳ hành động nào trong luồng mới được bắt đầu.
  5. Thread Join Rule: Tất cả các hành động trong một luồng happens-before việc luồng khác trả về kết quả từ thread.join().

e. volatilesynchronized

Đây là hai từ khóa chính để quản lý concurrency trong Java, và chúng trực tiếp áp dụng các quy tắc của JMM.

volatile

Từ khóa volatile đảm bảo hai điều:

  1. Visibility: Mọi thao tác ghi vào biến volatile sẽ được ghi thẳng vào Main Memory. Mọi thao tác đọc biến volatile sẽ đọc thẳng từ Main Memory. Điều này giải quyết vấn đề visibility.
  2. Ordering: Ngăn chặn việc sắp xếp lại chỉ thị (reordering) xung quanh biến volatile. Cụ thể, một lệnh ghi volatile happens-before các lệnh đọc volatile sau đó.

Sửa ví dụ TaskRunner bằng volatile:

class TaskRunner {
    private static volatile boolean running = true; // Dùng volatile

    public void run() {
        while (running) {
            // ...
        }
    }
    // ...
}

synchronized

Từ khóa synchronized cung cấp một cơ chế khóa mạnh mẽ hơn:

  1. Mutual Exclusion (Loại trừ tương hỗ): Chỉ một luồng có thể thực thi một khối mã synchronized trên cùng một đối tượng (monitor) tại một thời điểm. Điều này đảm bảo tính nguyên tử (atomicity) cho cả một khối lệnh.
  2. Visibility: Khi một luồng thoát khỏi khối synchronized (unlock), tất cả các thay đổi về biến mà nó đã thực hiện sẽ được đẩy ra Main Memory. Khi một luồng khác vào khối synchronized (lock), nó sẽ xóa cache cục bộ và đọc lại các biến từ Main Memory. Điều này tuân theo Monitor Lock Rule.

Sửa ví dụ Counter bằng synchronized:

class SynchronizedCounter {
    private int count = 0;

    public synchronized void increment() {
        count++; // Toàn bộ thao tác này trở thành nguyên tử
    }

    public int getCount() {
        return count;
    }
}

4. So sánh volatilesynchronized

Tính năng volatile synchronized
Mục đích chính Đảm bảo Visibility và Ordering Đảm bảo Atomicity và Visibility
Loại trừ tương hỗ Không Có (cung cấp khóa)
Tính nguyên tử Chỉ cho một thao tác đọc/ghi đơn lẻ Cho cả một khối mã
Phạm vi áp dụng Biến (instance/static variable) Phương thức hoặc khối lệnh
Khả năng gây block Không, luồng không bao giờ phải chờ Có, luồng có thể bị block nếu khóa đang bị giữ
Chi phí (Performance) Thấp hơn synchronized Cao hơn, do chi phí của việc lấy và giải phóng khóa

Khi nào dùng volatile? Khi bạn cần đảm bảo visibility của một biến trạng thái đơn giản (như cờ running) và việc ghi vào biến đó không phụ thuộc vào giá trị hiện tại của nó.

Khi nào dùng synchronized? Khi bạn cần thực hiện một chuỗi thao tác (read-modify-write) như một đơn vị nguyên tử (ví dụ count++) hoặc cần bảo vệ một đoạn mã phức tạp khỏi việc truy cập đồng thời.

5. Lỗi lập trình phổ biến do hiểu sai JMM

Double-Checked Locking (DCL) bị lỗi

Đây là ví dụ kinh điển. Mục đích là để khởi tạo một đối tượng singleton một cách lười biếng (lazy initialization) mà không cần dùng synchronized mỗi lần truy cập.

Phiên bản lỗi (trước Java 5):

class Singleton {
    private static Helper instance = null;

    public static Helper getInstance() {
        if (instance == null) {                 // Check 1
            synchronized(Singleton.class) {
                if (instance == null) {         // Check 2
                    instance = new Helper();    // Lỗi ở đây!
                }
            }
        }
        return instance;
    }
}

Vấn đề nằm ở instance = new Helper(). Thao tác này không nguyên tử và có thể được CPU/compiler thực hiện theo thứ tự sau:

  1. Cấp phát bộ nhớ cho Helper.
  2. Gán tham chiếu của vùng nhớ đó cho instance.
  3. Thực thi constructor của Helper để khởi tạo các trường.

Do reordering, bước 2 có thể xảy ra trước bước 3. Một luồng B có thể đi qua Check 1, thấy instance không còn là null (vì bước 2 đã xong) và trả về một đối tượng instance chưa được khởi tạo hoàn chỉnh.

Cách sửa: Dùng volatile cho biến instance.

private static volatile Helper instance = null;

Từ khóa volatile sẽ ngăn chặn việc reordering giữa việc ghi vào instance và việc khởi tạo đối tượng Helper, đảm bảo rằng luồng khác chỉ thấy instance khác null sau khi constructor đã chạy xong.

6. Khi nào cần quan tâm đến JMM?

Trong thực tế, bạn không cần phải nghĩ về JMM mỗi ngày. Các lớp tiện ích trong gói java.util.concurrent (như AtomicInteger, ConcurrentHashMap, Executors) đã được thiết kế cẩn thận để xử lý các vấn đề này cho bạn.

Tuy nhiên, bạn phải quan tâm đến JMM khi:

  1. Viết code đa luồng cấp thấp: Khi bạn tự quản lý luồng (new Thread()) và sử dụng các cơ chế đồng bộ hóa cơ bản như volatilesynchronized.
  2. Thiết kế các thư viện hoặc framework: Nếu bạn đang xây dựng các thành phần có thể tái sử dụng và sẽ được dùng trong môi trường đa luồng.
  3. Tối ưu hóa hiệu năng: Khi bạn cần tránh synchronized vì chi phí cao và tìm cách sử dụng các giải pháp nhẹ hơn như volatile hoặc các lớp Atomic.
  4. Debug các lỗi concurrency: Khi gặp các lỗi khó tái hiện như race conditions, deadlock, hoặc các vấn đề về visibility, việc hiểu JMM là tối quan trọng để chẩn đoán và sửa lỗi.

7. Kết luận

Java Memory Model là một phần phức tạp nhưng cực kỳ quan trọng của nền tảng Java. Nó cung cấp các đảm bảo cần thiết để lập trình viên có thể xây dựng các ứng dụng đa luồng một cách đáng tin cậy. Bằng cách hiểu rõ các khái niệm như happens-before, visibility, atomicity và vai trò của volatilesynchronized, các lập trình viên Java có kinh nghiệm có thể viết ra những đoạn mã không chỉ đúng đắn mà còn hiệu quả và an toàn trong môi trường đa luồng.