Java Internals & Concurrency/AQS — bộ khung state và hàng đợi sau mọi khóa j.u.c
21/75
Bài 21 / 75~13 phútConcurrency cơ bảnMiễn phí lượt xem

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

AbstractQueuedSynchronizer: biến state cập nhật bằng CAS cộng hàng đợi FIFO park/unpark — khung chung sau ReentrantLock, Semaphore, CountDownLatch.

TL;DR: AbstractQueuedSynchronizer (AQS) là bộ khung chung mà gần như mọi khóa của java.util.concurrentReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch — dựng trên. AQS quản lý đúng hai thứ: một biến state kiểu int cập nhật bằng CAS, và một hàng đợi FIFO (biến thể CLH) các thread đang chờ. Mỗi synchronizer chỉ định nghĩa "state nghĩa là gì" và điều kiện giành/nhả; AQS lo phần khó: xếp hàng, park/unpark đúng thread, fair lẫn unfair, timeout, interruptible. Tầng ngủ/đánh thức là LockSupport.park/unpark, với permit cho phép unpark đến trước park mà không mất tín hiệu.

Bài 12 — ReadWriteLock & StampedLock chọn khóa theo hồ sơ đọc-ghi. Nhưng ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch — tất cả đứng trên cùng một bộ khung. (StampedLock là ngoại lệ đáng nhớ: nó không dựng trên AQS mà tự cài state kiểu long cộng hàng đợi riêng, vì mô hình exclusive/shared của AQS không đỡ được optimistic read.) Hiểu bộ khung đó, bạn hiểu cả họ nhà khóa cùng lúc, và tự viết được synchronizer khi thư viện thiếu thứ bạn cần.

1. AQS: một biến state cộng một hàng đợi FIFO

Hình dung AQS như quầy gửi đồ ở rạp hát: một bảng số cho biết còn bao nhiêu chỗ (đó là state), và một hàng người xếp chờ tới lượt (hàng đợi FIFO). Ai tới thấy còn chỗ thì lấy ngay và đi (CAS thành công, đường nhanh); hết chỗ thì ghi tên vào cuối hàng rồi ngồi chờ được gọi thay vì đứng hỏi liên tục (park thay vì spin). Nhân viên xong việc thì gọi đúng người đầu hàng (unpark).

Quầy gửi đồAQS
Bảng số còn chỗbiến state (int, cập nhật bằng CAS)
Lấy chỗ ngay khi còn trốngCAS state thành công — đường nhanh
Ghi tên vào cuối hàngnối node vào cuối hàng đợi FIFO
Ngồi chờ được gọi thay vì đứng hỏipark() thay vì spin đốt CPU
Nhân viên gọi người đầu hàngunpark() node đầu hàng đợi

Phần lớn đồng bộ hóa trong java.util.concurrentReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch — đều dựng trên một khung chung tên AbstractQueuedSynchronizer, viết tắt AQS. AQS quản lý một thứ tưởng đơn giản mà đủ tổng quát: một số nguyên trạng thái cập nhật bằng CAS, cộng một hàng đợi FIFO — biến thể của hàng đợi CLH (Craig–Landin–Hagersten, một hàng đợi khóa dựa trên danh sách liên kết, mỗi thread chờ dựa trên node của chính mình) — các thread đang chờ. Mỗi lớp khóa chỉ định nghĩa "trạng thái nghĩa là gì" và "khi nào giành/nhả được"; AQS lo phần khó: xếp hàng, park, đánh thức đúng thread khi tới lượt, fair lẫn unfair, interruptible lẫn timeout. Cùng biến state kiểu int, mỗi lớp diễn giải một kiểu:

SynchronizerÝ nghĩa của state
ReentrantLockAcquisition count — 0 là tự do, mỗi lần tái nhập cộng 1 (ta đã gặp count này ở bài volatile & synchronized)
ReentrantReadWriteLockMột int chẻ đôi: 16 bit cao đếm số reader, 16 bit thấp đếm write reentrancy
SemaphoreSố giấy phép (permit) còn lại
CountDownLatchSố đếm còn lại trước khi cổng mở
💡 Tự điền trước

Một Semaphore khởi tạo 3 permit. Theo bảng trên, state ban đầu bằng mấy? Một thread acquire() thành công thì state đổi thế nào, và khi nào một thread acquire phải xếp hàng chờ? Tự viết câu trả lời trước khi đọc tiếp.

Đáp án: state khởi đầu là 3; mỗi acquire() thành công CAS giảm state đi 1; khi state chạm 0, lần acquire kế tiếp thấy không còn permit nên điều kiện giành thất bại ngay (chưa cần CAS) — thread bị xếp vào hàng đợi và park, chính cơ chế ở mục 2. release() thì CAS tăng state lên 1 rồi unpark node đầu hàng. Cùng một khung state+hàng đợi, chỉ khác cách diễn giải con số.

Thử đoán

Một thread gọi lock() nhưng khóa đang bị thread khác giữ, nên cú CAS đổi state thất bại. Theo bạn, JVM làm gì tiếp với thread này để nó chờ tới lượt mà không phí CPU? Viết dự đoán của bạn trước khi đọc mục dưới.

2. Vòng đời một lần giành khóa: đường nhanh và hàng đợi

flowchart TD
    T["Thread goi lock()"] --> C{"CAS state 0 -> 1<br/>thanh cong?"}
    C -->|"co"| H["Gianh duoc khoa ngay<br/>(khong block, khong xep hang)"]
    C -->|"khong"| Q["Tao node, noi vao cuoi<br/>hang doi FIFO cua AQS"]
    Q --> P["park() - thread ngu,<br/>cho duoc unpark"]
    H --> R["unlock(): state ve 0,<br/>unpark(thread o node dau hang doi)"]
    R --> W["Node dau hang doi tinh day"]
    W --> C
    style H fill:#6EE7B7
    style Q fill:#FCD34D
    style P fill:#FCD34D

Sơ đồ trên là vòng đời một lần giành khóa: đường nhanh là một cú CAS duy nhất trên state — không hàng đợi, không syscall; CAS thất bại thì thread mới trả giá xếp hàng và ngủ. Điều này giải thích hành vi bài trước: unfair lock cho thread mới CAS thẳng vào state không nhìn hàng đợi (barging), fair lock bắt kiểm tra hàng đợi trước — đắt hơn nhưng đúng thứ tự.

Vì sao xếp hàng cho thread ngủ thay vì quay vòng CAS đến khi thành công? Vì spinning đốt CPU: thread chiếm trọn một core chỉ để hỏi "đến lượt tôi chưa", và khi critical section đủ dài, chi phí đó vượt xa một lần ngủ-rồi-tỉnh. AQS thiên về park thay vì spin vô hạn — chỉ thử lại rất ngắn rồi chịu ngủ; ranh giới giữa hai thái cực spin và park đã so ở bài Atomic & CAS.

3. LockSupport: tầng ngủ và đánh thức

Tầng ngủ/đánh thức nằm thấp hơn nữa, ở LockSupport với cặp park()/unpark(thread): park tạm dừng thread hiện tại, unpark đánh thức một thread cụ thể. Khác biệt then chốt so với wait/notify: unpark gọi trước park vẫn có hiệu lực — nó để lại một "giấy phép" (permit) khiến lần park kế tiếp trả về ngay, tránh race "đánh thức trước khi kịp ngủ" vốn ám ảnh wait/notify.

AQS dùng chính park/unpark để hiện thực hàng đợi: khi tới lượt, thread giữ khóa gọi unpark cho node đầu hàng đợi; permit đảm bảo dù unpark chạy trước khi thread kia kịp park, tín hiệu vẫn không mất. Đây cũng là nền để tự viết synchronizer khi thư viện thiếu thứ bạn cần — extends AQS, định nghĩa state, để phần xếp hàng/park cho khung lo.

4. Khi nào chọn cơ chế đồng bộ nào?

Sau bốn bài của khối Synchronization, ta có một dải công cụ từ nhẹ tới nặng; nguyên tắc là chọn công cụ đơn giản nhất đủ cho bài toán. Cây quyết định dưới đi đúng thứ tự nên cân nhắc:

flowchart TD
    A{"Chi can visibility<br/>tren mot bien don?"} -->|"co"| V["volatile"]
    A -->|"khong"| B{"Read-modify-write<br/>tren dung mot bien?"}
    B -->|"co"| AT["Atomic* / CAS"]
    B -->|"khong"| C{"synchronized du?<br/>(khong can tryLock, timeout,<br/>fairness, nhieu Condition)"}
    C -->|"du"| SY["synchronized"]
    C -->|"thieu kha nang"| D{"Doc ap dao ghi,<br/>moi lan doc du dai?"}
    D -->|"khong"| RL["ReentrantLock"]
    D -->|"co"| E{"Doc cuc nhieu, chap nhan<br/>non-reentrant + khong Condition?"}
    E -->|"khong"| RW["ReentrantReadWriteLock"]
    E -->|"co"| ST["StampedLock"]
    style V fill:#6EE7B7
    style SY fill:#6EE7B7
    style RW fill:#FCD34D
    style ST fill:#C4B5FD

Cùng nội dung đó dưới dạng bảng tra cứu, kèm cột nhắc mặt trái của mỗi nấc:

Cơ chếDùng khiKhông hợp khi
volatileMột cờ trạng thái hoặc tham chiếu, một writer hoặc ghi độc lập với giá trị cũ, không có invariant nhiều biếnCần read-modify-write nguyên tử, hoặc nhiều biến ràng buộc nhau
Atomic* / CASRead-modify-write nguyên tử trên đúng một biến; counter, accumulator, tham chiếu cập nhật lạc quanCần phối hợp nhiều biến trong một thao tác, hoặc cần chờ điều kiện
synchronizedMặc định cho mọi compound action cần loại trừ; gọn, an toàn, JVM tự nhảCần tryLock, timeout, interruptible, fairness, hoặc nhiều wait set
ReentrantLockKhi cần đúng một trong các khả năng synchronized thiếuKhi synchronized đã đủ — đừng đổi chỉ vì "nghe mạnh hơn"
ReentrantReadWriteLockĐọc áp đảo ghi, mỗi lần đọc đủ dài để song song có ý nghĩaGhi nhiều, hoặc thao tác đọc cực ngắn
StampedLockĐọc cực nhiều trên cấu trúc dữ liệu nhỏ, chấp nhận đổi sự tiện lấy throughputCần reentrancy hoặc Condition; code chưa kỷ luật về validate
💡 Cách nhớ

Leo thang từ nhẹ tới nặng và dừng ở nấc đầu tiên đủ dùng. Mỗi nấc đi lên mua thêm một khả năng cụ thể và trả bằng một bề mặt lỗi cụ thể: explicit lock trả bằng kỷ luật unlock, read-write trả bằng bookkeeping, stamped trả bằng reentrancy và kỷ luật validate.

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

  • Bài 12 — ReadWriteLock & StampedLock: ReentrantReadWriteLock dựng trên AQS — state của nó chính là int chẻ đôi reader/writer nói ở mục 1; còn StampedLock có bộ khung state+hàng đợi riêng, không phải AQS.
  • Bài 10 — Atomic & CAS: CAS là viên gạch của AQS — đường nhanh của mọi lần giành khóa là một cú CAS trên state.
  • Bài 11 — ReentrantLock & Condition: acquisition count của ReentrantLockstate của AQS ở dạng đơn giản nhất.
  • Bài 16 — Synchronizers: SemaphoreCountDownLatch là các cách diễn giải khác nhau của cùng biến state AQS; còn CyclicBarrier dựng trên ReentrantLock + Condition (tức dùng AQS gián tiếp, không tự định nghĩa state).
  • Bài 17 — Executor & thread pool: lớp Worker trong ThreadPoolExecutor kế thừa thẳng AQS — bằng chứng AQS là khung dựng synchronizer, không chỉ chi tiết hiện thực của lock công khai.
  • Bài 20 — Virtual threads: LockSupport.park là điểm virtual thread "tháo" khỏi carrier thread — hiểu park/unpark ở đây là nền để hiểu vì sao virtual thread block rẻ.

6. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

7. Tóm tắt

  • AQS là khung chung của ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch: một biến state kiểu int cập nhật bằng CAS cộng hàng đợi FIFO (biến thể CLH) thread chờ. Mỗi synchronizer chỉ định nghĩa ý nghĩa của state và điều kiện giành/nhả.
  • Đường nhanh giành khóa là một cú CAS trên state; thất bại thì thread mới xếp hàng và bị park. Unfair lock cho barging (CAS thẳng không nhìn hàng đợi), fair lock bắt xếp hàng trước.
  • LockSupport.park/unpark là tầng ngủ/đánh thức dưới AQS; permit cho phép unpark đến trước park mà không mất tín hiệu — điều wait/notify không làm được.
  • Chọn cơ chế từ nhẹ tới nặng: volatileAtomic*synchronizedReentrantLock → read-write → stamped; mỗi nấc thêm sức mạnh là thêm bề mặt để sai.

Khối Synchronization khép lại với một nhận xét: tự dựng giao thức khóa có thể làm đúng, nhưng dễ sai. Cách đạt thread safety bền nhất thường là không tự viết phần đồng bộ, mà giao cho component đã được kiểm thử và tối ưu sẵn — hướng delegation với các concurrent collection. Đó là nơi BookingService tiến tới v2, và là chủ đề bài sau.

8. Tự kiểm tra

Tự kiểm tra
Q1
AQS chỉ quản lý một biến state int cộng một hàng đợi. Vì sao chừng đó đủ để dựng được cả ReentrantLock, Semaphore lẫn CountDownLatch — ba thứ hành xử rất khác nhau?
Vì "giành/nhả" của mọi khóa quy về đúng một câu hỏi: state hiện tại có cho đi qua không, và cập nhật state thế nào. Mỗi synchronizer chỉ cần định nghĩa ý nghĩa của state và điều kiện đó — với ReentrantLock là acquisition count, với Semaphore là số permit còn lại, với CountDownLatch là số đếm còn lại. Toàn bộ phần khó — xếp hàng, park/unpark đúng thread, fair/unfair, timeout, interruptible — AQS làm một lần cho tất cả, nên chỉ một biến state (int, CAS) cộng một hàng đợi FIFO (biến thể CLH) là đủ tổng quát cho cả họ nhà khóa.
Q2
Một thread CAS state thất bại vì khóa đang bận. Vì sao AQS cho nó park (ngủ) trong hàng đợi thay vì quay vòng CAS đến khi thành công?
Vì spinning đốt CPU: thread chiếm trọn một core chỉ để liên tục hỏi "đến lượt tôi chưa". Khi critical section đủ dài, cái giá đó vượt xa chi phí một lần ngủ-rồi-tỉnh, và còn cướp CPU của chính thread đang giữ khóa (làm nó nhả khóa chậm hơn). Nên AQS xếp thread vào hàng đợi FIFO rồi park nó; khi tới lượt, thread nhả khóa sẽ unpark đúng nó. Spin chỉ đáng khi critical section cực ngắn — và AQS chỉ thử lại rất ngắn trước khi chịu park.
Q3
Đường nhanh (fast path) của một lần giành khóa AQS gồm những gì, và vì sao unfair lock nhanh hơn fair lock?
Đường nhanh là một cú CAS duy nhất trên state (ví dụ 0 -> 1): thành công thì thread giành khóa ngay, không tạo node, không xếp hàng, không syscall. Unfair lock cho thread mới thử CAS thẳng vào state mà không nhìn hàng đợi (barging) — nếu đúng lúc khóa vừa trống, nó chiếm luôn, bỏ qua cả chi phí xếp hàng lẫn các thread đang chờ. Fair lock bắt mọi thread kiểm tra hàng đợi trước: nếu có ai chờ sẵn thì phải xếp sau — đúng thứ tự nhưng đắt hơn, và mất luôn cơ hội đi đường nhanh khi hàng đợi không rỗng.
Q4
park/unpark của LockSupport khác wait/notify ở điểm then chốt nào, và permit đóng vai trò gì?
unpark(thread) có thể được gọi trước khi thread đó park() mà tín hiệu không bị mất: nó để lại một permit, khiến lần park kế tiếp trả về ngay. notify thì ngược lại — nếu được gọi khi chưa có ai wait, tín hiệu biến mất không dấu vết, sinh ra loại race "đánh thức trước khi kịp ngủ". Ngoài ra unpark nhắm đích một thread cụ thể, còn notify đánh thức một thread tùy ý (không xác định, theo JLS) trong wait set; và park không đòi hỏi đang giữ khóa nào. Chính các tính chất này khiến AQS chọn park/unpark làm tầng ngủ/đánh thức.
Q5
Bạn cần một bộ đếm tăng/giảm nguyên tử trên đúng một biến, nhiều thread cùng cập nhật. Theo cây quyết định, nên dừng ở nấc nào và vì sao không leo lên lock?
Dừng ở nấc Atomic* / CAS (ví dụ AtomicLong hoặc LongAdder). Cây quyết định hỏi: chỉ cần visibility trên một biến? — không, vì đây là read-modify-write. Read-modify-write trên đúng một biến? — có, nên dừng ngay ở Atomic, không cần leo lên synchronized hay lock. Lý do: CAS cho cập nhật nguyên tử không cần khối loại trừ, không có thread nào phải park, nên throughput cao hơn lock dưới contention vừa phải. Chỉ khi phải phối hợp nhiều biến trong một thao tác (invariant nhiều biến) mới cần leo lên khóa loại trừ.

Bài tiếp theo: Delegation & concurrent collections

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

Delegation: tái dùng lớp thread-safe và khi nào nó vỡ