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.concurrent — ReentrantLock, 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ống | CAS state thành công — đường nhanh |
| Ghi tên vào cuối hàng | nối node vào cuối hàng đợi FIFO |
| Ngồi chờ được gọi thay vì đứng hỏi | park() thay vì spin đốt CPU |
| Nhân viên gọi người đầu hàng | unpark() node đầu hàng đợi |
Phần lớn đồng bộ hóa trong java.util.concurrent — ReentrantLock, 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 |
|---|---|
ReentrantLock | Acquisition 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) |
ReentrantReadWriteLock | Một int chẻ đôi: 16 bit cao đếm số reader, 16 bit thấp đếm write reentrancy |
Semaphore | Số giấy phép (permit) còn lại |
CountDownLatch | Số đếm còn lại trước khi cổng mở |
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ố.
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:#FCD34DSơ đồ 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:#C4B5FDCù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 khi | Không hợp khi |
|---|---|---|
volatile | Mộ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ến | Cần read-modify-write nguyên tử, hoặc nhiều biến ràng buộc nhau |
Atomic* / CAS | Read-modify-write nguyên tử trên đúng một biến; counter, accumulator, tham chiếu cập nhật lạc quan | Cần phối hợp nhiều biến trong một thao tác, hoặc cần chờ điều kiện |
synchronized | Mặ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 |
ReentrantLock | Khi cần đúng một trong các khả năng synchronized thiếu | Khi 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ĩa | Ghi 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 throughput | Cần reentrancy hoặc Condition; code chưa kỷ luật về validate |
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:
ReentrantReadWriteLockdựng trên AQS —statecủa nó chính làintchẻ đôi reader/writer nói ở mục 1; cònStampedLockcó 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
ReentrantLocklàstatecủa AQS ở dạng đơn giản nhất. - Bài 16 — Synchronizers:
SemaphorevàCountDownLatchlà các cách diễn giải khác nhau của cùng biếnstateAQS; cònCyclicBarrierdựng trênReentrantLock+Condition(tức dùng AQS gián tiếp, không tự định nghĩastate). - Bài 17 — Executor & thread pool: lớp
WorkertrongThreadPoolExecutorkế 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.parklà đ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
Spec / reference chính thức:
- The java.util.concurrent Synchronizer Framework — Doug Lea — paper gốc về AQS: vì sao chọn một biến
intstate + hàng đợi (biến thể CLH), các đánh đổi fair/unfair. 12 trang, giải thích trọn kiến trúc đứng sau gần như mọi synchronizer. - AbstractQueuedSynchronizer (Java 21 API) — hợp đồng
tryAcquire/tryReleaseđể tự dựng synchronizer. - LockSupport (Java 21 API) — ngữ nghĩa permit của
park/unpark.
7. Tóm tắt
- AQS là khung chung của
ReentrantLock,ReentrantReadWriteLock,Semaphore,CountDownLatch: một biếnstatekiểuintcậ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ủastatevà đ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/unparklà tầng ngủ/đánh thức dưới AQS; permit cho phépunparkđến trướcparkmà không mất tín hiệu — điềuwait/notifykhông làm được.- Chọn cơ chế từ nhẹ tới nặng:
volatile→Atomic*→synchronized→ReentrantLock→ 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
Q1AQS 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?▸
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.Q2Mộ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?▸
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?▸
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.Q4park/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.Q5Bạ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?▸
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
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