高併發學習之05關鍵字synchronized

1.synchronized簡介

在前文中JMM內存模型中我們已經瞭解了Java內存模型的一些知識,並且已經知道出現線程安全的主要來源於JMM的設計,主要集中在主內存和線程的工作內存而導致的內存可見性問題,以及重排序導致的問題。線程運行時擁有自己的棧空間,會在自己的棧空間運行,如果多線程間沒有共享的數據也就是說多線程間並沒有協作完成一件事情,那麼,多線程就不能發揮優勢,不能帶來巨大的價值。那麼共享數據的線程安全問題怎樣處理?很自然而然的想法就是每一個線程依次去讀寫這個共享變量,這樣就不會有任何數據安全的問題,因爲每個線程所操作的都是當前最新的版本數據。那麼,在Java關鍵字synchronized就具有使每個線程依次排隊操作共享變量的功能。
在多線程併發編程中synchronized一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了,Java SE 1.6中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。下面這個案例,然後通過synchronized關鍵字來修飾在inc的方法上。看下執行結果:

public class Demo{
  private static int count=0;
  public static void inc(){
    synchronized (Demo.class) {
      try {
        Thread.sleep(1);
     } catch (InterruptedException e) {
        e.printStackTrace();
     }
      count++;
   }
 }
  public static void main(String[] args) throws InterruptedException {
    for(int i=0;i<1000;i++){
      new Thread(()->Demo.inc()).start();
   }
    Thread.sleep(3000);
    System.out.println("運行結果"+count);
 }
}

2. synchronized的三種應用方式

synchronized有三種方式來加鎖,分別是:

  • 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖,如事例1 ,事例2
  • 靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖,如事例3
  • 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。如事例4、事例5

事例1:

//鎖住的是當前實例
synchronized (this) {
	....
}

事例2:

//鎖住的是當前實例
public synchronized void method1(){}

事例3:

//鎖住的是當前類
public static synchronized void method1(){}

事例4:

//鎖住的是類
synchronized (Demo.class) {
	....
}

事例5:

//鎖住的是配置的類
Object lock=new Object
synchronized (lock) {
	....
}

synchronized擴後後面的對象是一把鎖,在java中任意一個對象都可以成爲鎖,簡單來說,我們把object比喻是一個key,擁有這個key的線程才能執行這個方法,拿到這個key以後在執行方法過程中,這個key是隨身攜帶的,並且只有一把。如果後續的線程想訪問當前方法,因爲沒有key所以不能訪問只能在門口等着,等之前的線程把key放回去。所以,synchronized鎖定的對象必須是同一個,如果是不同對象,就意味着是不同的房間的鑰匙,對於訪問者來說是沒有任何影響的。

3.synchronized的字節碼指令

通過javap -v(JAVAP 查看方式) 來查看對應代碼的字節碼指令,對於同步塊的實現使用了monitorenter和monitorexit指令,前面我們在講JMM線程間通信的時候,提到過這兩個指令,他們隱式的執行了Lock和UnLock操作,用於提供原子性保證。monitorenter指令插入到同步代碼塊開始的位置、monitorexit指令插入到同步代碼塊結束位置,jvm需要保證每個monitorenter都有一個monitorexit對應。
這兩個指令,本質上都是對一個對象的監視器(monitor)進行獲取,這個過程是排他的,也就是說同一時刻只能有一個線程獲取到由synchronized所保護對象的監視器線程執行到monitorenter指令時,會嘗試獲取對象所對應的monitor所有權,也就是嘗試獲取對象的鎖;而執行monitorexit,就是釋放monitor的所有權。

下面是第一個事例中部分JAVAP指令:

//inc方法生成指令
public static void inc();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: ldc           #2                  // class com/herman/concurrent/one/Demo8
         2: dup
         3: astore_0
         4: monitorenter   //指令進入
         5: lconst_1
         6: invokestatic  #3                  // Method java/lang/Thread.sleep:(J)V
         9: goto          17
        12: astore_1
        13: aload_1
        14: invokevirtual #5                  // Method java/lang/InterruptedException.printStackTrace:()V
        17: getstatic     #6                  // Field count:I
        20: iconst_1
        21: iadd
        22: putstatic     #6                  // Field count:I
        25: aload_0
        26: monitorexit     //指令退出
        27: goto          35
        30: astore_2
        31: aload_0
        32: monitorexit  //指令退出
        33: aload_2
        34: athrow
        35: return
      Exception table:
         from    to  target type
             5     9    12   Class java/lang/InterruptedException
             5    27    30   any
            30    33    30   any
      LineNumberTable:
        line 10: 0
        line 12: 5
        line 15: 9
        line 13: 12
        line 14: 13
        line 16: 17
        line 17: 25
        line 18: 35
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
           13       4     1     e   Ljava/lang/InterruptedException;
      StackMapTable: number_of_entries = 4
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class java/lang/Object ]
          stack = [ class java/lang/InterruptedException ]
        frame_type = 4 /* same */
        frame_type = 76 /* same_locals_1_stack_item */
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

通過上面指令,在第4行執行了指令進入,第26行和第32行執行了指令退出,至於爲什麼執行兩次,是因爲一個是正常退出,一個是異常退出。
對象、對象的監視器、同步隊列和執行線程之間的關係。該圖可以看出,任意線程對Object的訪問,首先要獲得Object的監視器,如果獲取失敗,該線程就進入同步狀態,線程狀態變爲BLOCKED,當Object的監視器佔有者釋放後,在同步隊列中得線程就會有機會重新獲取該監視器。

4.CAS

先解釋下幾個核心概念: 竟態條件與臨界區、共享資源、不可變對象、原子操作。

4.1 竟態條件與臨界區
public class Demo{
	public int i=0;
	public void incr(){
		i++;
	}
}

多個線程訪問相同的資源,向這些資源做了多寫操作時,對執行順序的要求。
臨界區:incr方法內部就是臨界區,關鍵部分的代碼併發執行,會對執行結果產生影響。
竟態條件:可能發生在臨界區內的特殊條件。多線程執行incr方法中i++關鍵代碼時,產生竟態條件。

4.2 共享資源
  • 如果一段代碼是線程安全的,則它不包含竟態條件。只有當多個線程更新共享資源時,纔會發生競態條件。
  • 棧封閉時,不會在線程之間共享的變量,都是線程安全的。
  • 局部對象引用本身不共享,但是引用的對象存儲在共享堆中。如果方法內創建的對象,只是在方法中傳遞,並且不對其他線程可用,那麼也是線程安全的。
public void someMethod(){
	LocalObject localObject = new LocalObject);
	localObject.callMethod0);
	method2(localObject);

}
public void method2(LocalObject localObject)(
	localObject.setValue("value");
}

判定規則:如果創建、使用和處理資源,永遠不會速脫單個線程的控制,該資源的使用是線程安全的。

4.3 不可變對象

如果創建不可變的共享對象來保證對象在線程間共享時不可修改,從而實現線程安全。實例被創建,value就不可修改,這就是不可變性。

public class Demo{
	private int value=0;
	public Demo(int value){
		this.value=value;
	}
	public int getValue(){
		return this.value;
	}
}
4.4 原子操作

原子操作可以是一個步驟,也可以是多個步驟,但順序不可被打亂。也不可以只執行其中某幾步或一步。
現在原子的操作,有CAS、鎖。

4.3 什麼是CAS?

使用鎖時,線程獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區代碼都會產生衝突,所以當前線程獲取到鎖的時候同時也會阻塞其他線程獲取該鎖。而CAS操作(又稱爲無鎖操作)是一種樂觀鎖策略,它假設所有線程訪問共享資源的時候不會出現衝突,既然不會出現衝突自然而然就不會阻塞其他線程的操作。因此,線程就不會出現阻塞停頓的狀態。那麼,如果出現衝突了怎麼辦?無鎖操作是使用CAS(compare and swap)又叫做比較交換來鑑別線程是否出現衝突,出現衝突就重試當前操作直到沒有衝突爲止。
CAS比較交換的過程可以通俗的理解爲CAS(value,old,now),包含三個值分別爲:value 內存地址存放的實際值;old 預期的值(舊值);now 更新的新值。當value和old相同時,也就是說舊值和內存中實際的值相同表明該值沒有被其他線程更改過,即該舊值old就是目前來說最新的值了,自然而然可以將新值now賦值給value。反之,value和old不相同,表明該值已經被其他線程改過了則該舊值Oold不是最新版本的值了,所以不能將新值now賦給value,返回value即可。當多個線程使用CAS操作一個變量時,只有一個線程會成功,併成功更新,其餘會失敗。失敗的線程會重新嘗試,當然也可以選擇掛起線程。
CAS是底層指令,必須要得到硬件的支持。在JDK1.5後虛擬機纔可以使用處理器提供的CMPXCHG指令實現。JAVA中CAS也是被native關鍵字修飾。

4.4 CAS的應用場景

在J.UC包中大量的工具類使用了CAS,後期我們會專門學習J.U.C下併發編程工具類。

4.5 CAS的問題
  1. ABA問題
    因爲CAS會檢查舊值有沒有變化,這裏存在這樣一個有意思的問題。比如一箇舊值A變爲了成B,然後再變成A,剛好在做CAS時檢查發現舊值並沒有變化依然爲A,但是實際上的確發生了變化。解決方案可以沿襲數據庫中常用的樂觀鎖方式,添加一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。Java這麼優秀的語言,當然在Java 1.5後的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。

  2. 自旋時間過長

使用CAS時非阻塞同步,也就是說不會將線程掛起,會自旋(無非就是一個死循環)進行下一次嘗試,如果這裏自旋時間過長對性能是很大的消耗。如果JVM能支持處理器提供的pause指令,那麼在效率上會有一定的提升。

  1. 只能保證一個共享變量的原子操作

當對一個共享變量執行操作時CAS能保證其原子性,如果對多個共享變量進行操作,CAS就不能保證其原子性。有一個解決方案是利用對象整合多個共享變量,即一個類中的成員變量就是這幾個共享變量。然後將這個對象做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用對象之間的原子性。

5.synchronized的鎖的原理(以下內容摘自《Java併發編程的藝術》)

jdk1.6以後對synchronized鎖進行了優化,包含偏向鎖、輕量級鎖、重量級鎖; 在瞭解synchronized鎖之前,我們需要了解兩個重要的概念,一個是對象頭、另一個monitor

5.1 Java對象頭

在Hotspot虛擬機中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據和對齊填充;Java對象頭是實現synchronized的鎖對象的基礎,一般而言,synchronized使用的鎖對象是存儲在Java對象頭裏。它是輕量級鎖和偏向鎖的關鍵。
synchronized用的鎖是存在Java對象頭裏的。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,1字寬等於4字節,即32bit
Java對象頭的長度
Java對象頭裏的Mark Word裏默認存儲對象的HashCode、分代年齡和鎖標記位。32位JVM的Mark Word的默認存儲結構如下圖:
Mark Word的存儲結構
在運行期間,Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。Mark Word可能變化爲存儲以下4種數據:
Mark Word的狀態變化
在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下:
Mark Word的存儲結構

5.2 Mawrk Word

Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等。Java對象頭一般佔有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit)

5.3 鎖的升級與對比

Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率,下面會詳細分析。

5.3.1 偏向鎖

大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

5.3.1.1 偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作爲偏向鎖,最後喚醒暫停的線程。下圖演示了偏向鎖的初始化流程,線程2演示偏向鎖撤銷流程:
偏向鎖初始化及撤銷流程

5.3.1.2 關閉偏向鎖

偏向鎖在Java 6和Java 7裏是默認啓用的,但是它在應用程序啓動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲:
-XX:BiasedLockingStartupDelay=0。如果你確定應用程序裏所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖:
-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。

5.3.2 輕量級鎖
5.3.2.1 輕量級鎖加鎖

線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

5.3.2.2 輕量級鎖解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。
輕量級鎖膨脹流程圖
因爲自旋會消耗CPU,爲了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

5.3.3 重量級鎖

所謂的重量級鎖,其實就是最原始和最開始java實現的阻塞鎖。在JVM中又叫對象監視器(monitor,它的本質是依賴於底層操作系統的Mutex Lock實現,操作系統實現線程之間的切換需要從用戶態到內核態的切換,切換成本非常高)。這時的鎖對象的對象頭字段指向的是一個互斥量,所有線程競爭重量級鎖,競爭失敗的線程進入阻塞狀態(操作系統層面),並且在鎖對象的一個等待池中等待被喚醒,被喚醒後的線程再次去競爭鎖資源。
對象、對象的監視器、同步隊列和執行線程之間的關係。

5.3.4 鎖的優缺點對比

鎖的優缺點對比

5.3.4 鎖的完整流程

鎖膨脹的完整流程圖

5.3.5 總結

所謂的鎖升級,其實就是從偏向鎖->輕量級鎖(CAS自旋鎖)->重量級鎖,其實說白了,一切一切的開始源於java對synchronized同步機制的性能優化,最原始的synchronized同步機制是直接跳過前幾個步驟,直接進入重量級鎖的,而重量級鎖因爲需要線程進入阻塞狀態(從用戶態進入內核態)這種操作系統層面的操作非常消耗資源,這樣的話,synchronized同步機制就顯得很笨重,效率不高。那麼爲了解決這個問題,java才引入了偏向鎖,輕量級鎖,自旋鎖這幾個概念。
個人在縷下順序:

  1. 在沒有線程線程進入時,當前是無鎖的狀態
  2. 如果有一個線程A進入,那麼當前鎖會升爲偏向鎖(JAVA頭中設置),即A進入不需要驗證鎖
  3. 如果有兩個線程進入(少量線程),會釋放偏向鎖,並將鎖升級爲輕量鎖(CAS自旋鎖)線程棧中記錄鎖頭記錄。
  4. 如果多個線程中,有一個線程通過一定次數的自旋還沒有拿到鎖,那麼它會將鎖升級爲重量級鎖(大家都別自旋了,一起阻塞等待吧),鎖指針指向monitor。

提出這幾個概念優化了什麼?

  1. 偏向鎖是爲了避免CAS操作,儘量在對比對象頭就把加鎖問題解決掉,只有衝突的情況下才指向一次CAS操作。
  2. 而輕量級鎖和自旋鎖呢,其實兩個是一體使用的,爲的是儘量避免線程進入內核的阻塞狀態,這對性能非常不利,試圖用CAS操作和循環把加鎖問題解決掉
  3. 而重量級鎖是最終的無奈解決方案。

哈哈哈,看到這裏有沒有感覺上面那麼多字,還不如總結裏面的幾十個字來的精闢!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章