CodeGym /Các khóa học /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Làm quen với Java Memory Model (JMM)

Vấn đề về khả năng hiển thị và thứ tự

Trong chương trình một luồng thì mọi thứ đơn giản: ghi một giá trị vào biến — và có thể đọc nó ngay lập tức. Trong thế giới đa luồng, điều này lại khác. CPU lưu đệm (cache) giá trị, trình biên dịch và JVM đôi khi thay đổi thứ tự lệnh, và một luồng có thể nhìn thấy giá trị “cũ” ngay cả khi luồng khác vừa mới thay đổi nó.

Java Memory Model (JMM) mô tả cách các luồng giao tiếp qua bộ nhớ: khi nào thay đổi của một luồng trở nên nhìn thấy với luồng khác và các thao tác diễn ra theo thứ tự nào. Nếu không tính đến điều này, chương trình có thể cư xử không thể đoán trước, dù thoạt nhìn mọi thứ đều có vẻ bình thường.

Hiểu JMM giúp bạn lý giải vì sao đôi khi một luồng không thấy dữ liệu mới, cách dùng đúng volatile, synchronized và các lớp nguyên tử (atomic), và vì sao lỗi trong mã đa luồng có thể chỉ xuất hiện ở môi trường production. Nói ngắn gọn, JMM là “luật chơi” của bộ nhớ; nếu bỏ qua, ngay cả mã gọn gàng nhất cũng có thể “phản” lại bạn.

So sánh

Hãy tưởng tượng bạn có hai người (hai luồng) viết và đọc giấy ghi chú (biến) trên bảng (bộ nhớ). Đôi khi một người vừa viết, người kia vẫn chưa thấy bản ghi mới — vì họ đang nhìn “bản sao” của bảng (cache) của riêng mình. JMM xác định khi nào và bằng cách nào các ghi chú này trở nên nhìn thấy với tất cả.

2. happens-before: nền tảng của JMM

happens-before là gì?

happens-before là quan hệ giữa hai hành động trong chương trình: nếu hành động A happens-before hành động B, thì mọi thay đổi được thực hiện trong A được đảm bảo nhìn thấy trong B.

Quan trọng: happens-before không chỉ là “xảy ra trước”, mà là “được đảm bảo nhìn thấy”.

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

1. Bên trong một luồng

Mọi thứ diễn ra trong cùng một luồng đều có thứ tự: nếu bạn ghi vào biến rồi mới đọc nó — bạn sẽ thấy thay đổi của chính mình.

2. Khối/monitor đồng bộ hóa

Mọi thứ xảy ra trước khi thoát khỏi khối đồng bộ (synchronized) sẽ trở nên nhìn thấy với luồng sau đó đi vào khối này.

synchronized(lock) {
    sharedVar = 42; // ghi
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // chắc chắn sẽ thấy 42
}

3. Ghi/đọc volatile

Ghi vào trường volatile happens-before mọi lần đọc sau đó của trường này từ luồng khác.

volatile boolean ready = false;

// Luồng 1
data = 123;
ready = true; // volatile write

// Luồng 2
if (ready) { // volatile read
    System.out.println(data); // chắc chắn sẽ thấy data = 123
}

4. Khởi chạy và kết thúc luồng

  • Lời gọi Thread.start() happens-before thời điểm luồng bắt đầu chạy.
  • Việc kết thúc luồng happens-before khi Thread.join() trả về.

5. Hoàn thành tác vụ trong Executor

Nếu bạn gửi tác vụ vào Executor và đợi nó hoàn thành (Future.get()), mọi thay đổi được thực hiện trong tác vụ sẽ nhìn thấy sau khi gọi get().

6. Các trường final

Khởi tạo các trường với modifier final trong constructor happens-before việc publish tham chiếu đến đối tượng. Điều này quan trọng với các đối tượng bất biến (immutable).

3. Publish đối tượng an toàn

Vấn đề: đối tượng “không mới”

Nếu một luồng tạo đối tượng và truyền nó cho luồng khác mà không có đồng bộ hóa, luồng kia có thể thấy các giá trị “thô” của trường (ví dụ: chưa khởi tạo hoặc giá trị cũ).

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// Luồng 1
holder = new Holder(); // tạo đối tượng

// Luồng 2
if (holder != null) {
    System.out.println(holder.value); // có thể thấy 0, chứ không phải 42!
}

Publish đối tượng đúng cách như thế nào?

1. Thông qua các trường final

Nếu mọi trường của đối tượng là final và được khởi tạo trong constructor, có thể publish đối tượng an toàn mà không cần đồng bộ hóa bổ sung.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. Thông qua tham chiếu volatile

Nếu tham chiếu đến đối tượng được khai báo là volatile, thì sau khi gán, đối tượng được đảm bảo nhìn thấy với các luồng khác.

volatile Holder holder;

// Luồng 1
holder = new Holder();

// Luồng 2
if (holder != null) {
    System.out.println(holder.value); // chắc chắn sẽ thấy 42
}

3. Khởi tạo đơn luồng trước khi publish

Nếu đối tượng được tạo và khởi tạo trước khi tham chiếu đến nó trở nên khả dụng với các luồng khác, thì an toàn.

Holder holder = new Holder(); // chỉ trong một luồng
// ... sau đó holder có thể được truy cập bởi các luồng khác

4. Thông qua khóa

Nếu đối tượng được tạo bên trong khối đồng bộ và tham chiếu đến nó chỉ được đọc bên trong khối tương tự, thì an toàn.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... ở luồng khác
synchronized(lock) {
    if (holder != null) {
        // an toàn
    }
}

4. Double-checked locking và volatile

Double-checked locking là gì?

Đây là mẫu (pattern) để khởi tạo lười (lazy) đối tượng singleton:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // kiểm tra lần 1
            synchronized (Singleton.class) {
                if (instance == null) { // kiểm tra lần 2
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Vấn đề: Không có tham chiếu volatile đến instance thì mã này không hoạt động đúng! Một luồng có thể thấy đối tượng chưa được khởi tạo đầy đủ.

Vì sao thiếu volatile là không ổn?

JVM có thể “sắp xếp lại” lệnh sao cho tham chiếu đến đối tượng được gán trước khi constructor hoàn tất. Luồng khác sẽ thấy đối tượng chưa khởi tạo.

Làm thế nào cho đúng?

Khai báo instancevolatile:

private static volatile Singleton instance;

Giờ double-checked locking hoạt động đúng: volatile đảm bảo quan hệ happens-before giữa ghi và đọc tham chiếu.

Phương án thay thế: khởi tạo tĩnh

Cách đơn giản và an toàn nhất để làm singleton — dùng khởi tạo tĩnh:

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

Ở đây JVM tự đảm bảo khởi tạo đúng.

5. VarHandle: truy cập mức thấp hiện đại

VarHandle là gì?

VarHandle là API hiện đại (Java 9+) cho phép làm việc với biến ở mức thấp: đọc, ghi, thực hiện các thao tác nguyên tử, kiểm soát khả năng hiển thị và thứ tự lệnh.

Tại sao cần VarHandle khi đã có các lớp nguyên tử?
VarHandle cho phép làm việc với mọi trường (không chỉ int/long/Reference).
— Cho phép chọn tường minh ngữ nghĩa truy cập: volatile, acquire/release, opaque.
— Được dùng để hiện thực các cấu trúc dữ liệu hiệu năng cao.

Các ngữ nghĩa truy cập

  • Volatile: đảm bảo đầy đủ happens-before (như trường volatile).
  • Acquire/Release: bảo đảm yếu hơn nhưng nhanh hơn (dùng cho cấu trúc lock-free).
  • Opaque: bảo đảm khả năng hiển thị tối thiểu, nhưng hiệu năng tối đa.

Ví dụ dùng VarHandle

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

Khi nào nên dùng VarHandle?

  • Để hiện thực cấu trúc dữ liệu lock-free của riêng bạn.
  • Khi cần hiệu năng tối đa và kiểm soát thứ tự lệnh.
  • Trong ứng dụng thông thường, các lớp nguyên tử và synchronized thường là đủ.

6. False sharing và căn hàng cache

False sharing là tình huống hai luồng làm việc với hai biến khác nhau nhưng các biến nằm trên cùng một cache line của CPU. Kết quả là các luồng cản trở lẫn nhau, vì thay đổi một biến làm vô hiệu cache cho biến kia.

So sánh: Hai người ngồi chung một bàn (cache line), nhưng mỗi người viết trên nửa tờ giấy của mình. Nếu một người thay đổi gì đó, người kia phải “đọc lại” cả tờ giấy.

Vì sao điều này tệ?

  • Hiệu năng giảm mạnh: CPU tốn thời gian đồng bộ cache.
  • Đặc biệt nghiêm trọng với các biến “nóng”, được nhiều luồng thay đổi thường xuyên.

Tránh thế nào?

Tách các trường “nóng” vào những đối tượng khác nhau hoặc dùng annotation/cấu trúc đặc biệt để “padding” (ví dụ @Contended). Trên JVM hiện đại, có thể bật tùy chọn -XX:-RestrictContended và dùng @sun.misc.Contended (Java 8+) để căn hàng cho các trường.

Ví dụ:

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

Lưu ý: Annotation @Contended không thuộc API chuẩn, nhưng được dùng trong JDK để tối ưu các lớp nguyên tử.

7. Thực hành: sửa singleton và mini-benchmark JMH

Sửa singleton không hoạt động

Không tốt (không có volatile):

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

Tốt (có volatile):

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

Tốt nhất — khởi tạo tĩnh:

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

Mini-benchmark JMH: khả năng hiển thị và tính nguyên tử

Lưu ý: JMH là framework chuyên dụng cho microbenchmark trong Java. Đừng rút ra kết luận về hiệu năng nếu không dùng JMH — kết quả có thể đánh lừa bạn!

Ví dụ: kiểm tra khả năng hiển thị của volatile

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // quay vòng cho đến khi thấy true
        }
        // đã thấy thay đổi
    }
}

Ví dụ: volatile không nguyên tử

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // không nguyên tử!
    }
}

Mặc dù có volatile, khi nhiều luồng cùng tăng biến, giá trị cuối cùng sẽ nhỏ hơn kỳ vọng (toán tử counter++ tách ra thành đọc, tính toán và ghi).

8. Lỗi thường gặp khi làm việc với JMM, volatile và publish

Lỗi số 1: Kỳ vọng tính nguyên tử từ volatile.
volatile chỉ đảm bảo khả năng hiển thị thay đổi, không đảm bảo tính nguyên tử của thao tác. Toán tử counter++ không trở nên nguyên tử chỉ vì biến là volatile.

Lỗi số 2: Publish đối tượng mà không có đồng bộ hóa.
Nếu bạn tạo đối tượng ở một luồng và đưa nó cho luồng khác mà không có volatile, synchronized hoặc các trường final — luồng kia có thể thấy các giá trị “thô”.

Lỗi số 3: Double-checked locking không có volatile.
Không có tham chiếu volatile đến singleton, bạn có thể nhận đối tượng chưa khởi tạo ở luồng khác.

Lỗi số 4: Dùng khóa kiểu cũ với virtual threads.
Một số cơ chế đồng bộ hóa cũ (native monitor) có thể cản trở JVM quản lý hiệu quả virtual threads.

Lỗi số 5: Bỏ qua false sharing.
Nếu các biến “nóng” nằm gần nhau trong bộ nhớ, các luồng sẽ cản trở nhau do cache line.

Lỗi số 6: Rút kết luận về hiệu năng mà không có JMH.
Microbenchmark không dùng JMH thường cho kết quả sai lệch do tối ưu của JVM và cache của CPU.

1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Đào sâu vào lập trình đa luồng
Đào sâu vào lập trình đa luồng
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION