volatile và synchronized: hai cơ chế đồng bộ nội tại Java
Hai cơ chế đồng bộ nội tại của Java: volatile chỉ lo visibility qua happens-before; synchronized cho cả loại trừ lẫn nhau lẫn visibility, tự nhả khi exception.
TL;DR: Khi dữ liệu vừa shared vừa mutable, Java cài sẵn hai cơ chế đồng bộ nội tại ở hai đầu một phổ. volatile chỉ lo visibility và ordering qua happens-before — đúng cho cờ trạng thái một writer nhiều reader và tham chiếu trỏ tới immutable holder, nhưng vô dụng với read-modify-write như sold++; nó cũng chặn torn read của long/double. synchronized cho cả mutual exclusion lẫn visibility: mỗi object mang một intrinsic lock, và vì "nhả khóa happens-before giành lại chính khóa đó", reader cũng phải khóa mới thấy dữ liệu mới nhất. Khóa reentrant, tự nhả kể cả khi exception. Chọn volatile khi chỉ cần thấy-được-thay-đổi; chọn synchronized khi cần quây cả cụm thao tác thành một khối nguyên tử.
1. Khi dữ liệu vừa shared vừa mutable
Confinement triệt tính "shared", immutability triệt tính "mutable" — cả hai làm vấn đề biến mất, nhưng chỉ dùng được khi ta có quyền chọn không chia sẻ hoặc không thay đổi. Nhiều trạng thái không cho ta quyền đó: số vé đã bán của một sự kiện trong TicketFlow phải được nhiều thread cùng thấy và tăng dần theo từng booking — vừa shared vừa mutable, không thể giam, không thể đóng băng. Rơi vào ô đó, chỉ còn một đường: canh gác mọi truy cập bằng synchronization.
Java cài sẵn hai cơ chế đồng bộ ngay trong ngôn ngữ, ở hai đầu một phổ: volatile nhẹ nhất — chỉ lo visibility, không lo loại trừ lẫn nhau; synchronized đầy đủ — cả loại trừ lẫn nhau lẫn visibility. Hiểu mỗi cái bảo đảm gì, và quan trọng hơn là không bảo đảm gì, mới 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 Visibility đã mổ volatile đủ sâu; đây chỉ recap để so với synchronized. Theo happens-before: một lần ghi biến volatile happens-before mọi lần đọc sau đó của chính biến ấy — khi B đọc thấy giá trị A vừa ghi, mọi thứ A viết trước lúc ghi cũng hiển thị với B (reader đọc biến thường không được hưởng gì). Nó chỉ lo visibility và ordering, không lo loại trừ lẫn nhau.
Use case đúng kiểu 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, nhieu worker doc
public void closeSales() { salesOpen = false; }
public boolean isOpen() { return salesOpen; }
}
Thước đo khi nào được dùng vẫn là checklist ba điều kiện của bài Visibility: (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ì lý do nào khác. salesOpen thỏa cả ba — ghi nó là gán thẳng một hằng. 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 đúng một thread công bố.
Còn sold của TicketFlow vi phạm ngay điều đầu — đó 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ẻ đúng, nhưng không ngăn 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. Với sold, volatile vô dụng; ta cần một cơ chế quây cả cụm thao tác thành một khối. Đó là synchronized.
2.1 Ranh giới còn lại: torn read của long và double
Có đúng một trường hợp volatile lo cả tính nguyên tử của một lần truy cập đơn lẻ. (Không độc quyền của nó: synchronized, AtomicLong, hay một field final được công bố an toàn cũng chặn được hiện tượng dưới đây — volatile chỉ là cách rẻ nhất.)
Java Memory Model bảo đảm mọi lần đọc/ghi một biến đều lấy ra một giá trị ai đó đã thực sự ghi — kể cả biến reference rộng 64 bit. Ngoại lệ duy nhất: long và double không khai volatile. Với chúng, JLS §17.7 cho phép JVM tách một lần ghi 64-bit thành hai lần ghi 32-bit. Thread đọc khi đó có thể ghép 32 bit cao của lần ghi này với 32 bit thấp của lần ghi khác, thu về một con số chưa ai từng ghi — gọi là torn read/write.
private long revenue; // co the bi torn read
private volatile long revenueOk; // doc/ghi luon nguyen tu
Trên phần cứng 64-bit ngày nay JVM hầu như luôn ghi nguyên tử nên lỗi rất hiếm, nhưng tới tận JLS SE 25 §17.7 nó vẫn nằm trong đặc tả: code dựa vào tính nguyên tử của một long chia sẻ là sai về nguyên tắc — đúng trên máy hôm nay, hỏng trên kiến trúc khác, gần như không tái hiện được khi debug. Đừng nhầm với atomicity của phép cập nhật: revenue += amount vẫn là read-modify-write ba bước mà volatile không cứu — đúng ranh giới cứng vừa nêu.
3. synchronized — intrinsic lock
3.1 Mọi object đều mang một monitor
book của TicketFlow cần cả hai thứ cùng lúc — atomicity cho cụm check-then-act (bài Atomicity) và visibility cho mọi reader (bài Visibility) — mà volatile chỉ cho được vế sau. Mảnh còn thiếu là synchronized, cơ chế đồng bộ đầy đủ Java gắn thẳng vào ngôn ngữ.
Mỗi đối tượng Java ngầm mang một khóa — intrinsic lock (monitor lock) — gắn vào chính header của object, không cần khai báo. Một khối synchronized mượn khóa đó để dựng vùng tới hạn:
synchronized (lock) {
// vung toi han: tai mot thoi diem nhieu nhat mot thread vao duoc
}
Vào khối, thread tự động giành lock; rời khối, nó tự nhả — dù rời bình thường hay vì một exception. Chính cái "tự nhả kể cả khi exception" này khiến 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. Intrinsic lock hoạt động như mutex - mutual exclusion lock: nhiều nhất một thread giữ được; A muốn giành khóa đang trong tay B thì phải chờ, bị block, tới khi B nhả. Vì chỉ một thread chạy được vùng code một khóa 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 thread khác đang ở giữa chừng một khối cùng khóa.
Một synchronized method chỉ là cú pháp tắt cho khối synchronized ôm trọn thân method, khóa trên this (instance method) hoặc trên đối tượng Class (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 vì giải quyết cả hai vấn đề nền tảng cùng lúc. Phần atomicity đến từ loại trừ lẫn nhau: vì chỉ một thread vào được vùng tới hạn, cả cụm compound action bên trong khối diễn ra như một khối không ai chen được. Đây đúng thứ volatile thiếu. Phần visibility đến từ một quy tắc happens-before khác: nhả một khóa happens-before giành lại chính khóa đó. Khi A rời khối synchronized, mọi thay đổi nó làm bên trong hiển thị với B khi B vào khối synchronized cùng khóa. Khóa vừa ngăn hai thread vào cùng lúc, vừa bắc một cây cầu bộ nhớ, y như cặp ghi-đọc volatile.
Hệ quả 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 khóa. Tưởng chỉ cần đồng bộ khi ghi là sai: reader không khóa vẫn có thể đọc trúng dữ liệu dở dang hoặc giá trị cũ vì 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 tái nhập (reentrant): một thread đang giữ khóa mà lại gặp khối synchronized khác trên cùng khóa thì vào được luôn, không tự khóa chính mình. JVM cài bằng một bộ đếm số lần giành - acquisition count - và một thread sở hữu: thread sở hữu giành lại thì count tăng, rời mỗi khối thì count giảm, chỉ khi count về 0 khóa mới thực sự nhả cho thread khác. Khóa Java vì vậy cấp theo từng lần-giành-của-một-thread, không theo từng lần-giành tuyệt đối.
Reentrancy cứu ta khỏi deadlock trong một tình huống rất tự nhiên: method synchronized của lớp con gọi super cũng synchronized trên cùng đối tượng.
public class Widget {
public synchronized void doSomething() { /* ... */ }
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(this + ": calling doSomething");
super.doSomething(); // gianh lai CUNG khoa tren this
}
}
Khi gọi LoggingWidget.doSomething, thread đã giữ khóa trên this; super.doSomething() lại cần đúng khóa đó. Không reentrant thì thread chờ một khóa chính nó đang giữ, treo vĩnh viễn. Vì reentrant, count tăng lên 2 rồi về 0, 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 ngăn thread khác đọc/ghi field của nó; đ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 và các field — bảo vệ chỉ tồn tại khi mọi thread cùng tuân "chạm biến này thì phải giữ khóa kia".
synchronized đúng về hành vi, nhưng còn hai câu hỏi thực dụng: nó có chậm không, và dùng sai kiểu nào thì vỡ? Cả hai — cơ chế mark word với thin/fat lock, cùng ba nhóm cạm bẫy (lock scope, contention, deadlock) — là nội dung bài kế: Bên trong synchronized.
4. Liên hệ các bài khác
- Bài 08b — Bên trong synchronized: phần tiếp trực tiếp — mark word, thin/fat lock, và ba nhóm cạm bẫy chi phí của chính cơ chế bài này.
- Bài 09 — Guarded block với wait/notify: đặt intrinsic lock của bài này vào Java Monitor Pattern, rồi mở rộng monitor để một thread chờ điều kiện bằng
wait/notify. - Bài 03 — Thread safety: nguồn của hai vấn đề atomicity + visibility mà
synchronizedgiải quyết trọn trong một cơ chế duy nhất. - Bài 05 — Visibility: mổ
volatile+ happens-before sâu; bài này dùng lại checklist ba điều kiện từ đó. - Bài 07 — 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.
5. Tóm tắt
volatilelà synchronization nhẹ nhất: chỉ bảo đảm visibility và ordering qua happens-before, không loại trừ lẫn nhau. Đúng cho cờ trạng thái độc lập và tham chiếu trỏ tới immutable holder một-writer; 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. Nó cũng chặn torn read củalong/double(JLS §17.7) — nhưng đó là atomicity của một lần truy cập, không phải của cả cụm.synchronizedlà synchronization đầy đủ: intrinsic lock của mỗi object cho loại trừ lẫn nhau cộng visibility cùng lúc; nhả khóa happens-before giành lại cùng khóa nên reader cũng phải khóa. Khóa reentrant, nhả tự động kể cả khi exception.synchronizedmethod static khóa đối tượngClass, instance method khóathis— hai khóa khác nhau, không loại trừ lẫn nhau; canh cùng một dữ liệu bằng cả hai loại là bug.- Chi phí thật của
synchronized(mark word, thin/fat lock) và ba nhóm cạm bẫy dùng sai là chủ đề bài kế: Bên trong synchronized.
6. Tự kiểm tra
- Q1Mộ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?
- Q2Reader 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?
- Q3Khi 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?
- Q4Intrinsic lock là reentrant nghĩa là gì? Cho một tình huống sẽ deadlock nếu nó không reentrant.
- Q5long không khai volatile có thể bị torn read — hiện tượng đó là gì, và vì sao int thì không dính?
Bài tiếp theo: Bên trong synchronized — mark word, thin lock và fat lock
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