volatile & synchronized: Hai cơ chế đồng bộ nội tại của Java
Hai cơ chế đồng bộ có sẵn trong ngôn ngữ ở hai đầu của phổ: volatile (chỉ visibility) và synchronized (mutual exclusion + visibility), Java Monitor Pattern, wait/notify.
TL;DR: Khi dữ liệu vừa shared vừa mutable, Java cài sẵn hai cơ chế đồng bộ ở hai đầu của phổ. volatile chỉ lo visibility qua happens-before — đúng cho cờ trạng thái và immutable holder một writer, vô dụng với read-modify-write. synchronized — intrinsic lock gắn vào mọi object — cho cả mutual exclusion lẫn visibility, reentrant, tự nhả kể cả khi exception. Bên dưới, JVM giữ lock state trong mark word của object header và chỉ inflate lên OS monitor khi có tranh chấp thật, nên khóa không tranh chấp rất rẻ. wait/notify mở rộng monitor để chờ điều kiện: luôn kiểm trong while, mặc định notifyAll. Pitfall lớn nhất: tưởng volatile làm ++ nguyên tử, và quên rằng reader cũng phải khóa.
1. Giới thiệu
Hai bài vừa rồi đi theo cùng một logic: nếu dữ liệu không bị chia sẻ thì không ai tranh, nếu dữ liệu không đổi thì đọc lúc nào cũng cho cùng kết quả. Confinement triệt tính "shared", immutability triệt tính "mutable". Cả hai đẹp ở chỗ chúng làm cho vấn đề biến mất thay vì xử lý nó. Nhưng cũng cả hai đều có một điểm dừng: chúng chỉ áp dụng được khi ta có quyền chọn không chia sẻ hoặc không thay đổi.
Có những trạng thái không cho ta cái quyền đó. Số vé đã bán của một sự kiện trong TicketFlow phải được nhiều thread cùng nhìn thấy, và phải tăng dần theo từng booking. Nó vừa shared vừa mutable, không thể giam, không thể đóng băng. Khi rơi vào ô cuối cùng đó của ma trận, chỉ còn một con đường: canh gác mọi truy cập bằng synchronization.
Đây là bài đầu trong chuỗi bài về synchronization. Java cài sẵn hai cơ chế đồng bộ ngay trong ngôn ngữ, và chúng nằm ở hai đầu của một phổ. Một đầu là volatile, dạng synchronization nhẹ nhất có thể: nó chỉ lo visibility, không lo loại trừ lẫn nhau. Đầu kia là synchronized, dạng synchronization đầy đủ: vừa loại trừ lẫn nhau, vừa lo visibility. Hiểu rõ mỗi cái bảo đảm gì, và quan trọng hơn là không bảo đảm gì, là điều kiện để chọn đúng công cụ thay vì rải khóa theo cảm tính. Ta đi từ nhẹ tới nặng.
2. volatile — recap nhanh và ranh giới cứng
Bài Thread safety đã mổ volatile đủ sâu trong mục visibility, nên ở đây chỉ recap phần cốt lõi để làm nền so sánh với synchronized; chi tiết JMM, reordering, ví dụ NoVisibility xem lại bài đó. Điều volatile bảo đảm, phát biểu theo happens-before: một lần ghi vào biến volatile happens-before mọi lần đọc sau đó của chính biến ấy, và hiệu ứng lan ra ngoài biến — khi A ghi volatile rồi B đọc thấy giá trị A vừa ghi, mọi thứ A viết trước lúc ghi volatile cũng hiển thị với B. Nó chỉ lo visibility và ordering, hoàn toàn không lo loại trừ lẫn nhau.
Phép so sánh đời thường, kèm một điều kiện quan trọng để nó đúng với JMM. Mỗi thread làm việc trên bản nháp riêng, bộ nhớ chính là cái bảng tin chung. Ghi một biến volatile giống như dán toàn bộ trang nháp hiện tại lên bảng tin, cùng lúc với tờ giấy ghi giá trị mới. Nhưng happens-before chỉ thiết lập với reader đọc đúng tờ giấy đó: ai nhìn lên bảng tin và thấy giá trị mới vừa dán mới chắc chắn thấy mọi thứ được viết trước lúc dán. Một thread đọc biến thường — không hề nhìn bảng tin — không được hưởng bảo đảm nào cả.
Use case đúng kiểu của volatile là cờ trạng thái một thread bật, nhiều thread đọc:
public class SalesGate {
private volatile boolean salesOpen = true; // admin ghi, nhiều worker đọc
public void closeSales() { salesOpen = false; }
public boolean isOpen() { return salesOpen; }
}
Thước đo khi nào được dùng nó vẫn là checklist ba điều kiện của bài 03: (1) lệnh ghi không phụ thuộc giá trị hiện tại của biến, hoặc chỉ một thread duy nhất ghi; (2) biến không tham gia invariant nào với biến khác; (3) không cần khóa vì bất kỳ lý do nào khác. salesOpen thỏa cả ba — ghi nó là gán thẳng một hằng, không phải đọc-rồi-sửa. Một tham chiếu volatile trỏ tới immutable holder — mẫu PriceBoard của bài Immutability — cũng thỏa, miễn là đúng một thread công bố.
Còn sold của TicketFlow vi phạm ngay điều đầu tiên, và đó là ranh giới cứng. sold++ là một read-modify-write ba bước rời rạc; volatile bảo đảm từng lần đọc lẻ, ghi lẻ thấy giá trị đúng, nhưng không ngăn được hai thread chen nhau giữa cụm ba bước — cùng đọc 9, cùng cộng thành 10, cùng ghi 10, một lần tăng bốc hơi. Nó còn vi phạm điều thứ hai, vì invariant "không vượt capacity" trói nó với Event. Với sold, volatile vô dụng; ta cần một cơ chế quây được cả cụm thao tác thành một khối. Đó là synchronized.
3. synchronized — intrinsic lock
3.1 Mọi object đều mang một monitor
Bài Thread safety cho thấy book của TicketFlow cần cả hai thứ cùng lúc: atomicity cho cụm check-then-act và visibility cho mọi reader — và volatile chỉ cho được vế sau. Mảnh còn thiếu là synchronized, cơ chế đồng bộ đầy đủ mà Java gắn thẳng vào ngôn ngữ, và đây là nơi ta mổ nó lần đầu cho đến tận cơ chế: intrinsic lock là mutex thế nào, vì sao reentrant, và hai bảo đảm atomicity với visibility của nó đến từ đâu.
Mỗi đối tượng Java đều ngầm mang theo một khóa, gọi là intrinsic lock hoặc monitor lock. Không cần khai báo gì; cái khóa đó luôn có sẵn, gắn vào chính header của object. Một khối synchronized mượn cái khóa đó để dựng nên một vùng tới hạn:
synchronized (lock) {
// vùng tới hạn: tại một thời điểm nhiều nhất một thread vào được
}
Khi một thread bước vào khối, nó tự động giành lock; khi rời khối, nó tự động nhả, dù rời bằng đường bình thường hay vì một exception ném ra. Cái "tự động nhả kể cả khi exception" này quan trọng hơn vẻ ngoài: nó là lý do synchronized an toàn hơn các khóa tường minh khi code có thể ném lỗi giữa chừng, một chủ đề sẽ trở lại ở bài explicit locks.
Intrinsic lock hoạt động như một mutex - mutual exclusion lock. Tại một thời điểm nhiều nhất một thread giữ được khóa. Khi A muốn giành một khóa đang nằm trong tay B, A phải chờ, bị block, cho tới khi B nhả. Vì chỉ một thread tại một thời điểm chạy được vùng code mà một khóa nhất định canh giữ, các khối synchronized cùng khóa thực thi nguyên tử so với nhau: không thread nào quan sát được một thread khác đang ở giữa chừng một khối synchronized cùng khóa.
Một synchronized method chỉ là cú pháp tắt cho một khối synchronized ôm trọn thân method, khóa trên this đối với instance method, hoặc trên đối tượng Class đối với static method. Hai dạng sau tương đương:
public synchronized void f() { /* ... */ }
public void f() { synchronized (this) { /* ... */ } }
3.2 synchronized lo cả atomicity lẫn visibility
synchronized mạnh hơn volatile ở chỗ nó giải quyết cả hai vấn đề nền tảng cùng lúc.
Phần atomicity đến từ tính loại trừ lẫn nhau. Vì chỉ một thread vào được vùng tới hạn, cả một cụm compound action bên trong khối, dù gồm bao nhiêu bước đọc và ghi, đều diễn ra như một khối không ai chen được vào. Đây đúng là thứ volatile thiếu.
Phần visibility đến từ một quy tắc happens-before khác: việc nhả một khóa happens-before việc giành lại chính khóa đó. Khi thread A rời một khối synchronized, mọi thay đổi nó làm bên trong được bảo đảm hiển thị với thread B khi B vào một khối synchronized cùng khóa. Khóa không chỉ ngăn hai thread vào vùng tới hạn cùng lúc; nó còn bắc một cây cầu bộ nhớ giữa chúng, y như một cặp ghi-đọc volatile.
Hệ quả của vế visibility hay bị bỏ quên: reader cũng phải khóa. Nếu một biến được canh bằng khóa thì mọi đường truy cập tới nó, đọc lẫn ghi, đều phải giữ cùng một khóa. Một sai lầm phổ biến là tưởng chỉ cần đồng bộ khi ghi; không phải, vì một reader không khóa vẫn có thể đọc trúng dữ liệu dở dang hoặc một giá trị cũ do không có cây cầu bộ nhớ nào bắc tới nó.
3.3 Reentrant — khóa tái nhập
Intrinsic lock có một tính chất quan trọng: nó tái nhập, reentrant. Nếu một thread đang giữ một khóa rồi lại gặp một khối synchronized khác trên cùng khóa đó, nó vào được luôn chứ không tự khóa chính mình.
JVM cài đặt điều này bằng cách gắn cho mỗi khóa một bộ đếm số lần giành - acquisition count - và một thread sở hữu. Khi thread sở hữu giành lại khóa, count tăng; khi nó rời mỗi khối, count giảm; chỉ khi count về 0 thì khóa mới thực sự được nhả cho thread khác. Khóa của Java vì vậy được cấp theo từng lần-giành-của-một-thread, không phải theo từng lần-giành tuyệt đối.
Reentrancy không phải tiểu tiết; nó cứu ta khỏi deadlock trong một tình huống rất tự nhiên, là một method synchronized của lớp con gọi super cũng synchronized trên cùng đối tượng. Lấy cặp Widget/LoggingWidget làm ví dụ:
public class Widget {
public synchronized void doSomething() { /* ... */ }
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(this + ": calling doSomething");
super.doSomething(); // giành lại CÙNG khóa trên this
}
}
Khi một thread gọi LoggingWidget.doSomething, nó đã giữ khóa trên this. Lệnh super.doSomething() lại cần đúng cái khóa đó. Nếu intrinsic lock không reentrant, thread sẽ chờ một khóa mà chính nó đang giữ, và treo vĩnh viễn. Vì khóa reentrant, count chỉ tăng lên 2 rồi giảm về 0, và mọi thứ chạy trơn.
Khóa là một quy ước, không phải phép thuật. Giành khóa của một đối tượng không hề ngăn thread khác đọc hay ghi các field của đối tượng đó; điều duy nhất nó ngăn là thread khác giành cùng cái khóa ấy. Không có liên hệ nội tại nào giữa intrinsic lock của một đối tượng và các field của nó. Bảo vệ chỉ tồn tại khi mọi thread cùng tuân theo quy ước "chạm vào biến này thì phải giữ khóa kia".
4. Cơ chế bên dưới — mark word và lock inflation
"Mọi object đều mang một khóa" nghe như mỗi object phải vác theo một cấu trúc đồng bộ của hệ điều hành — một mutex thật với hàng đợi và syscall. Nếu thế thì synchronized đắt vô lý: một chương trình Java tạo hàng triệu object, và tuyệt đại đa số không bao giờ bị khóa, hoặc bị khóa mà không bao giờ có hai thread tranh nhau. JVM giải bài toán này bằng cách trả khóa theo nhu cầu: trạng thái khóa khởi đầu chỉ là vài bit trong header của object, và chỉ "phình" thành cấu trúc đắt tiền khi có tranh chấp thật.
4.1 Mark word — nơi khóa sống
Mỗi object trên heap của HotSpot mở đầu bằng một header, trong đó word đầu tiên gọi là mark word — một word 64-bit đa dụng. Tùy trạng thái, mark word chứa identity hash code và tuổi GC của object, hoặc thông tin khóa. Vài bit thấp nhất của nó là tag cho biết object đang ở trạng thái khóa nào, và phần còn lại được diễn giải theo tag đó. "Intrinsic lock gắn vào object" vì vậy đúng theo nghĩa đen: khóa không phải một field ẩn trỏ tới một cấu trúc xa xôi nào, mà là chính mấy chục bit nằm ngay đầu object.
4.2 Thin lock — một cú CAS khi không ai tranh
Khi một thread vào khối synchronized mà khóa còn trống, JVM không hề gọi hệ điều hành. Nó dựng một lock record nhỏ trên stack của thread, rồi dùng một lệnh compare-and-swap — CAS, lệnh so-sánh-rồi-ghi nguyên tử của CPU, nhân vật chính của bài kế tiếp — để ghi con trỏ tới lock record đó vào mark word. CAS thành công nghĩa là thread đã sở hữu khóa. Toàn bộ chi phí là một lệnh CPU: không syscall, không context switch, không cấp phát gì trên heap. Trạng thái này gọi là thin lock — khóa mỏng. Khi rời khối, một CAS nữa trả mark word về trạng thái cũ.
Đây là câu trả lời cho câu hỏi muôn thuở "synchronized có chậm không": với khóa không tranh chấp — trường hợp áp đảo trong code thật — chi phí gần như chỉ là một CAS, rẻ hơn rất nhiều so với hình dung "khóa nghĩa là gọi OS". Lời khuyên hiệu năng đúng vì vậy không phải là né synchronized, mà là né contention.
Trước đây HotSpot còn một tầng rẻ hơn nữa: biased locking — khóa "thiên vị" thread đầu tiên giành nó, để các lần giành lại sau của chính thread đó không tốn cả CAS. Tầng này đã bị tắt mặc định từ JDK 15 và gỡ hẳn sau đó (JEP 374): chi phí duy trì và thu hồi bias vượt quá lợi ích trên phần cứng hiện đại, nơi một CAS không tranh chấp đã đủ rẻ.
4.3 Fat lock — inflate khi có tranh chấp
Chuyện đổi khác khi thread B CAS vào một mark word đang thuộc về A. CAS thất bại, JVM biết có tranh chấp, và nó inflate — làm phình — cái khóa: dựng một cấu trúc ObjectMonitor đầy đủ trong bộ nhớ native rồi ghi con trỏ tới nó vào mark word. Monitor này mới là "khóa thật" với đầy đủ bộ phận: thread owner, bộ đếm reentrancy (chính cái acquisition count ở mục 3.3), entry queue chứa các thread đang chờ giành khóa, và wait set chứa các thread đã gọi wait() — nhân vật của mục 6. Thread thua cuộc được đưa vào entry queue và block qua primitive của hệ điều hành; đây là lúc chi phí context switch xuất hiện. Khi owner nhả khóa, OS đánh thức một thread trong queue dậy tranh tiếp.
flowchart TD
A[Thread vao khoi synchronized] --> B{Mark word con trong?}
B -- "con trong" --> C[CAS lock record vao mark word]
C -- "thanh cong" --> D[Thin lock: vao vung toi han, khong syscall]
C -- "that bai" --> E[Phat hien tranh chap]
B -- "da co chu" --> E
E --> F[Inflate: dung ObjectMonitor - fat lock]
F --> G[Thread thua vao entry queue, block qua OS]
D --> H[Roi khoi: CAS tra lai mark word]
G --> I[Owner nha khoa, OS danh thuc thread trong queue]
I --> DHai hệ quả thực dụng rút ra từ bức tranh này. Thứ nhất, chi phí của synchronized không cố định mà phụ thuộc lịch sử tranh chấp của từng object: cùng một dòng code có thể tốn một CAS ở chỗ này và tốn hai lần context switch ở chỗ khác. Thứ hai, mọi kỹ thuật giảm contention ở mục 7 — thu hẹp vùng khóa, tách khóa theo dữ liệu — thực chất đều nhằm một việc: giữ cho khóa đừng bao giờ phải inflate.
Spec / reference chính thức:
- JLS §17.1 — Synchronization — định nghĩa monitor, lock action và unlock action ở mức đặc tả ngôn ngữ.
- JEP 374 — Deprecate and Disable Biased Locking — lý do tầng biased locking bị gỡ: chi phí revocation vượt lợi ích trên CPU hiện đại.
Ghi chú: mark word và thin/fat lock là chi tiết cài đặt của HotSpot, không nằm trong JLS — spec chỉ đặc tả hành vi (mutual exclusion + happens-before), còn JVM được tự do chọn cách cài rẻ nhất thỏa hành vi đó.
Một liên hệ về phía trước: ObjectMonitor là code C++ nằm trong JVM, ta không can thiệp được. Bài ReadWriteLock, StampedLock và AQS sẽ cho thấy thư viện java.util.concurrent dựng lại đúng bộ máy này — trạng thái khóa là một biến int CAS được, cộng một hàng đợi thread chờ — nhưng hoàn toàn ở tầng Java, qua framework AbstractQueuedSynchronizer. Hiểu monitor của JVM trước giúp đọc AQS sau dễ hơn hẳn: cùng một bài toán, hai tầng cài đặt.
5. Java Monitor Pattern
Đẩy nguyên tắc đóng gói tới tận cùng cho ra một mẫu thiết kế gọn: Java Monitor Pattern. Gói toàn bộ state mutable vào bên trong đối tượng, và canh nó bằng một khóa, sao cho mọi method chạm vào state đều giữ cùng khóa đó. Khi ấy có đúng một nơi áp đặt kỷ luật đồng bộ, và ta kết luận được class an toàn mà không phải đọc cả chương trình.
Đây chính là lời giải đúng cho BookingService của TicketFlow, và sẽ là nội dung của bước v1 trong capstone. Ở v0, book là một check-then-act trên state dùng chung: đọc số vé đã bán, kiểm tra còn chỗ, rồi ghi tăng. Hai thread cùng chạy có thể cùng đọc thấy còn một chỗ rồi cùng bán, phá vỡ invariant "không vượt capacity". Bài Immutability đã chỉ ra rằng đổi HashMap thành ConcurrentHashMap không cứu được, vì cụm compound action vẫn không nguyên tử và invariant trói sold với Event bắc cầu qua nhiều thao tác. Monitor Pattern quây cả cụm vào một khóa:
public class BookingService {
private final Object lock = new Object();
@GuardedBy("lock") private final Map<String, Event> events = new HashMap<>();
@GuardedBy("lock") private final Map<String, Integer> sold = new HashMap<>();
public void register(Event event) {
Objects.requireNonNull(event);
synchronized (lock) {
if (events.putIfAbsent(event.id(), event) != null)
throw new IllegalArgumentException("Sự kiện đã tồn tại: " + event.id());
sold.put(event.id(), 0);
}
}
public Booking book(String eventId, String userId) {
synchronized (lock) {
Event event = events.get(eventId);
if (event == null) throw new IllegalArgumentException("Không có sự kiện: " + eventId);
int current = sold.getOrDefault(eventId, 0); // check ...
if (current >= event.capacity()) throw new SoldOutException(eventId);
sold.put(eventId, current + 1); // ... rồi act, cùng một khối
return new Booking(eventId, userId, current + 1);
}
}
public int soldCount(String eventId) { // reader cũng PHẢI khóa
synchronized (lock) {
return sold.getOrDefault(eventId, 0);
}
}
}
Kịch bản xen kẽ ở v0 không còn xảy ra. Nếu A đã vào, B phải chờ tới khi A ghi xong và nhả khóa; B vào sau đọc current = 1, thấy 1 >= 1, và đúng đắn ném SoldOutException. Invariant được giữ. Cách nói chặt hơn cho điều này là book đã khóa có một linearization point, thời điểm ghi sold bên trong khối: trước đó chưa khách nào giữ chỗ, sau đó chỗ đã thuộc về booking này, không tồn tại trạng thái "ở giữa" mà thread khác quan sát được.
Để ý hai chi tiết. Thứ nhất, soldCount cũng synchronized trên cùng lock, vì reader không khóa có thể đọc trúng giá trị cũ. Thứ hai, ở đây dùng một private final Object lock thay vì khóa trên this. Lý do là khi khóa trên this, bất kỳ code bên ngoài nào cầm tham chiếu tới BookingService cũng có thể synchronized trên nó và vô tình tham gia, đúng hoặc sai, vào chính sách khóa của ta, thậm chí gây deadlock — chính kỹ thuật client-side locking ở bài Thread safety khai thác cánh cửa này một cách cố ý, và nó hữu ích khi cố ý bao nhiêu thì nguy hiểm khi vô tình bấy nhiêu. Để kiểm chứng một khóa công khai được dùng đúng, ta phải soi cả chương trình; một lock riêng tư đóng kín chính sách đồng bộ trong đúng một file. Annotation @GuardedBy("lock") ghi lại cho người bảo trì biết biến nào canh bởi khóa nào, một dòng rẻ hơn nhiều so với một đêm truy lỗi race condition trên production.
6. wait / notify — guarded blocks
Đôi khi một thread không chỉ cần loại trừ lẫn nhau, mà còn cần chờ một điều kiện trở thành đúng trước khi tiếp tục. Một worker phải chờ tới khi hàng đợi có việc; một consumer phải chờ tới khi buffer có dữ liệu. Mẫu này gọi là guarded block - khối được canh bởi một điều kiện. Cách ngây thơ là quay vòng bận:
// PHẢN VÍ DỤ — quay vòng bận, đốt CPU vô ích
synchronized (lock) {
while (!conditionHolds()) { /* lặp suông, giữ khóa, chặn cả người khác */ }
doWork();
}
Cách này vừa đốt CPU vừa tệ hơn: nó giữ khóa trong lúc quay, nên thread duy nhất có thể làm điều kiện thành đúng lại không vào được. Cơ chế đúng là wait/notify, gắn liền với intrinsic lock của mỗi object.
wait() làm ba việc nguyên tử với nhau: nhả khóa đang giữ, đưa thread vào trạng thái chờ, và treo nó ở đó. Khi một thread khác gọi notify() hoặc notifyAll() trên cùng object, thread đang chờ được đánh thức, giành lại khóa, rồi mới chạy tiếp từ ngay sau lệnh wait. Việc nhả khóa khi chờ là điểm mấu chốt: nó cho thread khác cơ hội vào và thay đổi điều kiện. Cả ba phương thức wait, notify, notifyAll chỉ được gọi khi đang giữ khóa của chính object đó, nếu không sẽ ném IllegalMonitorStateException.
public class BookingQueue {
private final Object lock = new Object();
@GuardedBy("lock") private final Deque<Request> queue = new ArrayDeque<>();
public void submit(Request r) {
synchronized (lock) {
queue.addLast(r);
lock.notifyAll(); // đánh thức worker đang chờ
}
}
public Request take() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) { // while, KHÔNG phải if
lock.wait(); // nhả khóa và chờ; tỉnh dậy thì giành lại
}
return queue.removeFirst();
}
}
}
Chữ ký take khai báo ném InterruptedException vì wait là một blocking method: một thread đang treo vô hạn trong wait set cần một đường thoát khi bị gọi interrupt(), và đường thoát đó là tỉnh dậy với InterruptedException — đúng cơ chế cooperative cancellation đã mổ ở bài Thread API và vòng đời.
Toàn bộ vũ điệu nhả khóa, chờ, được đánh thức, rồi tranh lại khóa nhìn theo trình tự như sau:
sequenceDiagram
participant A as Thread A (worker)
participant M as Monitor cua lock
participant B as Thread B (submitter)
A->>M: giu lock, thay queue rong
A->>M: wait() - nha lock, vao wait set
Note over A: A treo, KHONG giu lock
B->>M: gianh lock, addLast(request)
B->>M: notifyAll() - A roi wait set, sang entry queue
B->>M: roi khoi synchronized, nha lock
M->>A: A tranh lai lock thanh cong
A->>A: tinh day sau wait(), kiem lai dieu kien trong whileChi tiết quan trọng nhất, và là lỗi kinh điển nhất, là điều kiện phải được kiểm trong vòng while, không bao giờ trong if. Có hai lý do, đều thật.
Thứ nhất là spurious wakeup. Đặc tả của JVM cho phép một thread đang wait tỉnh dậy mà không có ai notify, hiếm nhưng được phép. Nếu dùng if, thread sẽ chạy tiếp dù điều kiện chưa đúng. Với while, nó kiểm lại, thấy điều kiện vẫn sai, và chờ tiếp.
Thứ hai là lost wakeup theo nghĩa cấu trúc, vốn còn phổ biến hơn. Giữa lúc một thread được đánh thức và lúc nó thực sự giành lại được khóa, một thread thứ ba có thể đã chen vào và làm điều kiện thành sai trở lại, ví dụ lấy mất phần tử mà nó định lấy. Vì giữa notify và lúc tỉnh dậy luôn có một quãng nhả-rồi-giành-lại khóa, trạng thái có thể đã đổi. while buộc kiểm lại điều kiện sau khi đã cầm khóa, nên không bao giờ hành động trên một quan sát đã cũ.
Còn lựa chọn giữa notify và notifyAll. notify chỉ đánh thức một thread đang chờ, tùy JVM chọn thread nào; notifyAll đánh thức tất cả, để chúng cùng tranh khóa rồi tự kiểm điều kiện. notify rẻ hơn, nhưng chỉ an toàn khi mọi thread chờ trên cùng object đều chờ đúng một điều kiện và chỉ cần đánh thức một thread là đủ. Nếu các thread chờ những điều kiện khác nhau, notify có thể đánh thức nhầm một thread mà điều kiện của nó chưa đúng, thread đó kiểm while rồi ngủ lại, còn thread thật sự nên chạy thì không bao giờ được gọi. Mặc định an toàn là notifyAll, và chỉ hạ xuống notify khi đã chứng minh được điều kiện đồng nhất.
Trong code thật, hiếm khi nên tự viết wait/notify. java.util.concurrent từ Java 5 đã đóng gói các mẫu này thành những building block đã được kiểm chứng, như BlockingQueue với put/take tự lo chờ và đánh thức. Hiểu wait/notify vẫn cần thiết để đọc được những building block đó từ bên trong, và để biết vì sao chúng tồn tại.
7. Cạm bẫy và chi phí
synchronized đúng, nhưng không miễn phí, và rất dễ dùng sai theo những kiểu không lộ ra trong test đơn luồng. Có ba nhóm vấn đề đáng nắm trước khi sang các công cụ nhẹ hơn.
7.1 Lock scope quá rộng hay quá hẹp
Phía khóa quá rộng: coarse-grained lock của BookingService ở mục 5 bắt hai khách đặt vé cho hai sự kiện khác nhau xếp hàng chờ nhau, dù dữ liệu của họ độc lập. Tệ hơn nữa là giữ khóa qua những việc dài hơi: một lần ghi log ra đĩa hay một network call nằm trong khối synchronized kéo thời gian giữ khóa từ nano giây lên mili giây, nhân contention lên hàng nghìn lần. Từ đây rút ra một nguyên tắc đáng thuộc lòng: nhả khóa trước khi làm I/O hay bất kỳ việc chậm nào không cần khóa. Chiều ngược lại — khóa quá hẹp — còn nguy hơn, vì nó âm thầm sai. Tách một compound action thành nhiều khối synchronized nhỏ phá vỡ chính tính nguyên tử ta cần:
// SAI — hai khối tách rời không nguyên tử với nhau
synchronized (lock) { current = sold.getOrDefault(eventId, 0); } // check
if (current < capacity)
synchronized (lock) { sold.put(eventId, current + 1); } // act, đã quá muộn
Giữa hai khối, một thread khác có thể chen vào và đổi sold. Quan sát current trở nên cũ ngay khi rời khối đầu. Nguyên tắc cân bằng: giữ khóa đủ lâu để bao trọn cả compound action, nhưng nhả nó trước khi làm những việc dài hơi không cần khóa.
7.2 Contention
Vì khóa serialize các thread, một khóa bị nhiều thread cùng tranh trở thành nút cổ chai. Càng nhiều core, càng nhiều thread dồn vào một khóa coarse-grained thì throughput càng đi ngang hoặc tụt, vì phần chạy song song co lại còn phần tuần tự thì không. Một thread thua trong cuộc tranh khóa phải block, kéo theo chi phí context switch của hệ điều hành.
Hai hướng giảm contention sẽ là chủ đề các bài sau. Một là khóa chi tiết hơn, ví dụ một khóa riêng cho mỗi sự kiện - per-event lock hoặc striped lock - để các sự kiện độc lập không chặn nhau. Hai là bỏ khóa hẳn cho các thao tác trên một biến, dùng CAS lock-free — cánh cửa mở sang bài Atomic & CAS kế tiếp.
7.3 Deadlock
Khi một thread cần giữ nhiều hơn một khóa, xuất hiện rủi ro deadlock: hai thread chờ nhau vĩnh viễn, mỗi bên giữ một khóa mà bên kia cần.
// Thread A: chuyển từ tài khoản x sang y
synchronized (x) { synchronized (y) { /* ... */ } }
// Thread B: chuyển từ y sang x — thứ tự giành khóa NGƯỢC lại
synchronized (y) { synchronized (x) { /* ... */ } }
Nếu A giành được x và B giành được y cùng lúc, A chờ y mãi mãi còn B chờ x mãi mãi. Cả hai treo, không exception, không log, ứng dụng cứ thế đứng im. Cách phòng cơ bản và hiệu quả nhất là áp một thứ tự giành khóa toàn cục nhất quán: mọi thread luôn giành các khóa theo cùng một thứ tự, ví dụ sắp theo một id ổn định của object. Khi đó không thể có chu trình chờ, và deadlock kiểu này biến mất. Đây mới là cái nhìn cơ bản; deadlock và các liveness hazard khác sẽ trở lại với nhiều sắc thái hơn khi ta cầm các khóa tường minh ở bài ReentrantLock và Condition — nơi tryLock có timeout cho ta một lối thoát mà synchronized không có.
8. Liên hệ các bài khác
- Bài 02 — Thread API và vòng đời:
waitnémInterruptedException— cơ chế interrupt và cooperative cancellation nằm ở đó; viết blocking code đúng bắt buộc hiểu nó. - Bài 03 — Thread safety: nguồn của hai vấn đề atomicity + visibility mà
synchronizedgiải quyết trọn, của checklist ba điều kiện chovolatile, và của kỹ thuật client-side locking dựa trên khóa public. - Bài 05 — Immutability: immutable holder + tham chiếu volatile là mẫu "né khóa" khi chỉ một writer; bài này là lời giải khi có nhiều writer cùng ghi.
- Bài 07 — Atomic & CAS: chính lệnh CAS mà JVM dùng cho thin lock, phơi ra cho code ứng dụng — đường thoát khỏi khóa cho thao tác trên một biến.
- Bài 09 — ReadWriteLock, StampedLock và AQS: AQS dựng lại bộ máy monitor (state CAS được + hàng đợi thread chờ) ở tầng Java, làm nền cho mọi lock thư viện.
9. Tổng kết
Java cài sẵn hai cơ chế đồng bộ trong ngôn ngữ, nằm ở hai đầu của một phổ. volatile là synchronization nhẹ nhất: nó chỉ bảo đảm visibility và ordering qua quan hệ happens-before, hoàn toàn không lo loại trừ lẫn nhau. Nó đúng và gọn cho cờ trạng thái độc lập và cho tham chiếu trỏ tới immutable holder, nhưng vô dụng ngay khi thao tác là read-modify-write hoặc biến dính invariant với biến khác. synchronized là synchronization đầy đủ: intrinsic lock của mỗi object cho ta loại trừ lẫn nhau cộng visibility cùng lúc, là khóa reentrant, và nhả tự động kể cả khi exception. Java Monitor Pattern đóng gói state mutable sau một private lock duy nhất, và đó sẽ là bước v1 vá race condition của TicketFlow. wait/notify mở rộng monitor để chờ một điều kiện, với hai luật không được quên: luôn kiểm trong while, và mặc định notifyAll.
Nhưng synchronized tuy đơn giản lại thô. Khóa nguyên cả BookingService chỉ để tăng một bộ đếm là dùng dao mổ trâu giết gà: ta trả giá block, context switch và contention cho một thao tác lẽ ra chỉ cần một lệnh CPU. Với những thao tác trên một biến duy nhất, có một con đường nhẹ hơn nhiều, đạt atomicity mà không cần khóa, dựa trên chỉ thị compare-and-swap của phần cứng. Đó là chủ đề bài kế tiếp về Atomic và CAS, nơi ta sẽ thay bộ đếm giữ chỗ của TicketFlow bằng atomic và đối chiếu với bản synchronized. Và khi nào ngay cả CAS cũng không đủ, vì cần phối hợp nhiều biến cùng lúc với những khả năng mà synchronized không có như khóa có thể hủy, có thời hạn, hay tách đọc khỏi ghi, ta sẽ bước sang explicit locks ở bài ReentrantLock và Condition.
10. Tự kiểm tra
Q1Vì sao điều kiện chờ phải được kiểm trong vòng while quanh wait(), không bao giờ trong if?▸
Hai lý do độc lập. Spurious wakeup: đặc tả JVM cho phép một thread đang wait tỉnh dậy mà không có ai notify — với if, thread sẽ chạy tiếp dù điều kiện chưa đúng.
Quan sát đã cũ: giữa lúc được notify và lúc thực sự giành lại được khóa, luôn tồn tại một quãng thread không giữ khóa. Một thread thứ ba có thể chen vào quãng đó và làm điều kiện sai trở lại — ví dụ lấy mất phần tử trong queue. while buộc kiểm lại điều kiện sau khi đã cầm khóa, nên thread không bao giờ hành động trên một quan sát đã hết hạn.
Q2Một synchronized method static khóa cái gì? Thread đang chạy nó có chặn được thread khác chạy synchronized instance method của cùng class không?▸
Method static synchronized khóa trên đối tượng Class của class đó (ví dụ BookingService.class), còn instance method synchronized khóa trên this — hai object hoàn toàn khác nhau, tức hai khóa khác nhau.
Vì vậy chúng không loại trừ lẫn nhau: một thread chạy method static và một thread chạy instance method có thể chạy song song, kể cả khi cả hai cùng chạm vào một static field. Nếu hai loại method cùng canh một dữ liệu, đó là bug — phải thống nhất một khóa duy nhất cho dữ liệu đó.
Q3Reader chỉ đọc một biến, không ghi gì — vì sao vẫn phải synchronized trên cùng khóa với writer?▸
Vì synchronized có hai bảo đảm, và reader cần vế thứ hai: visibility. Quy tắc happens-before của khóa là nhả khóa happens-before giành lại chính khóa đó — cây cầu bộ nhớ chỉ bắc giữa hai thread cùng đi qua một khóa.
Reader không khóa thì không có cây cầu nào bắc tới nó: nó có thể đọc trúng giá trị cũ bị cache, hoặc trạng thái dở dang giữa chừng một compound action của writer. Đây là lỗi âm thầm vì test đơn luồng và cả phần lớn test đa luồng đều không lộ.
Q4Vì sao synchronized gần như miễn phí khi không có tranh chấp? Điều gì xảy ra ở lần tranh chấp đầu tiên?▸
Khi khóa còn trống, JVM chỉ thực hiện một lệnh CAS ghi con trỏ lock record vào mark word của object — trạng thái thin lock. Không syscall, không context switch, không cấp phát monitor; chi phí cỡ một lệnh CPU.
Khi một thread CAS thất bại vì khóa đã có chủ, JVM inflate khóa: dựng ObjectMonitor đầy đủ (owner, reentrancy count, entry queue, wait set) trong bộ nhớ native và ghi con trỏ vào mark word. Từ đó thread thua phải block qua hệ điều hành — chi phí nhảy từ một lệnh CPU lên hai lần context switch. Vì vậy thứ cần né không phải synchronized mà là contention.
Q5Khi nào volatile là đủ thay cho synchronized? Vì sao volatile int sold với sold++ vẫn sai dù mọi lần đọc đều thấy giá trị mới nhất?▸
volatile đủ khi cả ba điều kiện cùng đúng: ghi không phụ thuộc giá trị hiện tại của biến (hoặc chỉ một thread ghi), biến không dính invariant với biến khác, và không cần khóa vì lý do nào khác. Cờ trạng thái và tham chiếu trỏ tới immutable holder một-writer là hai use case chuẩn.
sold++ sai vì nó là read-modify-write ba bước: đọc, cộng, ghi. volatile bảo đảm từng lần đọc lẻ và ghi lẻ tôn trọng synchronization order, nhưng không gói ba bước thành một khối nguyên tử — hai thread vẫn cùng đọc 9, cùng ghi 10, mất một lần tăng. Visibility không thay được atomicity.
Q6Vì sao Java Monitor Pattern nên khóa trên một private final Object lock thay vì khóa trên this?▸
Khóa trên this là khóa công khai: bất kỳ code nào cầm tham chiếu tới object đều có thể synchronized trên nó — vô tình tham gia vào chính sách khóa của class, giữ khóa lâu bất thường, thậm chí gây deadlock. Muốn kiểm chứng khóa công khai được dùng đúng, phải soi cả chương trình.
Một private final Object lock đóng kín chính sách đồng bộ trong đúng một file: chỉ code của class chạm được khóa, nên việc chứng minh tính đúng đắn trở thành việc cục bộ. Cái giá là từ bỏ khả năng client-side locking — vốn chỉ nên là kỹ thuật có chủ đích, không phải cửa mở mặc định.
Q7Khi nào dùng notify thay notifyAll là an toàn? Rủi ro nếu chọn sai là gì?▸
notify chỉ an toàn khi mọi thread chờ trên cùng object đều chờ đúng một điều kiện và đánh thức một thread là đủ — uniform waiters. Khi đó nó rẻ hơn vì tránh đánh thức hàng loạt thread chỉ để chúng kiểm điều kiện rồi ngủ lại.
Nếu các thread chờ những điều kiện khác nhau, notify có thể đánh thức nhầm một thread mà điều kiện của nó chưa đúng; thread đó kiểm while rồi ngủ tiếp, còn thread đáng lẽ phải chạy thì không bao giờ được gọi — một dạng lost wakeup làm hệ thống treo từng phần. Mặc định an toàn là notifyAll, chỉ hạ xuống notify khi chứng minh được điều kiện đồng nhất.
Bài tiếp theo: Atomic & CAS — đồng bộ lock-free cho thao tác đơn
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