您的位置:首頁>正文

談談 Java 記憶體模型

0. 前言

Java 併發程式要比單執行緒串列程式複雜很多, 很重要的原因在於併發環境下的共用資料一致性和安全性將受到嚴重挑戰。

Java 記憶體模型 (JMM) 定義了 JVM 如何正確訪問電腦主記憶體。 JMM 指定了不同執行緒如何以及何時可以看到其他執行緒寫入到共用變數的值, 以及如何在必要時同步訪問共用變數。

作為一名開發者, 如果想要設計出能夠正確運行的並行程式, 理解 JMM 是必要條件。

Java 多執行緒之間通信一般有兩種方式: 共用記憶體和消息傳遞 。 Java 的併發採用共用記憶體的方式, 共用記憶體通信方式對於程式師而言總是透明隱式進行的。

JMM 關鍵技術點都是圍繞著多執行緒的原子性、可見性、有序來討論的。 JMM 解決了 可見性和有序性 的問題, 而 鎖 解決了 原子性 的問題。

作為一名有追求的 Java 程式猿, 你必須要去瞭解 Java 記憶體模型(JMM)。 通過學習 JMM, 你對 java 語言會有種撥開雲霧見天日的感覺, 這 n 難道還是我最初認識的 Java 嗎 ?

1. 什麼是 Java 記憶體模型 (JMM)

假設一個執行緒為一個共用變數賦值:

count = 1;

那麼 Java 記憶體模型就是為了解決這個問題, ” 在什麼情況下, 讀取共用變數 count 的執行緒能夠讀取到值 1”。

這似乎看起來是一個很傻的問題。 如果在單執行緒串列環境下的確不算一個問題, 但在多執行緒環境下, 若沒有記憶體模型提供正確的同步機制, 那麼很多情況下執行緒是不能馬上讀取到共用變數 count 最新值。

一方面 Java 程式書寫順序與編譯後的指令順序不一定相同, 指令存在重排序情況;另一方面每個 CPU 處理器存在緩存, 緩存存儲了執行緒讀寫共用變數的副本。

2. Java 記憶體模型的抽象

Java 記憶體模型 (JMM) 定義了 JVM 如何正確訪問電腦主記憶體。 JMM 指定了不同執行緒如何以及何時可以看到其他執行緒寫入到共用變數的值, 以及如何在必要時同步訪問共用變數。

早期 JDK 的 Java 記憶體模型不夠完善, 所以 Java 記憶體模型在 Java 1.5 中進行了修改。 目前, 該版本的 Java 記憶體模型仍然在 Java 8 中使用。

現代電腦硬體記憶體模型如下所示:

Java 記憶體模型抽象示意圖如下所示:

工作記憶體在 Java 記憶體中是一個抽象的概念, 一般是指 CPU 快取記憶體、寄存器等。

在現代多核處理器系統中處理器一般會存在多層的快取記憶體, 因為訪問記憶體資料的速度遠落後於直接從寄存器、快取記憶體獲取資料。

快取記憶體減少了共用記憶體匯流排的訪問流量衝突, 極大改善了 CPU 的訪問性能。

緩存使性能得到優化同時也帶來的新的具有挑戰性的問題。

例如, 當兩個執行緒 (處理器) 同時訪問同一記憶體位置的共用變數, 在什麼條件下雙方可以看到相同的值。 這其實就是多執行緒併發中的 記憶體可見性 問題。

Java 記憶體模型的可見性問題的底層實現是通過記憶體屏障 (memory barriers) 實現。

3. JVM 指令重排序 ( Instruction Reorder)

對於一段代碼而言, 我麼習慣性認為代碼總是從前而後執行的, 依次執行的。 在單執行緒串列環境下, 這麼理解也是沒有錯的。

但在多執行緒併發環境下, 那麼就有可能出現亂序的情況。 從直觀感覺可以知道, 後面的代碼先於之前代碼執行。 這似乎有點讓人有點難以理解?

根本原因在於 JVM 編譯器為了提高程式的執行效率,

一般會對代碼進行優化。

因此, 不能保證程式中的代碼順序一定是按照書寫循序執行的, 也就是編譯器和處理器會對指令進行重排序。

指令重排序不會對單執行緒程式有影響, 但是在多執行緒併發環境下就會存在很多問題。

3.1 指令重排序對併發程式的影響

我們來看看指令重排序對併發程式的影響, 假設有兩個執行緒 A 和執行緒 B, 執行緒 A 首先執行 write() 方法, 僅接著執行緒 B 再執行 read() 方法。

/*** @author pez1420@gmail.com* @version $Id: ReorderSample.java v 0.1 2018/1/6 18:38 pez1420 Exp $$*/public class ReorderService { private int x = 0; private boolean flag = false; public void write() { x = 1; //1 flag = true;//2 } public void read() { if (flag == true) { //3 x = x * 1; //4 } if (x == 0) { System.out.println("x==0"); } } }public class ThreadA extends Thread { private ReorderService reorderService; public ThreadA(ReorderService reorderService) { this.reorderService = reorderService; } @Override public void run() { reorderService.write(); }}public class ThreadB extends Thread { private ReorderService reorderService; public ThreadB(ReorderService reorderService) { this.reorderService = reorderService; } @Override public void run() { reorderService.read(); }}public class StartMain { public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10000; i++) { ReorderService reorderService = new ReorderService(); ThreadA threadA = new ThreadA(reorderService); ThreadB threadB = new ThreadB(reorderService); threadA.start(); threadB.start(); threadA.join(); threadB.join(); } }}

步驟 1 和步驟 2, 步驟 3 和步驟 4 都可能存在重排序的情況。 如果發生了重排序, 那麼執行緒 B 在執行到步驟 4 時, 不一定能看到 x 被賦值為 1。

步驟 1 和步驟 2 重排序

從上圖我們可以看到執行緒 A 的步驟 1 和步驟 2 進行了重排序。

程式運行時,執行緒 A 首先執行步驟 2flag 的賦值操作將其設置為 true;接著執行緒 B 執行步驟 3,由於條件為真,執行緒 B 將讀取共用變數 x 的值,此時 x 的值還未被執行緒 A 寫入,所有多執行緒程式語義由於程式重排序被破壞了!

這是一個讓然覺得很神奇的現象,不過它的確可能存在。但指令重排序有一個基本前提:指令重排序需要確保串列語義一致,但不能夠確保多執行緒間的語義也是一致的。

步驟 3 和步驟 4 重排序

如果步驟 3 和步驟 4 重排序會產生什麼奇怪的結果?步驟 3 和步驟 4 存在依賴關係,因此編譯器和 CPU 會採用一種猜測方式來克服控制相關性對並行度的影響。

處理器會提前讀取 x * 1,然後把計算結果保存至一個緩衝區中。當操作 3 條件為真時,就將緩衝區結果寫入共用變數 x 中。

從上圖可以看出,編譯器和處理器將步驟 3 和步驟 4 進行了重排序。

3.2 為什麼要指令重排序 ?

對於處理器而言,一條彙編指令的執行時分為很多步驟的。在多處理器下,一個彙編指令不一定是原子操作的。

指令重排序涉及到一些計算機組成原理課程的知識,想深入研究的同學可以回去翻翻課本。為提高 CPU 利用率,加快執行速度,將指令分為若干個階段,可並存執行不同指令的不同階段,從而多個指令可以同時執行。

在有效地控制了流水線阻塞的情況下,流水線可大大提高指令執行速度。經典的五級流水線,也就是一條指令可以分為 5 個步驟:

取址 (IF,Instruction Fetch, 取指 );

解碼 / 讀寄存器 (ID,Instruction Decode, 解碼);

執行 / 計算有效位址 (EX,Execute, 執行);

訪問記憶體(讀或寫)(MEM,Memory Access, 記憶體資料讀或者寫);

結果寫回寄存器 (WB,Write Back, 資料寫回到通用寄存器中)。

上圖左邊操作為彙編指令,LW 表示把值 B 載入至寄存器 R1;ADD 指令表示加法,將寄存器 R1 和 R2 值相加寫入寄存器 R3;SW 表示將寄存器 R3 的值寫入變數 A 中。

ADD 指令和 SW 指令分別有個紅叉,表示流水線中斷。ADD 指令中斷原因是等待 R2 的結果,SW 指令中斷原因是等待 R3 的結果。

我們再分析一個複雜的例子:

a = b + c;d = e - f;

上面的代碼指令流水線為:

由於中斷流水線的存在導致停頓, 哪是否有消除停頓的方法?顯然是有的,我們可以將

LW Re,eLW Rf,f

移動前面執行,理解起來很簡單,因為這兩個操作對程式的執行語義是沒有影響的。

指令進行重排序之後的結果,可以看到所有的中斷都已經被消除掉。

顯然指令重排序對於提高 CPU 的吞吐能力是有極大的提升的。但也帶來了程式運行亂序的負面問題,不過與性能相比較這點犧牲是值得的。

指令重排序還有一個非常經典的例子,就是單例模式與雙重檢查鎖問題 (double-checked locking)。

3.3 雙重檢查鎖問題 (double-checked locking)

單例模式是所有設計模式中最為簡單最好理解的設計方式。通過單例模式與多執行緒指令重排序、記憶體可見性相結合,你能夠考慮到之前許多重來未考慮的問題。

3.3.1 餓漢模式

餓漢單例模式是在單例類被載入時,物件實例已經被初始化。

public class SingleInstance { private static final SingleInstance INSTANCE = new SingleInstance(); private SingleInstance() { } public static SingleInstance getInstance() { return INSTANCE; }}

3.3.2 懶漢模式

懶漢模式是指在調用 getInstance 方法時,實例物件才會被創建,最為常見的方法是在 getInstance 方法中進行產生實體。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; }}

懶漢模式延遲載入模式在多執行緒併發環境中,就會出現獲取多個實例的情況,與單例模式初衷是相違背的。

3.3.3 懶漢模式解決方案

既然多個執行緒可以同時進入 getInstance 方法,需要對該方法進行同步,在方法增加同步關鍵字 synchronized。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; }}

顯然,每次調用 getInstance 方法都需要進行同步,效率太低了。。那有沒有更好的辦好呢?我們想到可以用同步代碼塊的方式減小同步代碼的細微性。

public static SingleInstance getInstance() { synchronized (SingleInstance.class) { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } } return INSTANCE; }

需要指出的是上面這種優化方式並沒有減小同步代碼的細微性與 public static synchronized SingleInstance 幾乎是一樣的,效率並沒有提升。

後來又有大神提出使用 DCL 雙檢查鎖機制來實現多執行緒併發環境的單例物件模式。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { //1 第一次檢查 synchronized (SingleInstance.class) { //2 加鎖 if (INSTANCE == null) { //3 第二次檢查 INSTANCE = new SingleInstance(); //4 物件產生實體 , 問題所在 } } } return INSTANCE; }}

上訴的解決方案在第一次檢查 INSTANCE 不為 null,則不需要進行加鎖操作和物件產生實體操作,可極大降低 synchronized 帶來的性能消耗。

解決方案看起來似乎是完美的,但這是一個完全錯誤的優化。

問題的根源出在第 4 個步驟物件產生實體,在執行緒運行至該步驟時,可能拿到的物件的引用為 null。這其中有什麼令人詭異的事情發生。

讓我們好好回憶之前的指令重排序問題。

實際上,INSTANCE = new SingleInstance();這一步操作可以分解為 3 個步驟:

memory = allocateMemory(); //1-分配記憶體initInstance(memory); //2-初始化記憶體 instance = memory; //3-實例物件 instance 指向 memory 位置

上訴三個步驟中 2 與 3 是存在指令重排序的可能性 (有些 JIT 編譯器就會這麼幹)。2 和 3 指令重排序之後的執行順序如下:

memory = allocateMemory(); //1-分配記憶體instance = memory; //3-實例物件 instance 指向 memory 位置initInstance(memory); //2-初始化記憶體

顯然此時的 instance 還未進行初始化 ,因此 getInstance() 返回給程式的可能的 值為 null。那麼解決這個問題呢?

由於單例模式採用雙重檢查鎖 (DCL) 時存在指令重排問題,Java 語言中 final、synchronized、volatile、lock 等都能保證有序性。

public class SingleInstance { private static volatile SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { synchronized (SingleInstance.class) { if (INSTANCE == null) { INSTANCE = new SingleInstance(); } } } return INSTANCE; }}

這個方法需要 JDK5 以及更高版本的支援,JDK5 開始使用 JSR-133 記憶體模型

3.3.4 volatile 關鍵字說明

volatile 變數修飾的共用變數進行寫操作前會在彙編代碼前增加 lock 首碼:

將當前處理器緩存行的資料寫回到系統記憶體;

這個寫會記憶體的操作會使其它 cpu 緩存該記憶體位址的資料無效。

Java 語言 volatile 關鍵字可以用一句貼切的話來描述 “ 人皆用之,莫見其形 “。理解 volatile 對理解它對理解 Java 的整個多執行緒的機制非常有幫助。

JVM 記憶體結構中有一個非常重要的記憶體區域叫做執行緒棧 , 每個執行緒的棧大小可以通過設置 JVM 參數-Xss, -Xss128k 表示每個執行緒堆疊大小為 128K,JDK1.5 預設值為 1M。

執行緒棧記憶體存儲了基本類型變數和物件引用,當訪問了物件的某一執行個體變數時,通過在棧中獲得物件引用再獲取變數的值,然後將變數的值拷貝至執行緒的工作記憶體。

每個執行緒 (處理器) 都有工作記憶體,工作記憶體存了該執行緒以讀寫共用變數的副本。工作記憶體是 JMM 抽象概念 , 並不真實存在。

它涵蓋了緩存、寫緩衝區、寄存器和其它硬體和編譯器優化。

read and load 從主存複製變數到當前工作記憶體;

use and assign 執行代碼,改變共用變數值;

store and write 用工作記憶體資料刷新主存相關內容;

其中 use and assign 可以多次出現。

但是這一些操作並不是原子性,也就是在 read load 之後,如果主記憶體 count 變數發生修改之後,執行緒工作記憶體中的值由於已經載入,不會產生對應的變化,所以計算出來的結果會和預期不一樣。

可見性指的是一個執行緒對變數的寫操作對其他執行緒後續的讀操作可見。

由於現代 CPU 都有多級緩存,CPU 的操作都是基於快取記憶體的,而執行緒通信是基於記憶體的,這中間有一個 Gap, 可見性的關鍵還是在對變數的寫操作之後能夠在某個時間點顯示地寫回到主記憶體,這樣其他執行緒就能從主記憶體中看到最新的寫的值。

volatile,synchronized(隱式鎖), 顯式鎖,原子變數這些同步手段都可以保證可見性。可見性底層的實現是通過加記憶體屏障實現的:

寫變數後加寫屏障,保證 CPU 寫緩衝區的值強制刷新回主記憶體;

讀變數之前加讀屏障,使緩存失效,從而強制從主記憶體讀取變數最新值。

總結: 在併發環境下,volatile 能夠保證有序性、可見性,但原子性沒辦法保證。

4. happens-before 原則

《JSR-133:Java Memory Model and Thread Specfication》定了如下的 happen-before 規則。

happen 與 before 規則闡述操作之間的記憶體可見性,目的都是為了在不改變程式的語義情況下提高程式的並行度。在 JMM 中,如果一個操作執行的結果需要對另一個操作執行緒,那麼這兩個操作之間必須存在 happen-before 關係。

程式次序規則:一個執行緒內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作;

鎖定規則:一個 unLock 操作先行發生於後面對同一個鎖的 lock 操作;

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

傳遞規則:如果操作 A 先行發生於操作 B,而操作 B 又先行發生於操作 C,則可以得出操作 A 先行發生於操作 C;

執行緒啟動規則:Thread 物件的 start() 方法先行發生於此執行緒的每個一個動作;

執行緒中斷規則:對執行緒 interrupt() 方法的調用先行發生於被中斷執行緒的代碼檢測到中斷事件的發生;

執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到執行緒已經終止執行;

物件終結規則:一個物件的初始化完成先行發生於他的 finalize() 方法的開始;

程式順序規則:一個執行緒中的每個操作,happen-before 於該執行緒中的任意後續操作;

監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖;

Volatile 變數規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀;

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

Start 規則: 如果執行緒 A 執行操作 ThreadB.start()(啟動執行緒 B), 那麼 A 執行緒的 ThreadB.start() 操作 happens-before 於執行緒 B 中的任意操作。

從上圖我們可以看到執行緒 A 的步驟 1 和步驟 2 進行了重排序。

程式運行時,執行緒 A 首先執行步驟 2flag 的賦值操作將其設置為 true;接著執行緒 B 執行步驟 3,由於條件為真,執行緒 B 將讀取共用變數 x 的值,此時 x 的值還未被執行緒 A 寫入,所有多執行緒程式語義由於程式重排序被破壞了!

這是一個讓然覺得很神奇的現象,不過它的確可能存在。但指令重排序有一個基本前提:指令重排序需要確保串列語義一致,但不能夠確保多執行緒間的語義也是一致的。

步驟 3 和步驟 4 重排序

如果步驟 3 和步驟 4 重排序會產生什麼奇怪的結果?步驟 3 和步驟 4 存在依賴關係,因此編譯器和 CPU 會採用一種猜測方式來克服控制相關性對並行度的影響。

處理器會提前讀取 x * 1,然後把計算結果保存至一個緩衝區中。當操作 3 條件為真時,就將緩衝區結果寫入共用變數 x 中。

從上圖可以看出,編譯器和處理器將步驟 3 和步驟 4 進行了重排序。

3.2 為什麼要指令重排序 ?

對於處理器而言,一條彙編指令的執行時分為很多步驟的。在多處理器下,一個彙編指令不一定是原子操作的。

指令重排序涉及到一些計算機組成原理課程的知識,想深入研究的同學可以回去翻翻課本。為提高 CPU 利用率,加快執行速度,將指令分為若干個階段,可並存執行不同指令的不同階段,從而多個指令可以同時執行。

在有效地控制了流水線阻塞的情況下,流水線可大大提高指令執行速度。經典的五級流水線,也就是一條指令可以分為 5 個步驟:

取址 (IF,Instruction Fetch, 取指 );

解碼 / 讀寄存器 (ID,Instruction Decode, 解碼);

執行 / 計算有效位址 (EX,Execute, 執行);

訪問記憶體(讀或寫)(MEM,Memory Access, 記憶體資料讀或者寫);

結果寫回寄存器 (WB,Write Back, 資料寫回到通用寄存器中)。

上圖左邊操作為彙編指令,LW 表示把值 B 載入至寄存器 R1;ADD 指令表示加法,將寄存器 R1 和 R2 值相加寫入寄存器 R3;SW 表示將寄存器 R3 的值寫入變數 A 中。

ADD 指令和 SW 指令分別有個紅叉,表示流水線中斷。ADD 指令中斷原因是等待 R2 的結果,SW 指令中斷原因是等待 R3 的結果。

我們再分析一個複雜的例子:

a = b + c;d = e - f;

上面的代碼指令流水線為:

由於中斷流水線的存在導致停頓, 哪是否有消除停頓的方法?顯然是有的,我們可以將

LW Re,eLW Rf,f

移動前面執行,理解起來很簡單,因為這兩個操作對程式的執行語義是沒有影響的。

指令進行重排序之後的結果,可以看到所有的中斷都已經被消除掉。

顯然指令重排序對於提高 CPU 的吞吐能力是有極大的提升的。但也帶來了程式運行亂序的負面問題,不過與性能相比較這點犧牲是值得的。

指令重排序還有一個非常經典的例子,就是單例模式與雙重檢查鎖問題 (double-checked locking)。

3.3 雙重檢查鎖問題 (double-checked locking)

單例模式是所有設計模式中最為簡單最好理解的設計方式。通過單例模式與多執行緒指令重排序、記憶體可見性相結合,你能夠考慮到之前許多重來未考慮的問題。

3.3.1 餓漢模式

餓漢單例模式是在單例類被載入時,物件實例已經被初始化。

public class SingleInstance { private static final SingleInstance INSTANCE = new SingleInstance(); private SingleInstance() { } public static SingleInstance getInstance() { return INSTANCE; }}

3.3.2 懶漢模式

懶漢模式是指在調用 getInstance 方法時,實例物件才會被創建,最為常見的方法是在 getInstance 方法中進行產生實體。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; }}

懶漢模式延遲載入模式在多執行緒併發環境中,就會出現獲取多個實例的情況,與單例模式初衷是相違背的。

3.3.3 懶漢模式解決方案

既然多個執行緒可以同時進入 getInstance 方法,需要對該方法進行同步,在方法增加同步關鍵字 synchronized。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; }}

顯然,每次調用 getInstance 方法都需要進行同步,效率太低了。。那有沒有更好的辦好呢?我們想到可以用同步代碼塊的方式減小同步代碼的細微性。

public static SingleInstance getInstance() { synchronized (SingleInstance.class) { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } } return INSTANCE; }

需要指出的是上面這種優化方式並沒有減小同步代碼的細微性與 public static synchronized SingleInstance 幾乎是一樣的,效率並沒有提升。

後來又有大神提出使用 DCL 雙檢查鎖機制來實現多執行緒併發環境的單例物件模式。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { //1 第一次檢查 synchronized (SingleInstance.class) { //2 加鎖 if (INSTANCE == null) { //3 第二次檢查 INSTANCE = new SingleInstance(); //4 物件產生實體 , 問題所在 } } } return INSTANCE; }}

上訴的解決方案在第一次檢查 INSTANCE 不為 null,則不需要進行加鎖操作和物件產生實體操作,可極大降低 synchronized 帶來的性能消耗。

解決方案看起來似乎是完美的,但這是一個完全錯誤的優化。

問題的根源出在第 4 個步驟物件產生實體,在執行緒運行至該步驟時,可能拿到的物件的引用為 null。這其中有什麼令人詭異的事情發生。

讓我們好好回憶之前的指令重排序問題。

實際上,INSTANCE = new SingleInstance();這一步操作可以分解為 3 個步驟:

memory = allocateMemory(); //1-分配記憶體initInstance(memory); //2-初始化記憶體 instance = memory; //3-實例物件 instance 指向 memory 位置

上訴三個步驟中 2 與 3 是存在指令重排序的可能性 (有些 JIT 編譯器就會這麼幹)。2 和 3 指令重排序之後的執行順序如下:

memory = allocateMemory(); //1-分配記憶體instance = memory; //3-實例物件 instance 指向 memory 位置initInstance(memory); //2-初始化記憶體

顯然此時的 instance 還未進行初始化 ,因此 getInstance() 返回給程式的可能的 值為 null。那麼解決這個問題呢?

由於單例模式採用雙重檢查鎖 (DCL) 時存在指令重排問題,Java 語言中 final、synchronized、volatile、lock 等都能保證有序性。

public class SingleInstance { private static volatile SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { synchronized (SingleInstance.class) { if (INSTANCE == null) { INSTANCE = new SingleInstance(); } } } return INSTANCE; }}

這個方法需要 JDK5 以及更高版本的支援,JDK5 開始使用 JSR-133 記憶體模型

3.3.4 volatile 關鍵字說明

volatile 變數修飾的共用變數進行寫操作前會在彙編代碼前增加 lock 首碼:

將當前處理器緩存行的資料寫回到系統記憶體;

這個寫會記憶體的操作會使其它 cpu 緩存該記憶體位址的資料無效。

Java 語言 volatile 關鍵字可以用一句貼切的話來描述 “ 人皆用之,莫見其形 “。理解 volatile 對理解它對理解 Java 的整個多執行緒的機制非常有幫助。

JVM 記憶體結構中有一個非常重要的記憶體區域叫做執行緒棧 , 每個執行緒的棧大小可以通過設置 JVM 參數-Xss, -Xss128k 表示每個執行緒堆疊大小為 128K,JDK1.5 預設值為 1M。

執行緒棧記憶體存儲了基本類型變數和物件引用,當訪問了物件的某一執行個體變數時,通過在棧中獲得物件引用再獲取變數的值,然後將變數的值拷貝至執行緒的工作記憶體。

每個執行緒 (處理器) 都有工作記憶體,工作記憶體存了該執行緒以讀寫共用變數的副本。工作記憶體是 JMM 抽象概念 , 並不真實存在。

它涵蓋了緩存、寫緩衝區、寄存器和其它硬體和編譯器優化。

read and load 從主存複製變數到當前工作記憶體;

use and assign 執行代碼,改變共用變數值;

store and write 用工作記憶體資料刷新主存相關內容;

其中 use and assign 可以多次出現。

但是這一些操作並不是原子性,也就是在 read load 之後,如果主記憶體 count 變數發生修改之後,執行緒工作記憶體中的值由於已經載入,不會產生對應的變化,所以計算出來的結果會和預期不一樣。

可見性指的是一個執行緒對變數的寫操作對其他執行緒後續的讀操作可見。

由於現代 CPU 都有多級緩存,CPU 的操作都是基於快取記憶體的,而執行緒通信是基於記憶體的,這中間有一個 Gap, 可見性的關鍵還是在對變數的寫操作之後能夠在某個時間點顯示地寫回到主記憶體,這樣其他執行緒就能從主記憶體中看到最新的寫的值。

volatile,synchronized(隱式鎖), 顯式鎖,原子變數這些同步手段都可以保證可見性。可見性底層的實現是通過加記憶體屏障實現的:

寫變數後加寫屏障,保證 CPU 寫緩衝區的值強制刷新回主記憶體;

讀變數之前加讀屏障,使緩存失效,從而強制從主記憶體讀取變數最新值。

總結: 在併發環境下,volatile 能夠保證有序性、可見性,但原子性沒辦法保證。

4. happens-before 原則

《JSR-133:Java Memory Model and Thread Specfication》定了如下的 happen-before 規則。

happen 與 before 規則闡述操作之間的記憶體可見性,目的都是為了在不改變程式的語義情況下提高程式的並行度。在 JMM 中,如果一個操作執行的結果需要對另一個操作執行緒,那麼這兩個操作之間必須存在 happen-before 關係。

程式次序規則:一個執行緒內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作;

鎖定規則:一個 unLock 操作先行發生於後面對同一個鎖的 lock 操作;

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

傳遞規則:如果操作 A 先行發生於操作 B,而操作 B 又先行發生於操作 C,則可以得出操作 A 先行發生於操作 C;

執行緒啟動規則:Thread 物件的 start() 方法先行發生於此執行緒的每個一個動作;

執行緒中斷規則:對執行緒 interrupt() 方法的調用先行發生於被中斷執行緒的代碼檢測到中斷事件的發生;

執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到執行緒已經終止執行;

物件終結規則:一個物件的初始化完成先行發生於他的 finalize() 方法的開始;

程式順序規則:一個執行緒中的每個操作,happen-before 於該執行緒中的任意後續操作;

監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖;

Volatile 變數規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀;

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

Start 規則: 如果執行緒 A 執行操作 ThreadB.start()(啟動執行緒 B), 那麼 A 執行緒的 ThreadB.start() 操作 happens-before 於執行緒 B 中的任意操作。

同類文章
Next Article
喜欢就按个赞吧!!!
点击关闭提示