Java併發編程- 內存模型詳解

本文分爲四個部分來講解:

  • Java內存模型的基礎, 主要介紹內存模型相關的基本概念;
  • Java內存模型中的順序一致性, 主要介紹重排序與順序一致性內存模型;
  • 同步原語, 主要介紹三個同步原語(synchronized, volatile, final)的內存語義及重排序規則在處理器中的實現;
  • Java內存模型的設計, 主要介紹Java內存模型的設計原理, 及其與處理器內存模型和順序一致性內存模型的關係;

Java內存模型基礎

併發編程模型的兩個關鍵問題

  1. 線程之間如何通信;
  2. 線程之間如何同步;

線程通信機制主要有兩種: 共享內存和消息傳遞. Java的併發採用的是共享內存模型.

Java內存模型(JMM)的抽象結構

在Java中, 所有實例域, 靜態域和數組元素都存儲在堆內存中, 堆內存在線程之間共享. 局部變量, 方法定義參數,
和異常處理參數不會在線程之間共享, 它們不會有內存可見性問題, 也不受內存模型的影響.

JMM定義了線程和主內存(Main Memory)之間的抽象關係, 屬於語言級的內存模型:

線程之間的共享變量存儲在主內存中, 每個線程又有一個私有的本地內存(Local Memory, 實際上就是Java虛擬機棧, 寄存器, 處理器高速緩存等), 本地內存中存儲了該線程已操作過的共享變量的副本.
本地內存是JMM的一個抽象概念, 並不真實存在, 因爲它涵蓋了緩存, 寫緩衝區, 寄存器及其他硬件和編譯器的諸多優化的集合.

如果線程A和線程B要通信的話, 必須要經歷下面兩個步驟:

  1. 線程A把本地內存更新過的共享變量刷新到主內存中;
  2. 線程B到主內存去讀取線程A之前已更新過的共享變量.

可以看出, JMM通過控制主內存和每個線程的本地內存(包含緩存, 寄存器等等)之間的交互, 來爲Java程序提供內存可見性的保證.

從源代碼到指令序列的重排序

重排序主要是爲了提高性能, 通常分爲三種:

  1. 編譯器優化的重排序. 原則是在不改變單線程程序語義的前提下, 重新安排語句的執行順序;
  2. 指令級並行的重排序. 在不存在數據依賴性的時候, 處理器可以改變語句對應的機器指令的執行順序, 甚至並行執行指令;
  3. 內存系統的重排序. 由於處理器使用高速緩存和讀/寫緩衝區, 這使得加載和存儲操作看上去可能是在亂序執行.

Java從源代碼到最終執行的指令序列, 會依次進行以上三種重排序. 其中1屬於編譯器重排序, 2和3屬於處理器重排序.
重排序會導致內存可見性的問題. JMM通過設定重排序規則, 禁止特定的編譯器重排序, 對於處理器重排序,
則是通過插入特定類型的內存屏障(Memory Barriers)指令, 來禁止特定類型的處理器重排序, 以確保在不同編譯器和處理器平臺下,
始終能爲程序員提供一致的內存可見性保證.

內存屏障類型表

注意: 內存屏障要特別注意Store類型的屏障, 每個Store類型的屏障都對應着將線程私有的寫緩衝寫回到主存的操作, 也就是實現線程間可見性的操作

內存屏障實際上是通過限制單線程內指令的重排序來作用的.

JMM將內存屏障指令分爲4種類型:

屏障類型 指令示例 說明
LoadLoad Barriers Load1; LoadLoad; Load2 確保Load1數據的裝載先於Load2指令的裝載(load2的裝載是本線程內部的狀態,其他線程的決定不了)
StoreStore Barriers Store1; StoreStore; Store2 確保Store1數據對其它處理器可見(將Store1及之前的Store操作數據刷入主內存中)先於Store2的存儲(store2的存儲是本線程內部的存儲, 其他線程的存儲決定不了)
LoadStore Barriers Load1; LoadStore; Store2 確保Load1數據的裝載先於Store2的存儲(store2的存儲是本線程內部的存儲, 其他線程的存儲決定不了)
StoreLoad Barriers Store1; StoreLoad; Load2 確保Store1數據對其它處理器可見(將Store1及之前的Store操作刷入主內存中)先於Load2的裝載(load2的裝載發生在線程私有內存內部)

其中, StoreLoad屏障是一個全能屏障, 因爲它包含了其他所有屏障的效果, 但是開銷大, 因爲要把寫緩衝區的所有數據全部刷新到內存中.

happens-before簡介

在JMM中, 如果一個操作執行的結果要對另一個操作可見(通常指的是數據依賴性), 那麼這兩個操作之間必須要存在happens-before關係. 主要有以下規則:

  • 程序順序規則: 單線程中的某操作, happens-before於對其有數據依賴性的操作;
  • 監視器鎖規則: 對一個鎖的解鎖, happens-before於隨後對這個鎖的加鎖;
  • volatile變量規則: 對一個volatile域的寫, happens-before於後續對這個域的讀(實質上是通過緩存鎖定的LOCK信號來實現的);
    使所有處理器的相應地址的緩存行失效, 強制重新從共享主存中讀取. 而且不允許兩個線程同時更改同一個緩存行
  • 傳遞性: A happens-before B happens-before C, 則 A happens-before C

重排序

重排序遵守一個統一的原則, 就是讓重排序後的程序至少能夠在單線程的情況下正確運行(意思是在單線程下和重排序前的運行結果相同).

順序一致性

即所有操作具有全序關係, 是一個理想化的模型. 但是JMM天然並不能保證順序一致性,
需要通過同步原語(Synchronized, volatile, final)來輔助完成.

volatile的內存語義

volatile作用於一個filed上, 能夠確保它的可見性. 例如, 現在有一個filed名爲l, 我們定義private volatile long l,
就相當於定義:

private long l;
public synchronized long get() {
    return this.l;
}
public synchronized set(long l) {
    this.l = l;
}

volatile寫-讀與內存屏障

從內存語義的角度來說, volatile的寫和鎖的釋放有相同的內存語義; volatile的讀與鎖的獲取有相同的語義.

volatile底層實際上是通過內存屏障的方式來確保了可見性, 以下是volatile附近的內存屏障的情況:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障;
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障;
  • 在每個volatile讀操作後面插入一個LoadLoad屏障;
  • 在每個volatile讀操作後面插入一個LoadStore屏障;

實際使用中volatile常用做if或者循環的標識位.

定義成volatile的變量, 能夠在線程間保持可見性, 能夠被多線程同時讀(注意: 內存屏障只是限制了單線程內的語句排序), 但是同時只能被一個線程寫.

鎖的內存語義

當線程釋放鎖時, JMM會把該線程對應的本地內存中的共享變量刷新到主內存中去;
當線程獲取鎖時, JMM會把該線程對應的本地內存置爲無效, 臨界區代碼必須從主內存重新讀取共享變量;

在底層的實現上

  • 在鎖的釋放上, 公平鎖和非公平鎖最後都需要寫一個volatile變量state;
  • 在鎖的獲取時, 公平鎖會讀volatile變量, 非公平鎖會用CAS更新volatile變量.

所以鎖的釋放與volatile的寫, 鎖的獲取同時具有volatile讀寫的語義.

concurrent包的實現

concurrent包的基礎就是volatile變量的讀/寫, 以及CAS. CAS兼具volatile變量讀寫的內存語義

final域的內存語義

  • final域的寫之後, 會插入一個StoreStore屏障
  • final域的讀之前, 會插入一個LoadLoad屏障

只要被構造的對象的引用在構造函數中沒有逸出, 那麼基於上述兩條規則, 就不需要使用同步,
就可以保證任意線程都能看到這個final域在構造函數中被初始化之後的值. 如果逸出了, 那麼可能會引起重排序, 導致引用在final域初始化之前被其他線程獲取, 導致獲得未經初始化的final域的值.

happens-before

最實用的三種happens-before

1. volatile寫, happens-before後續volatile讀;

volatile-happens-before

以下是一個例子:

/**
* 下面一段語句, 能夠保證1 happens before 4, 也就是無論運行多少次, 結果都輸出100
*
* <p>所以結論是, volatile變量非常適合作爲循環的標識位.
*
* Created by [email protected] on 16-11-4.
*/
public class VolatileHappensBefore {
private volatile static boolean ready = true;
private static int number = 1;

private static class ReaderThread extends Thread {
    @Override
    public void run() {
        // 3. 子線程讀volatile變量
        while (VolatileHappensBefore.ready) {
            // 這裏是LoadLoad+LoadStore屏障
        }
        // 這裏是LoadLoad+LoadStore屏障
        // 4. 子線程讀共享變量
        out.println(VolatileHappensBefore.number);
    }
}

public static void main(String[] args) throws InterruptedException {
    ReaderThread readerThread = new ReaderThread();
    readerThread.start();
    Thread.sleep(100);

    /*下面語句復現的是volatile寫讀的happens-before規則*/

    // 1. 主線程修改共享變量
    VolatileHappensBefore.number = 100;

    // 這裏是StoreStore屏障
    // 2. 主線程寫volatile變量
    VolatileHappensBefore.ready = false;
    // 這裏是StoreLoad屏障
    // 如此一來能夠保證只要volatile變量的修改能夠讀到, 那麼之前的修改一定能夠被讀到
}
}

2. start()規則: 如果線程A執行操作ThreadB.start(), 那麼線程AThreadB.start()操作happens-before線程B中的任何操作;

thread-start-happens-before

3. join()規則: 如果線程A執行操作ThreadB.join()併成功返回, 那麼線程B中的任意操作happens-before與線程A在ThreadB.join()操作的成功返回.

thread-join-happens-before

單例模式 - 雙重檢查鎖定與延遲初始化

雙重檢查鎖定其實是錯誤的, 因爲可能一個實例還沒有被完全初始化, 就返回了引用. 導致外層的檢查失效, 使得其他線程獲得一個不完整的對象引用.

替代方案1: 使用volatile關鍵字修飾單例對象, 確保可見性, 不會讓寫了一半的對象被其他線程讀到;
替代方案2: 基於類的初始化方案;
替代方案3(推薦): 使用enum進行單例的初始化;

總結

  • CAS操作同時具有volatile的讀寫語義, 也就是之前之後的代碼都不能重排序. 底層是通過一個lock指令, 進行緩存鎖定, 確保讀-改-寫操作的原子性.
  • 緩存一致性和緩存鎖定說的是同一件事, 都是lock指令造成的緩存鎖定(或者說獨佔僅那一個地址的主存和緩存).
  • 只有volatile寫操作或者是CAS(一種內置的複合操作)纔會觸發lock
發佈了38 篇原創文章 · 獲贊 37 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章