如果有人跟你撕逼Java內存模型,就把這些問題甩給他

  • <strong>
  • <strong>
  • <strong>
  • <strong>

導讀

JVM內存模型(JMM)是併發的基礎,要是想紮實的理解併發原理,那麼就必須對JMM有比較深刻的認識。相信大部分朋友都有所瞭解了。這兩天回顧了一下相關內容,在琢磨怎麼才能更加直觀的表達出這個內存模型,並且對這個模型有比較深刻的認識。剛好最近想做做動畫,所以打算練練手嘗試下以動畫的形式來描述下這個模型,順便看看有沒有成長為一個動畫大師的資質。

為此,本文我我將從以下幾個方面展開來說明:

  • 內存模型是什麼,有什麼用,以及Java內存模型是怎樣的;
  • Java內存模型是如何實現多線程同步的;
  • 常見的同步問題;

無論你是跟同事、同學、上下級、同行、或者面試官撕逼的時候,大家都會使出自己畢生所學,通過各種手段一步一步的把對方逼上投降之路,典型的招式如奪命連環問,一環扣一環,直至分出高下。而討論到JMM,大家常見的撕逼方式如下:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


如果有人跟你撕逼Java內存模型,就把這些問題甩給他


如果有人跟你撕逼Java內存模型,就把這些問題甩給他

如果您對這些問題都瞭如指掌,那麼恭喜你,說明你的基礎很紮實,是個狠人。但是也可以看看我下面精心準備的動圖和說明圖,交流下,看看有無錯漏之處。如果你剛好有不太明白的知識點,可以繼續往下看,可以解開你一切的迷惑,撥開Java代碼背後的內存模型迷霧。下次有人跟你討論Java內存模型,就把這些問題甩給他。


本文我們來探討一下Java內存模型JMM。

說到JMM,我們不得不提及多處理器體系結構,以及多線程。

1、什麼是內存模型

什麼是內存模型,為什麼需要內存模型。我們得從高速緩存帶來的一些問題說起。

1.1、高速緩存

在多處理器系統中,處理器通常具有一層或者多層的高速緩存,這可以通過加快對數據的訪問速度(因為數據更靠近處理器)和減少共享內存總線上的流量(因為可以滿足許多內存操作)來提高性能。

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


但是以上的流程又帶來了許多新的挑戰,例如:

當兩個處理器同時讀取和寫入相同的內存位置的時候會發生什麼呢?他們將在什麼條件下才可以看到一致的內容,怎麼確保所有的緩存都是一致的呢?

為了解決一致性問題,需要各個處理器訪問緩存的時候都遵循一些協議,讀寫的時候需要根據協議來進行操作,相關協議有:MSI、MESI、MOSI、Synapse、Firefly、Dragon Protocol等,如下圖:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


內存模型可以理解為在特定操作協議下對特定的內存或高速緩存進行讀寫訪問過程的一個抽象,即內存模型。

1.2、內存模型

在處理器級別,內存模型定義了必要和充分的條件,以便當讓其他處理器對內存的改動對當前處理器可見,不同的處理器有不同的內存模型:

  • 一些處理器內存模型比較強大,表現為其所有處理器始終在任何給定的內存位置看到完全相同的值;
  • 其他處理器的內存模型表現的比較弱,需要通過特殊的稱為內存屏障(memory barriers)的指令來刷新緩存,以便對緩存的寫入對其他處理器可見,或使本地處理器高速緩存無效,以便重新獲取其他處理器寫入的緩存。這些內存屏障通常在執行鎖定和解鎖操作的時候執行,對於高級語言來說,它們是不可見的。後面在講解Java內存模型的時候會專門介紹下內存屏障。

2、Java內存模型

先來點概念性的東西,後面再上圖。

Java內存模型描述了多線程代碼中哪些行為是合法的,以及線程如何通過內存進行交互。它描述了程序中變量與低級別的詳細信息之間的關係,這些低級別詳細信息在實際計算機系統中的存儲器或寄存器之間進行存儲和檢索。

Java語言提供了volatile, final, 和 synchronized 旨在幫助程序員向編譯器描述程序的併發要求。

Java內存模型定義了volatile和synchronized的行為,更重要的是,確保做了正確同步的Java程序可以在所有的處理器體系結構上正確運行。

Java內存模型主要參與者:

變量:這裡的變量,主要指實例字段、類變量,以及數組中的對象元素,不包括局部變量和方法參數(線程私有);

主內存:共享的主存儲器,變量保存在這裡;因為一個線程不可能訪問另一個線程的參數和局部變量,所以將局部變量視為駐留在共享主存儲器或者工作內存裡面都沒有關係;

工作內存:每個線程都有一個工作內存,在其中保留了自己必須使用或分配的變量的工作副本。線程執行的時候,將對這些工作副本繼續操作。主內存包含每個變量的主副本。對於何時允許或要求線程將其變量的工作副本的內容傳輸到主副本存在一些規則,反之亦然;

Java線程:後面介紹Java線程的文章會詳細講解。

那麼可以得出一些的Java內存模型參與者協作圖:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


其中的線程引擎指的是JVM執行引擎。

2.1、Java內存模型原子操作

線程和主存的交互,Java內存模型定義了8種操作[1]:這些操作都是原子的(double和long類型有例外):

  • use:變量從工作內存傳遞給執行引擎。每當虛擬機線程遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作;
  • assign:把一個從執行引擎接收到的值複製給工作內存的變量。每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作;
  • read:把一個變量的值從主內存拷貝到工作內存,以便為隨後的load動作使用;
  • load:把read操作從主內存獲取的變量值放入工作空間的副本中;
  • store:把工作內存中的變量值傳送到主內存中,為後續的write操作使用;
  • write:把store操作從工作內存得到的變量的值放入主內存的變量中;
  • lock:把一個變量標識為線程獨佔狀態;
  • unlock:釋放線程獨佔的變量。

我在想,怎麼樣才能更好地解釋這8個操作呢?這8個操作主要是為變量服務的,讓變量在主內存和工作內存之間來回移動,並傳遞給線程引擎去執行,最終我覺得用下面的代碼例子製作成動畫效果來解釋下這個步驟,其中執行引擎執行的代碼片段為:

<code> 1public class InterMemoryInteraction { 2 3    public synchronized static void add() { 4        ClassA classA = new ClassA(); 5        classA.var +=2; 6        System.out.println(classA.var); 7    } 8 9    public static void main(String[] args) {10        add();11    }12}1314class ClassA {15    Integer var = 10;16}複製代碼/<code>

對應的關鍵反彙編指令:

<code>112: getfield      #4                  // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;215: invokevirtual #5                  // Method java/lang/Integer.intValue:()I318: iconst_2419: iadd520: invokestatic  #6                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;623: dup_x1724: putfield      #4                  // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;複製代碼/<code>

這8個操作執行的可以通過如下動畫演示:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


這動畫效果,看來還是離動畫大師差個十萬八千里,但是夢想還是要有的。畫動畫不易,畫動圖比較花時間,動畫方式闡釋到此告一段落。大家要是覺得好就給我點個贊,說不定第二季很快就上映了…

在Javase8中的文檔,為了方便理解,這些操作做了調整,改為了以下幾種操作[4] ,其實底層的模型並沒有變。

Read:讀一個變量 Write:寫一個變量 同步操作: Volatile read:易變讀一個變量 Volatile write:易變寫一個變量 Lock:鎖定獨佔一個變量 Unlock:釋放一個獨佔的變量 線程的第一個或者最後一個操作 啟動線程或檢測到線程已終止的操作

也可以通過如下流程描述這8個指令的工作:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


2.2、volatile可見性和和有序性

volatile是JVM最輕量級的同步機制。

Java中的volatile關鍵字用作Java編譯器和Thread的指示符,它們不緩存變量的值,始終從主內存讀取它,因此,如果您希望共享實例中的讀寫操作是原子性的,可以將他們聲明為volatile變量。

2.2.1、volatile的作用

變量使用了volatile之後意味著具有兩種特性:

2.2.1.1、可見性:保證變量對所有的線程可見

變量的值在線程間傳遞均需要通過主內存來完成:在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值。

使用了volatile之後,能夠保證新值能夠立即同步到主內存,以及每次使用前立即從主內存刷新。

如下圖:針對volatile變量,執行use操作之前,總是會同時觸發read和load操作,執行assign操作之後,總是會同時觸發store和write操作:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


但可見性並不意味這在併發下是安全的,考慮一下代碼,開啟20個線程,每個線程循環10000次給一個volatile的變量+1,我們期望結果是20000:

<code> 1public class VolatileTest { 2 3    public static volatile int race = 0; 4 5    public synchronized static void increase() { 6        race ++; 7    } 8 9    private static final int THREADS_COUNT = 20;1011    public static void main(String[] args) {12        Thread[] threads = new Thread[THREADS_COUNT];13        for (int i = 0; i  {15                for (int j = 0; j  2)23        Thread.yield();24        System.out.println(race);25    }2627}複製代碼/<code>

但是我們最終發現每次執行的結果都不太一樣,但總是不會達到20000。

原因是雖然volatile確保了可見性,更新之後可以對其他線程立刻可見,但是這裡的+1操作並不是原子的,看反彙編代碼就比較清楚了:

<code>10: getstatic     #2                  // Field race:I23: iconst_134: iadd45: putstatic     #2                  // Field race:I複製代碼/<code>

嚴格上來說,即使反彙編的代碼只有一條指令,實際翻譯為本地機器碼的時候也可能會對應多條機器指令,也就是說一條指令也不一定是原子操作。

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


如上圖,兩個線程同時執行getstatic指令,都獲取到了最新的r(race這裡簡寫為r)值10。

假設線程1先執行完了所有指令,那麼會把工作內存1中最終的值11寫回r變量;

然後線程2也執行相同的指令,把工作內存2中最終的值11寫回r變量。

可見線程1的值被線程2覆蓋了。

結論

對於不依賴當前值的assign操作,並且變量不需要與其他的狀態變量共同參與不變約束,volatile可以確保其原子性。

典型的應用如:不管當前開關狀態是什麼,我現在要打開開關,那麼操作之後,打開狀態可以立即對其他線程可見。

2.2.1.2、有序性:禁止指令重排

下面我們看一個經典的雙重檢查鎖定DCL問題。

為了支持惰性初始化,並且避免同步開銷,我們編寫的檢查鎖定代碼可能會像下面這樣:

<code> 1public class Singleton { 2 3    private static Singleton instance; 4 5    public static Singleton getInstance() { 6        if (instance == null) { 7            synchronized (Singleton.class) { 8                if (instance == null) { 9                    instance = new Singleton();10                    // 這一句代碼實際上會翻譯為如下三句11                              // reg0 = calloc(sizeof(Singleton));12                              // reg0.<init>();13                              // instance = reg0;14                }15            }16        }17        return instance;18    }1920    /**21     * hsdis-amd64.dylib  https://cloud.tencent.com/developer/article/108267522     * HSDIS是一個Java官方推薦 HotSpot虛擬機JIT編譯代碼的反彙編插件。我們有了這個插件後,通過JVM參數-XX:+PrintAssembly就可以加載這個HSDIS插件,23     * 然後為我們把JIT動態生成的那些本地代碼還原成彙編代碼,然後打印出來。24     * @param args25     */26    public static void main(String[] args) {27        // 由於指令編排問題,可能返回空對象28        Singleton.getInstance();29    }3031}複製代碼/<init>/<code>

如上面註釋所屬,創建單例的語句instance = new Singleton();會翻譯為三個語句,而這三個語句缺少順序限制,即使是順序的,也可能在單個CPU內核上面併發執行,導致執行順序不確定。

在現代x86芯片上,即使在單個內核上,多個指令也肯能並行發生,早在1993年發佈的第一批奔騰處理器中,x86就能夠在一個內核上同時運行多個指令。從1995年的Pentium Pro開始,x86芯片開始無序運行我們的代碼。

也就是說編譯器或者CPU內核都有可能對操作指令進行重排序。

最終的執行順序有可能是這樣的

<code>1reg0 = calloc(sizeof(Singleton));2instance = reg0;3reg0.<init>();複製代碼/<init>/<code>

這樣子,另一個線程就可能拿到了這個還沒有執行構造方法<init>()的空對象。/<init>

為了避免這個不期望的情況出現,我們需要在instance變量前面添加volatile:

private static volatile Singleton instance;

添加之後我們再次執行,可以看到生成的彙編代碼有一個指令包含了lock前綴:

<code> 1  0x0000000113fc76a4: movabs $0x7957d2e18,%rax  ;   {oop(a 'java/lang/Class' = 'com/itzhai/jvm/executeengine/concurrency/Singleton')} 2  0x0000000113fc76ae: mov    0x20(%rsp),%rsi 3  0x0000000113fc76b3: mov    %rsi,%r10 4  0x0000000113fc76b6: shr    $0x3,%r10 5  0x0000000113fc76ba: mov    %r10d,0x68(%rax) 6  0x0000000113fc76be: shr    $0x9,%rax 7  0x0000000113fc76c2: movabs $0x10d94f000,%rsi 8  0x0000000113fc76cc: movb   $0x0,(%rax,%rsi,1) 9  0x0000000113fc76d0: lock addl $0x0,(%rsp)     ;*putstatic instance10                                                ; - com.itzhai.jvm.executeengine.concurrency.Singleton::getInstance@24 (line 37)複製代碼/<code>

這個lock前綴指令之前的一條指令就是對instance的賦值操作。

內存屏障:這個lock操作相當於一個內存屏障。遇到這個lock前綴之後,會讓本CPU的cache寫入內存,並且讓其他CPU的cache無效化,從而實現了變量的可見性。

同時這個內存屏障能夠實現有序性:volatile變量賦值語句所在的位置相當於一個內存屏障,賦值語句前後的的指令不能跨過這道屏障。

volatile實際上是通過內存屏障實現了可見性和有序性。

2.2.2、什麼時候使用volatile

2.2.2.1、如果要讀寫long和double變量,可以使用volatile

long和double是64位數據類型,他們的原子性與平臺有關,許多平臺long和double變量分兩步進行寫,每步32位,可能會導致數據不一致。您可以通過在Java中使用volatile修飾long和double變量來避免此類問題。

2.2.2.2、需要使用可見性的場景

某一個線程更新一個具體的值(這個值的修改不依賴原值並且不需要與其他的狀態變量共同參與不變約束)之後,需要其他線程能夠立刻看到。

2.2.2.3、明確變量需要用於多線程訪問

volatile變量可用於通知編譯器特定字段將被多個線程訪問,這將阻止編譯器進行任何重排序或任何類型的優化,特別是在在多線程環境中是不希望的優化。

如下例

<code>1private boolean isActive = thread;2public void printMessage(){3  while(isActive){4     System.out.println("Thread is Active");5  }6}複製代碼/<code>

如果沒有volatile修飾符,則不能保證一個線程從另一線程中看到isActive的更新值。編譯器還可以自由緩存isActive的值,而不必在每次迭代中從主內存中讀取它。通過將isActive設置為volatile變量,可以避免這些問題。

2.2.2.4、雙重鎖檢查

上面已經列舉類這類例子,為了確保指令執行的有序性,所有需要加上volatile關鍵字。

2.2.3、volatile關鍵字使用要點

  • 僅適用於變量;
  • 保證變量值總是從主內存中讀取,而不是從Thread的本地緩存,也就是工作內存;
  • 使用volatile關鍵字聲明的操作不一定都是原子的,取決於編譯出來的彙編指令;
  • 除了long和double類型,即使不使用volatile關鍵字,原始類型變量讀和寫都具有可見性;
  • 如果一個變量沒有在多線程之間共享,則不需要對變量使用volatile關鍵字;
  • volatile變量訪問永遠不會有阻塞機會,因為我們只進行簡單的讀取和寫入操作,不會保持任何鎖或等待任何鎖。

2.3、同步操作Synchronized

通過使用同步,可以實現任意大語句塊的原子性單位,使我們能夠解決volatile無法實現的read-modify-write問題。

2.3.1、底層是怎麼實現的呢?

我們可以寫一個代碼來看看其反彙編代碼:

如果有人跟你撕逼Java內存模型,就把這些問題甩給他


可以發現,synchronized塊最終變為了由monitorenter和monitorexit包裹的反彙編指令語句塊。

翻看jvm規範看看這兩個指令的作用 Chapter 6. The Java Virtual Machine Instruction Set[2]:

monitorenter:操作對象是一個reference對象,每個對象都與一個監視器關聯,如果有其他線程獲取了這個對象的monitor,當前的線程就要等待。每個對象的監視器有一個objectref條目計數器對象,成功進入監視器之後,監視器的objectref+1,然後,該線程就成為監視器的所有者了。

同一個線程重複執行monitorenter,會重新進入監視器,並且objectref+1。

monitorexit:操作對象是一個reference對象,執行該指令,objectref-1,直到objectref=0的時候,線程退出監視器,不再是對象所有者。

2.3.2、Synchronized如何實現可見性

Synchronized確保以可運行的方式使線程在同步塊之前或者期間對內存的寫入對於監視同一個對象的其他線程可見。

  • 執行了monitorenter之後,釋放監視器,並且將工作內存刷新到主內存中,以便該線程進行的寫入對其他線程可見;
  • 在進入同步塊之前,我們先要先執行monitorenter,使得當前線程的工作內存無效化,以便從主內存中重新加載變量。

思考以下代碼有何問題?

1synchronized (new Object()) {}複製代碼

2.4、final

Java中使用final字段的時候,JVM保證對象的使用者僅在構造完成後才能看到該字段的最終值。

為了達到這個目的,JVM會在final對象構造函數的末尾引入凍結操作,該操作可以防止對構造函數進行任何後續操作,或者進行指令重排。

舉個例子:

<code>1instance = new Singleton();複製代碼/<code>

從宏觀上看,可以認為將new分解為3個語句:

<code>1reg0 = calloc(sizeof(Singleton));2reg0.<init>();3instance = reg0;複製代碼/<init>/<code>

在給instance賦值前,確保<init>()構造方法限制下,保證了instance將得到最終值。/<init>

2.4、關於非原子的double和long變量

虛擬機實現選擇可以不保證64位數據類型的load,store,read,write這個操作的原子性。

但一般虛擬機實現幾乎都把64位數據的讀寫操作作為原子操作來對待,方便編碼。

2.5、舊版本Java內存模型的問題

自1997年以來,在java語言規範中定義的Java內存模型中發現了一些嚴重的缺陷,這些缺陷使行為混亂(如final字段會更改其值),並且破壞了編譯器執行常規優化的能力。為此,引入了JSR 133提案[3],JSR 133為Java語言定義了一種新的內存模型,優化了final和volatile的語義,該模型修復了早期內存模型的缺陷。本文截止到目前以上內容均是基於JSR 133規範來闡述的。

舊的模型允許將volatile寫入與非volatile讀寫進行重新排序,這與大多數開發人員對volatile的直接並不一致,引發了混亂。程序員對於錯誤的同步其程序可能會發生什麼的直接通常是錯的,JSR 133的目標之一是引起人們對這一事實的關注。

3、Java內存模型併發不得不關注的三個問題

3.1、原子性

如何保證原子性?

  • Java中我們可以認為基本數據類型(除了double和long)的訪問讀寫是具備原子性的;
  • 可以通過synchronized關鍵字實現更大範圍的原子性保證,底層是用到了monitorenter和monitorexit指令實現的,對應的操作為:lock和unlock。

3.2、可見性

Java內存模型是通過變量修改後將新值同步會內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的。

普通變量和volatile變量都是這樣實現的。

volatile變量能夠立即同步到主內存,每次使用前立即從主內存刷新。所以volatile保證了多線程操作的變量可見性,而普通變量不能保證這一點。

另外兩個能實現可見性的關鍵字:

  • synchronzied:對一個變量執行unlock操作之前,必須先把此變量同步會主內存(執行store,write操作)
  • final:final字段在構造器中一旦初始化完成,並且構造器沒有把this引用傳遞出去的話,那麼其他線程就能看見final字段的值。

3.3、有序性

在線程內觀察,所有操作都是有序的(語義的串行),但是在一個線程內觀察另一個線程,所有操作都是無序的(指令重排序導致的)的。

實現有序性:

  • volatile關鍵字:禁止指令重排;
  • synchronized:基於一個變量在同一個時刻只允許一條線程對其進行lock操作實現的,表現為持有同一個鎖定兩個同步塊只能串行執行。

4、內存模型的先行發生規則

Java內存模型語義在內存操作(讀取變量,寫入變量,鎖定,解鎖)和其他線程操作(start和join)上約定了一些執行順序:

  • 程序次序規則:同一個線程,書寫在前面的操作先行發生於書寫在後面的操作;
  • 監控鎖定規則:unlock操作先行發生於後面同一個鎖的lock操作;
  • volatile變量規則:一個volatile變量的寫操作先行發生於後面對這個變量的讀操作;
  • 線程啟動規則:Thread的start()方法先行發生於此線程每一個動作;
  • 線程終止規則:線程所有操作都先行發生於對此線程的終止檢測;
  • 對象終結規則:對象從構造函數先行發生於它的finalize()方法;
  • 傳遞性:如果A操作先行發生於B,B操作先行發生於C,那麼A操作先行發生於C;

根據以上規則可言判斷程序是否線程安全的。


作者:Arthinking
原文鏈接:https://juejin.im/post/5e4bdbc8f265da57375c391f


分享到:


相關文章: