深入理解Java虛擬機之——高併發原理

聲明:原創作品,轉載請註明出處https://www.jianshu.com/p/a7c86cd45eac

今天來說下Java虛擬機的高併發問題,在講之前首先需要明白什麼是併發,說到併發有人還會聯想到並行,那麼他們到底是什麼,有什麼區別呢?這裏用一個例子來解釋下:假如你現在正在喫飯,這時來了一個電話,你喫完飯再去接電話,說明你不支持併發也不支持並行。如果你放下筷子去接電話,接完再接着喫,說明你支持併發,如果你邊喫飯邊接電話,說明你支持並行。可以看到併發和並行都是指在處理多任務,兩者的區別是是否可以同時進行,如果是同時進行的,那麼就是並行,否則就是併發。當然這是一種狹義上的定義,廣義上來講,併發指的就是多任務處理,並行是其中的一個特例。本文所講的併發就是這個概念。

併發產生的問題

接下來我們看下計算機在併發情況下會有什麼問題。我們知道計算機的處理器運算速度是很快的,但是它在與內存交互比如讀取計算數據,存儲計算結果時是很慢,兩者差了幾個數量級。爲了解決這個問題,現在的計算機都會在處理器和內存之間加入一層臨時緩衝,這個臨時緩衝數據讀取速度和處理器計算速度差不多,每次運算時從主內存中加載數據到臨時緩衝,計算完後再把緩衝中的數據寫回主內存。這就很好解決了兩邊速度不匹配的問題,但是這又引入了一個新的問題。


如上圖所示,假如該計算機中有兩個處理器A、B,主內存有個變量i,初始值爲0,每個處理器和主內存之間有個高速緩存,處理器A運算i++的指令,處理器B運算i=i+2的指令,處理器運算時會先從主內存把變量i加載到對應的緩衝中,計算完成後再把計算結果寫回主內存。處理器B也是同理。理論上經過處理器A、B的運算後這個主內存中的i會變成3。但是會出現這麼一種情況,就是當處理器A計算完i++後,i變成1,此時i還在緩衝中沒寫入主內存,也就是說現在主內存的值依然是0,但是就在這時處理器B開始運算,把主內存的i=0讀取到緩衝然後計算i = i+2,此時處理器B計算後i的值爲2,然後把i = 2寫回主內存,此時主內存中i的值變爲2,處理器B寫回主內存後,處理器A這時也把之前的計算結果,也就是處理器A中的緩衝中的i= 1又寫回主內存,結果主內存中i的值就從之前的2變成1了,可以看到與我們預期的值不符。這正是併發所產生的問題。我們稱這個問題爲緩衝一致性(Cache Coherence)問題。在計算機中,爲了解決剛纔的緩衝一致性問題,處理器在訪問內存時都需要遵循一些協議,比如MSI、MESI、MOSI等,我們稱之爲緩存一致性協議,如下圖所示,當然這些協議不是本文的重點,有興趣的同學可以查看相關文檔來了解。

Java虛擬機併發模型

上面我們分析了計算機在併發情況下,處理器,臨時緩存,主內存之間的關係,同理在虛擬機中也對應着這麼一套模型,如下所示:



這裏可以看到Java虛擬機併發的問題其實就是解決好線程,工作內存,和主內存之間的關係,同理每個線程在訪問數據的時候需要遵循一些協議,比如這裏的Save和Load等操作(下文會提到)。這裏的主內存和工作內存和Java虛擬機中內存區域堆、棧、方法區等不是同一個層次,爲了便於理解你可以勉強理解爲主內存對應Java堆中的對象實例數據部分,工作內存對應虛擬機棧中的部分區域。
接下來我們來詳細看下上述中的交互協議,也就是Save和Load操作。這個交互協議是規定了一個變量如何從主內存拷貝到工作內存,以及如何從工作內存同步到主內存之類的細節,虛擬機定義了8種操作來完成這一目的,這個操作如下:lock、unlock、read、load、use、assign、store、write,我們依次看下他們的作用:

  • lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
  • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,它把工作內存中的一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
    知道了這幾個操作,我們再來看下上面提到的,要想把一個變量從主內存複製到工作內存中來,就要順序地執行read和load操作,如果要把變量從工作內存同步回主內存,就要按順序執行store和write操作。注意Java內存模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是說read和load之間、store和write之間是可以插入其他指令的。如對主內存中的變量a、b進行訪問時,一種可能出現的順序是read a、read b、load b、load a。除此以外Java虛擬機還規定了在執行上述八種基本操作時必須滿足如下規則:
  • 不允許read和load、store和write的操作之一單獨出現,即不允許一個變量從主內存讀取了但是工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現。
  • 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  • 不予許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
  • 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是一個變量實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,對此執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
  • 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值
  • 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
  • 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)

volatile在併發中的作用

接下來來說下volatile在併發中有什麼作用,說到 volatile我們首先了解下Java虛擬機併發機制下的幾個特性:原子性、可見性和有序性。可以這麼說,Java虛擬機的併發問題就是圍繞這幾個特性展開來的。我們依次來看下這幾個特性的定義:

  • 原子性:指的是一個不可被拆分的操作,在Java多線程中,一個原子操作指的是,如果一個操作正在被某個線程執行,它不會被其他線程打斷。比如上面介紹的read、load、assign、use、store和write這六個操作都是原子性的。
  • 可見性:可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。
  • 有序性:在計算機中,爲了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行優化,處理器在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果一致的,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,因此如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的先後順序來保證。與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有類似的指令重排序優化。在單線程中這樣的指令重排序並不會帶來問題,但是在多線程中就會出現問題。

    如上圖所示(截取自深入理解Java虛擬機一書),有兩個線程A和B,線程A做些初始化配置的工作,初始化成功後將initialized設置成true,線程B一直在循環判斷這個initialized變量,如果爲true說明初始化成功,可以接着執行下面的工作。但是線程A由於指令重排序的問題導致
    initialized = true這句語句被提前執行,這樣導致了線程B中還沒等初始化完成就直接執行doSomethingWithConfig();方法,自然程序就會出異常。
    在多線程中,volatile的作用主要是保證可見性和有序性。來具體看下volatile是如何保證這兩點的。
    volatile的可見性保證:
    在文章開頭中我們提到,在多線程中,一個線程A對一個主內存的普通變量做了修改,另一個線程B訪問這個變量時,可能獲取的值還是之前的值,因爲線程A修改的值還沒及時寫回主內存中,那麼這時這個變量對線程B是不可見的。但是如果用volatile修飾這個變量時,就不會出現這個問題。那麼volatile是如何保證這點的呢?其實很簡單,就是Java規定某個線程要訪問用volatile修飾的變量,每次執行use操作前都需要執行load和read,這樣就可以保證這個線程訪問到的是這個變量的最新值,每次執行assign後,都需要立刻執行store和write操作來把修改後的值同步到主內存中,這樣就保證了其他線程訪問這個變量是最新的值。
    volatile的有序性的保證:
    如果有兩個線程A、B,線程A先於線程B對一個volatile修飾的變量執行use或assign操作,那麼對應線程A的read或write也要先於線程B。這條規則保證volatile修飾的變量不會被指令重排序優化,保證代碼的執行順序與程序的順序相同。

先行發生原則

上面我們知道volatile可以保證有序性,那麼除此之外這個有序性還有其他方式可以保障嗎,或者說這個重排序有什麼規則可循呢?答案是肯定的,這個規則就是先行發生原則,如果兩個操作沒有遵循這個原則那麼虛擬機就可以對它們進行隨意的重排序。接下來我們來具體看下這個先行發生原則:
首先我們來解釋下什麼是先行發生,先行發生是Java內存模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被B觀察到,這裏的影響包括修改了內存中共享變量的值、發送了消息、調用了方法等。舉個例子:

// 線程A
i = 1;
// 線程B
j = i;
// 線程C
i = 2;

假設線程A中的操作i = 1先行發生於線程B的操作就j = i,那我們就可以確定在線程B的操作執行後,變量j的值一定是等於1,得出這個結論的依據有兩個,一是根據先行發生原則,i = 1的結果可以被觀察到;二是線程C登場之前,線程A操作結束之後沒有其他線程會修改變量i的值。現在再來考慮線程C,我們依然保持線程A和B之間的先行發生關係,而線程C出現在線程A和線程B的操作之間,但是線程C與線程B沒有先行發生關係,那j的值會是多少?答案是不確定,1和2都有可能,因爲線程C對變量i的影響可能會被線程B觀察到,也可能不會,這時候線程B就存在讀取到過期數據的風險,不具備多線程安全性。
接下來看下具體的先行發生原則:

  • 程序次序規則:在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說,應該是控制流的順序而不是程序代碼順序,因爲要考慮分支、循環等結構。
  • 管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。這裏必須強調的是同一個鎖,而後面是指時間上的先後順序。
  • volatile變量規則:對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,這裏的後面同樣是指時間上的先後順序。
  • 線程啓動規則:Thread對象的start方法先行發生於此線程的每一個動作。
  • 線程終止規則:線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則:對線程interrupt()方法調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生與操作C的結論。
    接下來舉一個例子來說明這個規則在多線程中扮演的作用。
private int value  = 0;
public void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}

上面的代碼很簡單,定義了一個value字段,然後定義這個字段的set和get方法。假設存在線層A和線程B,線程A先調用setValue(1)方法,即給value賦值爲1,然後線程B調用這個getValue方法,那麼此時線程B得到的返回值是什麼?答案是0和1都有可能,爲什麼會出現這種情況,我們用先行發生原則來看下。
由於兩個方法分別有線程A和線程B調用,不在一個線程中,所以這裏沒有程序次序規則;由於沒有同步塊即沒有加鎖,所以管程鎖定規則不適用;由於value變量沒有被volatile關鍵字修飾,所以volatile變量規則不適用;後面的線程啓動、終止、中斷規則和對象終結規則也和這裏沒有關係。因此沒有一個適用的先行發生規則,虛擬機在處理set和get時,即使set方法在時間順序上先於get執行,但由於沒有先行發生原則導致指令重排序,就會出現get到的value還是原先的值,不符合我們預期的結果。
我們看到上面的例子在多線程中出現了問題,我們也稱之爲線程不安全,那麼什麼是線程安全呢?《Java Concurrency In Practice》的作者Brian Goetz對線程安全做了一個比較恰當的定義:

線程安全:當多線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那這個對象就是線程安全的。

由於實現上面的線程安全非常困難,我們把線程安全由強至弱分成五類:不可變、絕對線程安全、相對線程安全、線程兼容、線程對立。

  • 不可變:不可變的對象一定是線程安全的,不需要加任何的線程安全保障措施,比如用final修飾的對象。
  • 絕對線程安全:絕對線程安全是完全滿足Brian Goetz給出的線程安全定義,一個定義非常嚴格,一個類要想達到這個意義上的線程安全可以說是不切實際的。
  • 相對線程安全:相對線程安全就是我們通常意義上所講的線程安全,比如Vector、HashTable
  • 線程兼容: 線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中安全使用,我們通常說一個類不是線程安全的,絕大多數指的這種情況。Java API中大部分的類都是線程兼容的,如前面Vector和HashTable相對應的集合類ArrayList和HashMap等。
  • 線程對立:線程對立是指不管調用端採用何種同步手段都無法在多線程環境中併發使用的代碼。這種通常是有害的,應當儘量避免。
    這裏你可能會有疑問,爲什麼我們平時說的線程安全的Vector僅僅是相對線程安全,而不是絕對線程安全。我們來看個例子:
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args){
    while(true){
        for(int i = 0;i<10;i++){
            vector.add(i);
        }
        Thread removeThread = new Thread(new Runnable(){
            @Override
            public void run(){
                for(int i = 0;i<vector.size();i++){
                    vector.remove(i);
                }
            }
        });
        Thread printThread = new Thread(new Runnable(){
            @Override
            public void run(){
                for(int i = 0;i<vector.size();i++){
                   System.out.println((vector.get(i)))
                }
            }
        });
        removeThread.start();
        printThread.start();
    }
}

上面的程序很簡單,有一個集合vector,然後同時開啓兩個線程,一個線程移除一個vector集合中的元素,另一個線程打印集合中的元素。最後運行後會發現程序報角標越界的錯誤了。因爲兩個線程同時運行,當printThread要打印的元素正好被removeThread線程移除掉了的話自然就找不到這個元素。

線程安全的實現方法

介紹了什麼是線程安全,接下來我們來看下該如何實現線程安全。
實現線程安全有兩種方式,一種爲互斥同步,另一種爲非阻塞同步。先來看下互斥同步:

互斥同步

互斥同步首先來理解下互斥,互斥就是當一段代碼正在被一個線程執行時,其他線程都無法進入該代碼執行,也就是說一段代碼只能被一個線程執行。這樣解釋可能有點抽象,舉個例子,這個代碼段可以理解成一個房間,這個房間是鎖着的,如果一個人要進入房間,那他必須得持有這個房間的鑰匙。當這個人進去後,就把門鎖了,別人想進去沒鑰匙自然就進不去,當這個人出去的時候,就會把鑰匙放在門外,這時其他人要想進去就可以用鑰匙來開門,同理他進去後其他人也是無法進去的。同樣虛擬機中也引入了鎖的概念。當一個線程執行一段有共享變量的代碼時,就對其加把鎖,這時其他線程都無法訪問,只有這個線程執行完了纔會讓其他線程進來執行。
那麼Java虛擬機是如何實現上述的鎖機制的呢?有兩種方式,可以用synchronized和ReetrantLock。
synchronized
先來看下synchronized的用法,當你想把一段代碼片鎖起來的時候只需要這樣:

synchronized(鎖對象){
    doSomething()  //需要加鎖的代碼
}

可以看到簡單,只需要在要加鎖的代碼片外套層synchronized,注意還需要傳入一個鎖對象,這個對象可以是任意的一個對象,甚至可以是這個類的類對象。
上面是對某段代碼片加鎖,當然你也可以直接對某個方法加鎖。如下

public synchronized void methodName(){
      doSomething()  //需要加鎖的代碼
}

有人可能說這裏是不是沒有指定鎖對象,其實是有的,這個鎖對象就是這個方法所在類的實例對象,如果是靜態方法那麼這個鎖對象就是類對象。
那麼synchronized到底是如何實現這種鎖機制呢?synchronized關鍵字經過編譯後,會在代碼塊的前後分別形成monitorenter和monitorexit這兩個字節碼指令,
當虛擬機在執行monitorenter指令時,首先會去嘗試獲取對象的鎖,如果這個對象沒有被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應地,在執行monitorexit指令時會將鎖計數器減1,當計數器爲0時鎖就被釋放了。如果獲取對象鎖失敗了,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放爲止。這裏有兩點需要注意,第一就是synchronized對同一條線程來說是可重入的。第二,當某個線程還沒執行完被synchronized加鎖的代碼前,其他線程都會被阻塞掉。一個線程的阻塞和喚醒都需要系統進行用戶態和核心態的轉換,因此synchronized會有點性能損耗。
ReentrantLock
這個和synchronized有點類似,只是代碼上寫法有點區別:

ReentrantLock lock = new ReentrantLock(); // not a fair lock
lock.lock();
try {
    //  doSomething()
} finally {
    lock.unlock();
}

可以看到這個每次要對代碼加鎖的時候都需要手動lock下,然後執行完在unlock掉。但是ReentrantLock比synchronized多了一些高級功能,主要有這個三項:等待可中斷、可實現公平鎖、以及鎖可以綁定多個條件,這裏就不展開說了。

非阻塞同步

上面我們介紹了阻塞同步方式,也就是說當一個線程正在執行同步代碼塊時,另一個線程要執行時是進不去的,會阻塞掉,當前一個線程執行完後這個線程纔會喚醒,線程頻繁的阻塞和喚醒是非常消耗性能的。如果要避免這個問題還可以用非阻塞同步的方式。什麼是非阻塞同步呢?就是當一個線程要執行代碼時,可以直接執行,如果沒有發生數據競爭,那麼可以順利執行,如果發生了數據競爭就不斷地重試直到執行成功。那麼有個問題就是,判讀數據是否發生了競爭,和具體的執行操作一定是原子性的,或者說是同步的,但這裏我們不能用上面的阻塞同步,不然就沒什麼意義了,因爲這裏要講的是非阻塞同步。那麼有什麼方法可以保證這兩步是原子性的呢?答案就是硬件自身。硬件保證很多從語義上來講需要很多步的操作只通過一條處理器指令就可以完成。這些指令常用的有:

  • 測試並設置(Test-and-Set)
  • 獲取並增加(Fetch-and-Increment)
  • 交換(Swap)
  • 比較並交換(Compare-and-Swap)
  • 加載鏈接/條件儲存(Load-Linked/Store-Conditional)

當然這裏與我們有關係的就是第四條比較並交換(Compare-and-Swap),我們簡稱CAS指令。CAS指令需要三個參數,第一個是內存地址,第二個是這個內存中的舊值,第三個是要替換這個值的新值。他的作用就是判斷這個內存中當前的值是否爲我們傳入的這個舊值,如果是說明數據沒有被改,也就沒有發生數據競爭,可以執行第二步也就是把這個值換成我們傳入的第三個參數。如果不是,說明這個值被別的線程改了,那麼就不斷地重試直到可以執行爲止。既然這個指令是硬件層的,那我們該怎麼用呢?在JDK 1.5 之後,Java程序中才可以使用CAS操作,該操作是由sun.misc.Unsafe類裏面的compareAndSwapInt()和compareAndSwapLong()等幾個包裝提供,虛擬機在內部對這些方法做了特殊處理,即使編譯出來的結果就是一條平臺相關的處理器CAS指令,沒有方法調用的過程,或者可以認爲是無條件內聯進入了。但是Unsafe類是隱藏的類,我們直接訪問不到只能通過其他Java API間接使用。如J.U.C包裏面的整數原子類,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作。接下來舉一個具體的例子來說明下:

public class IncreaseTest{
    public static int race = 0;
    public static void increase(){
        race++;
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) throw Exception{
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
            threads[i] = new Thread(new Runnable()){
                @Override
                public void run(){
                    for(int i = 0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount()>1){
            Thread.yield();
        }
        System.out.println(race);
    }
}

上面這段代碼很簡單,我們定義了一個初始量race = 0,然後定義了20個線程,每個線程對race進行累加1000遍,按理說執行完這段代碼,race的值是20000,但是實際執行後會發現這個值都會是小於20000,這個就是典型出現了線程同步問題,簡單的分析下,就是某個線程從主內存中讀取race這個量,然後執行累加操作,但是還沒等這個race量寫回主內存,就被打斷了,另一個線程來了從主內存中讀取原來的值,再執行完累加操後寫入了主內存,這是又切換回了之前的線程,這時的線程繼續接下來的操作也就是把原來累加完的值寫回主內存,但是這樣一寫回就會覆蓋掉剛纔中間插進來線程累計後的值,這樣程序執行完結果自然就小於期望的值。那麼這裏就可以用上面的CAS,我們把上面的代碼稍微改下:

public class AtomicTest{
    public static AtomicInteger race = new AtomicInteger(0);
    public static void increase(){
        race.incrementAndGet();
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) throw Exception{
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
            threads[i] = new Thread(new Runnable()){
                @Override
                public void run(){
                    for(int i = 0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount()>1){
            Thread.yield();
        }
        System.out.println(race);
    }
}
------
運行結果:
20000

可以看到我們用AtomicInteger代替int後,程序輸出了正確的結果,這一切都要歸功於incrementAndGet()方法的原子性,它的實現很簡單,如下:

public final int getAndIncrement(){
    for(;;){
        int current = get();
        int next = current + 1;
        if (compareAndSet(current,next)){
            return current;
        }
    }
}

可以看到,這是一個for循環,先獲取當前的值,然後計算加1後的值,接着就是通過compareAndSet方法進行比較,如果當前的值還是current,說明沒有數據競爭就把next替換這個舊值,否則就一直循環直到成功,這裏compareAndSet就是對硬件CAS指令的封裝,是一個原子操作。
通過這個例子,相信你對CAS有了一個好的理解,當然CAS還會有一個問題,就是如果我判斷某個值爲A時我就把這個A替換掉,但是存在這麼一種情況就是,這個值剛開始爲A然後被改成了B,接着又被改成了A,這樣用CAS判斷時會認爲這個值沒有發生更改,即沒有發生數據競爭。我們稱之爲ABA問題,J.U.C包爲了解決這個問題,提供了一個帶標記的原子引用類AtomicStampedReference,它可以通過控制變量值的版本來保證CAS的正確性,不過目前這個類比較雞肋,大部分情況下ABA問題不會影響程序併發的正確性,如果需要解決ABA問題,改用傳統的互質同步可能會比原子類更高效。

無同步方案

要保證線程安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保障共享數據爭用的手段,如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性,因此會有些代碼天生就是線程安全的,比如可重入代碼線程本地存儲
可重入代碼(Reentrant Code)
這種代碼也叫純代碼,可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤。相對線程安全來說,可重入性是更基本的特性,它可以保證線程安全,即所有的可重入的代碼都是線程安全的,但是並非說有的線程安全的代碼都是可重入的。可通過如下方法來判斷代碼是否可重入:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。
線程本地存儲
如果一段代碼中所需的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。

高效併發——鎖優化

上面我們分析了線程併發帶來的問題,以及如何解決這些問題,其實虛擬機爲了更好的更高效的支持併發,在內部做了很多優化,主要是運用一些鎖優化技術,如適應性自旋鎖、鎖消除、鎖粗化、輕量級鎖、偏向鎖等,這些技術都是爲了在線程之間更高效地共享數據,以及解決競爭問題,從而提高程序的執行效率。
自旋鎖與自適應自旋
前面我們提到互斥同步會掛起線程影響性能,如果物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程稍等一會兒,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖,爲了讓線程等待,我們只須讓線程執行一個忙循環(自旋),這項技術就是自旋鎖。
自旋鎖在JDK1.4.2中就已經引入,只不過默認是關閉的,可以使用-XX:UseSpinning參數來開啓,在JDK1.6中就已經改爲默認開啓了,自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了線程切換的開銷,但它是要佔用處理器時間的,所以如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之如果鎖被佔用的時間很長,那麼自旋的線程只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來性能的浪費。因此自選等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應使用傳統的方式去掛起線程了。自旋次數的默認值是10次,用戶可以使用參數-XX:PreBlockSpin來更改。
在JDK1.6中引入了自適應的自旋鎖。自適應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個循環。另一方面,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越準確,虛擬機就會變得越來越聰明瞭。
鎖消除
鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當做棧上數據對待,認爲它們是線程私有的,同步加鎖自然就無須進行。那麼爲什麼沒有共享數據競爭也存在同步邏輯呢,因爲有時候同步操作不是我們程序員自己加的,如下代碼:

public String concatString(String s1,String s2,String s3){
    return s1+s2+s3;
}

我們知道由於String是一個不可變的類,對字符串的連接操作總是通過生成新的String對象來進行的,因此Javac編譯器會對String連接做自動優化。在JDK 1.5之前,會轉化爲StringBuffer對象的連續append()操作,在JDK1.5及以後的版本中,會轉化爲StringBuilder對象的連續append操作。即如下代碼:

public String concatString(String s1,String s2,String s3){
    StringBuffer sb  = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

這裏的每一個append方法內部都是同步的,由於這些代碼都是在一個方法中,所以這裏雖然有鎖,但是可以被安全地消除掉,在即時編譯之後,這段代碼會忽略點所有的同步而直接執行了。
鎖粗化
原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變小,如果存在鎖競爭,那等待鎖的線程也能儘快地拿到鎖。
大部分情況下,上面的原則是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
輕量級鎖
輕量級鎖是JDK1.6中加入的新型鎖機制,它名字中的輕量級是相對於使用操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就被稱爲重量級鎖。首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能損耗。
要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運作過程,必須從HotSpot虛擬機的對象(對象頭部分)的內存佈局開始介紹。HotSpt虛擬機的對象頭(Object Header)分爲兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡等,這部分數據的長度在32位和64位的虛擬機中分別爲32個和64個Bits,官方稱它爲Mark Word,它是實現輕量級鎖和偏向鎖的關鍵。另外一部分用於存儲指向方法區對象類型數據的指針,如果是數組對象的話,還會有一個額外的部分用於存儲數組長度。
對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,MarkWord被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機中對象未被鎖定的狀態下,MarkWord的32個Bits空間中,25Bits用於存儲對象哈希碼,4Bits用於存儲對象分代年齡,2Bit用於存儲鎖標記位,1Bit固定爲0,在其他狀態下對象的存儲內容如下表:

存儲內容 標誌位 狀態
對象HashCode、對象分代年齡 01 未鎖定
指向鎖記錄的指針 00 輕量級鎖定
指向重量級鎖的指針 10 膨脹
空,不需要記錄信息 11 GC標記
偏向線程,偏向時間戳、對象分代年齡 01 可偏向

在代碼進入同步塊的時候,如果此同步對象沒有被鎖定,虛擬機首先將在當前線程的幀棧中建立一個名爲鎖記錄的空間,用於存儲鎖對象目前的MarkWord的拷貝,這時候線程堆棧與對象頭的狀態如下圖所示:


然後虛擬機將使用CAS操作嘗試將對象的MarkWord更新爲指向Lock Record的指針。如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象MarkWord的鎖標誌位將轉變爲00,即表示此對象處於輕量級鎖定的狀態,這時候線程堆棧與對象頭的狀態如下圖所示:

如果這個更新操作失敗了,虛擬機首先會檢查對象的MarkWord是否指向當前線程的幀棧,如果是就說明當前線程已經擁了這個對象的鎖,可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶佔了,如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹爲重量級鎖,鎖標誌的狀態變爲10,MarkWord中存儲的就是指向重量級鎖(互斥量的指針),後面等待鎖的線程也要進入阻塞狀態。
上面描述的輕量級鎖的加鎖過程,它的解鎖過程也是通過CAS操作來進行的,如果對象的MarkWord仍然指向線程的鎖記錄,那就用CAS操作把對象當前的MarkWord和線程中複製的Displaced Mark Word替換回來,如果替換成功,整個同步過程就完成了,如果替換失敗,說明有其他線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖能提升同步性能的依據是對於絕大部分的鎖,在整個同步週期內部都是不存在競爭的這是一個經驗數據,如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外產生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。
偏向鎖
偏向鎖也是JDK1.6中引入的一項鎖優化,它的目的是消除數據在無競爭情況下的同步,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。可通過參數-XX:+UseBiaseLocking來啓動偏向鎖。
最後用一個鎖狀態升級的圖結下尾:

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