[JUC第四天]Java內存模型基礎知識

Java內存模型概述

JMM是什麼

首先區分量名詞:爪哇內存分區爪哇內存模型

爪哇內存分區是指的這個:

image-20200307151909602

爪哇內存模型是指的這個:

image-20200307152142382

現在進入正題:

爪哇內存模型,即Java Memory Model(JMM),屬於語言級內存模型,指的是用爪哇語言抽象出來的一套對內存的管理機制,因爲爪哇的虛擬機是跨平臺跑的,對於各種不同的操作系統,都需要抽象出一套統一的規範,來屏蔽底層的不同,使得爪哇程序對內存的操作也能跨平臺

JMM負責了各種變量在虛擬機中的儲存管理,變量的可見性就是由JMM決定的,同時JMM也要負責線程通信,JMM的線程之間的通訊是採用的共享內存的方式來實現的…可以簡單理解成,你CPU有對不同進程有自己的內存緩存管理方式,對於我爪哇JVM而言,我這個進程內部也有自己方式來管理我的線程

而JMM的設計的抽象的工作結構,大概是下面這個樣子的:

JMM抽象結構

在JMM中,有兩個概念:工作內存主內存

主內存裏存放了一些共享變量(就是JVM分區裏Heap堆裏放的那些東西)

每個線程有自己獨自的工作內存,包含了自己的運行時棧,還有對主內存中共享變量的拷貝

image-20200307152839200

工作內存每次需要操作共享變量的時候,並不是從主內存中取了然後直接進行操作,而是先拷貝一份然後放到自己的工作內存空間中,對這個拷貝的變量來進行操作然後再做寫回操作

這個操作自然而然就讓人想起了CPU的緩存設計:

image-20200303205119392

其實JMM的設計就是照着緩存設計來的:

image-20200307153834061

所以,對於JMM來說,他也就會存在緩存不一致的問題了

JMM緩存一致性

不一致問題

看這樣一段代碼:

/**
 * @author Lehr
 * @create: 2020-03-06
 */
public class WorkingMemory {

    private static Boolean flag = false;

    public static void main(String[] args) {

        new Thread(()->{
           while(!flag){};
            System.out.println("我跳出了循環!");
        }).start();

        new Thread(()->{
            System.out.println("我現在看到的flag是:"+flag);
            System.out.println("我要開始修改了");
            flag = true;
            System.out.println("我好了");
            System.out.println("我現在看到的flag是:"+flag);
        }).start();


    }
}

運行這段代碼,則輸出的情況(多半)是這樣:

image-20200307154648973

注意看左邊的運行狀態,程序依然在運行,但是第一個程序始終沒有跳出循環,說明他看到的flag還是false

這就可以用我們上面說過的JMM的模型來解釋了:

image-20200307154918647

一開始的時候,對於兩個線程而言,他們都會把flag=false拷貝到自己的工作內存中

image-20200307155152471

然而後來,線程2修改了自己工作內存中的副本值,並寫回到主內存,可是對於線程1來說,暫時沒有情況觸發他刷新自己的拷貝副本,所以他一直以爲flag的值還是false,所以他會一直進行while循環

MESI

爲了解決上面的問題,我們可以使用volatile關鍵字來修飾(用AtomicBoolean也可以,但是他底層還不是用volatile來做的)

volatile修飾變量的可見性是通過MESI協議實現的,MESI是一個基於失效的緩存一致性協議,是支持回寫(write-back)緩存的最常用協議,大概就是,共享變量會有一個有效位標記,如果這個標記變了,則工作線程裏的副本也作廢了(以後研究研究再細講)

image-20200307160437442

由於volatile關鍵字具有特殊的內存語義,所以在使用volatile關鍵詞的時候,會觸發CPU總線嗅探機制,強制使得其他工作內存的變量失效,迫使他們不得不去主內存獲取最新的值,從而保證了共享變量的可見性(具體內容後續volatile內存語義的博客會講到)

8個原子操作

基本概念

爲了完成內存交換的操作過程,Java中定義了8個原子操作和一系列規範

8個原子操作如下:

名稱 用途
read 把一個變量從主內存傳送到工作內存
load 把read得到的值放入工作內存的變量副本中
use 從工作內存變量副本中取出來操作
assign 把操作好了的值再賦值給副本
store 把副本的值存回到工作內存
write 把剛剛存回的值寫到之前那個變量中
lock 把主內存中的某個變量鎖了,不解釋
unlock 解鎖,不解釋

光看這個可能有點懵,所以我結合上面那個不一致問題中的流程畫一下整個過程(lock不講):

image-20200307162433423

  • 第一步,read操作,把變量flag的值取到線程2的工作內存中
  • 第二步,load操作,把之前取到的false這個值放入到自己的flag變量副本中去
  • 第三步,use操作,拿去給執行引擎執行
  • 第四步,assign操作,把執行後的結果賦值給自己的副本變量
  • 第五步,store操作,把副本的值false保存回主內存
  • 第六步,write操作,把這個false寫回到主內存中的flag變量,完成

執行規則

上面這幾個原子操作,很顯然,有些是必須成隊出現的,所以,有下面這些規則(別問問就是全背XD):

  • 把變量從主內存複製到工作內存:read+load

  • 把變量從工作內存寫回到主內存:store+write

  • 發生assign操作後,就會觸發寫回主內存的操作

  • 沒assign就不能寫回去

  • 一個新變量只能在主內存中誕生,對一個變量進行,use,store操作之前,必然已經執行過assign,load操作

  • 一個變量同一時刻只能被一個線程lock,並且可以重複lock多次,多次lock之後,只有執行相同次數的unlock操作,線程纔會釋放這個變量

  • 如果對一個變量執行lock,工作內存裏的變量副本就會被清空,工作內存會去重同步一份最新的

  • 對一個變量進行unlock之前必須把值同步回主內存中去

  • JMM規定這些操作必須按時間順序執行,但是並不一定是連續執行,比如我這裏有3個參數a,b,c,我可以這樣執行:

    read a read b load a read c load c load b

重排序

編譯器和處理器爲了提高性能,會對指令做重排序,重排序分爲了三種類型

重排序分類

  1. 編譯器優化重排序:發生在編譯過程中
  2. 指令級並行重排序:現代處理器採用了指令級並行技術,可以把沒有存在依賴關係的指令重疊執行,
  3. 內存系統的重排序:由於處理器會採用讀寫緩衝區,所以加載和儲存操作可能看起來就是亂序執行的

image-20200307164344656

數據依賴性

如果這幾行代碼換個順序執行也沒有關係,他們之間沒有數據依賴性的話,就有可能被重排序,看下面這個例子:

public class OutofOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
        public static void main(String[] args)
            throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("(" + x + "," + y + ")");
    }
}

他的輸出結果是4種情況(0,0)or(1,1)or(1,0)or(0,1)都有可能

因爲每個線程裏執行的兩行代碼都沒有數據依賴性,他們有可能被重排序,比如對於a = 1;x = b;,換個位置變成x = b;a = 1;對當前線程也沒有影響

下面給出書上的官話解釋:

如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時就存在數據依賴性

數據依賴性分爲下面三種:

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

總之,編譯器重排序不會改變代碼之間的數據依賴

他這裏還遵守了一個叫做as-if-serial語義的東西:不管怎麼重排序,對於單線程而言,程序的執行結果不能改變

所以注意一下,這裏重排序保證的順序不變只是針對單線程而言,所以,當我們上面那個案例是2個線程的情況的時候,就爆炸了(這裏先挖一個坑,順序一致性模型,以後來填)

內存屏障

爲了保證內存可見性,Java會使用一種叫做內存屏障的東西來把指令劃分開,插入一條內存屏障,就意味着,在這個內存屏障之前的指令必須先執行完了,才能通過內存屏障,繼續去執行後面的指令,大概是這個意思:

A

B

C

D

-----內存屏障

E

F

G

對於上面的部分ABCD,他們之間的執行順序是可以由編譯器來隨便調整的,但是必須等到他們執行完了之後,程序纔會接着往下走,去執行EFG

btw:內存屏障還會強制刷出CPU的緩存數據,使得數據是最新的

內存屏障分類

在Java中,JMM把內存屏障分成了4類

名字 作用
LoadStore 前面的數據load完了後才能store
StoreStore 前面的數據store完了後才能store
StoreLoad 前面的數據store完了後才能load
LoadLoad 前面的數據load完了後才能load

其實就對應了上面幾種數據依賴性

下面我用volatile關鍵字插入內存屏障來大概解釋一下

volatile和內存屏障

JMM對volatile有這樣一個策略:

在每個volatile寫操作的前面插入一個StoreStore屏障;

在每個volatile寫操作的後面插入一個StoreLoad屏障;

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

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

對於寫操作,用圖表示就是:

image-20200307172244154

對於讀操作,用圖表示就是:

image-20200307172223213

Happens-Before

JDK 5開始,爪哇使用了新的JSR-133內存模型,然後用了一個叫做Happens-Before的概念來闡述操作之間的內存可見性,在JMM中,如果一個操作要對另外一個操作可見,則在兩個操作之間需要滿足Happens-Before關係

別問,問就是背,反正我沒背,爲了博客的完整性,網上找了一段貼上來了,還是挖坑以後補上

  • 單一線程原則:在一個線程內,在程序前面的操作先行發生於後面的操作。

  • 管程鎖定規則:先unlock再lock

  • volatile 變量規則:對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作

  • 線程啓動規則:Thread 對象的 start() 方法調用先行發生於此線程的每一個動作

  • 線程加入規則:Thread 對象的結束先行發生於 join() 方法返回。

  • 線程中斷規則:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過 interrupted() 方法檢測到是否有中斷髮生。

  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生於它的 finalize() 方法的開始。

  • 傳遞性:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C。

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