Tất cả bài viết
JavaConcurrencyJVMMultithreading

Java Memory Model: happens-before, volatile và những bug đa luồng “chạy đúng trên máy mình”

30/09/20260 lượt xem13 phút đọc

Một chương trình 15 dòng không bao giờ dừng

Hãy bắt đầu bằng một đoạn code trông vô hại. Thread worker đếm liên tục cho đến khi cờ running chuyển sang false; thread main đợi 1 giây rồi tắt cờ.

public class StopFlag {
    static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long count = 0;
            while (running) {
                count++;
            }
            System.out.println("Worker dừng sau " + count + " vòng lặp");
        });
        worker.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("main: đã set running = false");
        worker.join();
    }
}

Chạy trên Java 25, kết quả là:

main: đã set running = false

...và chương trình treo vĩnh viễn. Mình phải kill nó bằng timeout. Chỉ cần thêm một từ khóa, static volatile boolean running, chương trình dừng đúng như mong đợi:

main: đã set running = false
Worker dừng sau 3073824472 vòng lặp

Chuyện gì đã xảy ra? Sau vài nghìn vòng lặp, JIT compiler (C2) biên dịch vòng while sang mã máy. Nó thấy running là biến thường và không bị thay đổi bên trong vòng lặp, nên tối ưu bằng cách đọc biến một lần duy nhất ở ngoài vòng lặp — tương đương if (running) while (true) count++;. Tối ưu này hoàn toàn hợp lệ theo Java Memory Model (JMM), vì giữa lệnh ghi của main và lệnh đọc của worker không có quan hệ happens-before nào. Bài viết này giải thích quan hệ đó là gì, và làm sao để code đa luồng của bạn đúng vì thiết kế chứ không phải vì may mắn.

Ba vấn đề của code đa luồng

1. Visibility — thread này không thấy thay đổi của thread kia

Giá trị một biến có thể nằm trong register, trong cache riêng của từng core, hoặc bị compiler "cache" như ví dụ trên. Lệnh ghi của một thread không có gì đảm bảo sẽ hiển thị cho thread khác, trừ khi có đồng bộ hóa.

2. Ordering — lệnh không chạy theo thứ tự bạn viết

Compiler, JIT và chính CPU đều được phép đảo thứ tự lệnh, miễn là kết quả nhìn từ bên trong một thread không đổi (as-if-serial). Nhưng thread khác thì nhìn thấy thứ tự đã bị đảo. Ví dụ kinh điển:

int x = 0, y = 0;

// Thread 1          // Thread 2
x = 1;               y = 1;
int a = y;           int b = x;

Trực giác bảo rằng ít nhất một trong hai biến a, b phải bằng 1. Nhưng kết quả a == 0 && b == 0 là hợp lệ theo JMM, và xảy ra thật trên phần cứng x86 lẫn ARM: mỗi core ghi vào store buffer của nó trước khi flush ra bộ nhớ chung, nên lệnh đọc phía sau có thể "vượt mặt" lệnh ghi phía trước.

3. Atomicity — một dòng code không phải là một thao tác

count++ thực ra gồm ba bước: đọc, cộng, ghi. Hai thread có thể cùng đọc giá trị 41, cùng ghi 42, và mất một lần tăng. Ngoài ra, theo đặc tả (JLS 17.7), việc ghi biến long/double không phải volatile thậm chí có thể bị tách làm hai lần ghi 32-bit — dù JVM 64-bit hiện đại gần như luôn ghi nguyên khối.

Happens-before: hợp đồng giữa bạn và JVM

JMM (JLS chương 17, thiết kế lại từ Java 5 qua JSR-133) không mô tả cache hay store buffer. Nó định nghĩa một quan hệ gọi là happens-before: nếu hành động A happens-before hành động B, thì mọi thứ A ghi đều nhìn thấy được từ B, và A được coi như xảy ra trước B.

Từ đó có định nghĩa quan trọng nhất: data race là khi hai thread truy cập cùng một biến, ít nhất một bên ghi, và hai truy cập đó không được sắp thứ tự bởi happens-before. Và JMM đưa ra một lời hứa: chương trình không có data race thì hành xử như thể mọi lệnh chạy tuần tự theo một thứ tự duy nhất (sequential consistency). Nói cách khác: loại bỏ hết data race, bạn không cần nghĩ về reordering nữa.

Các quy tắc happens-before

  • Program order: trong cùng một thread, lệnh viết trước happens-before lệnh viết sau.
  • Monitor lock: việc nhả một lock (thoát synchronized) happens-before lần lấy lock đó tiếp theo.
  • Volatile: lệnh ghi một biến volatile happens-before mọi lệnh đọc sau đó (đọc thấy giá trị vừa ghi) của cùng biến.
  • Thread start: thread.start() happens-before mọi hành động bên trong thread đó.
  • Thread join: mọi hành động trong thread happens-before việc join() trên thread đó trả về.
  • Transitivity: A hb B và B hb C thì A hb C.

Package java.util.concurrent bổ sung thêm các đảm bảo tương tự: hành động trước khi đưa object vào một concurrent collection happens-before hành động sau khi lấy nó ra; submit một task vào Executor happens-before khi task đó chạy; hành động bên trong task happens-before khi Future.get() trả về; countDown() happens-before khi await() trả về.

Ví dụ: "cõng" dữ liệu qua một biến volatile

Transitivity cho phép một biến volatile bảo vệ cả những biến thường được ghi trước nó:

class Config {
    private Map<String, String> values;           // biến thường
    private volatile boolean ready = false;

    void init() {                                 // Thread A
        values = loadFromFile();
        ready = true;                             // volatile write
    }

    String get(String key) {                      // Thread B
        if (!ready) return null;                  // volatile read
        return values.get(key);                   // chắc chắn thấy values
    }
}

Chuỗi lập luận: values = ... hb ready = true (program order) → hb lệnh đọc ready thấy true (volatile) → hb values.get (program order). Nhờ transitivity, Thread B chắc chắn thấy values đã được gán. Bỏ volatile đi, Thread B có thể thấy ready == true nhưng values == null.

volatile: đủ cho visibility, không đủ cho atomicity

Lỗi phổ biến nhất về volatile là nghĩ nó biến mọi thao tác thành thread-safe. Thử bốn thread, mỗi thread tăng biến đếm một triệu lần, so sánh ba cách:

static volatile int volatileCount = 0;
static final AtomicInteger atomicCount = new AtomicInteger();
static final LongAdder adder = new LongAdder();

// Mỗi thread chạy 1.000.000 lần:
volatileCount++;
atomicCount.incrementAndGet();
adder.increment();
Kỳ vọng:       4000000
volatile int:  2801338
AtomicInteger: 4000000
LongAdder:     4000000

Gần 30% số lần tăng biến mất. volatile đảm bảo mỗi lần đọc thấy giá trị mới nhất, nhưng count++ vẫn là ba bước tách rời, và thread khác có thể chen vào giữa. volatile chỉ phù hợp khi mỗi thao tác là một lần đọc hoặc một lần ghi độc lập: cờ dừng, cờ trạng thái, hay publish tham chiếu tới một object bất biến.

AtomicInteger, CAS và LongAdder

AtomicInteger dùng lệnh compare-and-set (CAS) của CPU: "chỉ ghi giá trị mới nếu giá trị hiện tại vẫn là giá trị tôi đã đọc". Về mặt khái niệm, incrementAndGet tương đương vòng lặp sau:

int current;
do {
    current = value.get();
} while (!value.compareAndSet(current, current + 1));

Khi nhiều thread cùng tranh nhau một biến, vòng CAS thất bại liên tục và tốn CPU. LongAdder giải quyết bằng cách chia biến đếm thành nhiều "ô" (cell); các thread tăng những ô khác nhau, và sum() cộng tất cả lại. Đánh đổi: sum() không phải một snapshot nguyên tử. Dùng LongAdder cho metric và bộ đếm ghi nhiều, đọc ít; dùng AtomicInteger/AtomicLong khi cần giá trị chính xác tại một thời điểm hoặc cần CAS.

synchronized và Lock

synchronized cho bạn hai thứ cùng lúc: loại trừ lẫn nhau (chỉ một thread trong vùng tới hạn) và visibility (mọi thứ được ghi trước khi nhả lock đều hiển thị cho thread lấy lock tiếp theo). ReentrantLock có cùng ngữ nghĩa bộ nhớ, kèm các tính năng synchronized không có: tryLock với timeout, lock có thể ngắt (lockInterruptibly), fairness, nhiều Condition. Nhớ luôn unlock() trong khối finally.

Một lưu ý liên quan đến virtual threads: trước Java 24, virtual thread bị "pin" vào carrier thread khi block bên trong synchronized. Chủ đề này được phân tích chi tiết ở bài Virtual Threads trong cùng series.

Check-then-act: từng lệnh thread-safe chưa chắc cả cụm thread-safe

ConcurrentHashMap là thread-safe, nên đoạn code dưới đây cũng thread-safe... đúng không?

Map<String, Integer> views = new ConcurrentHashMap<>();

// ❌ 8 thread, mỗi thread chạy 100.000 lần
Integer old = views.get("views");
views.put("views", old == null ? 1 : old + 1);

// ✅
views.merge("views", 1, Integer::sum);
Kỳ vọng: 800000
get + put: 660222
merge:     800000

Mỗi lệnh get và put riêng lẻ đều an toàn, nhưng khoảng trống giữa chúng thì không: hai thread cùng đọc 41, cùng ghi 42. Đây là lỗi check-then-act (hay read-modify-write), và nó mất 17% số lần cập nhật trong ví dụ trên. Hãy dùng các method nguyên tử theo từng key của ConcurrentHashMap: merge, compute, computeIfAbsent, putIfAbsent. Giữ hàm truyền vào ngắn gọn và không sửa chính map đó bên trong hàm.

Safe publication và double-checked locking

"Publish" một object nghĩa là làm cho thread khác nhìn thấy tham chiếu tới nó. Publish sai cách thì thread khác có thể thấy tham chiếu nhưng thấy object ở trạng thái chưa khởi tạo xong. Ví dụ nổi tiếng nhất là double-checked locking viết sai:

// ❌ Sai: thiếu volatile
public class ConnectionManager {
    private static ConnectionManager instance;

    public static ConnectionManager getInstance() {
        if (instance == null) {                          // check lần 1, không lock
            synchronized (ConnectionManager.class) {
                if (instance == null) {                  // check lần 2, có lock
                    instance = new ConnectionManager();
                }
            }
        }
        return instance;
    }
}

new ConnectionManager() gồm: cấp phát bộ nhớ, chạy constructor, gán tham chiếu vào instance. Không có happens-before, phép gán có thể hiển thị với thread khác trước các lệnh ghi trong constructor. Thread thứ hai đi qua check lần 1 (không lock), thấy instance != null, và dùng một object mà field vẫn còn là giá trị mặc định. Sửa bằng cách khai báo private static volatile ConnectionManager instance;.

Tuy nhiên, cách gọn và an toàn hơn là holder class idiom, dựa trên đảm bảo của JVM rằng việc khởi tạo class là thread-safe và lazy:

public class ConnectionManager {
    private ConnectionManager() {}

    private static class Holder {
        static final ConnectionManager INSTANCE = new ConnectionManager();
    }

    public static ConnectionManager getInstance() {
        return Holder.INSTANCE;   // Holder chỉ được khởi tạo ở lần gọi đầu tiên
    }
}

Các cách publish an toàn (theo cuốn Java Concurrency in Practice):

  • Khởi tạo trong static initializer.
  • Lưu tham chiếu vào field volatile hoặc AtomicReference.
  • Lưu vào field final của một object được khởi tạo đúng cách.
  • Lưu vào field được bảo vệ bởi lock.
  • Đưa vào một concurrent collection.

final field và object bất biến

JMM có một đảm bảo đặc biệt cho field final (JLS 17.5): khi constructor chạy xong, mọi thread nhìn thấy tham chiếu tới object đều thấy field final đã được khởi tạo đúng — kể cả khi object được publish không an toàn. Đây là lý do object bất biến (mọi field final, như record) có thể chia sẻ giữa các thread mà không cần đồng bộ.

Điều kiện đi kèm: tham chiếu this không được "thoát" ra ngoài trong lúc constructor đang chạy.

public class AuditListener {
    private final List<String> events;

    public AuditListener(EventBus bus) {
        bus.register(this);             // ❌ this thoát ra khi constructor chưa xong
        this.events = new ArrayList<>();
    }
}

Thread của EventBus có thể gọi listener ngay sau register, lúc events vẫn là null. Cách sửa: đăng ký listener từ bên ngoài sau khi constructor xong, ví dụ qua một static factory method.

Kiểm thử code đồng thời

Unit test thông thường gần như không bao giờ bắt được bug đồng thời: interleaving gây lỗi có thể chỉ xuất hiện một lần trong hàng triệu lần chạy, và chỉ trên một số kiến trúc CPU. OpenJDK có công cụ chuyên dụng là jcstress, chạy một đoạn code hàng triệu lần trên nhiều thread và thống kê mọi kết quả quan sát được:

@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "Cả hai thấy giá trị mới")
@Outcome(id = {"0, 1", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Một thread chạy trước")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Reordering!")
@State
public class StoreLoadTest {
    int x, y;

    @Actor
    public void actor1(II_Result r) { x = 1; r.r1 = y; }

    @Actor
    public void actor2(II_Result r) { y = 1; r.r2 = x; }
}

Đây chính là ví dụ reordering ở đầu bài. Chạy test này trên máy thật, bạn sẽ thấy kết quả 0, 0 xuất hiện với tần suất đáng kể.

Checklist khi viết code đa luồng

  • Mọi biến được chia sẻ và có ít nhất một thread ghi đều phải có cơ chế happens-before: volatile, lock, atomic, hoặc concurrent collection.
  • volatile cho cờ và publish tham chiếu; atomic cho bộ đếm và CAS; lock cho bất biến (invariant) liên quan nhiều biến.
  • Cảnh giác với check-then-act, kể cả trên collection thread-safe.
  • Ưu tiên object bất biến: record và field final loại bỏ cả một lớp bug.
  • Ưu tiên công cụ cấp cao (ExecutorService, ConcurrentHashMap, CompletableFuture, virtual threads) thay vì tự quản lý wait/notify.
  • "Chạy đúng trên máy mình" không chứng minh được gì. Dùng jcstress cho các cấu trúc đồng bộ tự viết.

Kết luận

Java Memory Model thoạt nhìn trừu tượng, nhưng mọi thứ quy về một câu hỏi: giữa lệnh ghi này và lệnh đọc kia có quan hệ happens-before không? Nếu có, bạn an toàn. Nếu không, compiler và phần cứng được phép làm những điều trái trực giác — như biến một vòng lặp có điều kiện dừng thành vòng lặp vô hạn. Nắm vững vài quy tắc happens-before và các công cụ trong java.util.concurrent, bạn sẽ viết code đa luồng đúng vì thiết kế, không phải vì may mắn.

# Java
# Concurrency
# JVM
# Multithreading