Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

Java Memory Model & happens-before

Tung Bui Thanh (TungDaDev)14 mins read

Computer processor installed in motherboard

Code đa luồng (concurrency) trong Java khó không phải vì cú pháp, mà vì phần cứng bên dưới chạy theo luật khác hẳn với cách mình đọc code từ trên xuống.

Ở hệ thống chịu tải cả triệu TPS, mấy con bug khó chịu nhất thường chẳng để lại stack trace nào:

  • Biến cờ boolean stop = false đã bị thread khác đổi thành true, vậy mà worker vẫn quay mãi trong while (!stop).
  • Thread 1 gán hai biến độc lập (a = 1; b = 2;), nhưng thread 2 lại thấy b == 2 xảy ra trước a == 1.
  • Hai thread ghi vào hai biến khác hẳn nhau, vậy mà throughput tụt hẳn, có khi vài lần, vì False Sharing.

Để dev khỏi phải viết Assembly riêng cho từng kiến trúc CPU (x86, ARM, Apple Silicon, RISC-V), Java đưa ra một chuẩn chung: Java Memory Model (JMM). Bản JMM đang dùng được viết lại qua JSR-133, áp dụng từ Java 5, và nằm ở chương 17 của Java Language Specification.

Bài này đi từ tầng CPU, giao thức đồng bộ cache MESI, các Memory Barrier, tới VarHandle (Java 9+).


# vòng lặp không bao giờ dừng & jit hoisting

Bắt đầu bằng một bài test kinh điển:

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();
    }
}

Chạy chương trình này trên HotSpot 64-bit (JIT C2 bật mặc định), rất có thể nó sẽ không bao giờ kết thúc. Main thread đã in "Main thread đã set stop = true", mà thread worker vẫn quay mãi trong while (!stop). Chạy thử trên JDK 21 thì sau 4 giây worker vẫn quay. Chạy lại với -Xint (tắt JIT, chỉ thông dịch) thì worker dừng ngay sau khi main set cờ. JMM không bắt buộc chương trình phải treo, nó chỉ cho phép chuyện đó xảy ra vì stop không có quan hệ happens-before nào giữa hai thread.

# vì sao lại vậy?

  1. JIT Compiler Loop Hoisting: JIT (C2 Compiler) thấy trong thân vòng lặp while (!stop) count++; không có dòng nào đổi biến stop. Thế là nó tối ưu luôn mã máy thành:
    if (!stop) {
        while (true) { count++; } // Hoisted! Biến stop không bao giờ được đọc lại từ RAM nữa!
    }
  2. Phần cứng thì sao: giá trị stop = true mà main thread ghi ra sẽ nằm tạm trong Store Buffer của Core 1 một chút, nhưng store buffer tự xả rất nhanh, rồi giao thức cache coherence (phần dưới) lo cho core khác thấy giá trị mới. Phần cứng chỉ làm việc ghi trễ đi một chút, không làm nó mất mãi mãi. Thủ phạm khiến vòng lặp quay vĩnh viễn ở đây là bước 1: sau khi JIT hoist, worker không còn đọc stop từ bộ nhớ nữa.

Khai báo private static volatile boolean stop là hết: JIT không được hoist lần đọc biến volatile, và lần ghi volatile của main happens-before lần đọc của worker.


# phần cứng bên dưới: giao thức mesi, store buffers & invalidate queues

Muốn hiểu vì sao CPU đảo thứ tự lệnh (Instruction Reordering), phải nhìn vào giao thức đồng bộ cache của phần cứng: MESI Protocol.

Mỗi Cache Line (64 bytes trên x86 và đa số ARM; Apple M-series dùng 128 bytes) trong CPU luôn ở 1 trong 4 trạng thái:

  • M (Modified): dữ liệu đã bị sửa trong cache của core hiện tại, khác với RAM.
  • E (Exclusive): dữ liệu chỉ nằm trong cache của core hiện tại, khớp với RAM.
  • S (Shared): dữ liệu nằm trong cache của nhiều core cùng lúc, chỉ đọc.
  • I (Invalid): dữ liệu trong cache đã cũ, không được đọc.

Đây là mô hình đơn giản hoá, theo cách Paul McKenney trình bày trong bài Memory Barriers: a Hardware View for Software Hackers. CPU thật khác nhau nhiều: x86 có mô hình TSO khá chặt, chỉ cho phép đúng một kiểu đảo là ghi trước, đọc sau (store-load); ARM và RISC-V thì cho phép đảo gần như mọi kiểu. Chính vì khác nhau vậy nên Java mới cần một memory model chung.

# gốc rễ của lỗi reordering:

  • Store Buffer: giúp CPU khỏi phải đứng chờ các core khác xác nhận invalidate. Nhưng nó làm lệnh ghi (write) bị trễ so với các lệnh đọc phía sau → Store-Load Reordering.
  • Invalidate Queue: giúp core trả lời xác nhận ngay, nhưng vì xoá cache bị hoãn lại nên core đó vẫn đọc tiếp dữ liệu cũ → Visibility Delay.

# bốn loại rào chắn bộ nhớ (memory barriers / fences)

Để bắt CPU và compiler không được đảo lệnh, JVM chèn Memory Barrier. Nói cho đúng thì JMM trong đặc tả không hề nhắc tới barrier, nó chỉ định nghĩa quan hệ happens-before (phần sau). Bốn loại barrier dưới đây đến từ tài liệu The JSR-133 Cookbook for Compiler Writers của Doug Lea, một mô hình để người viết JVM hiện thực JMM. Mỗi barrier chỉ cấm một kiểu đảo thứ tự:

Tên barrierCách đặtÝ nghĩa với phần cứng CPU
LoadLoadLoad1; LoadLoad; Load2Dữ liệu của Load1 phải nạp vào CPU xong trước khi nạp Load2. Xử lý hết Invalidate Queue.
StoreStoreStore1; StoreStore; Store2Dữ liệu của Store1 phải xuống cache trước khi ghi Store2. Xả hết Store Buffer.
LoadStoreLoad1; LoadStore; Store2Lệnh đọc Load1 phải xong trước khi dữ liệu của Store2 được ghi ra.
StoreLoadStore1; StoreLoad; Load2Barrier đắt nhất. Bắt Store Buffer xả sạch ra Cache/RAM rồi mới được đọc Load2. Trên x86, nó tương đương lệnh assembly mfence hoặc lock prefix.

# volatile được biên dịch thành memory barrier thế nào

Theo công thức bảo thủ trong Cookbook, khi một biến được đánh dấu volatile:

  • Trước mỗi lần ghi volatile: chèn barrier StoreStore và LoadStore (mọi lệnh đọc, ghi thường phía trên không được tụt xuống sau lần ghi volatile).
  • Sau mỗi lần ghi volatile: chèn barrier StoreLoad (lần ghi volatile hiện ra ngay với các thread khác, trước khi đọc bất kỳ biến nào sau đó).
  • Sau mỗi lần đọc volatile: chèn barrier LoadLoad và LoadStore (không cho các lệnh đọc/ghi phía sau chen lên trước).

JVM thật chỉ phát ra những barrier mà CPU đích cần. Trên x86, ba loại đầu không tốn lệnh nào vì TSO đã đảm bảo sẵn, nên đọc volatile rẻ gần bằng đọc thường; chỉ ghi volatile là phải trả thêm một lệnh lock addl (HotSpot dùng lệnh này thay cho mfence vì rẻ hơn). Trên ARM thì có nhiều lệnh barrier hơn.


# nguyên lý happens-before (jsr-133)

Quan hệ Happens-Before (A ≺ B) là lời cam kết của Java: nếu A happens-before B thì B chắc chắn thấy toàn bộ kết quả của A, và về logic A đứng trước B.

# các quy tắc happens-before

  1. Program Order Rule: trong cùng một thread, hành động đứng trước trong code xảy ra trước hành động đứng sau.
  2. Volatile Variable Rule: ghi vào biến volatile luôn happens-before mọi lần đọc sau đó trên chính biến đó.
  3. Monitor Lock Rule: unlock một khối synchronized luôn happens-before mọi lần lock sau đó trên cùng monitor.
  4. Thread Start Rule: thread.start() luôn happens-before mọi hành động trong thread con.
  5. Thread Join Rule: mọi hành động trong thread con luôn happens-before lúc thread.join() ở thread cha trả về.
  6. Transitivity (bắc cầu): nếu A ≺ B và B ≺ C thì chắc chắn A ≺ C.

Quy tắc bắc cầu là thứ làm volatile hữu ích thật sự. Thread 1 ghi data = 42 (biến thường) rồi ghi ready = true (volatile); thread 2 đọc thấy ready == true rồi đọc data. Program order cho ta data = 42 ≺ ready = true, volatile rule cho ready = true ≺ đọc ready, bắc cầu ra thread 2 chắc chắn thấy data == 42, dù data không hề volatile. Ngược lại, hai thread cùng truy cập một biến, ít nhất một bên ghi, mà không có happens-before nối giữa chúng thì gọi là data race, và khi đó JMM gần như không hứa gì về giá trị đọc được.

Cũng đừng hiểu happens-before là "xảy ra trước về thời gian". Nó là cam kết về việc thấy được: A có thể chạy sau B theo đồng hồ mà vẫn đúng luật, miễn là B thấy kết quả như thể A đã chạy trước.


# false sharing & giải pháp @Contended (jep 142)

Một trong những thứ âm thầm giết hiệu năng nhất trong hệ thống đa luồng là False Sharing.

CPU nạp dữ liệu theo từng Cache Line 64 bytes, nên nếu hai biến độc lập do hai thread khác nhau dùng mà vô tình nằm chung một cache line:

# giải pháp: chèn padding với @Contended (java 8+)

Để tách hai biến ra hai cache line riêng, Java có annotation @Contended. Ở Java 8 nó là sun.misc.Contended; từ Java 9 chuyển thành @jdk.internal.vm.annotation.Contended, nằm trong package nội bộ không export (hoặc tự chèn 7 field long = 56 bytes padding mỗi bên bằng tay như thư viện LMAX Disruptor):

public class FalseSharingPrevention {
 
    // Ép JVM chèn padding 128 bytes xung quanh trường này,
    // cô lập nó vào một Cache Line hoàn toàn độc lập!
    @jdk.internal.vm.annotation.Contended
    public volatile long core1Counter = 0L;
 
    @jdk.internal.vm.annotation.Contended
    public volatile long core2Counter = 0L;
}

TIP

Muốn dùng @Contended trong ứng dụng bình thường (Java 9+) thì cần hai thứ: lúc compile thêm --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED (package này không export), và lúc chạy bật cờ -XX:-RestrictContended, không thì JVM lặng lẽ bỏ qua annotation. Vì phụ thuộc API nội bộ nên thư viện thường chọn tự chèn padding. Mấy class lõi của JDK như LongAdder (các Cell của Striped64) và bộ đếm CounterCell của ConcurrentHashMap đều dùng @Contended để các thread cộng dồn vào những ô khác nhau mà không đá cache line của nhau.


# VarHandle (java 9+): vượt qua giới hạn của volatile

Nhiều năm liền, ai cần hiệu năng cao đều phải đi "cửa sau" sun.misc.Unsafe, khá nguy hiểm. Từ Java 9, VarHandle (JEP 193) chuẩn hoá các chế độ truy cập bộ nhớ chi tiết hơn:

Opaque hợp với kiểu cờ dừng như ví dụ đầu bài: chỉ cần chắc chắn thread kia rồi sẽ thấy giá trị mới, không cần kéo theo dữ liệu nào khác.

# acquire / release semantics: rẻ hơn volatile

Sao phải trả giá cho barrier StoreLoad trong khi nhiều lúc mình chỉ cần:

  • Release (ghi): mọi lệnh đọc, ghi trước đó không được tụt xuống sau lệnh này.
  • Acquire (đọc): mọi lệnh đọc, ghi sau đó không được chen lên trước lệnh này.

Cặp này đủ cho mẫu "một thread chuẩn bị dữ liệu rồi công bố, thread khác thấy cờ thì đọc dữ liệu" (giống ví dụ data/ready ở trên). Cái nó không đảm bảo là thứ tự store-load: với các thuật toán kiểu Dekker, mỗi thread ghi cờ của mình rồi đọc cờ của thread kia, acquire/release sẽ sai, phải dùng volatile. Trên x86, release write chỉ là một lệnh mov thường, còn volatile write phải thêm lock addl, nên chênh lệch nằm ở phía ghi; phía đọc thì gần như bằng nhau.

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
 
public class HighThroughputBuffer {
 
    private int state = 0;
 
    private static final VarHandle STATE_HANDLE;
 
    static {
        try {
            STATE_HANDLE = MethodHandles.lookup()
                .findVarHandle(HighThroughputBuffer.class, "state", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }
 
    public void publishData(int newState) {
        // Release Write: nhẹ hơn volatile write vì không cần StoreLoad barrier
        STATE_HANDLE.setRelease(this, newState);
    }
 
    public int consumeData() {
        // Acquire Read: Bảo đảm thấy toàn bộ thay đổi do release write công bố
        return (int) STATE_HANDLE.getAcquire(this);
    }
}

# tổng kết

Hiểu Java Memory Model không chỉ để qua mấy câu phỏng vấn khó, mà còn là nền để thiết kế các cấu trúc dữ liệu concurrent không dùng lock (Lock-Free Data Structures):

  1. volatile không đảm bảo atomic (count++ vẫn dính race condition). Nó chỉ đảm bảo Visibility và Ordering.
  2. Luôn để ý False Sharing khi nhiều thread cùng cập nhật các biến nằm sát nhau trên Heap.
  3. Ở các hệ thống chịu tải rất cao, có thể chuyển từ volatile toàn phần sang VarHandle Acquire/Release semantics cho những chỗ chỉ cần công bố dữ liệu một chiều. Làm vậy khi đã đo được ghi volatile là điểm nghẽn, vì code sẽ khó đọc và khó kiểm chứng hơn nhiều; phần lớn trường hợp java.util.concurrent có sẵn đã đủ.

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 😎 👍🏻 🚀 🔥.