如果面試官再問你 volatile 你這樣跟他說
前言
在多執行緒併發程式設計中 synchronized
和 volatile
都扮演著重要的角色, volatile是輕量級的synchronized
,它在多處理器開發中保證了共享變數的“可見性”。 可見性
的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。如果volatile變數修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因為它不會引起執行緒 上下文的切換和排程
。
往往我們在面試過程中都能說出 volatile
的特性,但是對於深入和擴充套件的相關問題就回答的不是特別好了,然後 volatile
又是我們面試必問的。所以本文就準備詳細的講一下這個關鍵字,希望能幫到小夥伴們。
學習volatile之前先了解JMM
在介紹Java記憶體模型(JMM)之前,先來簡單聊一下計算機記憶體模型。
我們應該都知道,計算機在執行程式的時候,每條指令都是在 CPU中執行
的,而執行的時候,免不了會存在資料互動。而計算機上面的資料,是存放在主存當中的,也就是計算機的實體記憶體。
其實早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,但是隨著CPU技術的發展,cpu的指令速度遠超記憶體的存取速度,這就導致CPU每次操作記憶體都要耗費很多等待時間。
於是乎就想出瞭解決辦法,就是在CPU和記憶體之間加一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝。
當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。
雖然快取記憶體解決了cpu與記憶體速度不相符的問題,但是也為計算機系統帶來一個新的問題:快取一致性(CacheCoherence)問題,也就是說,在多核cpu中,每個核的自己的快取中,關於同一個資料的快取內容可能不一致。
聊完計算機記憶體模型之後,下面我們開始聊一下Java記憶體模型
java記憶體模型(JMM)
-
JMM是什麼?
JMM是Java記憶體模型,也就是 Java Memory Model
,簡稱JMM,本身是一種抽象的概念,實際上並不存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項域,靜態域和構成陣列物件的元素)的訪問方式。
Java執行緒之間的通訊由 Java記憶體模型
(本文簡稱為JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係 :執行緒之間的共享變數儲存在主記憶體(Main Memory)中
,每個執行緒都有一個 私有的本地記憶體(Local Memory)
,本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。
記憶體模型的抽象示意圖如下
-
JMM相關規定:
所有的 共享變數都儲存於主記憶體
。 共享變數
:例項變數和類變數,不包括區域性變數,因為區域性變數是執行緒私有的,所以不存線上程競爭問題。
每一個執行緒還存在自己的 工作記憶體
,執行緒的工作記憶體,保留了被執行緒使用的變數的 工作副本
。
執行緒對變數的 所有的操作(讀,取)都必須在工作記憶體中完成
,而不能直接讀寫主記憶體中的變數。
不同 執行緒之間
也不能直接 訪問對方工作記憶體中的變數
,執行緒間變數的值的傳遞需要通過 主記憶體來完成
。
這裡就會存在一個可見性的問題,下面講volatile的時候進行統一講解
-
JMM關於同步的規定:
-
執行緒解鎖前,必須把共享變數的值重新整理回主記憶體
-
執行緒加鎖前,必須讀取主記憶體的最新值,到自己的工作記憶體
-
加鎖和解鎖是同一把鎖
volatile的定義
Java程式語言允許 執行緒訪問共享變數
,為了確保 共享變數能被準確和一致地更新
,執行緒應該確保通過排他鎖單獨獲得這個變數。Java語言提供了volatile,在某些情況下比鎖要更加方便。 如果一個欄位被宣告成volatile
,Java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
volatile的三大特性
由 volatile的定義
可以看出Volatile在日常的單執行緒環境是應用不到的
volatile
是Java虛擬機器提供的輕量級的同步機制(三大特性)
-
保證可見性
-
不保證原子性
-
禁止指令重排
可見性
:是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。
原子性
:在一個操作中就是cpu不可以在中途暫停然後再排程,既不被中斷操作,要不執行完成,要不就不執行。
有序性
:即程式執行的順序按照程式碼的先後順序執行。
下面詳細說明這三個特性
volatile可見性
Java中的 volatile關鍵字
是通過呼叫 C語言
實現的,而在更底層的實現上,即 組合語言
的層面上,用volatile關鍵字修飾後的變數在操作時,最終解析的 彙編指令會在指令前加上lock字首指令
來保證工作記憶體中讀取到的資料是 主記憶體中最新的資料
。 具體的實現原理
是在硬體層面上通過: MESI快取一致性協議
:多個cpu從主記憶體讀取資料到 快取記憶體
中,如果其中一個 cpu修改了資料
,會通過 匯流排
立即回寫到主記憶體中,其他cpu會通過 匯流排嗅探機制
感知到快取中資料的變化並將工作記憶體中的資料失效,再去讀取主記憶體中的資料。
IA32架構軟體開發者手冊對lock字首指令的解釋:1、 會將當前處理器快取行的資料立即回寫到系統記憶體中, 2、這個寫回記憶體的操作會引起其他cpu裡快取了該記憶體地址的資料失效(MESI協議)
即: JMM記憶體模型的可見性
,指的是當主記憶體區域中的值被某個執行緒寫入更改後,其它執行緒會馬上知曉更改後的值,並重新得到更改後的值。
上面提到了兩個概念: 主記憶體
和 工作記憶體
-
主記憶體
:就是計算機的記憶體,也就是經常提到的8G記憶體,16G記憶體
-
工作記憶體
:但我們例項化 new student,那麼 age = 25 也是儲存在主記憶體中
當同時有三個執行緒同時訪問student中的age變數時,那麼 每個執行緒都會拷貝一份,到各自的工作記憶體
,從而實現了變數的拷貝
快取一致性
為什麼這裡主執行緒中某個值被更改後,其它執行緒能馬上知曉呢?其實這裡是用到了 匯流排嗅探技術
在說嗅探技術之前,首先談談 快取一致性
的問題,就是當多個處理器運算任務都涉及到同一塊主記憶體區域的時候,將可能導致各自的快取資料不一。
為了解決快取一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議進行操作,這類協議主要有 MSI
、 MESI
等等。
MESI
當 CPU寫資料
時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,會發出訊號通知其它CPU將該記憶體變數的 快取行
( CPU快取記憶體的中可以分配的最小儲存單位,快取記憶體中的變數都是存在快取行中的。
)設定為無效,因此當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。
匯流排嗅探
如何發現數據是否失效呢?
這裡是用到了 匯流排嗅探技術
,就是每個處理器通過 嗅探
在 總線上
傳播的資料來檢查自己快取值是否過期了,當處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作的時候,會重新從記憶體中把資料讀取到處理器快取中。
匯流排風暴
匯流排嗅探技術有哪些缺點?
由於Volatile的MESI快取一致性協議,需要不斷的從 主記憶體嗅探
和 CAS迴圈
,無效的互動會導致匯流排頻寬達到峰值。因此不要大量使用volatile關鍵字,至於什麼時候使用volatile、什麼時候用鎖以及Syschonized都是需要根據實際場景的。
可見性程式碼驗證
如何保證可見性呢?方法有哪些呢?
1、加鎖
class visibilityTest extends Thread {
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run(){
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
}
public class VolatileDemo {
public static void main(String[] args) {
visibilityTest visibilityTest = new visibilityTest();
visibilityTest.start();
for (; ; ) {
synchronized (visibilityTest) {
if (visibilityTest.isFlag()) {
System.out.println(Thread.currentThread() + "我進來了,你呢?");
}
}
}
}
}
將共享變數加鎖,無論是synchronized還是Lock都可以,加鎖達到的目的是在同一時間內只能有一個執行緒能對共享變數進行操作,就是說,共享變數從讀取到工作記憶體到更新值後,同步回主記憶體的過程中,其他執行緒是操作不了這個變數的。這樣自然就解決了可見性的問題了,但是這樣的效率比較低,操作不了共享變數的執行緒就只能阻塞。
2、volatile修飾修飾共享變數
成員變數沒有被新增任何修飾時,是無法感知其它執行緒修改後的值
package com.tinygray.volatileTest;
import lombok.Data;
/**
* @Author: tinygray
* @Description: 公眾號:Madison龍少,關注我你會越來越優秀。
* @className: VolatileDemo
* @create: 2021-05-12 22:50
*/
@Data
class ResourceData {
//沒有加volatile 修飾
//private boolean flag = false;
//加volatile 修飾
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
}
public class VolatileDemo {
public static void main(String[] args) {
visibility();
}
private static void visibility() {
/**
* 驗證volatile可見性
*
*/
ResourceData data = new ResourceData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 執行緒睡眠3秒,假設在進行運算
try {
Thread.currentThread().join(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
data.setFlag(true);
System.out.println(Thread.currentThread().getName() + "\t update flag value:" + data.isFlag());
}, "AAA").start();
while(!data.isFlag()) {
// main執行緒就一直在這裡等待迴圈,直到number的值不等於零
}
System.out.println(Thread.currentThread().getName() + "over");
}
}
-
成員變數沒有新增volatile關鍵字修飾結果
@Data
class ResourceData {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
}
最後執行緒沒有停止,並行沒有輸出主執行緒結果,說明沒有用volatile修飾的變數,是沒有可見性
-
成員變數新增volatile關鍵字修飾的結果
@Data
class ResourceData {
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
}
主執行緒也執行完畢了,說明volatile修飾的變數,是具備JVM輕量級同步機制的,能夠感知其它執行緒的修改後的值。
原子性驗證
通過前面對 JMM
的介紹,我們知道,各個執行緒對主記憶體中共享變數的操作都是各個執行緒各自拷貝到自己的工作記憶體進行操作後在寫回到主記憶體中的。
這就可能存在一個執行緒AAA修改了共享變數X的值,但是還未寫入主記憶體時,另外一個執行緒BBB又對主記憶體中同一共享變數X進行操作,但此時A執行緒工作記憶體中共享變數X對執行緒B來說是不可見,這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題。
-
原子性概念
不可分割
, 完整性
,也就是說某個執行緒正在做某個具體業務時,要麼同時成功,要麼同時失敗。資料庫也經常提到事務具備原子性。
程式碼驗證volatile不保證原子性
完整程式碼
package com.tinygray.volatileTest;
import lombok.Data;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: tinygray
* @Description: 公眾號:Madison龍少,關注我你會越來越優秀。
* @className: VolatileDemo
* @create: 2021-05-12 22:50
*/
@Data
class ResourceData {
private volatile int number = 0;
private AtomicInteger atomicInteger = new AtomicInteger();
public void addPlusPlus() {
number++;
//atomicInteger.getAndIncrement();
}
}
public class VolatileDemo {
public static void main(String[] args) {
atomicByVolatile();
}
private static void atomicByVolatile() {
/**
* 驗證volatile原子性
* 原子性:完整性,不可分割,某個執行緒在做某個具體業務的時候,中間不能被加塞或被分割,需整體完整,要莫同時成功,要同時失敗
*
*/
ResourceData data = new ResourceData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
data.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20個執行緒都計算完成後,在用main執行緒取得最終的結果值
// 這裡判斷執行緒數是否大於2,為什麼是2?因為預設是有兩個執行緒的,一個main執行緒,一個gc執行緒
while (Thread.activeCount() > 2) {
// yield表示不執行
Thread.yield();
}
// 檢視最終的值
// 假設volatile保證原子性,那麼輸出的值應該為: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + data.getNumber());
}
}
-
第一次結果:
-
第二次結果:
上面程式碼如果保證原子性的情況下,應該輸出20000的結果,但並不是,說明volatile不保證原子性
不保證原子性原因
volatile
:從最終組合語言從面來看,volatile使得每次將i進行了修改之後,增加了一個記憶體屏障lock addl $0x0,(%rsp)保證修改的值必須重新整理到主記憶體才能進行記憶體屏障後續的指令操作。但是記憶體屏障之前的指令並不是原子的
程式碼例子
public static volatile int race = 0;
public static void increase() {
race++;
}
位元組碼
public static void increase();
Code:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return
指令" lock; addl $0,0(%%esp)
"表示加鎖,把0加到棧頂的記憶體單元,該指令操作本身無意義,但這些指令起到記憶體屏障的作用,讓前面的指令執行完成。具有XMM2特徵的CPU已有記憶體屏障指令,就直接使用該指令
volatile方式的i++,總共是四個步驟:i++實際為 load
、 Increment
、 store
、 Memory Barriers
四個操作。
記憶體屏障是執行緒安全的
,但是記憶體屏障之前的指令並不是.在某一時刻執行緒1 將i的值load取
出來,放置到 cpu快取中
,然後再將此值放置到 暫存器A中
,然後 A中的值自增1
(暫存器A中儲存的是中間值,沒有直接修改i,因此其他執行緒並不會獲取到這個自增1的值)。如果在此時執行緒2也執行同樣的操作,獲取值i==10,自增1變為11,然後馬上刷入主記憶體。此時由於執行緒2修改了i的值,實時的執行緒1中的i==10的值快取失效,重新從主記憶體中讀取,變為11。接下來執行緒1恢復。將自增過後的A暫存器值11賦值給cpu快取i。這樣就出現了執行緒安全問題。
如何保證原子性
1、在方法上加入 synchronized
public synchronized void addPlusPlus() {
number++;
//atomicInteger.getAndIncrement();
}
我們能夠發現引入 synchronized
關鍵字後,保證了該方法每次只能夠一個執行緒進行訪問和操作,最終輸出的結果也就為20000
2、使用原子包裝類 AtomicInteger
(Atomic的底層可以去看一下,後續也會講)
上面的方法引入synchronized,雖然能夠保證原子性,但是為了解決number++,而引入重量級的同步機制,有種殺雞焉用牛刀。除了引用synchronized關鍵字外,還可以使用JUC下面的原子包裝類,即剛剛的int型別的number,可以使用 AtomicInteger
來代替
class ResourceData {
private AtomicInteger atomicInteger = new AtomicInteger();
public void addPlusPlus() {
atomicInteger.getAndIncrement();
}
}
private static void atomicByVolatile() {
/**
* 驗證volatile原子性
* 原子性:完整性,不可分割,某個執行緒在做某個具體業務的時候,需整體完整,要莫同時成功,要同時失敗
*/
ResourceData data = new ResourceData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
data.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20個執行緒都計算完成後,在用main執行緒取得最終的結果值
// 這裡判斷執行緒數是否大於2,為什麼是2?因為預設是有兩個執行緒的,一個main執行緒,一個gc執行緒
while (Thread.activeCount() > 2) {
// yield表示不執行
Thread.yield();
}
// 檢視最終的值
// 假設volatile保證原子性,那麼輸出的值應該為: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + data.getAtomicInteger());
}
Volatile的有序性(禁止指令重排)
一般來說,我們寫程式的時候,都是要把先程式碼從上往下寫,預設的認為程式是自頂向下順序執行的,但是CPU為了提高效率,在保證最終結果準確的情況下,是會對指令進行重新排序的。就是說寫在前的程式碼不一定先執行,在後面的也不一定晚執行。
注意
-
單執行緒環境裡面確保最終執行結果和程式碼順序的結果一致
-
處理器在進行重排序時,必須要考慮指令之間的
資料依賴性
-
多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。
為什麼需要禁止指令重排,看下面案例**
指令重排 - 1
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常單執行緒環境,執行順序是 1 2 3 4,但是在多執行緒環境下,可能出現以下的順序:(2 1 3 4)、(1 3 2 4 )
上述的過程就可以當做是指令的重排,即內部執行順序,和我們的程式碼順序不一樣,但是指令重排也是有限制的,即不會出現下面的順序(4 3 2 1)
因為處理器在進行重排時候,必須考慮到指令之間的資料依賴性
因為步驟 4:需要依賴於 y的申明,以及x的申明,故因為存在資料依賴,無法首先執行
例子
int a,b,x,y = 0
執行緒1 | 執行緒2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
x = 0; y = 0 |
因為上面的程式碼,不存在資料的依賴性,因此編譯器可能對資料進行重排
執行緒1 | 執行緒2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
x = 2; y = 1 |
這樣造成的結果,和最開始的就不一致了,這就是導致重排後,結果和最開始的不一樣,因此為了防止這種結果出現,volatile就規定禁止指令重排,為了保證資料的一致性
指令重排 - 2
看下面這段程式碼
/**
* ResortSeqDemo
* @Author: tinygray
* @Description: 公眾號:Madison龍少,關注我你會越來越優秀。
*/
public class Xxxx {
int a= 0;
boolean flag = false;
public void method01() {
a = 1;
flag = true;
}
public void method02() {
if(flag) {
a = a + 5;
System.out.println("reValue:" + a);
}
}
}
我們按照正常的順序,分別呼叫method01() 和 method02() 那麼,最終輸出就是 a = 6
但是如果在多執行緒環境下,因為方法1 和 方法2,他們之間不能存在資料依賴的問題,因此原先的順序可能是
a = 1;
flag = true;
a = a + 5;
System.out.println("reValue:" + a);
但是在經過編譯器,指令,或者記憶體的重排後,可能會出現這樣的情況
flag = true;
a = a + 5;
System.out.println("reValue:" + a);
a = 1;
也就是先執行 flag = true後,另外一個執行緒馬上呼叫方法2,滿足 flag的判斷,最終讓a + 5,結果為5,這樣同樣出現了資料不一致的問題
為什麼會出現這個結果:多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。
這樣就需要通過volatile來修飾,來保證執行緒安全性
什麼是重排序?重排序的作用是什麼?
為了提高處理效能,並保證最後結果正確的情況下,編譯器和處理器常常會對現有程式碼的執行順序進行指令重排序。
重排序的型別有哪些呢?
重排序一般分為三種:
編譯器重排序
。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序;
處理器重排序
。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序;
記憶體訪問重排序
。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行的。
這裡就要說一下 as-if-serial
語義
As-if-serial語義的意思是,所有的動作(Action)都可以為了優化而被重排序,但是必須保證它們重排序後的結果和程式程式碼本身的應有結果是一致的。Java編譯器、執行時和處理器都會保證單執行緒下的as-if-serial語義
原始碼到最終執行會經過哪些重排序呢?
原始碼--->編譯器優化重排序--->指令並行重排序--->記憶體訪問重排序--->最終執行指令順序
volatile如何實現的禁止指令重排呢
通過 記憶體屏障
實現的。
記憶體屏障是什麼?
記憶體屏障
(Memory Barrier,或有時叫做 記憶體柵欄
,Memory Fence)是 一種CPU指令
,用於控制特定條件下的 重排序和記憶體可見性
問題。Java編譯器也會根據記憶體屏障的規則禁止重排序。
記憶體屏障可以被分為以下幾種型別
-
LoadLoad屏障
:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。 -
StoreStore屏障
:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。 -
LoadStore屏障
:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。 -
StoreLoad屏障
:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。
有的處理器的重排序規則較嚴,無需記憶體屏障也能很好的工作,Java編譯器會在這種情況下不放置記憶體屏障
Intel 64/IA-32架構下的記憶體訪問重排序
Intel 64和IA-32是我們較常用的硬體環境,相對於其它處理器而言,它們擁有一種較嚴格的重排序規則。Pentium 4以後的Intel 64或IA-32處理的重排序規則如下。
在單CPU系統中:
-
讀操作不與其它讀操作重排序。
-
寫操作不與其之前的寫操作重排序。
-
寫記憶體操作不與其它寫操作重排序,但有以下幾種例外
-
CLFLUSH的寫操作
-
帶有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming寫入。
-
字串操作
-
讀操作可能會與其之前的寫不同位置的寫操作重排序,但不與其之前的寫相同位置的寫操作重排序。
-
讀和寫操作不與I/O指令,帶鎖的指令或序列化指令重排序。
-
讀操作不能重排序到LFENCE和MFENCE之前。
-
寫操作不能重排序到LFENCE、SFENCE和MFENCE之前。
-
LFENCE不能重排序到讀操作之前。
-
SFENCE不能重排序到寫之前。
-
MFENCE不能重排序到讀或寫操作之前。
在多處理器系統中:
-
各自處理器內部遵循單處理器的重排序規則。
-
單處理器的寫操作對所有處理器可見是同時的。
-
各自處理器的寫操作不會重排序。
-
記憶體重排序遵守因果性(causality)(記憶體重排序遵守傳遞可見性)。
-
任何寫操作對於執行這些寫操作的處理器之外的處理器來看都是一致的。
-
帶鎖指令是順序執行的。
-
值得注意的是,對於Java編譯器而言,Intel 64/IA-32架構下處理器不需要LoadLoad、LoadStore、StoreStore屏障,因為不會發生需要這三種屏障的重排序。
volatile
會在變數寫操作的前後加入兩個記憶體屏障,來保證前面的寫指令和後面的讀指令是有序的。
volatile
在變數的讀操作後面插入兩個指令,禁止後面的讀指令和寫指令重排序。
不光volatile能保證有序性,也有其他的實現方式也能保證,所以再JDK5出現了 happen-before
原則,也叫先行發生原則。
根據Java記憶體模型中的規定,可以總結出以下幾條happens-before規則。Happens-before的前後兩個操作不會被重排序且後者對前者的記憶體可見。
-
程式次序法則
:執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B,其中,在程式中,所有的動作B都能出現在A之後。 -
監視器鎖法則
:對一個監視器鎖的解鎖 happens-before於每一個後續對同一監視器鎖的加鎖。 -
volatile變數法則
:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。 -
執行緒啟動法則
:在一個執行緒裡,對Thread.start的呼叫會happens-before於每個啟動執行緒的動作。 -
執行緒終結法則
:執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已經終結、或者從Thread.join呼叫中成功返回,或Thread.isAlive返回false。 -
中斷法則
:一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷。 -
終結法則
:一個物件的建構函式的結束happens-before於這個物件finalizer的開始。 -
傳遞性
:如果A happens-before於B,且B happens-before於C,則A happens-before於C
Java記憶體模型關於重排序的規定,總結後如下表所示:
-
表中“第二項操作”的含義是指,第一項操作之後的所有指定操作。如,普通讀不能與其之後的所有volatile寫重排序。另外,JMM也規定了上述volatile和同步塊的規則盡適用於存在多執行緒訪問的情景。例如,若編譯器(這裡的編譯器也包括JIT,下同)證明了一個volatile變數只能被單執行緒訪問,那麼就可能會把它做為普通變數來處理。
-
留白的單元格代表允許在不違反Java基本語義的情況下重排序。例如,編譯器不會對對同一記憶體地址的讀和寫操作重排序,但是允許對不同地址的讀和寫操作重排序。
應用
單例模式-雙重檢查單例
最終版本程式碼
/**
* @Author: tinygray
* @Description: 公眾號:Madison龍少,關注我你會越來越優秀。
* @className: SingletonDemo
* @create: 2021-05-12 22:50
*/
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
// a 雙重檢查加鎖多執行緒情況下會出現某個執行緒雖然這裡已經為空,但是另外一個執行緒已經執行到d處
synchronized (SingletonDemo.class) //b
{
//c不加volitale關鍵字的話有可能會出現尚未完全初始化就獲取到的情況。原因是記憶體模型允許無序寫入
if(instance == null) {
// d 此時才開始初始化
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
// // 這裡的 == 是比較記憶體地址
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
雙重檢查為啥要使用volatile呢?
這就要說到物件的建立步驟
1、分配記憶體空間。2、呼叫構造器,例項化。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、初始化物件
這個過程中是有可能發生 指令重排
的,有可能建構函式在物件初始化完成前就賦值完成了,在記憶體裡面開闢了一片儲存區域後直接返回記憶體的引用,這個時候還沒真正的初始化完物件。這個時候如果其他執行緒就會發現例項物件不等於null,然而物件還沒有真正建立好,這個時候就會出現 空指標異常
。
也就是當我們執行到 重排後
的步驟2,試圖獲取instance的時候,會得到null,因為物件的初始化還沒有完成,而是在重排後的步驟3才完成,因此執行單例模式的程式碼時候,就會重新在建立一個instance例項.
指令重排只會保證序列語義的執行一致性(單執行緒),但並不會關係多執行緒間的語義一致性
所以當一條執行緒訪問instance不為null時,由於instance例項未必已初始化完成,這就造成了執行緒安全的問題
所以需要引入volatile,來保證出現指令重排的問題,從而保證單例模式的執行緒安全性
如何保證單例的
第一個執行緒走到第一次檢查時發現物件為空,然後進入鎖,第二次就檢查時也為空,那麼就去建立物件,但是這個時候又來了一個執行緒來到了第一次檢查,發現為空,但是這個時候因為鎖被佔用,所以就只能阻塞等待,然後第一個執行緒建立物件成功了,由於物件是被volatile修飾的能夠立即反饋到其他執行緒上,所以在第一個執行緒釋放鎖之後,第二個執行緒進入了鎖,然後進行第二次檢查時,發現物件已經被建立了,那麼就不在建立物件了。從而保證的單例。
小節
synchronized和volatile的區別
volatile關鍵字的本質是告訴jvm,該變數在暫存器中的值是不確定的,需要在主存中讀取,而synchronized關鍵字是鎖住當前變數,只有當前執行緒可以訪問,其他執行緒等待。
1、 volatile只能作用於變數,而synchronized可以作用於變數、方法和程式碼塊
2、多執行緒訪問volatile不會發生阻塞,而synchronized關鍵字可能發生阻塞。
3、 volatile能夠保證資料的可見性,就是在多個執行緒之間是可見的,不能保證原子性,而synchronized關鍵字都可以保證。
4、volatile關鍵字主要解決的是多個執行緒之間的可見性,而synchronized關鍵字保證的是多個執行緒訪問資源的同步性。
volatile適用於場景:
某個屬性被多個執行緒共享,其中有一個執行緒修改了此屬性,其他執行緒可以立即得到修改後的值,實現輕量級同步。
synchronized
關鍵字是防止多個執行緒同時執行一段程式碼,那麼就會很影響程式執行效率,而 volatile
關鍵字在某些情況下效能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。
通常來說,使用volatile必須具備以下2個條件:
1、對變數的寫操作不依賴於當前值。2、該變數沒有包含在具有其他變數的不變式中。
下面列舉兩個使用場景
1、狀態標記量。2、雙重檢查(單例模式)
結束語
感謝閱讀小生文章。祝大家早日富可敵國,實現財富自由。
寫文不易
,一定要 點贊、評論、收藏哦
, 感謝感謝感謝!!!
有任何問題可以在微信搜尋 公眾號
: Madison龍少
進行諮詢
或者微信掃描下面二維碼進行諮詢
掃碼關注我們
微訊號|Madison龍少