學習筆記三:Synchronized實現原理與應用

一、Synchronized實現同步基礎

java中的每個對象均可以做爲鎖,根據Synchronized用的位置能夠有這些使用場景:java

  • 對於普通同步方法,鎖是當前實例對象。
  • 對於靜態同步方法,鎖是當前類的class對象。
  • 對於同步方法塊,鎖是Synchronized括號裏配置的對象。

二、Synchronized實現原理

2.1對象鎖(monitor)機制

Synchronized在JVM裏的實現原理JVM基於進入退出Monitor對象來實現方法同步和代碼塊同步,但二者的實現細節不同。數據庫

咱們先來看看synchronized的具體底層實現。先寫一個簡單的demo:編程

public class SynchronizedJavapv {
    public static void main(String[] args) {
        synchronized (SynchronizedJavapv.class){
            System.out.println("對SynchronizedJavapv對象進行加鎖");
        }
        m();
    }
    public static synchronized void m(){
        System.out.println("靜態同步方法,對SynchronizedJavapv對象進行加鎖");
    }
}

編譯以後,切換到SynchronizedJavapv.class的同級目錄以後,而後用javap -v SynchronizedJavapv.class查看字節碼文件,分析實現細節:安全

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束和異常處.
代碼塊同步時使用monitorenter和monitorexit指令實現,方法同步則是依賴方法修飾符ACC_SYNCHRONIZED來完成。
不管採用哪一種方式,本質是對一個對象的監視器(monitor)進行獲取,而這個過程是排他的,也就是同一時刻只有一個線程獲取到由synchronized所保護對象的監視器。多線程

Synchronized先天具備重入性。每一個對象擁有一個計數器,當線程獲取該對象鎖後,計數器就會加一,釋放鎖後就會將計數器減一。併發

任意一個對象都擁有本身的監視器,當這個對象由同步塊或者這個對象的同步方法調用時,執行方法的線程必須先獲取該對象的監視器才能進入同步塊和同步方法,若是沒有獲取到監視器的線程將會被阻塞在同步塊和同步方法的入口處,進入到BLOCKED狀態。app

下圖表現了對象,對象監視器,同步隊列以及執行線程狀態之間的關係:ide

該圖能夠看出,任意線程對Object的訪問,首先要得到Object的監視器,若是獲取失敗,該線程就進入同步狀態,線程狀態變爲BLOCKED,當Object的監視器佔有者釋放後,在同步隊列中得線程就會有機會從新獲取該監視器。性能

2.2  synchronized的happens-before關係

在上一篇文章中討論過happens-before規則,咱們如今來看一看Synchronized的happens-before規則,即監視器鎖規則:對同一個監視器的解鎖,happens-before於對該監視器的加鎖。繼續來看代碼:學習

public class MonitorDemo {
   private int a = 0;

   public synchronized void writer() {     // 1
       a++;                                // 2
   }                                       // 3

   public synchronized void reader() {    // 4
       int i = a;                         // 5
   }                                      // 6
}

 

該代碼的happens-before關係如圖所示:

在圖中每個箭頭鏈接的兩個節點就表明之間的happens-before關係,黑色的是經過程序順序規則推導出來,紅色的爲監視器鎖規則推導而出:線程A釋放鎖happens-before線程B加鎖,藍色的則是經過程序順序規則和監視器鎖規則推測出來happens-befor關係,經過傳遞性規則進一步推導的happens-before關係。如今咱們來重點關注2 happens-before 5,經過這個關係咱們能夠得出什麼?

根據happens-before的定義中的一條:若是A happens-before B,則A的執行結果對B可見,而且A的執行順序先於B。線程A先對共享變量A進行加一,由2 happens-before 5關係可知線程A的執行結果對線程B可見即線程B所讀取到的a的值爲1。

2.3 鎖獲取和鎖釋放的內存語義

在上一篇文章提到過JMM核心爲兩個部分:happens-before規則以及內存抽象模型。咱們分析完Synchronized的happens-before關係後,仍是不太完整的,咱們接下來看看基於java內存抽象模型的Synchronized的內存語義。

廢話很少說依舊先上圖。

從上圖能夠看出,線程A會首先先從主內存中讀取共享變量a=0的值而後將該變量拷貝到本身的本地內存,進行加一操做後,再將該值刷新到主內存,整個過程即爲線程A 加鎖-->執行臨界區代碼-->釋放鎖相對應的內存語義。

 

線程B獲取鎖的時候一樣會從主內存中共享變量a的值,這個時候就是最新的值1,而後將該值拷貝到線程B的工做內存中去,釋放鎖的時候一樣會重寫到主內存中。

從總體上來看,線程A的執行結果(a=1)對線程B是可見的,實現原理爲:釋放鎖的時候會將值刷新到主內存中,其餘線程獲取鎖時會強制從主內存中獲取最新的值。另外也驗證了2 happens-before 5,2的執行結果對5是可見的。

從橫向來看,這就像線程A經過主內存中的共享變量和線程B進行通訊,A 告訴 B 咱們倆的共享數據如今爲1啦,這種線程間的通訊機制正好吻合java的內存模型正好是共享內存的併發模型結構。

三、synchronized優化

經過上面的討論如今咱們對Synchronized應該有所印象了,它最大的特徵就是在同一時刻只有一個線程可以得到對象的監視器(monitor),從而進入到同步代碼塊或者同步方法之中,即表現爲互斥性(排它性)。

這種方式確定效率低下,每次只能經過一個線程,既然每次只能經過一個,這種形式不能改變的話,那麼咱們能不能讓每次經過的速度變快一點了。

打個比方,去收銀臺付款,以前的方式是,你們都去排隊,而後去紙幣付款收銀員找零,有的時候付款的時候在包裏拿出錢包再去拿出錢,這個過程是比較耗時的,而後,支付寶解放了你們去錢包找錢的過程,如今只須要掃描下就能夠完成付款了,也省去了收銀員跟你找零的時間的了。

一樣是須要排隊,但整個付款的時間大大縮短,是否是總體的效率變高速率變快了?這種優化方式一樣能夠引伸到鎖優化上,縮短獲取鎖的時間,偉大的科學家們也是這樣作的,使人欽佩,畢竟java是這麼優秀的語言(微笑臉)。

在聊到鎖的優化也就是鎖的幾種狀態前,有兩個知識點須要先關注:(1)CAS操做 (2)Java對象頭,這是理解下面知識的前提條件

3.1 CAS操做 

3.1.1 什麼是CAS?

使用鎖時,線程獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區代碼都會產生衝突,因此當前線程獲取到鎖的時候同時也會阻塞其餘線程獲取該鎖。而CAS操做(又稱爲無鎖操做)是一種樂觀鎖策略,它假設全部線程訪問共享資源的時候不會出現衝突,既然不會出現衝突天然而然就不會阻塞其餘線程的操做。

所以,線程就不會出現阻塞停頓的狀態。那麼,若是出現衝突了怎麼辦?無鎖操做是使用**CAS(compare and swap)**又叫作比較交換來鑑別線程是否出現衝突,出現衝突就重試當前操做直到沒有衝突爲止。

3.1.2 CAS的操做過程

 

CAS比較交換的過程能夠通俗的理解爲CAS(V,O,N),包含三個值分別爲:V 內存地址存放的實際值;O 預期的值(舊值);N 更新的新值。當V和O相同時,也就是說舊值和內存中實際的值相同代表該值沒有被其餘線程更改過,即該舊值O就是目前來講最新的值了,天然而然能夠將新值N賦值給V。

反之,V和O不相同,代表該值已經被其餘線程改過了則該舊值O不是最新版本的值了,因此不能將新值N賦給V,返回V便可。當多個線程使用CAS操做一個變量是,只有一個線程會成功,併成功更新,其他會失敗。失敗的線程會從新嘗試,固然也能夠選擇掛起線程。

CAS的實現須要硬件指令集的支撐,在JDK1.5後虛擬機纔可使用處理器提供的CMPXCHG指令實現。

Synchronized VS CAS

元老級的Synchronized(未優化前)最主要的問題是:在存在線程競爭的狀況下會出現線程阻塞和喚醒鎖帶來的性能問題,由於這是一種互斥同步(阻塞同步)。而CAS並非武斷的將線程掛起,當CAS操做失敗後會進行必定的嘗試,而非進行耗時的掛起喚醒的操做,所以也叫作非阻塞同步。這是二者主要的區別。

 3.1.3 CAS的應用場景

在J.U.C包中利用CAS實現類有不少,能夠說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變量,在atomic包中的實現類也幾乎都是用CAS實現,關於這些具體的實現場景在以後會詳細聊聊,如今有個印象就行了(微笑臉)。

3.1.4 CAS的問題

 

  • ABA問題

由於CAS會檢查舊值有沒有變化,這裏存在這樣一個有意思的問題。好比一箇舊值A變爲了成B,而後再變成A,恰好在作CAS時檢查發現舊值並無變化依然爲A,可是實際上的確發生了變化。

解決方案能夠沿襲數據庫中經常使用的樂觀鎖方式,添加一個版本號能夠解決。原來的變化路徑A->B->A就變成了1A->2B->3C。java這麼優秀的語言,固然在java 1.5後的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。

  • 自旋時間過長

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

  • 只能保證一個共享變量的原子操做

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

 3.2 Java對象頭

在同步的時候是獲取對象的monitor,即獲取到對象的鎖。那麼對象的鎖怎麼理解?無非就是相似對對象的一個標誌,那麼這個標誌就是存放在Java對象的對象頭。Java對象頭裏的Mark Word裏默認的存放的對象的Hashcode,分代年齡和鎖標記位。32爲JVM Mark Word默認存儲結構爲(注:java對象頭以及下面的鎖狀態變化摘自《java併發編程的藝術》一書,該書我認爲寫的足夠好,就沒在本身組織語言班門弄斧了):

如圖在Mark Word會默認存放hasdcode,年齡值以及鎖標誌位等信息。

Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭狀況逐漸升級。鎖能夠升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提升得到鎖和釋放鎖的效率。對象的MarkWord變化爲下圖:

3.3 偏向鎖

HotSpot的做者通過研究發現,大多數狀況下,鎖不只不存在多線程競爭,並且老是由同一線程屢次得到,爲了讓線程得到鎖的代價更低而引入了偏向鎖。

偏向鎖的獲取

當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,之後該線程在進入和退出同步塊時不須要進行CAS操做來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。若是測試成功,表示線程已經得到了鎖。

若是測試失敗,則須要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):若是沒有設置,則使用CAS競爭鎖;若是設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程

偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,因此當其餘線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。

如圖,偏向鎖的撤銷,須要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,而後檢查持有偏向鎖的線程是否活着,若是線程不處於活動狀態,則將對象頭設置成無鎖狀態;

若是線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼從新偏向於其餘線程,要麼恢復到無鎖或者標記對象不適合做爲偏向鎖,最後喚醒暫停的線程。

下圖線程1展現了偏向鎖獲取的過程,線程2展現了偏向鎖撤銷的過程。

如何關閉偏向鎖

偏向鎖在Java 6和Java 7裏是默認啓用的,可是它在應用程序啓動幾秒鐘以後才激活,若有必要可使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。若是你肯定應用程序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態

3.3 輕量級鎖

加鎖

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

解鎖

輕量級解鎖時,會使用原子的CAS操做將Displaced Mark Word替換回到對象頭,若是成功,則表示沒有競爭發生。若是失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,致使鎖膨脹的流程圖。

由於自旋會消耗CPU,爲了不無用的自旋(好比得到鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其餘線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖以後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

3.5 各類鎖的比較

四、 一個例子

通過上面的理解,咱們如今應該知道了該怎樣解決了。更正後的代碼爲:

public class SynchronizedDemo implements Runnable {
   private static int count = 0;

   public static void main(String[] args) {
       for (int i = 0; i < 10; i++) {
           Thread thread = new Thread(new SynchronizedDemo());
           thread.start();
       }
       try {
           Thread.sleep(500);
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
       System.out.println("result: " + count);
   }

   @Override
   public void run() {
       synchronized (SynchronizedDemo.class) {
           for (int i = 0; i < 1000000; i++)
               count++;
       }
   }
}

開啓十個線程,每一個線程在原值上累加1000000次,最終正確的結果爲10X1000000=10000000,這裏可以計算出正確的結果是由於在作累加操做時使用了同步代碼塊,這樣就能保證每一個線程所得到共享變量的值都是當前最新的值,若是不使用同步的話,就可能會出現A線程累加後,而B線程作累加操做有多是使用原來的就值,即「髒值」。

這樣,就致使最終的計算結果不是正確的。而使用Syncnized就可能保證內存可見性,保證每一個線程都是操做的最新值。這裏只是一個示例性的demo,聰明的你,還有其餘辦法嗎?

上一篇:學習筆記二:Java內存模型以及happens-before規則

下一篇:學習筆記四:初識線程

相關文章
相關標籤/搜索