Java Internals & Concurrency/ReadWriteLock & StampedLock — khi đọc áp đảo ghi
20/75
Bài 20 / 75~13 phútConcurrency cơ bảnMiễn phí lượt xem

ReadWriteLock & StampedLock — khi đọc áp đảo ghi

ReadWriteLock cho nhiều reader song song khi đọc áp đảo ghi; StampedLock thêm optimistic read gần như miễn phí — cùng cạm bẫy upgrade, reentrancy và validate.

TL;DR: ReentrantLock mạnh hơn synchronized nhưng vẫn là khóa độc quyền: một thread giữ thì tất cả phải chờ, kể cả 99 thread chỉ muốn đọc. ReadWriteLock tách read khỏi write — nhiều reader chạy song song, writer độc quyền; cho phép downgrade write xuống read nhưng cấm upgrade ngược lại, vì một thread giữ read lock xin write lock sẽ tự treo. StampedLock đẩy thêm một bước với optimistic read: đọc không giành khóa, rồi validate kiểm tra có writer chen vào không — đổi lại nó không reentrant và không có Condition. ReentrantReadWriteLock đứng trên bộ khung AQS (mổ ở bài kế tiếp); StampedLock là ngoại lệ tự cài state riêng.

1. Vì sao khóa độc quyền bóp nghẹt workload đọc nhiều?

ReentrantLock ở bài trước cho ta tryLock, timeout, lockInterruptibly, Condition — nhưng vẫn là khóa độc quyền tuyệt đối: một thread giữ thì mọi thread khác phải chờ, bất kể đọc hay ghi. Đặt cạnh workload TicketFlow: tồn kho vé bị đọc liên tục, chỉ bị ghi khi đặt hoặc hủy — tỷ lệ đọc trên ghi hàng trăm trên một, mà hai reader không bao giờ đụng độ vì đọc không sửa state. Bắt hàng trăm lần đọc vô hại xếp hàng là bóp nghẹt throughput vô cớ.

ReadWriteLock nới đúng chỗ đó: tách thành hai khóa liên kết. Nhiều reader giữ read lock đồng thời chừng nào không có writer; write lock loại trừ với tất cả. Analogy quen thuộc là trang wiki nội bộ:

Đời thường (trang wiki)Cơ chế read-write lock
Nhiều người cùng mở xem một trangRead lock — share, không giới hạn số reader
Một người bấm Edit, mọi người khác phải chờWrite lock — độc quyền với cả reader lẫn writer
Lưu xong, chuyển ngay sang chế độ xem không rời trangDowngrade: giành read trước khi nhả write
Đang xem mà muốn sửa thì phải đóng chế độ xem, xin Edit lạiKhông có upgrade — nhả read rồi giành write như thao tác mới

Mấu chốt cho mục 1.1: writer không share với bất kỳ ai, kể cả "chính mình trong vai reader" — đó là gốc rễ của lệnh cấm upgrade. Đây cũng là biến thể v1 capstone gợi mở: Monitor Pattern ở bài volatile & synchronized serialize cả những lần đọc thuần túy, ReentrantReadWriteLock giải phóng chúng.

private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock readLock = rw.readLock(), writeLock = rw.writeLock();
private final Map<String, Integer> remaining = new HashMap<>();   // @GuardedBy("rw")

public int seatsLeft(String eventId) {                 // doc nhieu: reader chay song song
    readLock.lock();
    try { return remaining.getOrDefault(eventId, 0); }
    finally { readLock.unlock(); }
}

public Booking book(String eventId, String userId) {   // ghi it: loai tru
    writeLock.lock();
    try {                                              // compound action: doc-kiem-ghi
        int left = remaining.getOrDefault(eventId, 0);
        if (left <= 0) throw new SoldOutException(eventId);
        remaining.put(eventId, left - 1);
        return new Booking(eventId, userId, left);
    } finally { writeLock.unlock(); }
}

Cờ fairness qua constructor đánh đổi throughput như ReentrantLock: chế độ unfair có thể bỏ đói writer khi reader đến liên tục cứ nhập hội với reader đang giữ khóa; fair buộc reader mới xếp sau writer đang chờ.

1.1 Downgrade được, upgrade thì không

ReentrantReadWriteLock hỗ trợ lock downgrading: thread giữ write lock giành thêm read lock rồi nhả write lock, hạ xuống quyền đọc mà không buông khóa giữa chừng — hữu ích khi vừa ghi xong muốn đọc tiếp giá trị vừa ghi.

void updateThenRead(String eventId, int newLeft) {
    writeLock.lock();
    try {
        remaining.put(eventId, newLeft);
        readLock.lock();                 // gianh read TRUOC khi nha write - khong co khe ho
    } finally { writeLock.unlock(); }    // ha cap: gio chi con giu read lock
    try { /* doc gia tri vua ghi; reader khac da co the vao cung */ }
    finally { readLock.unlock(); }
}

Chiều ngược lại — upgrade từ read lên write — API không hỗ trợ: gọi writeLock().lock() khi đang giữ read lock thì chính một thread duy nhất cũng tự treo. Write lock chỉ được trao khi mọi read lock đã nhả, kể cả của chính thread đang xin — nó chờ chính mình buông read lock, mà đang đứng chờ write lock nên không bao giờ buông.

Đây là rationale thiết kế cho việc JDK không cung cấp upgrade kiểu "chờ rồi nâng": nếu API cho giữ read lock rồi chờ các reader khác nhả mới nâng, hai reader cùng xin nâng sẽ chờ nhau mãi mãi. Cách đúng là nhả read, giành write, rồi kiểm tra lại điều kiện — giữa hai bước, state có thể đã bị thread khác đổi.

2. StampedLock: optimistic read

StampedLock (Java 8) mở ra chế độ thứ ba mà cả synchronized lẫn ReentrantReadWriteLock đều không có: optimistic read. Tư tưởng vay từ CAS ở bài Atomic & CAS — thay vì giành khóa rồi mới đọc, ta đọc lạc quan như thể không ai ghi, rồi kiểm tra xem giả định đó còn đúng không.

Mỗi thao tác khóa trả về một long gọi là stamp — bằng chứng để nhả khóa hoặc xác thực. tryOptimisticRead() không giành khóa gì cả, chỉ trả stamp ghi lại "phiên bản" hiện tại của khóa. Ta đọc dữ liệu vào biến cục bộ rồi gọi validate(stamp): không writer nào chen vào từ lúc lấy stamp thì nó trả true và dữ liệu nhất quán; ngược lại trả false, ta phải đọc lại — thường là rơi xuống một read lock thật.

private final StampedLock sl = new StampedLock();
private int remaining;

public int seatsLeft() {
    long stamp = sl.tryOptimisticRead();     // khong gianh khoa, chi chup phien ban
    int value = remaining;                   // doc lac quan vao bien cuc bo
    if (!sl.validate(stamp)) {               // co writer chen vao giua chung?
        stamp = sl.readLock();               // co -> roi xuong read lock that
        try { value = remaining; }
        finally { sl.unlockRead(stamp); }
    }
    return value;
}

Ở đường nhanh — không writer — seatsLeft không ghi vào shared state nào của khóa, kể cả bộ đếm reader mà ReentrantReadWriteLock phải tăng giảm. Không ghi thì không tạo cache contention giữa các core, nên với đọc cực nhiều, StampedLock bỏ xa read-write lock thường.

Đường nhanh của tryOptimisticRead không ghi state khoá, đường còn lại rơi xuống readLock thật

tryConvertToWriteLock(stamp) là câu trả lời của StampedLock cho bài toán upgrade mà ReentrantReadWriteLock cấm tiệt ở mục 1.1. Mấu chốt ở chữ try: nó thử nâng cấp mà không chờ — nâng được ngay thì trả stamp mới chế độ write, không thì trả 0 lập tức để ta tự xử lý. Không chờ thì không có vòng chờ, nên upgrade kiểu này không thể deadlock:

long stamp = sl.readLock();
try {
    while (remaining > 0) {
        long ws = sl.tryConvertToWriteLock(stamp);   // thu nang cap, KHONG cho
        if (ws != 0L) { stamp = ws; remaining--; return; }   // thanh cong: stamp che do write
        sl.unlockRead(stamp);                        // that bai: nha read...
        stamp = sl.writeLock();                      // ...gianh write nhu thao tac moi
        // vong while kiem tra LAI dieu kien - state co the da doi trong khe ho
    }
} finally {
    sl.unlock(stamp);                                // unlock theo dung stamp dang giu
}

Đây là pattern lấy từ Javadoc của StampedLock, vạch rõ ranh giới với lệnh cấm upgrade của ReentrantReadWriteLock: upgrade chờ được thì không thể tồn tại, còn upgrade thử-rồi-rút-lui hợp lệ — cùng triết lý với tryLock ở bài trước.

Cạm bẫy thì sắc, cả ba đều đắt, và mục 3 mổ kỹ hai cái đầu: StampedLock không reentrant — gọi lại method cũng giành write lock trên cùng object sẽ tự khóa chính mình (Nhầm 2); đòi kỷ luật validate — đọc hết vào biến cục bộ rồi mới validate, không hành động trên dữ liệu chưa xác thực (Nhầm 3); và không hỗ trợ Condition, dùng với interrupt cũng có bẫy riêng. Nó là công cụ chuyên dụng cho cấu trúc dữ liệu đọc-rất-nhiều, không phải khóa đa dụng.

3. Pitfall tổng hợp

Nhầm 1: upgrade read lock lên write lock. Như mục 1.1 đã mổ, giữ read lock rồi gọi writeLock().lock() khiến chính một thread duy nhất tự treo — write lock chờ mọi read lock nhả, kể cả của chính nó.

✅ Nhả read lock trước, giành write lock như một thao tác mới, rồi kiểm tra lại điều kiện — state có thể đã đổi trong khe hở giữa hai khóa:

readLock.lock();
boolean available;
try { available = remaining.getOrDefault(eventId, 0) > 0; }
finally { readLock.unlock(); }                        // nha read TRUOC
if (available) {
    writeLock.lock();                                 // gianh write nhu thao tac moi
    try {
        if (remaining.getOrDefault(eventId, 0) > 0)   // kiem tra LAI dieu kien
            remaining.merge(eventId, -1, Integer::sum);
    } finally { writeLock.unlock(); }
}

Nhầm 2: gọi lại method giành khóa trên cùng StampedLock. Nó không reentrant — thread đang giữ write lock gọi tiếp một method cũng writeLock() trên cùng object là deadlock tức thì với chính mình.

public void book() {
    long stamp = sl.writeLock();
    try { audit(); }          // audit() ben trong cung goi sl.writeLock() -> tu treo
    finally { sl.unlockWrite(stamp); }
}

✅ Mỗi đường đi qua StampedLock chỉ giành khóa đúng một lần — tách phần việc cần khóa ra method private không tự giành khóa; nếu gọi lồng nhau là không tránh được, quay về ReentrantLock/ReentrantReadWriteLock.

Nhầm 3: hành động trên dữ liệu optimistic read trước khi validate. Dữ liệu đọc lạc quan có thể đang dở dang do writer chen ngang — dereference một tham chiếu như vậy có thể thấy state hỏng.

✅ Đọc hết vào biến cục bộ, gọi validate(stamp), chỉ dùng dữ liệu khi validate trả về true; nếu false, rơi xuống readLock() và đọc lại.

Nhầm 4: mặc định read-write lock nhanh hơn mutex thường. Bookkeeping của ReentrantReadWriteLock nặng hơn ReentrantLock; với ghi nhiều hoặc đọc cực ngắn, nó chậm hơn.

✅ Chỉ chuyển sang read-write lock khi đo đạc cho thấy đọc áp đảo ghi và mỗi lần đọc đủ dài để song song có ý nghĩa.

4. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Đọc section "Sample usages" của StampedLock để thấy pattern tryOptimisticReadvalidate → fallback readLock viết đúng chuẩn, và biến thể tryConvertToWriteLock.

5. Liên hệ các bài khác

  • Bài 08 — volatile & synchronized: Monitor Pattern serialize cả những lần đọc thuần túy; read-write lock giải phóng chúng.
  • Bài 10 — Atomic & CAS: optimistic read của StampedLock vay đúng tư tưởng "đọc lạc quan rồi kiểm lại" của CAS.
  • Bài 11 — ReentrantLock & Condition: nửa đầu câu chuyện explicit lock, mà bài này mở rộng theo trục read/write.
  • Bài 12b — AQS: ReentrantReadWriteLock (và cả ReentrantLock, Semaphore) đứng trên bộ khung state + hàng đợi AQS — bài kế mở nắp bộ khung đó; StampedLock là ngoại lệ tự quản state riêng để chứa stamp.

6. Tóm tắt

  • ReadWriteLock tách read khỏi write: nhiều reader song song khi không có writer, writer độc quyền — thắng lớn khi đọc áp đảo ghi và mỗi lần đọc đủ dài.
  • Downgrade write xuống read hợp lệ; upgrade read lên write bị cấm — một thread duy nhất cố upgrade cũng tự treo vì write chờ mọi read nhả, kể cả của chính nó.
  • StampedLock thêm optimistic read: tryOptimisticRead không giành khóa, đọc vào biến cục bộ, validate xác nhận không có writer chen vào; đường nhanh không ghi vào state nên không cache contention. Đổi lại không reentrant, không Condition.
  • Chọn theo hồ sơ đọc-ghi: read-write lock khi đọc áp đảo ghi và mỗi lần đọc đủ dài; StampedLock khi đọc cực nhiều và chấp nhận đổi reentrancy + Condition lấy throughput.

Ta đã chọn được khóa theo hồ sơ đọc-ghi. Phần lớn những khóa này — ReentrantLock, ReentrantReadWriteLock, cùng SemaphoreCountDownLatch — đứng trên cùng một bộ khung: AQS (StampedLock là ngoại lệ tự cài riêng). Bài 12b mở nắp bộ khung đó và đóng lại cả khối Synchronization.

7. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Một thread đang giữ read lock của ReentrantReadWriteLock gọi writeLock().lock() — chuyện gì xảy ra, và vì sao?
  2. Q2
    Vì sao JDK không cung cấp thao tác upgrade an toàn từ read lock lên write lock?
  3. Q3
    Vì sao optimistic read của StampedLock bắt buộc phải đọc hết vào biến cục bộ rồi mới validate, thay vì dùng dữ liệu ngay?
  4. Q4
    Vì sao tính non-reentrant của StampedLock đặc biệt nguy hiểm với người đã quen synchronized và ReentrantLock?
  5. Q5
    Khi nào ReentrantReadWriteLock lại chậm hơn một ReentrantLock thường, dù nghe có vẻ luôn ưu việt hơn?

Bài tiếp theo: AQS — bộ khung đứng sau mọi khóa

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

AQS — bộ khung state và hàng đợi sau mọi khóa j.u.c