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.

Đường thin lock chỉ tốn một CAS, đường có tranh chấp phải inflate lên ObjectMonitor

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
0/6 câu đã trả lời
  1. 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ụ?
  2. 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?
  3. 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?
  4. 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?
  5. 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?
  6. 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?

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