併發程式設計基礎——JMM簡介

謎一樣的Coder發表於2018-10-10

前言

這篇部落格嘗試針對JMM模型進行總結,並分析volatile和synchronized的一些原理(理解的並不深入)

JMM記憶體模型

在談JMM記憶體模型的之前,得先了解JVM記憶體模型。

JVM記憶體模型

JVM在執行程式的時候將自動管理的記憶體劃分為幾個區域。從總體上看主要分為兩大類,執行緒共享的資料區域和執行緒私有的記憶體區域。

圖中右側代表的是所有執行緒共享的資料區域,左側代表的是每個執行緒私有的資料區域

方法區:

方法區屬於執行緒共享的記憶體區域,稱為非堆(Non-Heap) ,主要儲存JVM載入的類資訊,常量,靜態變數,即時編譯器編譯後的程式碼等資料,根據JVM規範,當方法無法滿足記憶體分配需求的時候,將丟擲OutOfMemeoryError異常。在方法區中同時還存在一個執行時常量池的區域,用於存放編譯器生成的各種字面量和符號引用。常量池的存在只是為了方便程式執行時使用一些常見的符號和變數。

JVM堆:

執行緒共享記憶體區域,JVM啟動時建立,用於存放物件例項,幾乎所有的物件例項都在這裡分配記憶體,Java堆是垃圾收集器管理的主要區域,因此還有另外一個名字——GC堆,堆中沒有記憶體分配例項,而且也無法擴充套件空間,將會丟擲OutOfMemoryError的異常。

程式計數器:

執行緒私有的資料區域,記錄下一條需要執行的指令的位置,這個和計算機組成原理中的概念一樣。

虛擬機器棧:

執行緒私有的資料區域,與執行緒同時建立,總數與執行緒關聯,代表Java方法執行的記憶體模型。棧幀中會儲存物件在堆上的地址。

本地方法棧:

執行緒私有的資料區域,這個和native方法有關。

總覽

先上一張圖,JMM其實本身是一種抽象的概念,並不真實存在,它描述的是一種規範。JVM執行程式的實體是執行緒,每個執行緒建立的時候都會為其建立一個工作記憶體,用於儲存執行緒的私有資料,而Java記憶體模型中規定所有的變數都儲存在主記憶體,主記憶體是所有執行緒共享的,所有執行緒共享。執行緒對變數的操作必須在工作記憶體中完成。首先要將記憶體讀取到自己的工作記憶體空間,然後再對變數進行操作,操作完成後再將變數寫會主記憶體。同時不同執行緒間的工作記憶體是隔離的。執行緒間的資料通訊必須通過主記憶體來完成。 圖中還標記了8個原子操作。(圖片來自其他部落格)

主記憶體:

主要儲存的是Java例項物件,所有執行緒建立的例項物件都存在於主記憶體中,不管該例項物件是成員變數還是方法中的本地變數,同時也有類的資訊、常量、靜態變數。

多個執行緒同時訪問主記憶體會引發執行緒安全問題

工作記憶體:

主要儲存當前方法的所有本地變數資訊,每個執行緒只能訪問自己的工作記憶體,即線 程中的本地變數堆其他執行緒是不可見的。每個執行緒都有自己的私有資料,執行緒間無法相互訪問工作記憶體,因此儲存在工作記憶體的資料不存線上程安全問題。

主記憶體與工作記憶體的資料儲存型別以及操作方式:

根據虛擬機器規範,對於一個例項物件中的成員方法而言,如果方法中包含的本地變數是基本資料型別,將直接儲存在工作記憶體的棧幀中。如果本地變數是引用型別,那麼該變數的引用會儲存在功能記憶體的棧幀中,而物件示例儲存在主記憶體中。

對於一個例項物件的成員變數,不管它是基本資料型別或者包裝型別還是引用型別,都會儲存到堆區。至於static變數以及類本身相關資訊將會儲存在主記憶體中(方法區)。

在JVM記憶體模型中,主記憶體中的例項物件可以被多執行緒共享,倘若兩個執行緒同時呼叫了同一個物件的同一個方法,那麼兩條執行緒會將要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作之後才重新整理到主記憶體,示意圖如下(依舊盜的大牛的圖)

JMM記憶體模型的必要性

由於執行緒在運算元據的時候,會先將資料讀取到自己的執行緒私有記憶體中,然後再對資料進行操作之後再將資料寫到主記憶體中,這個過程會造成執行緒安全的問題。為了解決執行緒安全問題所以才提出了JMM記憶體模型。

執行緒安全問題

執行緒安全問題其實是一個大的問題,總體分為三個方面——原子性、有序性、可見性。

原子性

原子性這個知道怎麼回事就行,和資料庫原子性的概念一致

有序性

有序性是指對於單執行緒的執行程式碼,並不完全按照順序依次執行,對於多執行緒環境會出現亂序現象,因為程式編譯成機器碼指令後會出現指令重排現象,重排後的指令與原指令的順序未必一致。在多執行緒環境下,也可能出現亂序現象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令執行順序並不一致。

指令重排一般分為三種:編譯器優化的重排,指令並行的重排,記憶體系統的重排。其中編譯器優化的重排屬於編譯器重排,後兩者屬於處理器重排。多執行緒環境中指令的重排會出現很嚴重的問題。

指令重排例項

package com.learn.ThreadLuan;

/**
 * autor:liman
 * mobilNo:15528212893
 * mail:657271181@qq.com
 * comment:
 *      指令重排序的例項
 */
public class ReOrderDemo {

    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(()->{
            while(true){
                a = 1;
                x = b;
            }

        });

        Thread t2 = new Thread(()->{
            while(true){
                b = 1;
                y = a;
            }
        });

        t1.start();
        t2.start();
        while(true){
            System.out.println("x = "+x+"->y="+y);

        }
    }
}

執行結果並不唯一

由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致是無法確定的。處理器重排是對CPU的效能優化。

可見性

可見性指的是當一個執行緒修改了某個共享變數的值,其他執行緒是否能夠馬上得知這個修改的值。對於序列程式來說,可見性是不存在的。但在多執行緒環境中可就不一定了,前面我們分析過,由於執行緒對共享變數的操作都是執行緒拷貝到各自的工作記憶體進行操作後才寫回到主記憶體中的,這就可能存在一個執行緒A修改了共享變數x的值,還未寫回主記憶體時,另外一個執行緒B又對主記憶體中同一個共享變數x進行操作,但此時A執行緒工作記憶體中共享變數x對執行緒B來說並不可見,這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題,另外指令重排以及編譯器優化也可能導致可見性問題,通過前面的分析,我們知道無論是編譯器優化還是處理器優化的重排現象,在多執行緒環境下,確實會導致程式輪序執行的問題,從而也就導致可見性問題。

JMM的解決方法

除了JVM自身提供的對基本資料型別的讀寫操作的原子性外,對於方法級別的原子性可以用synchronized關鍵字和可重入鎖來解決。

對於工作記憶體與主記憶體同步延遲現象導致的可見性問題,也可以使用synchronized和volatile關鍵字來解決。兩者都會使一個執行緒修改後的變數立即對其他執行緒可見。

對於有序性問題可以使用volatile關鍵字解決,因為volatile關鍵字的另一個作用就是禁止重排序優化

除了上述解決方案之外,還有happens-before原則用於保證多執行緒環境下兩個操作間的原子性、可見性和有序性。

happen-before原則簡單理解

1、程式順序原則:一個執行緒內必須保證語義序列性

2、鎖規則:解鎖操作必須在加鎖操作之後

3、volatile規則:volatile變數的寫必須先發生於讀

4、執行緒啟動規則:start方法先於它的第一個動作,即如果執行緒A在執行執行緒B的start的方法之前修改了共享變數的值,那麼當執行緒B執行start方法時,執行緒A對共享變數的修改對執行緒B可見。

5、執行緒終止規則:線上程終止操作之前執行緒的操作都已經處理完成。

6、執行緒中斷規則:interrupt方法呼叫先發生於被中斷執行緒的程式碼檢測到中斷事件的發生。

7、物件終結規則:物件的建構函式執行,結束先於finalize方法

volatile關鍵字

保證可見性

需要明確的是volatile並不確保原子性,可以保證有序性和可見性。當寫一個volatile變數的時候,JMM會把該執行緒對應的工作記憶體中的共享變數值重新整理到主記憶體中,當讀取一個volatile變數時,JMM會把該執行緒對應的工作記憶體置為無效,那麼該執行緒將只能從記憶體中重新讀取共享變數。

禁止指令重排

volatile的另一個作用就是禁止指令重排,說到指令重排需要了解記憶體屏障這個概念。

記憶體屏障(Memory barrier)也叫記憶體柵欄,其實質是一個CPU指令,作用主要有兩個:1、保證特定操作的執行順序,2、保證某些變數的記憶體可見性(這就是volatile能保證可見性的原因)。在編譯器或CPU指令重排序優化的時候,如果在指令間加入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,既在記憶體屏障前和記憶體屏障後的指令不能進行重排序。

Memory Barrier的另一個作用就是強制刷出CPU的快取資料,任何CPU上的執行緒都能讀到快取中的最新版本。

單例模式的經典實現方式——DCL

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次檢測
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多執行緒環境下可能會出現問題的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

其實instance = new DoubleCheckLock();這一步實質可以分為三步,

1、分配物件記憶體空間

2、初始化物件

3、設定instance指向剛分配的記憶體地址,此時instance!=null

第2步和第3步不存在資料依賴關係,因此這兩條指令其實是可以重排的(單執行緒中是不影響語義的,所以不會有問題),如果第3步在第2步之前執行,當一個執行緒進入程式碼的時候,發現instance不為null,但是這個時候instance併為初始化完成,這也就出現了執行緒安全問題。

這個問題可以通過volatile解決

private volatile static DoubleCheckLock instance;

總結

文章簡單介紹了JMM模型,JMM模型其實就是一組規則,這組規則在解決併發的時候可能出現的執行緒安全問題,提供了一系列的內建解決方案。

參考資料:

文中大部分內容參考了這篇部落格:https://blog.csdn.net/javazejian/article/details/72772461

 

 

相關文章