Chúng ta đều biết rằng trong lập trình Java, đồng bộ hóa đa luồng được đánh dấu bằng từ khóa synchronized, vậy từ khóa này được triển khai ở cuối JVM như thế nào? Trước tiên, hãy nghĩ xem chúng ta sẽ làm gì nếu tự mình triển khai khóa:
- Trước hết, phải có một dấu hiệu để ghi lại xem đối tượng đã bị khóa chưa. Cờ này phải được đánh giá trước khi thực hiện mã đồng bộ hóa. Nếu đối tượng đã bị khóa, luồng sẽ bị chặn chờ khóa được giải phóng.
- Thứ hai, phải có một cấu trúc để duy trì các luồng chờ này. Sau khi khóa được giải phóng, các luồng này được duyệt qua để cho phép chúng nắm được khóa.
Đầu tiên, Java sử dụng tiêu đề đối tượng để duy trì trạng thái khóa của đối tượng. Thứ hai, Java sử dụng ObjectMonitor để duy trì các luồng đang chờ và các luồng giữ khóa.
Tiêu đề đối tượng
Trạng thái khóa được ghi lại trong tiêu đề đối tượng. Hiện tại có ba trạng thái khóa trong Java: khóa thiên vị, khóa nhẹ và khóa nặng. Khóa heavyweight được sử dụng để liên kết với ObjectMonitor. Lúc đầu, Java chỉ có khóa heavyweight, nhưng khóa heavyweight cần chặn luồng khi có tranh chấp khóa và đồng thời, chúng cần hoạt động trên cấu trúc dữ liệu của ObjectMonitor, tốn nhiều hiệu suất hơn. Sau đó, để cải thiện hiệu suất của khóa, Java đã giới thiệu khóa thiên vị và khóa nhẹ. Cần lưu ý rằng khóa thiên vị và khóa nhẹ không liên quan gì đến ObjectMonitor, điều này sẽ được mô tả chi tiết sau.
Đối tượng giám sát
Java sẽ gán một đối tượng ObjectMonitor cho mỗi đối tượng và đối tượng Class của đối tượng. Đây là một cấu trúc C++. ObjectMonitor được sử dụng để duy trì luồng hiện đang giữ khóa, danh sách luồng đang chặn chờ khóa được giải phóng và danh sách luồng đang gọi đang chặn chờ thông báo. Tôi sẽ không đi sâu vào quá nhiều chi tiết ở đây. Bạn có thể tìm kiếm các blog khác để biết logic bảo trì cụ thể.
//Cấu trúc như sauObjectMonitor::ObjectMonitor() { _header = NULL; _count = 0; _waiters = 0, _recursions = 0; //Số lần nhập lại của thread_object = NULL; _owner = NULL; //Xác định luồng sở hữu monitor_WaitSet = NULL; //Danh sách liên kết vòng hai chiều bao gồm các luồng đang chờ, _WaitSet là node đầu tiên_WaitSetLock = 0; _Responsible = NULL; _succ = NULL; _cxq = NULL; //Danh sách liên kết một chiều khi khóa cạnh tranh đa luồng enterFreeNext = NULL; _EntryList = NULL; //_owner đánh thức nút luồng từ danh sách liên kết vòng hai chiều, _EntryList là node đầu tiên_SpinFreq = 0; _SpinClock = 0; OwnerIsThread = 0; }
Logic khóa trong Java
Sau đây là mô tả về logic khóa của từ khóa synchronized trong Java. Có nhiều chi tiết ở đây, vì vậy chúng tôi sẽ chỉ mô tả quy trình chung. Đồng thời, chúng ta cũng phải chú ý đến những thay đổi trong mã băm được lưu trữ trong tiêu đề đối tượng. Khi đối tượng được tạo lần đầu tiên, mã băm trong tiêu đề đối tượng chưa được tạo. Mã băm sẽ chỉ được lưu trữ trong tiêu đề đối tượng khi chương trình gọi phương thức mã băm. Điều này đảm bảo rằng bất kể thuật toán mã băm nào được sử dụng, mã băm của cùng một đối tượng sẽ không thay đổi trong suốt vòng đời của nó. Cần nhấn mạnh ở đây rằng nếu đối tượng đang ở trạng thái khóa heavyweight, nó không thể nhập lại trạng thái khóa lightweight. Nếu đối tượng đang ở trạng thái khóa lightweight, nó không thể nhập lại trạng thái khóa biased. Bạn chỉ có thể đợi cho đến khi vật thể chuyển sang trạng thái mở khóa trước khi đưa ra phán đoán tiếp theo.
Khóa lệch
Khi chương trình Java thực thi theo mã được đồng bộ hóa, logic khóa thiên vị như sau:
- Kiểm tra xem mã băm trong tiêu đề đối tượng có được tạo không. Các đối tượng đã tạo mã băm không thể nhập khóa thiên vị (điều này là do không có nơi nào để sao lưu mã băm khi khóa thiên vị được thiết kế).
- Kiểm tra xem cờ khóa trong tiêu đề đối tượng có phải là 01 không. Nếu không, điều đó có nghĩa là đối tượng đang ở trạng thái khóa khác, sau đó thực thi logic của các khóa khác.
- Nếu ID luồng của khóa bị sai lệch là ID luồng của chính nó, khối mã đồng bộ hóa sẽ được thực thi trực tiếp, cho biết luồng này đã có được khóa.
- Nếu ID khóa bias không phải là ID luồng của chính nó, hãy thử ID luồng khóa bias thông qua thuật toán CAS. Nếu thành công, khóa sẽ được lấy và mã đồng bộ hóa sẽ được thực thi trực tiếp. Nếu không thành công, điều đó có nghĩa là một luồng đã có được khóa thiên vị. Vào thời điểm này, luồng sẽ yêu cầu luồng giữ khóa giải phóng khóa.
- Nếu luồng giữ khóa vẫn còn trong mã đồng bộ hóa, khóa không thể được giải phóng và khóa sẽ mở rộng thành khóa nhẹ. Khi mở rộng, tiêu đề đối tượng sẽ được sửa đổi thành khóa nhẹ.
- Sau khi mã đồng bộ hóa được thực thi, luồng sẽ không thiết lập lại dữ liệu trong tiêu đề đối tượng, nghĩa là nó sẽ không giải phóng khóa, do đó mã đồng bộ hóa có thể được nhập trực tiếp vào lần thực thi tiếp theo.
Chúng ta có thể thấy rằng nếu một phần mã đồng bộ hóa luôn được thực thi bởi một luồng, luồng này chỉ cần đưa ra các phán đoán đơn giản trong 2 và 3 để tiếp tục thực thi mã đồng bộ hóa. Mức tiêu thụ hiệu suất ban đầu chỉ là nhu cầu sửa đổi tiêu đề đối tượng khi khóa nó lần đầu tiên. Đây chính là vai trò của khóa thiên vị, có thể cải thiện đáng kể hiệu quả của khóa đồng bộ. Tuy nhiên, vì logic cơ bản để triển khai khóa thiên vị quá phức tạp nên khóa thiên vị đã bị tắt theo mặc định kể từ JDK15. Trong các chương trình hiện đại, rất hiếm khi cùng một luồng luôn giữ khóa. Để biết quy trình chuyển khóa cụ thể, vui lòng tham khảo blog này "Hiểu sâu hơn về khóa lệch".
Khóa nhẹ
Khi chương trình Java thực thi mã được đồng bộ hóa, logic của khóa nhẹ như sau:
- Kiểm tra xem cờ khóa tiêu đề đối tượng có phải là 01 không và sao chép tiêu đề đối tượng vào ngăn xếp để sao lưu
- Hãy thử sử dụng thuật toán CAS để sửa đổi tiêu đề đối tượng (ở đây là để ngăn các luồng khác sửa đổi tiêu đề đối tượng cùng lúc với luồng hiện tại để lấy khóa). Tại thời điểm này, tiêu đề đối tượng trỏ đến địa chỉ ngăn xếp hiện tại. Nếu sửa đổi thành công, khóa sẽ được lấy để thực thi mã đồng bộ hóa.
- Nếu sửa đổi không thành công, điều đó có nghĩa là các luồng khác đã lấy được khóa trước. Luồng hiện tại quay (vòng lặp) để lấy khóa. Nếu không thể lấy được khóa sau một số lần nhất định, khóa sẽ mở rộng thành khóa nặng. Trong quá trình mở rộng, tiêu đề đối tượng và cấu trúc dữ liệu của bảo trì ObjectMonitor sẽ được sửa đổi.
- Sau khi mã đồng bộ hóa được thực thi, CAS sẽ ghi tiêu đề đối tượng đã sao lưu trở lại tiêu đề đối tượng. Nếu việc sửa đổi không thành công, điều đó có nghĩa là khóa đã mở rộng thành khóa nặng và logic giải phóng khóa của khóa nặng được thực thi.
Chúng ta có thể thấy rằng nếu khóa nhẹ có mức độ tranh chấp khóa thấp (ít luồng hơn và thực thi chương trình đồng bộ nhanh hơn), luồng không cần phải chuyển sang trạng thái bị chặn và có thể đợi khóa được giải phóng bằng cách quay. Đồng thời, khóa nhẹ không cần phải duy trì dữ liệu ObjectMonitor, giúp cải thiện hiệu suất hơn nữa.
Khóa nặng
Vì heavyweight lock cần duy trì ObjectMonitor nên hiệu suất của chúng không tốt bằng lightweight lock. Lightweight lock chỉ cần sửa đổi object header, trong khi heavyweight lock không chỉ cần sửa đổi object header mà còn cần duy trì cấu trúc dữ liệu của ObjectMonitor. Khi chương trình Java thực thi mã được đồng bộ hóa, logic của khóa nặng như sau:
- Đối tượng ObjectMonitor được tìm thấy thông qua địa chỉ tham chiếu của ObjectMonitor trong tiêu đề đối tượng. Lúc này, ObjectMonitor lưu trữ bản sao lưu của tiêu đề đối tượng ở trạng thái mở khóa.
- Xác định xem _owner có phải là luồng hiện tại không. Nếu không, điều đó có nghĩa là khóa được giữ bởi các luồng khác. Trong trường hợp này, luồng hiện tại bị chặn (logic chặn phải giống với logic của LockSupport.park()) và luồng hiện tại được thêm vào danh sách chặn.
- Nếu _owner là luồng hiện tại, _recursions sẽ tăng thêm 1 để ghi lại số lần nhập lại (ví dụ: khóa sẽ được lấy nhiều lần trong quá trình đệ quy) và mã đồng bộ hóa sẽ được thực thi.
- Sau khi mã đồng bộ hóa được thực thi, _recursions sẽ giảm đi 1 (vì khóa nặng là khóa có thể nhập lại và có thể thoát nhiều lần khi thoát) và các luồng trong danh sách chặn sẽ được đánh thức để lấy khóa. Nếu không có luồng nào đang chờ, hãy sửa đổi tiêu đề đối tượng để ở trạng thái không khóa và ghi dữ liệu tiêu đề đối tượng đã sao lưu trở lại tiêu đề đối tượng. Lưu ý ở đây rằng nếu phương thức hascode được gọi trong khi giữ khóa, dữ liệu trong tiêu đề đối tượng được sao lưu cũng phải được sửa đổi.
Chúng ta có thể thấy rằng hiệu suất của heavyweight lock không cao vì chúng cần duy trì ObjectMonitor. Nếu đối tượng luôn có thể ở trạng thái khóa lightweight lock, hiệu suất sẽ được cải thiện đáng kể. Ngoài ra, lưu ý rằng khi bạn gọi lệnh wait trong mã đồng bộ, khóa nhẹ cần được mở rộng thành khóa nặng vì hàng đợi luồng wait cần được duy trì. Khi bạn gọi phương thức hashcode, khóa thiên vị sẽ mở rộng thành khóa nhẹ. Để biết quy trình chuyển khóa cụ thể, vui lòng tham khảo blog này "Hiểu sâu hơn về khóa lệch".
Nhưng tôi có một câu hỏi ở đây, đó là, ObjectMonitor được liên kết với đối tượng như thế nào, nghĩa là, khi heavyweight lock sửa đổi tiêu đề đối tượng, thì địa chỉ bộ nhớ của đối tượng ObjectMonitor tương ứng với đối tượng được tìm thấy như thế nào? Có ObjectMonitor Map được duy trì ở phía dưới không? Tôi đã kiểm tra một số thông tin và sách nhưng không có lời giải thích nào.
Tóm tắt
Chúng ta có thể thấy rằng khi gặp một khối mã được đồng bộ hóa, tiêu đề đối tượng có thể ở ba trạng thái: khóa thiên vị, khóa nhẹ và khóa nặng. Ba khóa này có đặc điểm riêng.
Khóa |
Thuận lợi |
Nhược điểm |
Cảnh kích hoạt |
Khóa lệch |
Chỉ cần sửa đổi tiêu đề đối tượng một lần |
Không hỗ trợ phương thức gọi hashcode. Nếu có sự cạnh tranh luồng, cần phải hủy khóa bổ sung, khiến việc bảo trì mã cơ bản trở nên khó khăn. |
Một luồng duy nhất liên tục giữ khóa trong một thời gian dài |
Khóa nhẹ |
Quay không cần chặn luồng, giảm việc chuyển đổi ngữ cảnh luồng |
Nếu không thể lấy được khóa, việc quay vòng sẽ tiêu tốn tài nguyên CPU (đây không phải là nhược điểm. Trong điều kiện đồng thời cao, đối tượng sẽ luôn ở trạng thái khóa nặng và logic của khóa nặng có thể được thực thi) |
Một số lượng nhỏ các luồng giữ khóa xen kẽ |
Khóa nặng |
Bạn có thể thực hiện các thao tác như chờ |
Luồng sẽ bị chặn và ObjectMonitor cần được duy trì, điều này có hiệu suất thấp. |
Một số lượng lớn các luồng đang cạnh tranh để khóa cùng một lúc |
Rốt cuộc, không có nhiều tình huống mà một số lượng lớn các luồng cạnh tranh để khóa cùng một lúc. Nếu đối tượng luôn ở trạng thái khóa nhẹ, hiệu suất khóa đã rất cao và hiệu suất gần giống với Lock trong JDK, vì lớp cơ bản của Lock cũng sử dụng thuật toán CAS để duy trì trạng thái khóa.
Sách tham khảo cho bài viết này:
- Cuốn sách "The Art of Concurrency Programming in Java" rất đáng đọc vì nó giải thích chi tiết các nguyên tắc cơ bản.
Cuối cùng, bài viết này về logic của Java locks (kết hợp object headers và ObjectMonitor) kết thúc tại đây. Nếu bạn muốn biết thêm về logic của Java locks (kết hợp object headers và ObjectMonitor), vui lòng tìm kiếm các bài viết trên CFSDN hoặc tiếp tục duyệt các bài viết liên quan. Tôi hy vọng bạn sẽ ủng hộ blog của tôi trong tương lai! .
Tôi là một lập trình viên xuất sắc, rất giỏi!