併發編程十二-Java內存模型以及底層實現原理

目錄在app上無法點擊,應該是CSDN的bug。大家當做簡介看好了。下拉正文。

目錄

前言:JMM基礎-計算機原理

1、物理內存模型帶來的問題

2、僞共享

3、Java內存模型(JMM)

4、Java內存模型帶來的問題

4.1 可見性問題

4.2 競爭問題

4.3 重排序

5、volatile詳解

5.1 volatile特性

5.2 volatile的內存語義

5.3 爲何volatile不是線程安全的

5.4 volatile內存語義的實現

5.5 volatile的實現原理

6、final的內存語義

6.1 final的兩個重排序規則

6.2 final域爲引用類型

6.3 final引用不能從構造函數內逃逸

6.4 final語義的實現

7、鎖的內存語義

7.1 鎖得內存語義

7.2 synchronized的實現原理

8、瞭解各種鎖

8.1 自旋鎖

8.2 鎖的狀態

8.3 偏向鎖

8.4 輕量級鎖

8.5 不同鎖的比較

8.6 JDK對鎖的更多優化措施


前言:JMM基礎-計算機原理

面試的時候,我們經常會將JVM的內存模型和JVM的運行時數據區搞混。

其實Java內存模型即Java Memory Model,簡稱JMM。JMM定義了Java 虛擬機(JVM)在計算機內存(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬於JVM的。Java1.5版本對其進行了重構,現在的Java仍沿用了Java1.5的版本。Jmm遇到的問題與現代計算機中遇到的問題是差不多的。

物理計算機中的併發問題,物理機遇到的併發問題與虛擬機中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機的實現也有相當大的參考意義。

根據《Jeff Dean在Google全體工程大會的報告》我們可以看到

計算機在做一些我們平時的基本操作時,需要的響應時間是不一樣的。(以下案例僅做說明,並不代表真實情況。)

如果從內存中讀取1M的int型數據由CPU進行累加,耗時要多久?

做個簡單的計算,1M的數據,Java裏int型爲32位,4個字節,共有1024*1024/4 = 262144個整數 ,則CPU 計算耗時:262144 *0.6 = 157 286 納秒,而我們知道從內存讀取1M數據需要250000納秒,兩者雖然有差距(當然這個差距並不小,十萬納秒的時間足夠CPU執行將近二十萬條指令了),但是還在一個數量級上。但是,沒有任何緩存機制的情況下,意味着每個數都需要從內存中讀取,這樣加上CPU讀取一次內存需要100納秒,262144個整數從內存讀取到CPU加上計算時間一共需要262144*100+250000 = 26 464 400 納秒,這就存在着數量級上的差異了。

而且現實情況中絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操作是基本上是無法消除的(無法僅靠寄存器來完成所有運算任務)。早期計算機中cpu和內存的速度是差不多的,但在現代計算機中,cpu的指令速度遠超內存的存取速度,由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。

在計算機系統中,寄存器劃是L0級緩存,接着依次是L1,L2,L3(接下來是內存,本地磁盤,遠程存儲)。越往上的緩存存儲空間越小,速度越快,成本也更高;越往下的存儲空間越大,速度更慢,成本也更低。從上至下,每一層都可以看做是更下一層的緩存,即:L0寄存器是L1一級緩存的緩存,L1是L2的緩存,依次類推;每一層的數據都是來至它的下一層,所以每一層的數據是下一層的數據的子集。

在現代CPU上,一般來說L0, L1,L2,L3都集成在CPU內部,而L1還分爲一級數據緩存(Data Cache,D-Cache,L1d)和一級指令緩存(Instruction Cache,I-Cache,L1i),分別用於存放數據和執行數據的指令解碼。每個核心擁有獨立的運算處理單元、控制器、寄存器、L1、L2緩存,然後一個CPU的多個核心共享最後一層CPU緩存L3

1、物理內存模型帶來的問題

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。

現代的處理器使用寫緩衝區臨時保存向內存寫入的數據。寫緩衝區可以保證指令流水線持續運行,它可以避免由於處理器停頓下來等待向內存寫入數據而產生的延遲。同時,通過以批處理的方式刷新寫緩衝區,以及合併寫緩衝區中對同一內存地址的多次寫,減少對內存總線的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對內存操作的執行順序產生重要的影響:處理器對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致。

處理器A和處理器B按程序的順序並行執行內存訪問,最終可能得到x=y=0的結果。

處理器A和處理器B可以同時把共享變量寫入自己的寫緩衝區(步驟A1,B1),然後從內存中讀取另一個共享變量(步驟A2,B2),最後才把自己寫緩存區中保存的髒數據刷新到內存中(步驟A3,B3)。當以這種時序執行時,程序就可以得到x=y=0的結果。

從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1纔算真正執行了。雖然處理器A執行內存操作的順序爲:A1→A2,但內存操作實際發生的順序卻是A2→A1。

如果真的發生這種情況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

2、僞共享

前面我們已經知道,CPU中有好幾級高速緩存。但是CPU緩存系統中是以緩存行(cache line)爲單位存儲的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line可以簡單的理解爲CPU Cache中的最小緩存單位,今天的CPU不再是按字節訪問內存,而是以64字節爲單位的塊(chunk)拿取,稱爲一個緩存行(cache line)。當你讀一個特定的內存地址,整個緩存行將從主存換入緩存。

一個緩存行可以存儲多個變量(存滿當前緩存行的字節數);而CPU對緩存的修改又是以緩存行爲最小單位的,在多線程情況下,如果需要修改“共享同一個緩存行的變量”,就會無意中影響彼此的性能,這就是僞共享(False Sharing)。

爲了避免僞共享,我們可以使用數據填充的方式來避免,即單個數據填充滿一個CacheLine。這本質是一種空間換時間的做法。但是這種方式在Java7以後可能失效。

Java8中已經提供了官方的解決方案,Java8中新增了一個註解@sun.misc.Contended。

比如JDK的ConcurrentHashMap中就有使用

加上這個註解的類會自動補齊緩存行,需要注意的是此註解默認是無效的,需要在jvm啓動時設置-XX:-RestrictContended纔會生效。

測試代碼

public class FalseSharing  implements Runnable
{
    public final static int NUM_THREADS =
            Runtime.getRuntime().availableProcessors();
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;

    /*數組大小和CPU數相同*/
//    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
//    private static VolatileLongPadding[] longs = new VolatileLongPadding[NUM_THREADS];

    private static VolatileLongAnno[] longs = new VolatileLongAnno[NUM_THREADS];
    static{
        /*將數組初始化*/
        for (int i = 0; i < longs.length; i++){
            longs[i] = new VolatileLongAnno();
        }
    }

    public FalseSharing(final int arrayIndex){
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception{
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }

    private static void runTest() throws InterruptedException{
        /*創建和CPU數相同的線程*/
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++){
            threads[i] = new Thread(new FalseSharing(i));
        }

        for (Thread t : threads){
            t.start();
        }

        /*等待所有線程執行完成*/
        for (Thread t : threads){
            t.join();
        }
    }

    /*訪問數組*/
    public void run(){
        long i = ITERATIONS + 1;
        while (0 != --i){
            longs[arrayIndex].value = i;
        }
    }

    public final static class VolatileLong {
        public volatile long value = 0L;
    }

    // long padding避免false sharing
    // 按理說jdk7以後long padding應該被優化掉了,但是從測試結果看padding仍然起作用
    public final static class VolatileLongPadding {
        public long p1, p2, p3, p4, p5, p6, p7;
        public volatile long value = 0L;
        volatile long q0, q1, q2, q3, q4, q5, q6;
    }

    /**
     * jdk8新特性,Contended註解避免false sharing
     * Restricted on user classpath
     * Unlock: -XX:-RestrictContended
     */
    @sun.misc.Contended
    public final static class VolatileLongAnno {
        public volatile long value = 0L;
    }

一個類中,只有一個long類型的變量:

定義一個VolatileLong類型的數組,然後讓多個線程同時併發訪問這個數組,這時可以想到,在多個線程同時處理數據時,數組中的多個VolatileLong對象可能存在同一個緩存行中。

運行後,可以得到運行時間

花費了39秒多。

我們改用進行了緩存行填充的變量

花費了8.1秒,如果任意註釋上下填充行的任何一行,時間表現不穩定,從8秒到20秒都有,但是還是比不填充要快。具體原因目前未知。

再次改用註解標識的變量,同時加入參數-XX:-RestrictContended

花費了7.7秒。

由上述的實驗結果表明,僞共享確實會影響應用的性能。

3、Java內存模型(JMM)

從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩衝區、寄存器以及其他的硬件和編譯器優化。

4、Java內存模型帶來的問題

4.1 可見性問題

左邊CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存,把對象obj的count變量改爲2。但這個變更對運行在右邊CPU中的線程不可見,因爲這個更改還沒有flush到主存中。

在多線程的環境下,如果某個線程首次讀取共享變量,則首先到主內存中獲取該變量,然後存入工作內存中,以後只需要在工作內存中讀取該變量即可。同樣如果對該變量執行了修改的操作,則先將新值寫入工作內存中,然後再刷新至主內存中。但是什麼時候最新的值會被刷新至主內存中是不太確定,一般來說會很快,但具體時間不知。

要解決共享對象可見性這個問題,我們可以使用volatile關鍵字或者是加鎖。

4.2 競爭問題

線程A和線程B共享一個對象obj。假設線程A從主存讀取Obj.count變量到自己的CPU緩存,同時,線程B也讀取了Obj.count變量到它的CPU緩存,並且這兩個線程都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU緩存中。

如果這兩個加1操作是串行執行的,那麼Obj.count變量便會在原始值上加2,最終主存中的Obj.count的值會是3。然而圖中兩個加1操作是並行的,不管是線程A還是線程B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,儘管一共有兩次加1操作。 要解決上面的問題我們可以使用java synchronized代碼塊

4.3 重排序

4.3.1 重排序類型

除了共享內存和工作內存帶來的問題,還存在重排序的問題:在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。

1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

3)內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

4.3.2 數據依賴性

數據依賴性:如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分爲下列3種類型,上面3種情況,只要重排序兩個操作的執行順序,程序的執行結果就會被改變。

例如:

很明顯,A和C存在數據依賴,B和C也存在數據依賴,而A和B之間不存在數據依賴,如果重排序了A和C或者B和C的執行順序,程序的執行結果就會被改變。

很明顯,不管如何重排序,都必須保證代碼在單線程下的運行正確,連單線程下都無法正確,更不用討論多線程併發的情況,所以就提出了一個as-if-serial的概念。

4.3.3 as-if-serial

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因爲這種重排序會改變執行結果。(強調一下,這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。)但是,如果操作之間不存在數據依賴關係,這些操作依然可能被編譯器和處理器重排序。

A和C之間存在數據依賴關係,同時B和C之間也存在數據依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的結果將會被改變)。但A和B之間沒有數據依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。

  

as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器、runtime和處理器可以讓我們感覺到:單線程程序看起來是按程序的順序來執行的。asif-serial語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

4.3.4 控制依賴性

上述代碼中,flag變量是個標記,用來標識變量a是否已被寫入,在use方法中變量i的賦值依賴if (flag)的判斷,這裏就叫控制依賴,如果發生了重排序,結果就不對了。

考察代碼,我們可以看見,

操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對這兩個操作重排序。操作3和操作4則存在所謂控制依賴關係

在程序中,當代碼中存在控制依賴性時,會影響指令序列執行的並行度。爲此,編譯器和處理器會採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行爲例,執行線程B的處理器可以提前讀取並計算a*a,然後把計算結果臨時保存到一個名爲重排序緩衝(Reorder Buffer,ROB)的硬件緩存中。當操作3的條件判斷爲真時,就把該計算結果寫入變量i中。猜測執行實質上對操作3和4做了重排序,問題在於這時候,a的值還沒被線程A賦值。

在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因)。

但是對多線程來說就完全不同了:這裏假設有兩個線程A和B,A首先執行init ()方法,隨後B線程接着執行use ()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?答案是:不一定能看到。

讓我們先來看看,當操作1和操作2重排序,操作3和操作4重排序時,可能會產生什麼效果?操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。由於條件判斷爲真,線程B將讀取變量a。此時,變量a還沒有被線程A寫入,這時就會發生錯誤!

所以在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。

4.3.4 內存屏障

Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按我們預想的流程去執行。

1、保證特定操作的執行順序。

2、影響某些數據(或則是某條指令的執行結果)的內存可見性。

編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化性能。插入一條Memory Barrier會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的數據,因此,任何CPU上的線程都能讀取到這些數據的最新版本。

JMM把內存屏障指令分爲4類

 StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。

4.3.5 臨界區

JMM會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,使得多線程在這兩個時間點按某種順序執行。

臨界區內的代碼則可以重排序(但JMM不允許臨界區內的代碼“逸出”到臨界區之外,那樣會破壞監視器的語義)。雖然線程A在臨界區內做了重排序,但由於監視器互斥執行的特性,這裏的線程B根本無法“觀察”到線程A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程序的執行結果。

回想一下,爲啥線程安全的單例模式中一般的雙重檢查不能保證真正的線程安全?

4.3.6 happens-before

在Java 規範提案中爲讓大家理解內存可見性的這個概念,提出了happens-before的概念來闡述操作之間的內存可見性。對應Java程序員來說,理解happens-before是理解JMM的關鍵。

JMM這麼做的原因是:程序員對於這兩個操作是否真的被重排序並不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關係本質上和as-if-serial語義是一回事。as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變。

定義

用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係 。

兩個操作之間具有happens-before關係,並不意味着前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)

加深理解

上面的定義看起來很矛盾,其實它是站在不同的角度來說的。

1)站在Java程序員的角度來說:JMM保證,如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)站在編譯器和處理器的角度來說:JMM允許,兩個操作之間存在happens-before關係,不要求Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序是允許的。

回顧我們前面存在數據依賴性的代碼:

站在我們Java程序員的角度:

但是仔細考察,2、3是必需的,而1並不是必需的,因此JMM對這三個happens-before關係的處理就分爲兩類:

1.會改變程序執行結果的重排序

2.不會改變程序執行結果的重排序

JMM對這兩種不同性質的重排序,採用了不同的策略,如下:

1.對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序;

2.對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求。

於是,站在我們程序員的角度,看起來這個三個操作滿足了happens-before關係,而站在編譯器和處理器的角度,進行了重排序,而排序後的執行結果,也是滿足happens-before關係的。

Happens-Before規則

JMM爲我們提供了以下的Happens-Before規則:

1)程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。

2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

4)傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

5)start()規則:如果線程A執行操作ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。

6)join()規則:如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。

7 )線程中斷規則:對線程interrupt方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生。

5、volatile詳解

5.1 volatile特性

之前章節都講過用法,本章主要講解volatile的實現以及原理。以及一些問題。

可以把對volatile變量的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步

可以看成

所以volatile變量自身具有下列特性:

可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。

原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

5.2 volatile的內存語義

內存語義:可以簡單理解爲 volatile,synchronize,atomic,lock 之類的在 JVM 中的內存方面實現原則。

volatile寫的內存語義如下:
當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。

volatile讀的內存語義如下:
當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

所以對於代碼

如果我們將flag變量以volatile關鍵字修飾,那麼實際上:線程A在寫flag變量後,本地內存A中被線程A更新過的兩個共享變量的值都被刷新到主內存中。

在讀flag變量後,本地內存B包含的值已經被置爲無效。此時,線程B必須從主內存中讀取共享變量。線程B的讀取操作將導致本地內存B與主內存中的共享變量的值變成一致。

如果我們把volatile寫和volatile讀兩個步驟綜合起來看的話,在讀線程B讀一個volatile變量後,寫線程A在寫這個volatile變量之前所有可見的共享變量的值都將立即變得對讀線程B可見。

5.3 爲何volatile不是線程安全的

5.4 volatile內存語義的實現

volatile重排序規則表

總結起來就是:

當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

volatile的內存屏障

在Java中對於volatile修飾的變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序問題。

volatile寫

storestore屏障:對於這樣的語句store1; storestore; store2,在store2及後續寫入操作執行前,保證store1的寫入操作對其它處理器可見。(也就是說如果出現storestore屏障,那麼store1指令一定會在store2之前執行,CPU不會store1與store2進行重排序)

storeload屏障:對於這樣的語句store1; storeload; load2,在load2及後續所有讀取操作執行前,保證store1的寫入對所有處理器可見。(也就是說如果出現storeload屏障,那麼store1指令一定會在load2之前執行,CPU不會對store1與load2進行重排序)

volatile讀

在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個loadstore屏障。

  loadload屏障:對於這樣的語句load1; loadload; load2,在load2及後續讀取操作要讀取的數據被訪問前,保證load1要讀取的數據被讀取完畢。(也就是說,如果出現loadload屏障,那麼load1指令一定會在load2之前執行,CPU不會對load1與load2進行重排序)

  loadstore屏障:對於這樣的語句load1; loadstore; store2,在store2及後續寫入操作被刷出前,保證load1要讀取的數據被讀取完畢。(也就是說,如果出現loadstore屏障,那麼load1指令一定會在store2之前執行,CPU不會對load1與store2進行重排序)

5.5 volatile的實現原理

通過對OpenJDK中的unsafe.cpp源碼的分析,會發現被volatile關鍵字修飾的變量會存在一個“lock:”的前綴。

Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解爲CPU指令級的一種鎖。

同時該指令會將當前處理器緩存行的數據直接寫會到系統內存中,且這個寫回內存的操作會使在其他CPU裏緩存了該地址的數據無效。

  1. 在具體的執行上,它先對總線和緩存加鎖,然後執行後面的指令,最後釋放鎖後會把高速緩存中的髒數據全部刷新回主內存。在Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。

6、final的內存語義

在構造線程的類時,我們有種方式就是讓類中所有的成員變量都不可變,利用的就是final關鍵字,那麼這個final爲何可以做到呢?重排序這種優化動作對構造方法,一樣也是存在的。這就說明,一個成員變量加了final關鍵字後,JMM一定是做了相關處理的。

6.1 final的兩個重排序規則

對應final域,編譯器和處理器需要遵守兩個重排序規則。我們以代碼cn.enjoyedu.ch9.semantics. FinalMemory來說明

/**
 * 類說明:final的內存語義
 */
public class FinalMemory {
    int i;                      // 普通變量
    final int j;                // final變量
    static FinalMemory obj;

    public FinalMemory() {    // 構造函數
        i = 1;                 // 寫普通域
        j = 2;                 // 寫final域
    }

    public static void writer() {// 寫線程A執行
        obj = new FinalMemory();
    }

    public static void reader() {   // 讀線程B執行
        FinalMemory object = obj;  // 讀對象引用
        int a = object.i;           // 讀普通域
        int b = object.j;           // 讀final域
    }
}

我們假設一個線程A執行writer方法,隨後另一個線程B執行reader方法。

1、在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

看write()方法,只包含一行代碼 obj = new FinalMemory();。這一行代碼包含兩個步驟:

構造一個FinalMemory類型的對象。

把這個對象的引用賦值給引用變量obj。

假設線程B讀對象引用(FinalMemory object = obj)與讀對象的成員域之間(int a = object.i;int b = object.j)沒有重排序,下面的圖是一種可能的執行時序:

 

從上面可能的時序圖中我們可以看到,寫普通域被編譯器重排序到了構造函數之外,讀線程B錯誤的讀取了普通變量i初始化之前的值。而寫final域的操作,被寫final域的重排序規則“限制”到了構造函數之內,讀線程B正確讀取了final變量初始化之後的值。

總結:寫final域的重排序規則可以確保在對象引用爲任意線程可見之前,對象的final域已經被正常的初始化了,而普通域不具有這樣的保證。

2、初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序

在一個線程中,初次讀對象引用與初次讀該對象包含的final,JMM禁止處理器重排序這兩個操作。編譯器會在讀final域操作的前面插入一個LoadLoad屏障

reader()方法包含3個步驟:

初次讀引用變量obj

初次讀引用變量obj指向對象的普通域 i

初次讀引用變量obj指向對象的final域 j

 我們假設寫線程A沒有發生任何重排序,則下圖是一種可能的時序:

讀對象的普通域的操作被處理器重排序到讀對象引用之前。讀普通域時,該域還沒有被線程A寫入,所以上面的是一個錯誤的讀取操作。但是讀final域的重排序規則把讀對象final域的操作“限定”在讀對象引用之後,該final域已經被A線程初始化了,是一個正確的讀取操作。

總結:讀final域的重排序規則可以確保在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用。

6.2 final域爲引用類型

/**
 * 類說明:
 */
public class FinalRefMemory {
    final int[] intArray;       // final 是引用類型
    static FinalRefMemory obj;

    public FinalRefMemory() {   // 構造函數
        intArray = new int[1];         // 1
        intArray[0] = 1;               // 2
    }

    public static void writerOne() {   // 寫線程A執行
        obj = new FinalRefMemory();// 3
    }

    public static void writeTwo() {     // 寫線程B執行
        obj.intArray[0] = 2;            // 4
    }

    public static void reader() {       // 讀線程C執行
        if (obj != null) {              // 5
            int temp1 = obj.intArray[0];// 6
        }
    }

}

 

在上面的代碼中,final域是一個引用類型,它引用了一個int類型的數組,對於引用類型,寫final域的重排序規則對編譯器和處理器增加了一下的約束:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

我們假設線程A先執行write0操作,執行完後線程B執行write1操作,執行完後線程C執行reader操作,下圖是一種可能的執行時序:

1是對final域的寫入,2是對這個final域引用的對象的成員域的寫入,3是把被構造的對象的引用賦值給某個引用變量。這裏除了前面提到的1不能和3重排序外,2和3也不能重排序。

JMM可以確保讀線程C至少能看到寫線程A在構造函數中對final引用對象的成員域的寫入。即C至少能看到數組下標0的值爲1。而寫線程B對數組元素的寫入,讀線程C可能看得到,也可能看不到。JMM不保證線程B的寫入對讀線程C可見,因爲寫線程B和讀線程C之間存在數據競爭,此時的執行結果不可預知。

如果想要確保讀線程C看到寫線程B對數組元素的寫入,寫線程B和讀線程C之間需要使用同步(lock或volatile)來確保內存可見性。

6.3 final引用不能從構造函數內逃逸

寫final域的重排序規則可以確保:在引用變量爲任意線程可見之前,該引用變量指向的對象的final域已經在構造函數中被正確初始化過了。其實,要得到這個效果,還需要一個保證:在構造函數內部,不能讓這個被構造對象的引用爲其他線程所見,也就是對象引用不能在構造函數中逃逸。

/**
 * 類說明:不能讓final引用從構造方法中溢出
 */
public class FinalEscape {
    final int i;
    static FinalEscape obj;

    public FinalEscape() {
        i = 10;         //寫final域
        obj = this;     //this引用溢出
    }

    public static void writer(){
        new FinalEscape();
    }

    public static void reader(){
        if(obj!=null){          //3
            int temp = obj.i;   //4
        }
    }
}

假設一個線程A執行writer()方法,另一個線程B執行reader()方法。這裏的操作2使得對象還未完成構造前就爲線程B可見。即使這裏的操作2是構造函數的最後一步,且在程序中操作2排在操作1後面,執行read()方法的線程仍然可能無法看到final域被初始化後的值,因爲這裏的操作1和操作2之間可能被重排序。

因此在構造函數返回前,被構造對象的引用不能爲其他線程所見,因爲此時的final域可能還沒有被初始化。

6.4 final語義的實現

會要求編譯器在final域的寫之後,構造函數return之前插入一個StoreStore障屏。

讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障

7、鎖的內存語義

7.1 鎖得內存語義

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。

當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

 

我們先看一個程序

 

/**
 * 類說明:演示鎖的內存語義
 */
public class SynMemory {
    private static boolean ready;
    private static int number;

    private static class PrintThread extends Thread{
        @Override
        public void run() {
            while(!ready){

            }
            System.out.println("number = "+number);
        }
    }

    public static void main(String[] args) {
        new PrintThread().start();
        SleepTools.second(1);
        number = 51;
        ready = true;
        SleepTools.second(5);
        System.out.println("main is ended!");
    }
}

發現上面的線程一直在死循環,即使main方法裏改了ready的值,直到程序結束。

但如果我們在循環里加個打印的話。在執行會發生什麼呢?

   while(!ready){
                System.out.println("number = "+number);
            }

加了打印發現線程讀的值被修改了,並且終止了執行。打印出了number的值,這是爲什麼呢?我們點開println方法可以發現

    /**
     * Prints a String and then terminate the line.  This method behaves as
     * though it invokes <code>{@link #print(String)}</code> and then
     * <code>{@link #println()}</code>.
     *
     * @param x  The <code>String</code> to be printed.
     */
    public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

結合前面鎖的內存語義,我們可以知道,當進入synchronized語句塊時,子線程會被強制從主內存中讀取共享變量,其中就包括了ready變量,所以子線程同樣中止了。

7.2 synchronized的實現原理

Synchronized在JVM裏的實現都是基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。

對同步塊,MonitorEnter指令插入在同步代碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該對象Monitor的所有權,即嘗試獲得該對象的鎖,而monitorExit指令則插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit。

對同步方法,從同步方法反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來實現,相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。

JVM就是根據該標示符來實現方法的同步的:當方法被調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。

synchronized使用的鎖是存放在Java對象頭裏面,

具體位置是對象頭裏面的MarkWord,MarkWord裏默認數據是存儲對象的HashCode等信息,

但是會隨着對象的運行改變而發生變化,不同的鎖狀態對應着不同的記錄存儲方式

8、瞭解各種鎖

8.1 自旋鎖

原理

自旋鎖原理非常簡單,如果持有鎖的線程能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的線程就不需要做內核態和用戶態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的線程釋放鎖後即可立即獲取鎖,這樣就避免用戶線程和內核的切換的消耗。

但是線程自旋是需要消耗CPU的,說白了就是讓CPU在做無用功,線程不能一直佔用CPU自旋做無用功,所以需要設定一個自旋等待的最大時間。

如果持有鎖的線程執行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的線程在最大等待時間內還是獲取不到鎖,這時爭用線程會停止自旋進入阻塞狀態。

自旋鎖的優缺點

自旋鎖儘可能的減少線程的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的代碼塊來說性能能大幅度的提升,因爲自旋的消耗會小於線程阻塞掛起操作的消耗!

但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因爲自旋鎖在獲取鎖前一直都是佔用cpu做無用功,佔着XX不XX,線程自旋的消耗大於線程阻塞掛起操作的消耗,其它需要cup的線程又不能獲取到cpu,造成cpu的浪費。

自旋鎖時間閾值

自旋鎖的目的是爲了佔着CPU的資源不釋放,等到獲取到鎖立即進行處理。但是如何去選擇自旋的執行時間呢?如果自旋執行時間太長,會有大量的線程處於自旋狀態佔用CPU資源,進而會影響整體系統的性能。因此自旋次數很重要

JVM對於自旋次數的選擇,jdk1.5默認爲10次,在1.6引入了適應性自旋鎖,適應性自旋鎖意味着自旋的時間不在是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,基本認爲一個線程上下文切換的時間是最佳的一個時間。

JDK1.6中-XX:+UseSpinning開啓自旋鎖; JDK1.7後,去掉此參數,由jvm控制;

8.2 鎖的狀態

一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態,它會隨着競爭情況逐漸升級。鎖可以升級但不能降級,目的是爲了提高獲得鎖和釋放鎖的效率。

8.3 偏向鎖

引入背景:大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖,減少不必要的CAS操作。

偏向鎖,顧名思義,它會偏向於第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,減少加鎖/解鎖的一些CAS操作(比如等待隊列的一些CAS操作),這種情況下,就會給線程加一個偏向鎖。 如果在運行過程中,遇到了其他線程搶佔鎖,則持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。它通過消除資源無競爭情況下的同步原語,進一步提高了程序的運行性能。

偏向鎖獲取過程:

步驟1、 訪問Mark Word中偏向鎖的標識是否設置成1,鎖標誌位是否爲01,確認爲可偏向狀態。

步驟2、 如果爲可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟5,否則進入步驟3。

步驟3、 如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置爲當前線程ID,然後執行5;如果競爭失敗,執行4。

步驟4、 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。(撤銷偏向鎖的時候會導致stop the word)

步驟5、 執行同步代碼。

偏向鎖的釋放:

偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放偏向鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。

偏向鎖的適用場景

始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級爲輕量級鎖,升級爲輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致stop the word操作; 

在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況下應當禁用。

jvm開啓/關閉偏向鎖

開啓偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

關閉偏向鎖:-XX:-UseBiasedLocking

8.4 輕量級鎖

輕量級鎖是由偏向鎖升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級爲輕量級鎖; 

輕量級鎖的加鎖過程:

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態且不允許進行偏向(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之爲 Displaced Mark Word。

拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock record裏的owner指針指向object mark word。如果更新成功,則執行步驟4,否則執行步驟5。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,即表示此對象處於輕量級鎖定狀態

如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,當競爭線程嘗試佔用輕量級鎖失敗多次之後,輕量級鎖就會膨脹爲重量級鎖,重量級線程指針指向競爭線程,競爭線程也會阻塞,等待輕量級線程釋放鎖後喚醒他。鎖標誌的狀態值變爲“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。

 

8.5 不同鎖的比較

8.6 JDK對鎖的更多優化措施

逃逸分析

如果證明一個對象不會逃逸方法外或者線程外,則可針對此變量進行優化:

同步消除synchronization Elimination,如果一個對象不會逃逸出線程,則對此變量的同步措施可消除。

鎖消除和粗化

鎖消除:虛擬機的運行時編譯器在運行時如果檢測到一些要求同步的代碼上不可能發生共享數據競爭,則會去掉這些鎖。

鎖粗化:將臨近的代碼塊用同一個鎖合併起來。

消除無意義的鎖獲取和釋放,可以提高程序運行性能。

 

到了這章,JAVA中併發編程相關的知識點差不多就已經整理完了,總共12篇文章,碼了大概10幾個demo,堅持了3個多月,希望自己可以堅持下去,繼續更新。也希望大家可以有所收穫。共勉~

其他閱讀   

併發編程十一java8新增的併發特性

併發編程專題十-其他的併發容器

併發編程專題九-併發容器ConcurrentHashMap源碼分析

併發編程專題八-hashMap死循環分析

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