天天看點

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

文章目錄

  • 前言
  • 并發程式設計的三大問題
    • 原子性問題
    • 可見性問題
    • 有序性問題
  • 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++會收到其他線程的幹擾。

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

可見性問題

可見性問題指的是一個線程在通路一個共享變量的時候,其他線程對該共享變量的修改對于第一個線程來說是不可見的,下面通過一個例子可以發現可見性問題。
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

修飾非靜态方法的時候其實是鎖定了

Demo

類的執行個體對象(this)。

修飾代碼塊

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());
    }
}
           
一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

這段代碼中聲明了兩個線程,線程t1一直占用着鎖對象,然後線程t2一直處于阻塞狀态,調用中斷函數也不可中斷線程t2的阻塞狀态,是以這就是

synchronized的不可中斷性

Q:synchronized與Lock的差別

提到synchronized的不可中斷特性,不得不提到Lock,對于Lock來說預設是不可中斷的。但是可以調用Lock對象的trylock()方法,可以線上程阻塞等待一段時間之後,自動中斷阻塞等待狀态,這也是synchronized與Lock的一個差別。其次還有Lock可以傳回鎖的狀态,而synchronized是一個無狀态鎖,你是不知道線程的鎖定狀态的。

可重入性

synchronized

的可重入性指的是同一個線程可以多次擷取同一把鎖,

synchronized

的鎖對象關聯的monitor對象中會有一個可重入計數器,當同一個線程通路該鎖對象,可重入計數器會加一,然後釋放鎖的時候,可重入計數器會減一。

synchronized的底層原理

synchronized

的底層原理與J

ava對象頭

Monitor對象

息息相關,是以先了解一下Java對象頭與Monitor對象的結構,然後再分别講解一下synchronized修飾方法和代碼塊的底層原理。

Java對象頭

Java對象在JVM記憶體結構的布局分為三部分:

對象頭

執行個體資料

對齊填充

結構 說明
對象頭 對象頭又分為

Mark Word

類型指針

數組長度

(可選)
執行個體資料 類中的屬性資料資訊,包括父類的屬性資料資訊
對齊填充 JVM要求對象的起始位址是8的倍數,如果不滿足,使用對齊填充

關于synchronized的鎖資訊是存放在對象頭中的

Mark Word

中,是以重點講解一下對象頭,對象頭的結構如下:

對象頭結構 說明
Mark Word 對象的運作時資料:hashcode、分代年齡、鎖資訊以及GC标記等資訊
類型指針 通過類型指針可以知道該對象屬于哪個類
數組長度(可選) 如果是數組對象,就代表數組的長度

其中與synchronized鎖對象相關的資訊是在

Mark Word(運作時資料)

中的,那我們來看看Mark Word的結構如下:

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

可以看到在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的底層原理synchronized的鎖更新過程鎖優化

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++;
        }
    }
}
           
一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

我們發現在count++的四個位元組碼指令外面包裹了一個

monitorenter

monitorexit

位元組碼指令。

monitorenter指令

當JVM執行到某個線程中某個方法的monitorenter位元組碼指令的時候,會嘗試去擷取目前對象的

monitor

對象的所有權,具體過程如下:
  1. 若monitor的進入計數器為0,則該線程可以獲得monitor的所有權,将monitor的計數器置為1,目前線程成為monitor的擁有者(owner);
  2. 若該線程已經擁有了monitor的所有權,可以直接重入,然後monitor的重入計數器自增1;
  3. 若monitor對象已經被其他線程擁有了,目前嘗試擷取monitor對象的線程會被阻塞,直到monitor的進入數為0時,才能重新嘗試擷取monitor對象。

monitorexit指令

monitorexit指令的執行過程如下:
  1. 首先執行

    monitorexit

    指令的線程一定是擁有了

    monitor

    對象的所有權的。
  2. 執行

    monitorexit

    指令會将

    monitor

    對象的可重入數減一,如果可重入數為0,目前線程就會釋放monitor對象,不再擁有monitor對象的所有權。此時其他阻塞等待擷取這個monitor對象的線程就會被喚醒,重新嘗試擷取monitor對象。

Q:為什麼會有兩個monitorexit指令?

這是因為當執行同步塊代碼過程中發生異常了,會自動釋放鎖,是以第二個monitorexit指令是用于

異常釋放鎖

的。

synchronized方法底層原理

public synchronized void test(){
        System.out.println("synchronized修飾方法");
}
           

通過反編譯可以看到被synchronized修飾的方法test()的位元組碼指令如下:

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化

可以看到被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之後進行了一個優化,根據并發量的不同,經曆了

無鎖

->

偏向鎖

->

輕量級鎖

->

自旋鎖

->

重量級鎖

這麼一個鎖更新的過程。

偏向鎖

偏向鎖,顧名思義就是偏向第一個擷取到鎖對象的線程,并且在運作過程中,隻有一個線程會通路同步代碼塊,不會存在多線程競争,這種情況下加的就是

偏向鎖

偏向鎖的擷取過程

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化
  1. 判斷對象的MarkWord。
  2. 判斷MarkWord中是否開啟偏向鎖模式。
  3. 如果為可偏向狀态,判斷MarkWord中的ThreadID是否為空,如果為空,則通過CAS操作設定成目前線程的線程ID,然後執行步驟5。否則執行步驟4。
  4. 如果不為空,則判斷是否是目前線程ID,如果是則直接執行同步代碼塊,如果不是,則存在鎖競争,等到全局安全點的時候撤銷偏向鎖,将鎖标記設定為無鎖或者輕量級鎖狀态。
  5. 執行同步代碼塊。

偏向鎖撤銷

  1. 偏向鎖的撤銷必須等到全局安全點,指的是沒有位元組碼執行執行的時刻。
  2. 暫停持有偏向鎖的線程,判斷鎖對象是否處于被鎖狀态。
  3. 撤銷偏向鎖,恢複到無鎖或者更新到輕量級鎖。

偏向鎖撤銷的過程中,在全局安全點的時候,會STW(STOP THE WORLD),如果确定應用程式中存在鎖競争,可以通過設定參數-XX:-UseBiasedLocking=false來關閉偏向鎖模式,減少額外的開銷

輕量級鎖

在偏向鎖狀态下,當有另一個線程嘗試進入同步代碼塊的時候,就會存在鎖競争,此時偏向鎖就會更新為輕量級鎖。

輕量級鎖的擷取過程

一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化
  1. 擷取MarkWord對象。如果同步對象鎖的狀态為無鎖狀态,首先在目前線程的棧幀中建立一個鎖記錄(Lock Record)。這時候棧幀和MarkWord的狀态如圖:
    一層一層揭開synchronized的神秘面紗前言并發程式設計的三大問題synchronized的使用synchronized的特性synchronized的底層原理synchronized的鎖更新過程鎖優化
  2. 拷貝對象頭的MarkWord複制到鎖記錄中的displaced hdr字段。
  3. 拷貝成功之後通過CAS操作讓MarkWord更新為指向Lock Record的指針,并将Lock Record中的owner指針指向MarkWord對象。如果更新成功則執行步驟4,否則執行步驟5。
  4. 如果更新成功,則線程擁有了鎖對象,執行同步代碼塊
  5. 如果更新不成功,判斷MarkWord中是否指向目前棧幀中的Lock Record,如果是則直接執行同步代碼塊,如果不是,則存在鎖同時競争,輕量級鎖就要更新為重量級鎖。

自旋鎖

如果存在多個線程同時競争輕量級鎖的時候,會膨脹更新為重量級鎖,但是對于目前線程來說,會嘗試自旋來擷取鎖,而不會立刻就阻塞等待,自旋鎖其實就是采用循環去擷取鎖的一個過程。

可以通過參數

-XX:+UseSpinning

來開啟自旋鎖。如果一直自旋的話,就會消耗完CPU資源,是以一般是自旋超過一定次數就會退出自旋模式,而進入重量級鎖。可以通過設定參數

-XX:PreBlockSpin

來更改自旋預設次數。

自适應自旋

在JDK1.6中對自旋鎖進行了優化,如果一個在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運作中,那麼就會延長自旋的次數。如果對于某個鎖,自旋很少成功獲得過鎖,則會直接忽略掉自旋過程,避免浪費CPU資源。

重量級鎖

重量級鎖指的就是擷取monitor對象的過程,需要調用作業系統的底層函數,是以涉及到核心态和使用者态的切換,切換成本非常高。

使用場景

偏向鎖:适用于沒有線程競争資源的場景。

輕量級鎖:适用于多個線程不同時刻競争資源的場景。

重量級鎖:适用于多個線程同時競争資源的場景。

小結

是以synchronized的鎖更新過程為
  1. 檢測Mark Word裡面是不是目前線程的ID,如果是,表示目前線程處于偏向鎖
  2. 如果不是,則使用CAS将目前線程的ID替換Mard Word,如果成功則表示目前線程獲得偏向鎖,置偏向标志位1
  3. 如果失敗,則說明發生競争,撤銷偏向鎖,進而更新為輕量級鎖。
  4. 目前線程使用CAS将對象頭的Mark Word替換為鎖記錄指針,如果成功,目前線程獲得鎖
  5. 如果失敗,表示其他線程競争鎖,目前線程便嘗試使用自旋來擷取鎖。
  6. 如果自旋成功則依然處于輕量級狀态。
  7. 如果自旋失敗,則更新為重量級鎖。

鎖優化

上面介紹了synchronized在JDK1.6之後的優化過程,進行了一個鎖更新過程。那麼我們在寫多線程代碼的時候也可以借鑒synchronized鎖優化中的一些思想來優化我們的代碼

減少鎖的時間

不需要同步執行的代碼,不要放到同步代碼塊中,可以讓鎖盡快釋放。

減少鎖的粒度

減少鎖的粒度的思想就是将一把鎖拆分成多把鎖,增加了并發度,減少了鎖競争,ConcurrentHashMap在JDK1.8之前就是采用了”分段鎖“的思想,還有LongAddr也是采用了”分段鎖“的思想,降低了鎖的粒度,提高了并發度。

鎖粗化

如果同步代碼塊中包含循環,應該将鎖放在循環以外,不然循環每次都會進入共享資源,效率會降低。

鎖消除

鎖消除其實就是對于同步代碼塊執行時間不長,并且并發量不大的情況下,可以采用CAS等樂觀鎖來進行同步操作,減少了加鎖釋放鎖帶來的開銷。

讀寫分離

讀寫分離指的是,寫的時候可以複制一份資料,然後寫操作可以加鎖,讀操作就讀原來的資料,比如CopyOnWriteArrayList集合類采用的就是讀寫分離的方式來解決并發安全的問題。