文章目錄
- 前言
- 并發程式設計的三大問題
-
- 原子性問題
- 可見性問題
- 有序性問題
- synchronized的使用
-
- 修飾方法
- 修飾代碼塊
- synchronized的特性
-
- 不可中斷特性
-
- Q:synchronized與Lock的差別
- 可重入性
- synchronized的底層原理
-
- Java對象頭
- Monitor對象
- synchronized代碼塊底層原理
-
- monitorenter指令
- monitorexit指令
- Q:為什麼會有兩個monitorexit指令?
- synchronized方法底層原理
- Q:為什麼說在JDK1.6之前synchronized是重量級鎖?
- synchronized的鎖更新過程
-
- 偏向鎖
-
- 偏向鎖的擷取過程
- 偏向鎖撤銷
- 輕量級鎖
-
- 輕量級鎖的擷取過程
- 自旋鎖
- 自适應自旋
- 重量級鎖
- 使用場景
- 小結
- 鎖優化
-
- 減少鎖的時間
- 減少鎖的粒度
- 鎖粗化
- 鎖消除
- 讀寫分離
前言
在上一篇講解CAS的文章裡提到加鎖開銷很大,那麼為什麼會開銷大呢?這篇文章主要講解的内容是
synchronized
以及
synchronized的底層原理
的過程。
鎖更新
并發程式設計的三大問題
首先我們要知道為什麼要使用加鎖,那是因為在并發程式設計中會出現
synchronized
、
原子性問題
、
可見性問題
,導緻結果不是我們希望的,是以需要進行同步操作,而使用
有序性問題
加鎖是一種保證同步性的方法。下面我們來講解一下并發程式設計的這三大問題。
synchronized
原子性問題
原子性問題指的是在一次或者多次操作中,要麼所有操作都執行,要麼所有操作都不執行。通過下面的例子你可以發現原子性問題。
public class Demo {
static int count;//記錄使用者通路次數
public static void request() throws InterruptedException {
//模拟請求耗時5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
public static void main(String[] args) throws InterruptedException {
//開始時間
long startTime = System.currentTimeMillis();
int threadSize = 100;
//CountDownLatch類就是要保證完成100個使用者請求之後再執行後續的代碼
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//模拟使用者行為,通路10次網站
try{
for (int j = 0; j < 10; j++)
request();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName()+"耗時:"+(endTime-startTime)+",count="+count);
}
}
同時又100個線程,每個線程執行了10次request()方法,對count變量自增,結果卻不是1000,這是因為count++并不是一個原子性操作,通過反編譯可以知道count++包含了四條位元組碼指令,是以多個線程同時操作的時候,線程執行count++會收到其他線程的幹擾。
可見性問題
可見性問題指的是一個線程在通路一個共享變量的時候,其他線程對該共享變量的修改對于第一個線程來說是不可見的,下面通過一個例子可以發現可見性問題。
public class Visable {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while(flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了共享變量flag的值");
}).start();
}
}
在這份代碼中,聲明了一個共享變量
flag
,然後聲明了一個線程一直在讀這個共享變量,另一個線程修改了共享變量,我們運作發現,當另一個線程修改了共享變量之後,第一個線程仍然在循環運作,是以這就是并發程式設計中的可見性問題。
有序性問題
有序性問題指的就是JVM在編譯器和運作期會對執行進行一個重排序,導緻最終程式代碼運作的順序與開發者一開始編寫的順序不一緻,導緻出現有序性問題。
synchronized的使用
synchronized可以修飾方法,也可以修飾代碼塊。
修飾方法
靜态方法
public class Demo{
public static synchronized void request(){
.....
}
}
修飾靜态方法的時候其實是鎖定了
synchronized
的類對象。
Demo
非靜态方法
public class Demo{
public synchronized void request(){
.....
}
}
修飾非靜态方法的時候其實是鎖定了
synchronized
類的執行個體對象(this)。
Demo
修飾代碼塊
public class Demo{
private Object o = new Object();
public static void request(){
synchronized(o) {
...
}
}
public static void request1(){
synchronized(this) {
...
}
}
}
鎖定的其實是
synchronized(o)
,而
o對象
鎖定的其實是
synchronized(this)
對象。
this
synchronized的特性
synchronized主要有兩大特性,分别是和
不可中斷特性
。
可重入特性
不可中斷特性
不可中斷特性指的是,當線程在競争共享資源的時候,如果資源已經上鎖了,那麼線程會阻塞等待,直到鎖釋放,這個阻塞等待過程是不能被中斷的。通過下面的例子可以證明 synchronized
的不可中斷特性。
public class UnBlocked {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (this) {
try {
TimeUnit.MILLISECONDS.sleep(8888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
Thread.sleep(1000);
t2.start();
System.out.println("t2中斷前");
t2.interrupt();
System.out.println("t2中斷後");
System.out.println("t1線程的狀态:"+t1.getState());
System.out.println("t2線程的狀态:"+t2.getState());
}
}
這段代碼中聲明了兩個線程,線程t1一直占用着鎖對象,然後線程t2一直處于阻塞狀态,調用中斷函數也不可中斷線程t2的阻塞狀态,是以這就是
synchronized的不可中斷性
。
Q:synchronized與Lock的差別
提到synchronized的不可中斷特性,不得不提到Lock,對于Lock來說預設是不可中斷的。但是可以調用Lock對象的trylock()方法,可以線上程阻塞等待一段時間之後,自動中斷阻塞等待狀态,這也是synchronized與Lock的一個差別。其次還有Lock可以傳回鎖的狀态,而synchronized是一個無狀态鎖,你是不知道線程的鎖定狀态的。
可重入性
的可重入性指的是同一個線程可以多次擷取同一把鎖,
synchronized
的鎖對象關聯的monitor對象中會有一個可重入計數器,當同一個線程通路該鎖對象,可重入計數器會加一,然後釋放鎖的時候,可重入計數器會減一。
synchronized
synchronized的底層原理
的底層原理與J
synchronized
和
ava對象頭
息息相關,是以先了解一下Java對象頭與Monitor對象的結構,然後再分别講解一下synchronized修飾方法和代碼塊的底層原理。
Monitor對象
Java對象頭
Java對象在JVM記憶體結構的布局分為三部分:
對象頭
、
執行個體資料
和
對齊填充
。
結構 | 說明 |
---|---|
對象頭 | 對象頭又分為 、 和 (可選) |
執行個體資料 | 類中的屬性資料資訊,包括父類的屬性資料資訊 |
對齊填充 | JVM要求對象的起始位址是8的倍數,如果不滿足,使用對齊填充 |
關于synchronized的鎖資訊是存放在對象頭中的
Mark Word
中,是以重點講解一下對象頭,對象頭的結構如下:
對象頭結構 | 說明 |
---|---|
Mark Word | 對象的運作時資料:hashcode、分代年齡、鎖資訊以及GC标記等資訊 |
類型指針 | 通過類型指針可以知道該對象屬于哪個類 |
數組長度(可選) | 如果是數組對象,就代表數組的長度 |
其中與synchronized鎖對象相關的資訊是在
Mark Word(運作時資料)
中的,那我們來看看Mark Word的結構如下:
可以看到在JDK1.6之前,還沒有鎖更新的概念,synchronized是重量級鎖,鎖辨別是
10
,Mark Word中存放了
指向重量級鎖對象monitor的指針
。
Monitor對象
每一個對象都會有一個關聯的
Monitor對象
,synchronized的鎖對象實際上是Monitor對象,是以
synchronized
可以鎖任何對象,Monitor對象對應的底層資料結構是
ObjectMonitor
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處于wait狀态的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀态的線程,會被加入到該清單
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
其中幾個重要的屬性是:
屬性名 | 說明 |
---|---|
_recursions | 代表一個線程的重入次數 |
_owner | 指向擷取到Monitor對象的線程 |
_WaitSet | 處于wait狀态的線程,會被加入到_WaitSet |
_EntryList | 處于等待鎖block狀态的線程,會被加入到_EntryList |
_object | 指向關聯的鎖對象 |
當多個線程通路同一個同步代碼的時候,會先進入
_EntryList區域
,擷取ObjectMonitor對象的線程會進入
_Owner區域
,并且将ObjectMonitor對象的_owner指向目前線程,計數器_count自增1,若線程調用wait()方法,線程會釋放持有的monitor,owner變量恢複為Null,_count自減1,同時該線程進入
_WaitSet
集合中等待被喚醒。若目前線程執行完畢也将釋放monitor對象并複位變量的值,以便其他線程可以進入擷取monitor鎖。如下圖所示:
synchronized代碼塊底層原理
通過反彙編的方式了解synchronized代碼塊的底層原理
首先我們反編譯用synchronized修飾的代碼塊,看看被synchronized修飾的代碼塊的位元組碼指令有什麼不同?
public class Demo1 {
private static Object object = new Object();
private static int count = 0;
public static void main(String[] args) {
synchronized (object) {
count++;
}
}
}
我們發現在count++的四個位元組碼指令外面包裹了一個
monitorenter
和
monitorexit
位元組碼指令。
monitorenter指令
當JVM執行到某個線程中某個方法的monitorenter位元組碼指令的時候,會嘗試去擷取目前對象的對象的所有權,具體過程如下:
monitor
- 若monitor的進入計數器為0,則該線程可以獲得monitor的所有權,将monitor的計數器置為1,目前線程成為monitor的擁有者(owner);
- 若該線程已經擁有了monitor的所有權,可以直接重入,然後monitor的重入計數器自增1;
- 若monitor對象已經被其他線程擁有了,目前嘗試擷取monitor對象的線程會被阻塞,直到monitor的進入數為0時,才能重新嘗試擷取monitor對象。
monitorexit指令
monitorexit指令的執行過程如下:
- 首先執行
指令的線程一定是擁有了
monitorexit
對象的所有權的。
monitor
- 執行
指令會将
monitorexit
對象的可重入數減一,如果可重入數為0,目前線程就會釋放monitor對象,不再擁有monitor對象的所有權。此時其他阻塞等待擷取這個monitor對象的線程就會被喚醒,重新嘗試擷取monitor對象。
monitor
Q:為什麼會有兩個monitorexit指令?
這是因為當執行同步塊代碼過程中發生異常了,會自動釋放鎖,是以第二個monitorexit指令是用于 異常釋放鎖
的。
synchronized方法底層原理
public synchronized void test(){
System.out.println("synchronized修飾方法");
}
通過反編譯可以看到被synchronized修飾的方法test()的位元組碼指令如下:
可以看到被synchronized修飾的方法并沒有
monitorenter
和
monitorexit
位元組碼指令,取而代之的是紅色框裡面的ACC_SYNCHRONIZED辨別,辨別了這是一個同步方法。當方法被調用時,會先檢測同步辨別ACC_SYNCHRONIZED是否被設定了,如果設定了,則執行線程先擷取monitor對象,然後再執行方法,最後方法完成時會釋放monitor對象。在方法執行期間,執行線程持有了monitor對象,其他線程是無法擷取同一個monitor對象的。
Q:為什麼說在JDK1.6之前synchronized是重量級鎖?
JDK1.6通路synchronized修飾的代碼塊或者方法,都會加鎖,首先加鎖的過程就是擷取monitor對象的過程,。其次,
涉及到一些系統調用,是以會有核心态和使用者态切換,就會消耗資源
,是以整體來說JDK1.6之前synchronized是一個重量級鎖。
線程的阻塞和喚醒過程涉及到了線程上下文切換,也是涉及了核心态和使用者态的切換
synchronized的鎖更新過程
synchronized在JDK1.6之後進行了一個優化,根據并發量的不同,經曆了->
無鎖
->
偏向鎖
->
輕量級鎖
->
自旋鎖
這麼一個鎖更新的過程。
重量級鎖
偏向鎖
偏向鎖,顧名思義就是偏向第一個擷取到鎖對象的線程,并且在運作過程中,隻有一個線程會通路同步代碼塊,不會存在多線程競争,這種情況下加的就是 偏向鎖
。
偏向鎖的擷取過程
- 判斷對象的MarkWord。
- 判斷MarkWord中是否開啟偏向鎖模式。
- 如果為可偏向狀态,判斷MarkWord中的ThreadID是否為空,如果為空,則通過CAS操作設定成目前線程的線程ID,然後執行步驟5。否則執行步驟4。
- 如果不為空,則判斷是否是目前線程ID,如果是則直接執行同步代碼塊,如果不是,則存在鎖競争,等到全局安全點的時候撤銷偏向鎖,将鎖标記設定為無鎖或者輕量級鎖狀态。
- 執行同步代碼塊。
偏向鎖撤銷
- 偏向鎖的撤銷必須等到全局安全點,指的是沒有位元組碼執行執行的時刻。
- 暫停持有偏向鎖的線程,判斷鎖對象是否處于被鎖狀态。
- 撤銷偏向鎖,恢複到無鎖或者更新到輕量級鎖。
偏向鎖撤銷的過程中,在全局安全點的時候,會STW(STOP THE WORLD),如果确定應用程式中存在鎖競争,可以通過設定參數-XX:-UseBiasedLocking=false來關閉偏向鎖模式,減少額外的開銷
。
輕量級鎖
在偏向鎖狀态下,當有另一個線程嘗試進入同步代碼塊的時候,就會存在鎖競争,此時偏向鎖就會更新為輕量級鎖。
輕量級鎖的擷取過程
- 擷取MarkWord對象。如果同步對象鎖的狀态為無鎖狀态,首先在目前線程的棧幀中建立一個鎖記錄(Lock Record)。這時候棧幀和MarkWord的狀态如圖:
- 拷貝對象頭的MarkWord複制到鎖記錄中的displaced hdr字段。
- 拷貝成功之後通過CAS操作讓MarkWord更新為指向Lock Record的指針,并将Lock Record中的owner指針指向MarkWord對象。如果更新成功則執行步驟4,否則執行步驟5。
- 如果更新成功,則線程擁有了鎖對象,執行同步代碼塊
- 如果更新不成功,判斷MarkWord中是否指向目前棧幀中的Lock Record,如果是則直接執行同步代碼塊,如果不是,則存在鎖同時競争,輕量級鎖就要更新為重量級鎖。
自旋鎖
如果存在多個線程同時競争輕量級鎖的時候,會膨脹更新為重量級鎖,但是對于目前線程來說,會嘗試自旋來擷取鎖,而不會立刻就阻塞等待,自旋鎖其實就是采用循環去擷取鎖的一個過程。
可以通過參數
來開啟自旋鎖。如果一直自旋的話,就會消耗完CPU資源,是以一般是自旋超過一定次數就會退出自旋模式,而進入重量級鎖。可以通過設定參數
-XX:+UseSpinning
來更改自旋預設次數。
-XX:PreBlockSpin
自适應自旋
在JDK1.6中對自旋鎖進行了優化,如果一個在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運作中,那麼就會延長自旋的次數。如果對于某個鎖,自旋很少成功獲得過鎖,則會直接忽略掉自旋過程,避免浪費CPU資源。
重量級鎖
重量級鎖指的就是擷取monitor對象的過程,需要調用作業系統的底層函數,是以涉及到核心态和使用者态的切換,切換成本非常高。
使用場景
偏向鎖:适用于沒有線程競争資源的場景。
輕量級鎖:适用于多個線程不同時刻競争資源的場景。
重量級鎖:适用于多個線程同時競争資源的場景。
小結
是以synchronized的鎖更新過程為
- 檢測Mark Word裡面是不是目前線程的ID,如果是,表示目前線程處于偏向鎖
- 如果不是,則使用CAS将目前線程的ID替換Mard Word,如果成功則表示目前線程獲得偏向鎖,置偏向标志位1
- 如果失敗,則說明發生競争,撤銷偏向鎖,進而更新為輕量級鎖。
- 目前線程使用CAS将對象頭的Mark Word替換為鎖記錄指針,如果成功,目前線程獲得鎖
- 如果失敗,表示其他線程競争鎖,目前線程便嘗試使用自旋來擷取鎖。
- 如果自旋成功則依然處于輕量級狀态。
- 如果自旋失敗,則更新為重量級鎖。
鎖優化
上面介紹了synchronized在JDK1.6之後的優化過程,進行了一個鎖更新過程。那麼我們在寫多線程代碼的時候也可以借鑒synchronized鎖優化中的一些思想來優化我們的代碼
減少鎖的時間
不需要同步執行的代碼,不要放到同步代碼塊中,可以讓鎖盡快釋放。
減少鎖的粒度
減少鎖的粒度的思想就是将一把鎖拆分成多把鎖,增加了并發度,減少了鎖競争,ConcurrentHashMap在JDK1.8之前就是采用了”分段鎖“的思想,還有LongAddr也是采用了”分段鎖“的思想,降低了鎖的粒度,提高了并發度。
鎖粗化
如果同步代碼塊中包含循環,應該将鎖放在循環以外,不然循環每次都會進入共享資源,效率會降低。
鎖消除
鎖消除其實就是對于同步代碼塊執行時間不長,并且并發量不大的情況下,可以采用CAS等樂觀鎖來進行同步操作,減少了加鎖釋放鎖帶來的開銷。
讀寫分離
讀寫分離指的是,寫的時候可以複制一份資料,然後寫操作可以加鎖,讀操作就讀原來的資料,比如CopyOnWriteArrayList集合類采用的就是讀寫分離的方式來解決并發安全的問題。