Java內存模型-JMM解析

1.前言

在解析JMM之前,我們首先要明確,java併發編程說到底就是爲了處理兩個關鍵問題:

  • 線程之間通信
  • 線程之間同步

我們先簡要概述一下,在徹底瞭解了java內存模型之後,我們可以往更深層次進行探究,那麼開始:

  • 線程通信指線程之間的信息交互,由於線程裏的內容是線程私有的,所以必須通過一些手段達到信息交換的目的,這裏有兩種:共享內存消息傳遞,其中共享內存會在本文重點介紹,並且也是java併發採取的模式
  • 線程同步指不同線程之間操作的相對順序,有過多線程基礎的同學很快指出,synchronized關鍵字和lock鎖或者volatile關鍵字,我們需要在代碼中明確寫出該在哪裏進行同步,從而讓線程互斥執行

java的併發採用內存模型,並且java的線程通信對我們程序員來說是透明的,在開發中可能會遇到各種問題,因此需要弄清楚java的內存模型

2.java內存模型的定義

2.1爲何定義

《Java虛擬機規範》,定義了一種Java內存模型(Java Memory Model) 來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的訪問效果,在JDK5之後,Java內存模型才終於成熟完善

2.2 主內存和工作內存

java內存模型的主要目的是:

  • 定義程序中各種變量的訪問規則(虛擬機中把變量值存儲到內存和從內存中取出這樣的底層細節)

我們這裏說的變量和我們在程序代碼裏寫的變量不同,這裏的變量指實例變量,靜態字段,和構成數組對象的元素(文章後面提到的變量都值這裏所說的變量),但是不包括局部變量和方法參數,因爲前面的變量都是線程之間共享,存儲在堆空間中(JKD8),被線程共享,而後面的變量都是線程私有的,jvm創建線程時會爲每個線程創建一個,也叫虛擬機棧,這些變量存儲在線程的局部變量表中,是線程私有的,有疑惑的同學請看我的這篇

Java內存模型規定了所有的變量都存儲主內存,注意這裏的主內存,是依附在物理上的內存,jvm就運行在物理內存上(我的電腦是16G),所以這裏的主內存就是虛擬機的一部分,除此之外,每條線程還有自己的工作內存(本地內存),工作內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩衝區,寄存器以及其他的硬件和編譯器優化線程的,工作內存中保存了被該線程使用的在主內存中的變量副本,線程對變量的讀取賦值等操作都必須在工作內存中進行,不能直接在主內存中進行讀寫,如圖所示:
在這裏插入圖片描述

2.3內存間的交互操作

經過上面的介紹,我們可以模擬出兩個線程通信的方式,線程A和線程B通信:

  • 線程A將工作內存中操作過的共享變量刷新到主內存中去
  • 線程B到主內存中獲取被線程A操作過的變量

將上面兩步更細緻的劃分,就可以探索接下來的一部分: 主內存和工作內存的交互協議,這個協議將主內存與工作內存的變量交互定義爲了以下8中操作:

  • lock(鎖定):作用於主內存的變量,它把一個變量標識爲一個線程獨佔的狀態;
  • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定;
  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳送到線程中的工作內存,以便隨後的load動作使用;
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中;
  • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎;
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存中的變量;
  • store(存儲):作用於工作內存的變量,它把工作內存中的一個變量的值傳送到主內存中,以便隨後的write操作;
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值寫入主內存的變量中。

我們通過一張圖來詳細描述這個過程,包括手寫的lock和unlock:
在這裏插入圖片描述

Java內存模型對這八種操作制定了以下規則:

  • 不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須
    write
  • 不允許線程丟棄他最近的assign操作,即工作變量的數據改變了之後,必須告知主存
  • 不允許一個線程將沒有assign的數據從工作內存同步回主內存
  • 一個新的變量必須在主內存中誕生,不允許工作內存直接使用一個未被初始化的變量。就是懟變量實施use、store操作之前,必須經過assign和load操作
  • 一個變量同一時間只有一個線程能對其進行lock。多次lock後,必須執行相同次數的unlock才能解
  • 如果對一個變量進行lock操作,會清空所有工作內存中此變量的值,在執行引擎使用這個變量前,必須重新load或assign操作初始化變量的值
  • 如果一個變量沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變量
  • 對一個變量進行unlock操作之前,必須把此變量同步回主內存

需要注意的是:read和load ,store和write必須按照順序來,但不要求連續,也就是這兩組操作可以插入其他指令,比如主內存中有a,b兩個變量,可以是這種順序:read a,read b,load b,load a;

2.4 volatile的出現

在2.3中通過圖示描述了工作內存和主內存是如何進行變量交互的,這種模式下很可能出現問題:
比如主內存存在一個變量num = 1,線程A對其操作,複製其副本到工作內存,此時線程B也對num進行操作比如num = 2,並且成功,但此時A線程還是原先num的值,這就出現了問題,導致線程不安全,這時候Java虛擬機提供瞭解決方案:提供了volatile來解決,它是一個輕量級的同步機制,在進行volatile詳細講解前,先通俗的說一下它的作用:

  • 保證了變量對所有線程可見,可見指的是當一個線程修改了這個變量的值,新值對其他線程來說是可以立刻得知,舉個例子:線程A修改了一個變量值,而這個變量剛好在線程B中也有一份,那麼A修改過後B線程就會得知變量已經修改了,進而操作修改過後的值,普通變量就只能循規蹈矩的等待線程Awrite進主內存,然後B再修改,而且無法保證安全性

這裏需要提出一個問題:雖然被volatile修飾的變量對所有線程可見,對volatile變量的修改都能立刻反應到其他線程中,但是卻無法保證基於volatile變量的運算在併發條件下是安全的,怎麼理解這句話呢,我們通過一個例子來說明:

public class Demo {
    private volatile static int num = 0;
    public static void add(){
        num++;
    }
    public static void main(String[] args) {
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 0; j < 1000 ; j++) {
                    add();
                }
            }).start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num);
    }
}

輸出結果:main 19210
我們這段程序的目的是開通20個線程對同一資源進行每個線程1000次的自增操作,按道理結果應該是20000,爲什麼結果會變小?答案是volatile雖然保證了變量在程序間的可見性,但是並不能保證該變量的運算的原子性,要解決這個問題可以使用另外更加重量級的同步機制: synchronized或者lock,我們來看一下爲什麼num++爲什麼不是一個原子性操作,我們反編譯剛纔的代碼,我們取出add()方法的字節碼文件:

  public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field num:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field num:I
         8: return
      LineNumberTable:
        line 13: 0
        line 14: 8

我們可以清楚的看到一個++操作對應的是5行字節碼命令,我們可以使用原子類操作來進行代替++操作,對原子類不瞭解的同學可以看一下:
JUC併發-CAS原子性操作和ABA問題及解決
最後總結一句話:volatile是一個輕量級的同步機制,保證可見性不保證原子性,可以用同步方法或者原子類來解決原子性問題

3.原子性,可見性,有序性

併發的三個特徵分別是原子性,可見性,有序,而Java內存模型就是圍繞着在併發過程中如何處理這三個特徵來建立的,本來這些概念不需要再說的,但在瞭解了java內存模型止後再來看確實有更好的理解:

  • 原子性是指不可再分的最小操作指令,即單條機器指令,原子性操作任意時刻只能有一個線程,因此是線程安全的。
    Java內存模型中通過read、load、assign、use、store和write這6個操作保證變量的原子性操作。

    long和double這兩個64位長度的數據類型java虛擬機並沒有強制規定他們的read、load、store和write操作的原子性,即所謂的非原子性協定,但是目前的各種商業java虛擬機都把long和double數據類型的4中非原子性協定操作實現爲原子性。所以java中基本數據類型的訪問讀寫是原子性操作。

    對於大範圍的原子性保證需要通過lock和unlock操作以及synchronized同步塊來保證。

  • 可見性是指當一個線程修改了共享變量的值,其他線程可以立即得知這個修改。
    Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值來實現可見性的。Java中通過volatilefinalsynchronized這三個關鍵字保證可見性:

    • volatile:通過刷新變量值確保可見性。
    • synchronized:同步塊通過變量lock鎖定前必須清空工作內存中變量值,重新從主內存中讀取變量值,unlock解鎖前必須把變量值同步回主內存來確保可見性。
    • final:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把this引用傳遞進去,那麼在其他線程中就能看見final字段的值,無需同步就可以被其他線程正確訪問。對於final文章後部分會有專門討論
  • 有序性是指:在線程內部,所有的操作都是有序執行的,而在線程之間,因爲工作內存和主內存同步的延遲,操作是亂序執行的。Java通過volatile和synchronized關鍵字確保線程之間操作的有序性。

    • volatile禁止指令重排序優化實現有序性。
    • synchronized通過一個變量在同一時刻只允許一個線程對其進行lock鎖定操作來確保有序性。

注意:synchronized都滿足以上三個特徵,看似是一種萬能的解決方案,但是注意使用它的時候會出現性能問題

4.指令重排序

前面提到了volatile禁止指令重排序優化實現有序性。什麼是指令重排,簡單的來說:你寫的程序,計算機並不是按照你寫的那樣去執行的。
在java代碼執行的時候,編譯器和處理器常常會對指令做重排序,這些排序可分爲三種類型:

  • 編譯器優化的重排序
  • 指令集並行的重排序
  • 內存系統的重排序

它們之間的順序:

  • 源代碼–>編譯器優化的重排–> 指令並行也可能會重排–> 內存系統也會重排—> 執行

關於指令重排,我們知道有這個概念就行了,也無法深入,涉及到計算機底層,來看一段代碼:

int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

這些語句我們希望的執行順序是從上到下依次執行,但是在經過一系列指令重排之後,它的順序可能是1324或者2134,但是對最終的結果沒有影響,但在多線程條件下,指令重排可能會導致一些問題:

  • 線程A有這樣一段代碼:x = a,b = 1,線程B有這樣一段代碼:y = b,a = 2(假設abxy默認值都是零),按照我們的設想,正確結果應該是x = 0;y = 0
  • 考慮指令重排的情況下,線程A就變成了:b=1,x=a,線程B變成了:a=2,y=b,指令重排導致的詭異結果: x = 2;y = 1;

出現問題的原因是:由於兩個線程中的代碼沒有數據依賴關係,所以在經過指令重排過後,代碼順序發生改變,導致最終結果也發生改變
數據依賴分爲以下三個類型

名稱 代碼示例 說明
寫後讀 a=1;b=1 寫一個變量後,再讀這個變量
寫後寫 a=1;a=2 寫一個變量後,再寫這個變量
讀後寫 a=b;b=1; 讀一個變量後,再寫這個變量

針對以上三種類型,重排序必定會導致結果發生變化

5.Happens-Before(先行發生原則)

Happens-Before是Java內存模型中一個非常重要的概念,happens-before是判斷數據是否存在競爭、線程是否安全的重要依據,想要一個操作執行的結果需要對另一個操作可見,那麼你們可以使用 happens-before 規則,我們先來看一段代碼:

i = 1//線程A中執行
j = i//線程B中執行
i = 2//線程C中執行

在解釋這段代碼前,我們要對先行發生原則(Happens-Before)有一個大概的瞭解:

  • 如果一個操作與另一個操作有Happens-Before關係,那麼第一個操作將對第二個操作可見,且第一個操作的順序要在第二個操作之前

我們回到代碼,假如線程A和線程B存在Happens-Before關係,那麼A的操作i = 1就先發生於B的j = i,於是我們就可以確定j的值爲1,這個時候來了一個線程C,而線程C的操作有不確定性,在A和B的先行關係不變的情況下,假如C在A和B之間發生,這時候B執行操作完了,其j的值是多少?1和2都有可能,因爲B和C沒有確定先行發生規則,這就不具備多線程的安全性
上面是我們假設的一種情況,這裏列舉幾個常見的Java“天然的”happens-before關係,這些關係沒有任何同步器協助就已經存在,可以直接在編碼中使用

  • 程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作(也就是說你寫的操作,如果是單線程執行,那麼前面的操作[程序邏輯上的前]就會happens-before於後面的操作)

  • 監視器鎖規則: 一個unlock操作先行發生於後面對同一個鎖的lock操作,是針對同一個鎖,後面是針對時間上的概念

  • volatile變量規則: 對一個 volatile域的寫操作,先行發生于于任意後續對這個volatile域的讀操作

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

  • 線程start()規則:主線程A啓動線程B,線程B中可以看到主線程啓動B之前的操作。也就是start() happens before 線程B中的操作。

  • 線程join()規則:主線程A等待子線程B完成,當子線程B執行完畢後,主線程A可以看到線程B的所有操作。也就是說,子線程B中的任意操作,happens-before join()的返回。

Java語言無需任何同步手段保障就能成立的先行發生規則就只有上面這些,這裏需要多提一下happend-before和指令重排序的關係,但是我們之前也提到了,指令重排序可能會使程序結果發生改變,雖然這個機率很小,而volatile可以避免指令重排序,而volatile是滿足寫後讀的happens-before規則,那麼volatile是怎麼做到的避免指令重排呢?

6.volatile避免指令重排

給大家畫個圖:

在這裏插入圖片描述
想要了解更多的請看:volatile內存解析

說明

本文參考書籍:《深入理解Java虛擬機》《Java併發編程的藝術》

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