Cơ chế hoạt động của synchronized trong Java

Giới thiệu

synchronized là từ khóa được sử dụng để giải quyết vấn đề đồng bộ dữ liệu trong môi trường đa luồng. Nói một cách đơn giản, khi có nhiều luồng chạy song song, các đoạn mã được đánh dấu bằng synchronized sẽ chỉ cho phép một luồng thực thi tại một thời điểm. Bài viết này sẽ hướng dẫn bạn cách sử dụng synchronized, cơ chế hoạt động của nó thông qua việc khóa đối tượng, và cách nó liên kết với đối tượng Monitor. Cuối cùng, chúng ta sẽ tìm hiểu về các loại khóa trong JDK 1.6 trở lên: biased lock, lightweight lock và heavyweight lock.

Cách sử dụng synchronized

Synchronized có 3 cách sử dụng chính:

  • Đồng bộ phương thức instance - khóa đối tượng hiện tại
  • Đồng bộ phương thức static - khóa đối tượng Class
  • Đồng bộ khối mã - khóa đối tượng được chỉ định trong ngoặc
public class ConcurrencyDemo {

    /**
     * Phương thức đồng bộ kiểu instance
     * Khóa đối tượng hiện tại
     */
    public synchronized void executeTask() {
    }

    /**
     * Phương thức đồng bộ kiểu static
     * Khóa đối tượng ConcurrencyDemo.class
     */
    public synchronized static void processData() {
    }

    /**
     * Khối đồng bộ
     */
    public void handleRequest() {
        String lockObject = "LOCK";
        // Khóa đối tượng cụ thể
        synchronized (lockObject) {
            // Xử lý công việc
        }
    }
}

Nguyên lý hoạt động của synchronized

Từ các ví dụ trên, có thể thấy synchronized luôn khóa một đối tượng cụ thể (đối tượng instance hoặc đối tượng Class). Để hiểu rõ hơn, chúng ta cần tìm hiểu về cấu trúc bộ nhớ của đối tượng trong Java.

Cấu trúc bộ nhớ đối tượng Java

Trong HotSpot JVM, một đối tượng được lưu trữ trong bộ nhớ gồm 3 phần:

  • Object Header (Phần đầu đối tượng): Chứa các thông tin siêu dữ liệu
  • Instance Data (Dữ liệu thực thể): Lưu trữ các thuộc tính của lớp
  • Padding (Phần đệm): Đảm bảo địa chỉ bắt đầu chia hết cho 8

Trong đó, phần quan trọng nhất là Object Header - nơi chứa thông tin liên quan đến cơ chế khóa.

Object Header

Object Header trong HotSpot VM gồm hai thành phần chính:

  • Class Pointer: Con trỏ trỏ đến metadata của lớp, giúp JVM xác định đối tượng thuộc lớp nào
  • Mark Word: Lưu trữ dữ liệu runtime của đối tượng, chứa thông tin quan trọng về khóa

Mark Word

Mark Word lưu trữ các thông tin về trạng thái khóa. Phần quan trọng cần chú ý là trạng thái heavyweight lock - chứa con trỏ đến đối tượng Monitor.

Đối tượng Monitor

Để hiểu rõ hơn về cơ chế hoạt động, chúng ta hãy xem bytecode được sinh ra khi biên dịch mã đồng bộ.

Sử dụng lệnh javap để phân rã file class:

javap -c ConcurrencyDemo.class

Kết quả phân rã bytecode:

Compiled from "ConcurrencyDemo.java"
public class ConcurrencyDemo {
  public ConcurrencyDemo();
    Code:
       0: aload_0
       1: invokespecial #1
       4: return

  public void executeTask();
    Code:
       0: ldc           #2
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3
       8: ldc           #4
      10: invokevirtual #5
      13: aload_1
      14: monitorexit
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}

Hai lệnh quan trọng trong bytecode là monitorentermonitorexit.

monitorenter: Thực hiện việc giành quyền sở hữu đối tượng Monitor (khóa nặng).

monitorexit: Khi luồng sở hữu Monitor hoàn thành công việc, giảm bộ đếm xuống 1. Nếu bộ đếm bằng 0, luồng đó không còn sở hữu monitor nữa, các luồng khác có thể thử giành quyền sở hữu.

Lý do có 2 lệnh monitorexit là để đảm bảo monitor được giải phóng cả trong trường hợp bình thường và khi có ngoại lệ xảy ra.

Monitor là gì?

Monitor (hay còn gọi là Monitor Lock) thực chất phụ thuộc vào Mutex Lock của hệ điều hành. Mỗi đối tượng trong Java đều có một "khóa mutex" đi kèm, đảm bảo tại một thời điểm chỉ có một luồng có thể truy cập đối tượng đó.

Mutex Lock: Dùng để bảo vệ vùng critical, đảm bảo chỉ một luồng được truy cập tại một thời điểm. Khi một luồng muốn truy cập tài nguyên chia sẻ, nó phải khóa mutex trước. Nếu mutex đã bị khóa, luồng đó sẽ bị chặn (block) cho đến khi mutex được mở khóa.

Kết luận về cơ chế

Từ khóa synchronized hoạt động theo cơ chế: Khóa đối tượng → Mark Word trong object header → Đối tượng Monitor → Mutex Lock của hệ điều hành.

Cấu trúc dữ liệu của Monitor được viết bằng C++ trong file ObjectMonitor.hpp của JVM:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0;
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0;
    _Responsible  = NULL;
    _succ         = NULL;
    _cxq          = NULL;
    FreeNext      = NULL;
    _EntryList    = NULL;
    _SpinFreq     = 0;
    _SpinClock    = 0;
    OwnerIsThread = 0 ;
}

Giải thích các trạng thái luồng:

  • Luồng trong synchronized bị chuyển sang trạng thái Blocked
  • Luồng gọi Object.wait() chuyển sang trạng thái Waiting
  • Luồng Waiting được đánh thức bằng notify() sẽ chuyển sang Blocked để giành lại khóa

Tối ưu hóa khóa trong JDK 1.6

Từ JDK 1.6, JVM đã được cải tiến với hai loại khóa mới: biased lock và lightweight lock.

Biased Lock (Khóa thiên vị)

Các nhà phát triển HotSpot nhận thấy trong hầu hết trường hợp, khóa không có cạnh tranh đa luồng và luôn được cùng một luồng sử dụng nhiều lần. Do đó, khái niệm "biased lock" ra đời. Chỉ khi có cạnh tranh từ luồng khác, khóa mới chuyển sang lightweight lock.

Quá trình giành biased lock:

  1. Kiểm tra bit biased trong Mark Word có được set không và lock flag có phải là 01 - xác nhận trạng thái biased
  2. Nếu là trạng thái biased, kiểm tra thread ID có trùng với luồng hiện tại không, nếu có thì thực thi mã đồng bộ
  3. Nếu thread ID khác, thực hiện CAS để giành khóa. Nếu thành công, set thread ID vào Mark Word
  4. 4Nếu CAS thất bại, chuyển sang bước 4
  5. Thực thi mã đồng bộ

Khi xảy ra cạnh tranh:

Biased lock không tự động giải phóng. Chỉ khi có luồng khác cố gắng giành khóa, luồng đang giữ khóa mới nhả khóa. Việc thu hồi biased lock cần đợi đến global safe point (thời điểm không có bytecode nào đang chạy). JVM sẽ tạm dừng luồng đang giữ khóa, kiểm tra xem luồng đó còn hoạt động không:

  • Nếu luồng đã kết thúc: thu hồi khóa về trạng thái không khóa (flag "01") rồi biased sang luồng mới
  • Nếu luồng còn hoạt động: nâng cấp lên lightweight lock (flag "00")

Biased lock được bật mặc định trong JVM 6 trở lên. Có thể tắt bằng tham số: -XX:-UseBiasedLocking=false

Lightweight Lock (Khóa nhẹ)

Khi có cạnh tranh nhưng không quá nhiều, các luồng khác sẽ sử dụng kỹ thuật spin (quay vòng) để thử giành khóa thay vì bị block ngay lập tức, giúp cải thiện hiệu suất.

Điều kiện nâng cấp lên heavyweight lock:

  • Số lần spin vượt quá giới hạn
  • Có nhiều hơn một luồng đang spin đồng thời

Quá trình lấy lightweight lock:

  1. Kiểm tra trạng thái khóa là không khóa (flag "01", biased bit = "0")
  2. Tạo một Lock Record trong stack frame của luồng hiện tại để lưu bản sao Mark Word
  3. Sao chép Mark Word vào Lock Record (Displaced Mark Word)
  4. Sử dụng CAS để cập nhật Mark Word của đối tượng trỏ đến Lock Record. Nếu thành công thì thực thi mã đồng bộ
  5. Nếu thất bại, kiểm tra xem Mark Word có trỏ đến stack frame của luồng hiện tại không (re-entrant). Nếu đúng thì thực thi mã. Nếu có nhiều luồng cạnh tranh, nâng cấp lên heavyweight lock

So sánh các loại khóa

Loại khóa Đặc điểm
Biased Lock Dành cho trường hợp không có cạnh tranh, chỉ một luồng sử dụng
Lightweight Lock Dành cho cạnh tranh nhẹ, sử dụng kỹ thuật spin
Heavyweight Lock Dành cho cạnh tranh nặng, sử dụng Mutex của hệ điều hành

Sự khác biệt chính giữa biased lock và lightweight lock là lightweight lock sử dụng cấu trúc Lock Record và cho phép một mức độ cạnh tranh nhất định.

Đăng vào ngày 13 tháng 6 lúc 23:08