Java Internals & Concurrency/Bên trong synchronized: mark word, thin lock và fat lock
14/75
Bài 14 / 75~15 phútConcurrency cơ bảnMiễn phí lượt xem

Bên trong synchronized: mark word, thin lock và fat lock

Chi phí synchronized không cố định: thin lock một CAS vào mark word, inflate lên ObjectMonitor khi tranh chấp; cạm bẫy lock scope, contention, deadlock.

TL;DR: "Mọi object đều mang một khóa" không có nghĩa mỗi object vác một mutex của OS. JVM trả khóa theo nhu cầu: trạng thái khóa khởi đầu chỉ là vài bit trong mark word ở đầu object header. Khi không ai tranh, vào khối synchronized chỉ tốn một lệnh CAS ghi con trỏ lock record vào mark word — thin lock, không syscall. Chỉ khi có tranh chấp thật, JVM inflate lên một ObjectMonitor native đầy đủ — fat lock — và thread thua phải block qua OS. Vì vậy thứ cần né không phải synchronized mà là contention. Ba nhóm cạm bẫy: lock scope sai, contention, và deadlock — phòng deadlock bằng một thứ tự giành khóa nhất quán.

Bài volatile và synchronized chốt rằng synchronized cho cả loại trừ lẫn nhau lẫn visibility. Nhưng cùng một dòng synchronized có chỗ chỉ tốn một CAS, chỗ khác tốn hai lần context switch — chênh nhau hàng trăm lần. Vì sao, khi nào rơi vào vế đắt, và dùng sai kiểu nào thì vỡ? Bài này trả lời cả ba.

1. Mark word — nơi khóa sống

"Mọi object đều mang một khóa" nghe như mỗi object phải vác một cấu trúc đồng bộ của OS — mutex thật với hàng đợi và syscall. Thế thì synchronized đắt vô lý: chương trình Java tạo hàng triệu object, phần lớn không bao giờ bị khóa, hoặc bị khóa mà không hề tranh chấp. JVM giải 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, chỉ "phình" thành cấu trúc đắt tiền khi có tranh chấp thật. Hình dung: thin lock như dán mẩu giấy ghi tên lên cửa phòng trống — chớp nhoáng; fat lock như khi có người khác cũng đòi vào, phải nhờ bảo vệ lập sổ xếp hàng và gọi tên từng người — nặng hơn hẳn.

Mỗi object trên heap HotSpot mở đầu bằng một header, và word đầu tiên — mark word, một word 64-bit đa dụng — là nơi khóa sống. Tùy trạng thái, mark word chứa identity hash code và tuổi GC, hoặc thông tin khóa: vài bit thấp là tag cho biết object đang ở trạng thái khóa nào, phần còn lại diễn giải theo tag đó. "Intrinsic lock gắn vào object" vì vậy đúng nghĩa đen — khóa là chính mấy chục bit nằm ngay đầu object, không phải một field ẩn trỏ đi đâu xa.

Cấu trúc đầy đủ của object header được mổ kỹ ở Object header và compressed oops; ở đây ta chỉ cần đúng phần liên quan tới khóa.

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 OS. Trên JDK 21 (cơ chế stack-lock cổ điển), 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 Atomic & CAS — 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. Chi phí chỉ cỡ một CAS: không syscall, không context switch, không cấp phát heap. Trạng thái này gọi là thin lock — khóa mỏng. Rời khối, một CAS nữa trả mark word về cũ. (Từ JDK 23, default đổi sang lightweight locking mới — bỏ displaced header trên stack — nhưng bức tranh "một CAS khi không tranh chấp" vẫn giữ nguyên.)

Đây là câu trả lời cho "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ỉ một CAS, rẻ hơn nhiều so với hình dung "khóa nghĩa là gọi OS". Lời khuyên đúng vì vậy không phải né synchronized, mà là né contention.

Trước đây HotSpot còn một tầng rẻ hơn nữa — biased locking, thiên vị thread đầu giành khóa để lần sau khỏi tốn cả một CAS. HotSpot đã tắt nó mặc định từ JDK 15 (JEP 374) rồi obsolete từ JDK 18 (JDK-8256425): chi phí thu hồi bias vượt lợi ích khi một CAS không tranh chấp trên CPU hiện đại đã đủ rẻ.

3. Khi nào khóa phải inflate lên fat lock?

Chuyện đổi khác khi thread B (khác chủ) cố CAS vào một mark word đang thuộc về A. CAS thất bại vì khóa đã có chủ khác — đây mới là tranh chấp thật (nếu chính A tái nhập, CAS cũng trượt fast-path nhưng JVM thấy owner cũ nên chỉ tăng reentrancy count, không inflate). Lúc này JVM inflate — làm phình — cái khóa: dựng một 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 đủ bộ phận: thread owner, bộ đếm reentrancy (chính acquisition count ở bài trước), entry queue chứa các thread đang chờ giành khóa, và wait set chứa các thread đã gọi wait() — chủ đề bài 09. Thread thua cuộc vào entry queue và block qua primitive của OS — một lần context switch để CPU cất thread thua đi ngủ. Owner nhả khóa thì OS đánh thức một thread trong queue dậy tranh tiếp — lần context switch thứ hai. Đó là "hai lần context switch" của một lượt tranh khóa.

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 -- "thread khac giu" --> 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 --> D
    style D fill:#6EE7B7
    style E fill:#FCD34D
    style F fill:#FCA5A5
    style G fill:#FCA5A5

Hệ quả thực dụng: chi phí synchronized phụ thuộc lịch sử tranh chấp của từng object, nên mọi kỹ thuật giảm contention ở phần sau — thu hẹp vùng khóa, tách khóa theo dữ liệu — đều nhằm một việc: giữ khóa đừng bao giờ phải inflate.

📚 Deep Dive Oracle

Spec / reference chính thức:

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 đó.

4. 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. Ba nhóm vấn đề đáng nắm trước khi sang các công cụ nhẹ hơn.

4.1 Lock scope quá rộng hay quá hẹp

Khóa quá rộng: một coarse-grained lock ôm trọn một service — kiểu Java Monitor Pattern ở bài kế tiếp — 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 lập. Tệ hơn là giữ khóa qua việc dài hơi — một lần ghi log ra đĩa hay network call trong khối synchronized kéo thời gian giữ khóa từ nano lên mili giây, nhân contention lên hàng nghìn lần. Nguyên tắc: 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ì âm thầm sai: tách một compound action (chuỗi thao tác phải nguyên tử với nhau, như kiểm-tra-rồi-hành-động) thành nhiều khối nhỏ phá vỡ chính tính nguyên tử ta cần.

// SAI — hai khoi tach roi khong nguyen tu voi nhau
synchronized (lock) { current = sold.getOrDefault(eventId, 0); }    // check
if (current < capacity)
    synchronized (lock) { sold.put(eventId, current + 1); }          // act, da qua muon

Giữa hai khối, một thread khác có thể chen vào đổi sold; quan sát current trở nên cũ ngay khi rời khối đầu. Cân bằng: giữ khóa đủ lâu để bao trọn cả compound action, nhưng nhả trước khi làm việc dài hơi không cần khóa.

4.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 song song co lại còn phần tuần tự thì không (đúng tinh thần định luật Amdahl). Hai hướng giảm contention là chủ đề các bài sau: khóa chi tiết hơn — 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; hoặc bỏ khóa hẳn cho thao tác trên một biến, dùng CAS lock-free — cánh cửa mở sang bài Atomic & CAS.

4.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 bên kia cần.

void transfer(Account from, Account to, long amount) {
    synchronized (from) {
        synchronized (to) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

Hai thread gọi transfer(x, y)transfer(y, x) cùng lúc: nếu thread đầu giành được x và thread sau giành được y cùng lúc, thread đầu chờ y mãi còn thread sau chờ x mãi — cả hai treo, không exception, không log, ứng dụng đứng im.

💡 Tự sửa trước khi đọc tiếp

Trước khi xem lời giải: viết lại thân transfer sao cho hai luồng gọi transfer(x, y)transfer(y, x) đồng thời không thể treo nhau. Bạn dùng thuộc tính ổn định nào của Account để quyết định khóa cái nào trước? Viết ra ý tưởng của bạn.

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 khóa theo cùng một thứ tự, ví dụ sắp theo một id ổn định của object.

void transfer(Account from, Account to, long amount) {
    Account first  = from.id() < to.id() ? from : to;   // luon khoa id nho truoc
    Account second = from.id() < to.id() ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

Giờ mọi thread giành khóa theo id tăng dần, bất kể truyền transfer(x, y) hay transfer(y, x). Không thể có chu trình chờ, và deadlock kiểu này biến mất. 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 một lối thoát mà synchronized không có.

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

6. Tóm tắt

  • Mark word ở đầu object header giữ trạng thái khóa; "intrinsic lock gắn vào object" đúng nghĩa đen, không phải một field ẩn.
  • Thin lock: khóa không tranh chấp gần như chỉ một CAS, không syscall. Biased locking (tầng rẻ hơn cũ) đã tắt mặc định từ JDK 15 (JEP 374) và obsolete từ JDK 18 (JDK-8256425). Thứ cần né là contention.
  • Fat lock: có tranh chấp thì JVM inflate lên ObjectMonitor native (owner, reentrancy count, entry queue, wait set); thread thua block qua OS — chi phí nhảy lên hai lần context switch. Chi phí synchronized vì vậy phụ thuộc lịch sử tranh chấp của từng object.
  • Ba cạm bẫy: lock scope quá hẹp phá atomicity của compound action; contention biến khóa thành nút cổ chai; deadlock khi giữ nhiều khóa sai thứ tự — phòng bằng một thứ tự giành khóa toàn cục nhất quán.

7. Tự kiểm tra

Tự kiểm tra
Q1
Mark word giữ những gì, và vì sao nói 'intrinsic lock gắn vào object' là đúng nghĩa đen chứ không phải cách nói ẩn dụ?

Mark word là word đầu tiên của object header trên heap HotSpot — một word 64-bit đa dụng. Tùy trạng thái, nó chứa identity hash code và tuổi GC, hoặc thông tin khóa: vài bit thấp là tag cho biết object ở trạng thái khóa nào, phần còn lại diễn giải theo tag đó.

Khi thread giành khóa, JVM ghi thẳng con trỏ lock record (thin lock) hoặc con trỏ ObjectMonitor (fat lock) vào chính mark word đó. Khóa vì vậy mấy chục bit nằm ngay đầu object, không phải một field ẩn trỏ tới cấu trúc khóa ở nơi khác — nên "gắn vào object" đúng nghĩa đen.

Q2
Vì 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.

Q3
Một counter bị nhiều thread cùng tăng trong hot path đang là nút cổ chai do contention trên khối synchronized. Vì sao AtomicInteger thường nhanh hơn ở đây, và khi nào bạn vẫn phải chọn synchronized?

Dưới contention cao, synchronized inflate lên fat lock: thread thua block qua OS (hai lần context switch). AtomicInteger dùng vòng lặp CAS lock-free — chính lệnh CAS mà thin lock dùng, phơi thẳng ra — nên thread không block mà retry ngay trong user space; trên một biến đơn, nó tránh được chi phí block của OS.

Nhưng CAS chỉ làm nguyên tử một biến. Khi bất biến trải nhiều field hay nhiều bước (compound action), hoặc khi cần wait()/notify(), bạn vẫn phải dùng synchronized (hoặc một Lock) — không thể thay bằng một CAS. Chọn theo pattern truy cập: một biến đếm/cờ đơn thì Atomic; bất biến nhiều bước thì khóa.

Q4
Vì sao thêm core và thêm thread vào một khóa coarse-grained lại có thể làm throughput đi ngang hoặc tụt thay vì tăng?

Khóa serialize các thread: vùng code một khóa canh giữ chỉ cho một thread chạy tại một thời điểm. Khi nhiều thread dồn vào cùng một khóa, phần code đó thành đoạn tuần tự không co lại được, còn phần song song thì có — đúng tinh thần định luật Amdahl, phần tuần tự đặt trần cho tốc độ.

Thêm core chỉ giúp phần song song; phần tuần tự sau khóa không nhanh lên. Tệ hơn, mỗi thread thua cuộc tranh khóa phải block, kéo theo context switch của OS — chi phí thuần túy không sinh việc. Vượt một điểm, thêm thread chỉ thêm tranh chấp: throughput đi ngang rồi tụt. Cách chữa là giảm contention (khóa chi tiết hơn) chứ không phải thêm core.

Q5
Vì sao chia một compound action thành nhiều khối synchronized nhỏ lại nguy hiểm hơn một khối quá rộng?

Khóa quá rộng chỉ hại hiệu năng — nó bắt các thread chờ nhau lâu hơn cần thiết, nhưng chương trình vẫn đúng. Khóa quá hẹp thì âm thầm sai: tách bước kiểm tra và bước hành động thành hai khối riêng để lại một khe giữa chúng mà thread khác chen vào được.

Ví dụ đọc current = sold.get() trong một khối rồi sold.put(current + 1) trong khối sau: giữa hai khối, một thread khác có thể đổi sold, khiến current trở nên cũ ngay khi rời khối đầu — đúng thứ atomicity cần bảo vệ bị phá. Nguyên tắc: giữ khóa đủ lâu để bao trọn cả compound action, nhưng nhả trước khi làm I/O hay việc chậm không cần khóa.

Q6
Vì sao áp một thứ tự giành khóa toàn cục nhất quán loại bỏ được deadlock hai khóa?

Deadlock hai khóa cần một chu trình chờ: thread A giữ x chờ y, trong khi thread B giữ y chờ x. Chu trình đó chỉ hình thành khi hai thread giành hai khóa theo thứ tự ngược nhau.

Nếu mọi thread luôn giành khóa theo cùng một thứ tự toàn cục — ví dụ theo id tăng dần của object — thì không thread nào giữ khóa id lớn mà lại chờ khóa id nhỏ. Đồ thị chờ không thể có chu trình, nên deadlock kiểu này biến mất. Trong transfer, chỉ cần sắp hai account theo id() trước khi vào khối synchronized là đủ.

Bài tiếp theo: Guarded block với wait/notify trên Java Monitor Pattern

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

Guarded block với wait/notify trên Java Monitor Pattern