大話Synchronized及鎖升級

jack_xu發表於2020-04-05

前言

小夥伴大家好,我是jack xu,今天是清明假期,跟大家來聊一聊synchronized。本篇是併發程式設計中的第一篇,為什麼說是第一篇呢,因為併發程式設計涉及的東西太多太多,晦澀難懂,隨便一個知識點拉出來都可以寫一篇文章,如此算來寫完併發程式設計一個系列最起碼要十篇。我將知識點進行了總結歸納,排類分類,用通俗易懂的方式來跟大家說清楚、講明白。。

為什麼要用Synchronized

這個問題很簡單,首先我們來看下面這個程式碼

大話Synchronized及鎖升級
開10000個執行緒,將變數count遞增,結果是9998,很顯然是出現了執行緒不安全。那為什麼會出現這樣的結果呢,答案也很簡單

這裡稍微解釋下為啥會得不到 100(知道的可直接跳過), i++ 這個操作,計算機需要分成三步來執行。
1、讀取 i 的值。
2、把 i 加 1.
3、把 最終 i 的結果寫入記憶體之中。
所以,(1)、假如執行緒 A 讀取了 i 的值為 i = 0,(2)、這個時候執行緒 B 也讀取了 i 的值 i = 0。
(3)、接著 A把 i 加 1,然後寫入記憶體,此時 i = 1。(4)、緊接著,B也把 i 加 1,此時執行緒B中的 i = 1,
然後執行緒B 把 i 寫入記憶體,此時記憶體中的 i = 1。也就是說,執行緒 A, B 都對 i 進行了自增,但最終的結果卻是1,不是 2.
複製程式碼

歸根到底一句話就是這麼多操作不是原子性,那怎麼解決這個問題呢,加上Synchronized即可

大話Synchronized及鎖升級

三大特性

在上面例子演示的是原子性。synchronized 可以確保可見性,根據happens-before規定,在一個執行緒執行完 synchronized 程式碼後,所有程式碼中對變數值的變化都能立即被其它執行緒所看到。順序性的話就是禁止指令重排,程式碼塊中的程式碼從上往下依次執行,歸根到底再一句話,併發問題中的三個特性synchronized都能保證,也就是synchronized是萬金油,用他準沒錯!

使用方法

從語法上講,Synchronized總共有三種用法:

  • 修飾例項方法
public synchronized void eat(){
	.......
  .......
}
複製程式碼
  • 修飾靜態方法
public static synchronized void eat(){
	.......
  .......
}
複製程式碼
  • 修飾程式碼塊
public void eat(){
   synchronized(this){
   	.......
 	.......
   }
}
複製程式碼
public void eat(){
   synchronized(Eat.class){
   	.......
 	.......
   }
}
複製程式碼

其中第一種和第三種對等,第二種和第四種對等,這個很簡單,下面是使用 synchronized的總結:

  • 選用一個鎖物件,可以是任意物件;
  • 鎖物件鎖的是同步程式碼塊,並不是自己;
  • 不同型別的多個 Thread 如果有程式碼要同步執行,鎖物件要使用所有執行緒共同持有的同一個物件;
  • 需要同步的程式碼放到大括號中。需要同步的意思就是需要保證原子性、可見性、有序性中的任何一種或多種。不要放不需要同步的程式碼進來,影響程式碼效率。

鎖升級

好,本文的高潮來了,大家仔細聽,在JDK的早期,synchronized叫做重量級鎖,因為申請鎖資源必須通過kernel,系統呼叫,從使用者態 -> 核心態的轉換,效率比較低,JDK1.6 之後做了一些優化,為了減少獲得鎖和釋放鎖帶來的效能開銷,引入了偏向鎖、輕量級鎖的概念。因此大家會發現在 synchronized 中,鎖存在四種狀態分別是:無鎖、偏向鎖、輕量級鎖、重量級鎖;

我們知道synchronized鎖的是物件,物件就是Object,Object在heap中的佈局,如下圖所示

大話Synchronized及鎖升級
前面8個位元組就是markword,後面4個位元組是class pointer就是這個物件屬於哪個類的,People就是People.class,Cat類就是Cat.class,在後面例項資料就是看你類裡面欄位的具體大小了,int age就是4個位元組,string name就是英文1個位元組, 中文2個位元組(String的中文位元組數要看用的編碼集合,如果是utf-8型別的,那麼中文佔2到3個位元組,如果是GBK型別的,那麼中文佔2個位元組),最後前面三項加起來不能被8整除的,就是補齊到能夠被8整除。下圖就是markword(8*8=64位)的分佈圖,鎖升級就是markdown裡面標誌位的變化。

大話Synchronized及鎖升級
網上所以的圖都是32位的,我這裡畫的是64位的,大家發現一共有五種狀態,用兩位是不夠的,所以01的時候在向前借一位。

偏向鎖

大話Synchronized及鎖升級
hotspot虛擬機器的作者經過調查發現,大部分情況下,加鎖的程式碼不僅僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲得。所以基於這樣一個概率,我們一開始加鎖上的是偏向鎖,當一個執行緒訪問加了同步鎖的程式碼塊時,首先會嘗試通過CAS操作在物件頭中儲存當前執行緒的ID

(1)如果成功markword則儲存當前執行緒ID,接著執行同步程式碼塊

(2)如果是同一個執行緒加鎖的時候,不需要爭用,只需要判斷執行緒指標是否同一個,可直接執行同步程式碼塊

(3)如果有其他執行緒已經獲得了偏向鎖,這種情況說明當前鎖存在競爭,需要撤銷已獲得偏向鎖的執行緒,並且把它持有的鎖升級為輕量級鎖(這個操作需要等到全域性安全點,也就是沒有執行緒在執行位元組碼)才能執行

在我們的應用開發中,絕大部分情況下一定會存在 2 個以上的執行緒競爭,那麼如果開啟偏向鎖,反而會提升獲取鎖的資源消耗。所以可以通過jvm引數UseBiasedLocking 來設定開啟或關閉偏向鎖

輕量級鎖

大話Synchronized及鎖升級
撤銷偏向鎖,升級輕量級鎖,每個執行緒在自己的執行緒棧生成LockRecord,用CAS操作將markword設定為指向自己這個執行緒的LR的指標,設定成功者得到鎖。 輕量級鎖在加鎖過程中,用到了自旋鎖,自旋鎖的使用,其實也是有一定條件的,如果一個執行緒執行同步程式碼塊的時間很長,那麼這個執行緒不斷的迴圈反而會消耗 CPU 資源。

(1)預設情況下自旋的次數是 10 次,可以通過-XX:PreBlockSpin來修改,或者自旋執行緒數超過CPU核數的一半

(2)在 JDK1.6 之後,引入了自適應自旋鎖,自適應意味著自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費處理器資源

滿足這兩種情況之一後升級為重量級鎖

重量級鎖

這時候就驚動老佛爺了,向作業系統申請資源,linux mutex , CPU從3級-0級系統呼叫,執行緒掛起,進入等待佇列,等待作業系統的排程,然後再對映回使用者空間。

我們隨便寫一段簡單的帶有 synchronized 關鍵字的程式碼。先將其編譯為.class 檔案,然後使用 javap -c xxx.class 進行反彙編。我們就可以得到 java 程式碼對應的彙編指令。裡面可以找到如下兩行指令。

大話Synchronized及鎖升級
位元組碼層面就是關鍵的這兩條指令,monitorenter,moniterexit  (注:程式碼塊用的是ACC_SYNCHRONIZED,這是一個標誌位,底層原理還是這兩條指令)

java中每個物件都關聯了一個監視器鎖monitor,當monitor被佔用時就會處於鎖定狀態。執行緒執行monitorenter 指令時嘗試獲取monitor的所有權,過程如下:

  • 如果monitor的進入數為 0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor 的所有者。
  • 如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加 1。
  • 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為 0,再重新嘗試獲取monitor的所有權。

從上面過程可以看出兩點,第一:monitor是可重入的,他有計數器,第二:monitor是非公平鎖

monitor 依賴作業系統的mutexLock(互斥鎖)來實現的,執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能

鎖消除

我們都知道 StringBuffer 是執行緒安全的,因為它的關鍵方法都是被 synchronized修飾過的,但我們看上面這段程式碼,我們會發現,sb 這個引用只會在 add方法中使用,不可能被其它執行緒引用(因為是區域性變數,棧私有),因此 sb是不可能共享的資源,JVM 會自動消除 StringBuffer 物件內部的鎖。

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}
複製程式碼

總結

好,本文對synchronized所涵蓋的知識點已經講解的很清楚了。synchronized是Java併發程式設計中最常用的用於保證執行緒安全的方式,其使用相對也比較簡單。在synchronized優化以前,synchronized的效能是比ReentrantLock差很多的,但是自從synchronized引入了偏向鎖,輕量級鎖(自旋鎖)後,兩者的效能就差不多了。 在兩種方法都可用的情況下,官方甚至建議使用synchronized,其實synchronized的優化我感覺就借鑑了ReentrantLock中的CAS技術。都是試圖在使用者態就把加鎖問題解決,避免進入核心態的執行緒阻塞。

相關文章