音視頻開發之旅(52) - Java併發編程 之內存模型與volatile

目錄

  1. JVM內存結構和內存模型
  2. 併發編程中的三個概念與重排序
  3. happens-before原則
  4. volatile原理
  5. volatile使用場景
  6. 資料
  7. 收穫

一、JVM內存結構和內存模型

1.1 JVM內存結構

圖片來自圖書《深入理解Java虛擬機》

Java虛擬機在運行程序時會把其自動管理的內存劃分爲以上幾個區域,每個區域都有的用途以及創建銷燬的時機

方法區屬於線程共享的內存區域,主要用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

Java 堆也是屬於線程共享的內存區域,它在虛擬機啓動時創建,是Java 虛擬機所管理的內存中最大的一塊,主要用於存放對象實例,幾乎所有的對象實例都在這裏分配內存,注意Java 堆是垃圾收集器管理的主要區域

** 程序計數器** 屬於線程私有的數據區域,是一小塊內存空間,主要代表當前線程所執行的字節碼行號指示器。

虛擬機棧屬於線程私有的數據區域,與線程同時創建,總數與線程關聯,代表Java方法執行的內存模型。每個方法執行時都會創建一個棧楨來存儲方法的的變量表、操作數棧、動態鏈接方法、返回值、返回地址等信息。每個方法從調用直結束就對於一個棧楨在虛擬機棧中的入棧和出棧過程

** 本地方法棧** 屬於線程私有的數據區域,這部分主要與虛擬機用到的 Native 方法相關。

1.2 JVM內存模型

圖片來自:圖書《Java並行編程的藝術》

Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。由於JVM運行程序的實體是線程,而每個線程創建時JVM都會爲其創建一個工作內存(有些地方稱爲棧空間),用於存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量,工作內存中存儲着主內存中的變量副本拷貝,前面說過,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成
引用自:全面理解Java內存模型(JMM)及volatile關鍵字

二、 併發編程中的三個概念與重排序

2.1 原子性

原子性指的是一個操作是不可中斷的,即使是在多線程環境下,一個操作一旦開始就不會被其他線程影響

2.2 可見性

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

2.3 有序性

程序編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一致

重排序

計算機在執行程序時,爲了提高性能,編譯器和處理器的常常會對指令做重排,一般分以下3種

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

  • 指令並行的重排
    現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序

  • 內存系統的重排
    由於處理器使用緩存和讀寫緩存衝區,這使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因爲三級緩存的存在,導致內存與緩存的數據同步存在時間差

重排只會保證單線程中串行語義的執行的一致性,但並不會關心多線程間的語義一致性

三、happens-before原則

《JSR-133:Java Memory Model and Thread Specification》中定義了happens-before的規則,具體如下:

3.1 程序順序規則

一個線程中的每個操作,happens-before於該線程中的任意後續操作

這裏有個疑惑:** 程序順序規則和編譯器的指令重排序不衝突嗎?**
今天和朋友討論了這個問題,發現自己對happens-before的含義沒有理解。
其實happens-before關注的是結果上的可見性,而不是執行順序上的可見性。
引用朋友的一句很到位的話:執行結果沒有相關性是一種特殊的可見
下面我們 看下來自如何理解happens-before中的程序順序規則和編譯器的指令重排序?
https://www.zhihu.com/question/65373167/answer/331717849 案例 學習下:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

程序順序規則
根據happens- before的程序順序規則,上面計算圓的面積的示例代碼存在三個happens- before關係:A happens- before B;
B happens- before C;
A happens- before C;
這裏的第3個happens- before關係,是根據happens- before的傳遞性推導出來的。

這裏A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序後的執行順序)。

如果A happens- before B,JMM並不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裏操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B後的執行結果,與操作A和操作B按happens- before順序執行的結果一致。在這種情況下,JMM會認爲這種重排序並不非法(not illegal),JMM允許這種重排序。

3.2 監視器鎖規則

對於一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖

3.3 volatile變量規則

對於一個volatile變量的寫,happens-before於任意後續對這個volatile變量的讀

3.4 傳遞性

如果A happens-before B,且B happens-before C,那麼 A happens-before C.

3.5 start()規則

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

3.6 join()規則

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

四、volatile作用及原理

在程序的執行過程中,涉及到兩個方面:指令的執行和數據的讀寫。其中指令的執行通過處理器來完成,而數據的讀寫則要依賴於系統內存,但是處理器的執行速度要遠大於內存數據的讀寫,因此在處理器中加入了高速緩存。在程序的執行過程中,會先將數據拷貝到處理器的高速緩存中,待運算結束後再回寫到系統內存當中。

4.1 volatile的作用

這樣如果在多個線程中多一個變量進行讀寫,就可能引起可見性的問題。而volatile可以很好的解決可見性和有序性問題。但不能保證原子性。它相比 synchronized 不會引起線程上下文的切換和調度。

那它是如何保證可見性和有序性的吶?

變量聲明瞭volatile,在對該變量進行寫操作時,

  1. JVM會向CPU發送一條Lock前綴的指令,將這個變量所在緩存行(CPU高速緩存中可以分配的最小存儲單元,一個高速緩衝行通常是64個字節寬)寫回到系統內存。
  2. 對於多處理器,爲了保證各個處理器的緩存一致性,每個處理器通過嗅探(類似於觀察者)在總線上傳播的數據來檢查自己的緩存是否過期,當處理器發現自己緩存行對應的內存地址被修改,就會將該處理器的高速緩存行設置爲無效狀態,當需要這個變量的數據時,會重新從系統內存中讀取到該處理器的高速緩存中。
    引用自 《Java併發編程的藝術》

volatile在併發編程中很常見,但也容易被濫用
volatile變量帶來可見性的保證,還防止了指令重排序。不過這一切是以犧牲優化(消除緩存,直接操作主存開銷增加)爲代價,所以不應該濫用volatile,僅在確實需要增強變量可見性的時候使用。

4.2 volatile寫-讀建立的happens-before關係

我們通過《Java併發編程的藝術》中的例子來一起學習下

class VolatileExample {

    int a = 0;
    volatile boolean flag = false;
    
    public void write(){
    
        a = 1;         //1
        flag = true;   //2
    }

    public void reader(){
    
        if(flag) {.      //3
            int i = a;   //4
        }
    }
}

假設線程A執行write方法之後,線程B執行reader方法,根據happens-before規則
關係如下:
**根據程序順序規則: ** 1 happens-before 2; 3 happens-before 4
根據volatile規則: 2 happens-before 3
傳遞性規則: 1 happens-before 4

五、volatile使用場景

5.1 狀態標誌

線程a執行doWork()的過程中,可能有另外的線程b調用了release,給flag變量添加volatile標記,保證其可見性。

volatile boolean flag;  
   
public void release() {   
    flag = true;   
}  
  
public void doWork() {   
    while (!flag) {   
        // do something
    }  
}  

5.2 一次性安全發佈

volatile可以禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象。下面看一個非常典型的禁止重排優化的例子雙重檢測鎖的例子

private volatile static Singleton mInstace;     
 
public static Singleton getInstance(){     
 //第一次null檢查       
 if(mInstace == null){              
     synchronized(Singleton.class) {           
         //第二次null檢查         
         if(mInstace == null){              
             mInstace = new Singleton();  
         }    
     }             
 }    
 return mInstace;  

其中mInstace = new Singleton();可以分爲以下3步完成(僞代碼)
memory = allocate(); //1.分配對象內存空間
instance(memory); //2.初始化對象
instance = memory; //3.設置instance指向剛分配的內存地址,此時instance!=null

由於步驟2和步驟3間可能會重排序,如下:
memory = allocate(); //1.分配對象內存空間
instance = memory; //3.設置instance指向剛分配的內存地址,此時instance!=null,但是對象還沒有初始化完成!
instance(memory); //2.初始化對象

如果mInstace沒有加volatile標記,當一條線程訪問mInstace不爲null時,由於instance實例未必已初始化完成,也就造成了線程安全問題。

5.3 開銷較低的“讀-寫鎖”策略

使用 synchronized 確保增量操作是原子的,並使用 volatile 保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的性能.

public class CheesyCounter {  
     private volatile int value;  
  
    //讀操作,沒有synchronized,提高性能  
    public int getValue() {   
        return value;   
    }   
  
    //寫操作,必須synchronized。因爲x++不是原子操作  
    public synchronized int increment() {  
        return value++;  
    }  

5.4 獨立觀察(independent observation)

public class UserManager {  
    public volatile String lastUser;
  
    public boolean authenticate(String user, String password) {  
        boolean valid = passwordIsValid(user, password);  
        if (valid) {  
            User u = new User();  
            activeUsers.add(u);  
            //賦值操作,不涉及運算操作
            lastUser = user;  
        }  
        return valid;  
    }  
}   

5.5 “volatile bean” 模式

volatile bean 模式的基本原理是:用volatile修飾易變數據容器,該容器中所有的數據成員都是volatile類型(並且 getter 和 setter 方法必須非常普通),放入這些容器中的對象必須是線程安全的。

六、資料

  1. 圖書:《java併發編程的藝術》
  2. 圖書:《深入理解Java虛擬機》
  3. 深入理解Java內存模型
  4. 強烈推薦-全面理解Java內存模型(JMM)及volatile關鍵字
  5. 多線程知識梳理(8) - volatile 關鍵字
  6. Java中Volatile關鍵字詳解
  7. Java併發編程:volatile關鍵字解析
  8. volatile的使用
  9. 如何理解happens-before中的程序順序規則和編譯器的指令重排序?

七、 收穫

通過本篇的學習實踐

  1. 瞭解了JVM的內存結構和內存模型
  2. 瞭解了併發編程的重排序、原子性、可見性、有序性
  3. 瞭解了happen-before原則
  4. 瞭解了volatile的原理以及使用場景

感謝你的閱讀
下一篇我們繼續併發編程系列之 synchronized,歡迎關注公衆號“音視頻開發之旅”,一起學習成長。
歡迎交流

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