前言
說起Java面試中最高頻的知識點非多線程莫屬。每每提起多線程都繞不過一個Java關鍵字——synchronized
。我們都知道該關鍵字可以保證在同一時刻,只有一個線程可以執行某個方法或者某個代碼塊以保證多線程的安全性。那么,本篇文章我們就來揭開這個synchronized
的面紗。
線程安全的實現方法
在詳細介紹synchronized
之前,我們首先了解一下實現線程安全的不同方式,了解synchronized
是如何實現線程安全的理論基礎,做到心中有數。目前主要有三種線程安全實現方法:互斥同步(阻塞同步)、非阻塞同步以及無需同步的線程安全方案。
- 互斥同步(Mutual Exclusion & Synchnronization)
互斥同步是指在多個線程並發訪問共享數據時,保證共享數據在同一時刻只被一個(或一些,使用信號量的時候)線程使用。而互斥是實現同步的一種手段,臨界區、互斥量和信號量都是主要的互斥實現方式。因此在互斥同步四個字中,互斥是因,同步是果;互斥是方法,同步是目的。
Java中最基本的互斥同步手段就是synchronized
,具體如何實現的互斥同步請繼續往下看。
btw,除了synchronized
,還有另外一種實現同步的方式,那就是java.util.concurrent
包中的重入鎖ReentrantLock
,具體細節就不細說了,它和synchronized
用法幾乎一樣。只是synchronized
是原生語法,而ReentrantLock
是JDK提供的API層面的互斥鎖。
- 非阻塞同步
互斥同步主要同步阻塞線程來保證線程安全,因此也被稱為阻塞同步。它認為只要不去做正確的同步方式(例如加鎖),那就一定會出現問題,無論共享數據是否會出現競爭(悲觀鎖)。
回來隨着硬件指令集的發展,我們有了另外一種選擇:先進行操作,如果沒有其他線程爭用,那操作就成功了;如果有其他線程爭用,產生了沖突,那就再采取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止)。這種樂觀的並發策略的許多實現都不需要把線程掛起,所以這種同步方式成為非阻塞同步。
- 無需同步的線程安全方案
要保證線程安全,並不一定就要進行同步,兩者並沒有因果關系。如果一個方法本來就不涉及共享數據,那它自然無需任何同步手段去保證正確性,因此會有一些代碼天生線程安全。比如可重入代碼(Reentrant Code)和線程本地存儲(Thread Local Storage)等。
JDK中的synchronized改進
在 JDK1.5 之前,Java 是依靠 Synchronized 關鍵字實現鎖功能來做到線程安全。Synchronized 是 JVM 實現的一種內置鎖,鎖的獲取和釋放是由 JVM 隱式實現。
到了 JDK1.5 版本,java.util.concurrent
包中新增了 Lock 接口來實現鎖功能,它提供了與 Synchronized 關鍵字類似的同步功能,只是在使用時需要顯示獲取和釋放鎖。前邊我們提到過,Lock 同步鎖是基於 Java 實現的,而 Synchronized 是基於底層操作系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來用戶態和內核態的切換,從而增加系統性能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在性能上就表現得非常糟糕,它也常被大家稱為重量級鎖。特別是在單個線程重復申請鎖的情況下,JDK1.5 版本的 Synchronized 鎖性能要比 Lock 的性能差很多。例如,在 Dubbo 基於 Netty 實現的通信中,消費端向服務端通信之后,由於接收返回消息是異步,所以需要一個線程輪詢監聽返回信息。而在接收消息時,就需要用到鎖來確保 request session 的原子性。如果我們這里使用 Synchronized 同步鎖,那么每當同一個線程請求鎖資源時,都會發生一次用戶態和內核態的切換。
到了 JDK1.6 版本之后,Java 對 Synchronized 同步鎖做了充分的優化,甚至在某些場景下,它的性能已經超越了 Lock 同步鎖。
synchronized使用方式
Java中萬物皆對象,而每一個對象都可以加鎖,這是synchronized
保證線程安全的基礎。
-
對於同步方法,鎖是當前實例對象,即
this
,對該類其他實例對象無影響。 -
對於靜態同步方法,鎖是當前對象的 Class 對象, 影響其他該類的實例化對象。
-
對於同步方法塊,鎖是
synchronized
括號里配置的對象。
也就是說,我們可以利用synchronized
修飾類,類中的方法或者方法塊。如下面的代碼,分別對應上述三種情形。
public class synchronizedTest implements Runnable {
static synchronizedTest instance=new synchronizedTest();
public void run() {
synchronized(instance){
//同步代碼塊,對應文章中第3點
//*******
}
}
void synchronized method1() {} //類中的同步方法 對應文章中第1點
void static synchronized method2() {} ////類中靜態同步方法 對應文章中第2點
}
同步方法塊
當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。那么鎖存在哪里呢?鎖里面會存儲什么信息呢?我們先來看一段代碼以及它的字節碼(我這里用的Idea的jclasslib插件)。
package techgo.blog;
public class SynchronizedTest {
private int i = 0;
public void fun() {
synchronized (this) {
i ++;
}
}
}
我們看到monitorenter和monitorexit,之后查閱虛擬機字節碼指令表,我們知道這兩個字節碼操作分別表示獲得和釋放對象的鎖。進入 monitorenter 指令后,線程將持有 Monitor 對象,退出 monitorenter 指令后,線程將釋放該 Monitor 對象。以上這是同步方法塊的實現方式。
同步方法
對於同步方法來說,如果去查看其字節碼,我們會看不到這兩個指令,因為同步方法依靠的是方法修飾符上的ACC_SYNCHRONIZED來實現的:
public synchronized void fun1() {
}
當方法調用時,調用指令將會檢查該方法是否被設置 ACC_SYNCHRONIZED 訪問標志。如果設置了該標志,執行線程將先持有 Monitor 對象,然后再執行方法。在該方法運行期間,其它線程將無法獲取到該 Mointor 對象,當方法執行完成后,再釋放該 Monitor 對象。
synchronized鎖的實現
synchronized的對象鎖,其指針指向的是一個monitor對象(由C++實現)的起始地址。每個對象實例都會有一個 monitor。其中monitor可以與對象一起創建、銷毀;亦或者當線程試圖獲取對象鎖時自動生成。需要注意的是monitor不是Java特有的概念,想了解更多monitor的詳細介紹可以查看這篇文章。
在HotSpot虛擬機中,最終采用ObjectMonitor類實現monitor。
openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp源碼如下:
ObjectMonitor() {
_count = 0;
_owner = NULL;//指向獲得ObjectMonitor對象的線程或基礎鎖
_EntryList = NULL ;//處於等待鎖block狀態的線程,會被加入到entry set;
_WaitSet = NULL;//處於wait狀態的線程,會被加入到wait set;
_WaitSetLock = 0 ;
_header = NULL;//markOop對象頭
_waiters = 0,//等待線程數
_recursions = 0;//重入次數
_object = NULL;//監視器鎖寄生的對象。鎖不是平白出現的,而是寄托存儲於對象中。
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock
_previous_owner_tid = 0;// 監視器前一個擁有者線程的ID
}
當多個線程同時訪問一段同步代碼時,多個線程會先被存放在 ContentionList 和 _EntryList 集合中,處於 block 狀態的線程,都會被加入到該列表。接下來當線程獲取到對象的 Monitor 時,Monitor 是依靠底層操作系統的 Mutex Lock 來實現互斥的,線程申請 Mutex 成功,則持有該 Mutex,其它線程將無法獲取到該 Mutex,競爭失敗的線程會再次進入 ContentionList 被掛起。
如果線程調用 wait() 方法,就會釋放當前持有的 Mutex,並且該線程會進入 WaitSet 集合中,等待下一次被喚醒。如果當前線程順利執行完方法,也將釋放 Mutex。
繼續深入(鎖優化)
我們都知道,對象被創建在堆中。並且對象在內存中的存儲布局方式可以分為3塊區域:對象頭、實例數據、對齊填充。
對於對象頭來說,主要是包括倆部分信息Mark Word和Klass Point:
- Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程 ID、偏向時間戳等等。Java對象頭一般占有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit),但是如果對象是數組類型,則需要三個機器碼,因為JVM虛擬機可以通過Java對象的元數據信息確定Java對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。
- 另一部分是類型指針Klass Point:JVM通過這個指針來確定這個對象是哪個類的實例。
鎖升級功能主要依賴於 Mark Word 中的鎖標志位和釋放偏向鎖標志位,Synchronized 同步鎖就是從偏向鎖開始的,隨着競爭越來越激烈,偏向鎖升級到輕量級鎖,最終升級到重量級鎖。好了今天就先到這了,鎖優化的細節還在碼字中。。
參考資料:
《深入理解Java虛擬機》 第二版
https://blog.csdn.net/wangyadong317/article/details/84065828
https://blog.csdn.net/zjy15203167987/article/details/82531772
https://www.cnblogs.com/JsonShare/p/11433302.html
https://baijiahao.baidu.com/s?id=1612142459503895416&wfr=spider&for=pc
https://www.php.cn/java-article-410323.html
本文由博客一文多發平台 OpenWrite 發布!
文章首發:https://zhuanlan.zhihu.com/lovebell
個人公眾號:技術Go
您的點贊與支持是作者持續更新的最大動力!