前言
在類似于電商、大數(shù)據(jù)分析平臺等,往往都要面臨極高的并發(fā)量,而這些情況下,數(shù)據(jù)往往會錯亂,不一致,但在這些場景下,往往不需要完全滿足ACID規(guī)范,因為這樣會嚴(yán)重影響業(yè)務(wù)的并發(fā)量。為此,這類場景只需要保證最終數(shù)據(jù)一致性即可。而類似于金融等,這種對數(shù)據(jù)的一致性要求極高,為此會選擇犧牲一定的并發(fā)量來保證數(shù)據(jù)的一致性。那么在Java中,是如何保證數(shù)據(jù)的一致性呢?那就是Java內(nèi)存模型。
1.JMM試圖解決什么問題?
在沒有內(nèi)存模型之前,程序運行依賴于處理器的內(nèi)存一致性模型,而不同處理器之間又有很大差異,導(dǎo)致同一個程序運行在不同機器上表現(xiàn)不一致。而JMM就是為了解決這種不一致,同時保證多線程程序運行時的正確性。接下來我們開始進入正題,介紹JMM相關(guān)的原理。
2.Java內(nèi)存模型(JMM)
Java 內(nèi)存模型是抽象的概念,描述的是程序間變量的訪問規(guī)則(多線程程序允許表現(xiàn)出的行為),Java線程內(nèi)存模型與CPU緩存模型類似,它是標(biāo)準(zhǔn)化的,用于屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異。
舉個例子,如我們多個線程在訪問內(nèi)存中某個共享變量的時候,往往不是直接訪問內(nèi)存中的共享變量,而是將共享變量拷貝到線程工作內(nèi)存中,這個變量即為共享變量的副本。而這個行為即是內(nèi)存模型的一個抽象出來的規(guī)范。

2.CPU多級緩存
CPU每次從主存中讀取數(shù)據(jù)太慢,現(xiàn)代CPU通常被設(shè)計為多級緩存,CPU讀主存按照空間局部性加載原則,load局部區(qū)塊的數(shù)據(jù)到緩存。

3.CPU緩存一致性原理詳解
我們來看下如下簡單例子,我們測試線程A是否可以嗅探到線程B對initFlag的修改。
public class VolatileVisibilitySample {
private static boolean initFlag = false;
//private static volatile boolean initFlag = false;
public static void refresh(){
System.out.println("refresh data--------");
initFlag = true ;
System.out.println("refresh data success---------");
}
public static void main (String[] args){
Thread threadA = new Thread( ()->{
while (!initFlag){
}
System.out.println("線程:" + Thread.currentThread().getName() + "當(dāng)前線程嗅探到initFlag的狀態(tài)改變");
} ,"threadA");
threadA.start();
try{
Thread.sleep(500);
}catch(InterruptedException e){
e.printStackTrace();
}
Thread threadB = new Thread( ()->{
refresh();
},"threadB");
threadB.start();
}
}
其運行結(jié)果如下圖所示:

從運行結(jié)果我們發(fā)現(xiàn),線程A一直在while循環(huán)中,程序一直沒有結(jié)束,這是為什么呢?在解釋原理之前我們先來認(rèn)識一下JMM中的8大數(shù)據(jù)原子操作。
3.1 JMM八大數(shù)據(jù)原子操作
-
lock (鎖定):作用于主內(nèi)存變量,把一個變量標(biāo)記為一條線程獨占狀態(tài); -
unlock (解鎖):作用于主內(nèi)存的變量,把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定; -
read (讀取):把一個變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用; -
load(載入):它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存變量的副本中; -
use(使用):把工作內(nèi)存中的一個變量值傳遞給執(zhí)行引擎; -
assign (賦值):將計算好的值重新賦值到工作內(nèi)存中; -
store(存儲):把工作內(nèi)存中的一個變量的值傳送到主內(nèi)存中,以便隨后的write操作; -
write(寫入):把store操作從工作內(nèi)存中的一個變量的值傳送到主內(nèi)存的變量中。
3.2 代碼詳解
從示例代碼中可以看到,線程A先于線程B啟動。
線程A在啟動時,先通過read(讀取)原子操作將initFlag這個共享變量從主內(nèi)存中讀取,再通過load(載入)原子操作將read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中。當(dāng)線程A在執(zhí)行到while時,會去工作內(nèi)存中查找initflag的變量副本。
同樣,線程B加載共享變量initFlag的過程與線程A類似。但我們在線程B中,對initFlag進行了賦值操作,線程B要將該值寫到主內(nèi)存中。我們來看下這個寫回主內(nèi)存的過程:首先,通過assign(賦值)原子操作,將修改后的值寫入到線程的工作內(nèi)存中,再通過store(存儲)將工作內(nèi)存中的變量值傳送到主內(nèi)存中(預(yù)傳送),最后通過write(寫入)原子操作,將變量值最終寫入到主內(nèi)存中。
整個過程示例圖如下:

然而,雖然主內(nèi)存中initFlag的值雖然已經(jīng)被修改了,但是線程A卻無法知道該值已經(jīng)被修改,仍然使用的是工作內(nèi)存中的initFlag=false的值。
我們做如下修改,給initFlag前加上關(guān)鍵字volatile,如下所示:
private static volatile boolean initFlag = false;
再次運行的結(jié)果如下圖所示:

volatile關(guān)鍵字可以幫助我們解決這個問題,這是為什么呢?接下來我們來詳細了解這個實現(xiàn)原理。
4.volatile可見性底層實現(xiàn)原理
volatile的可見性實現(xiàn)原理:
- 底層實現(xiàn):通過匯編lock前綴指令觸發(fā)底層緩存鎖定機制(如緩存一致性協(xié)議(MESI)、總線鎖)。

例如觸發(fā)MESI協(xié)議,lock指令會觸發(fā)鎖定變量緩存行區(qū)域并寫回主內(nèi)存,這個操作被稱為"緩存鎖定":
- 緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內(nèi)存區(qū)域數(shù)據(jù)(MESI協(xié)議)
- 一個處理器的緩存回寫到內(nèi)存會導(dǎo)致其它處理器的緩存無效(MESI協(xié)議)IA-32架構(gòu)
4.1總線鎖
總線鎖會在CPU與內(nèi)存條之間的總線加入鎖,當(dāng)CPU某一核心成功在總線上加鎖后可以無障礙的去讀寫主內(nèi)存中存儲的數(shù)據(jù),但其余核心是無法訪問主內(nèi)存的任何數(shù)據(jù)的(類似于synchronized關(guān)鍵字、悲觀鎖)。
總線鎖缺點效率極低,但其也作為緩存一致性協(xié)議的輔助方式,當(dāng)緩存一致性協(xié)議無效時,底層依然會使用總線鎖。
4.2緩存一致性協(xié)議(MESI)
緩存一致性協(xié)議(MESI):
-
M:修改; -
E:獨占; -
S:共享; -
I:invalid 無效。
在該協(xié)議下,雖然L3級緩存在CPU中是各核心共享的,但是各核心在讀取主內(nèi)存數(shù)據(jù)時,在L3級緩存上都擁有各自的副本。我們通過下圖來解釋加入volatile關(guān)鍵字后,底層是如何實現(xiàn)數(shù)據(jù)可見的。

總線嗅探機制(每個核心會監(jiān)聽總線上的數(shù)據(jù)交互,消息)、消息發(fā)布機制
假設(shè)核心0中的線程0先于核心1的線程1讀取主內(nèi)存中的數(shù)據(jù)initFlag。
1)核心0一開始讀取主內(nèi)存中的initFlag時,需要發(fā)送總線讀消息到總線上,沿著總線傳輸,若無其他核嗅探該總線讀消息,那么核心0將initFlag從主內(nèi)存中復(fù)制到L3緩存,數(shù)據(jù)initFlag被標(biāo)記為E(獨占狀態(tài));
2)若此時核心1也需要讀取該數(shù)據(jù),那么它將往總線上發(fā)送總線讀消息,嗅探到有其它核心已經(jīng)讀取過該數(shù)據(jù),此時核心1將復(fù)制一份主內(nèi)存數(shù)據(jù)x到L3緩存中,其它所有在L3緩存中的initFlag數(shù)據(jù)副本的狀態(tài)都被標(biāo)記為共享狀態(tài);
3)兩個核心得到副本后,都會逐級將副本往上復(fù)制(L3->L2->L1);
4)假設(shè)此時,核心0要修改該數(shù)據(jù)initFlag。核心0會往總線上發(fā)送總線本地寫消息進行加鎖(緩存行鎖:CPU緩存的最小存儲單元)鎖定變量,擁有該變量并在使用的核心1嗅探到有其他核心在給該變量加鎖,則認(rèn)為被加鎖變量很有可能無效了,為此會將該變量數(shù)據(jù)標(biāo)記為I(失效狀態(tài))。而核心0中的數(shù)據(jù)狀態(tài)被標(biāo)記為M(修改狀態(tài))。
5)在將修改的數(shù)據(jù)同步寫回主內(nèi)存前,核心0會發(fā)送一個總線寫回消息,該消息沿著總線傳播,其它擁有該變量數(shù)據(jù)的線程在嗅探到該消息后,會去主內(nèi)存中拉取新的數(shù)據(jù)副本,并逐級復(fù)制到工作內(nèi)存中。
注:在核心1中變量副本失效后,執(zhí)行的while語句中的initFlag由于不存在,可能會發(fā)生上下文切換,并且有可能發(fā)生指令重排。
在第4)步中,我們可以想象,由于CPU執(zhí)行速度很快,那么極有可能兩個線程同時要修改數(shù)據(jù)initFlag,那么這個時候是誰成功的給變量加鎖呢?這個時候就依靠于總線裁決了。
5 指令重排
在編程中,我們往往會想到的是程序按順序執(zhí)行(即從上往下執(zhí)行),但在高并發(fā)場景下,往往會發(fā)生指令重排。我們先來看下如下例子來驗證指令會發(fā)生重排:
public class VolatileReOrderSample {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException{
int i = 0;
for (;;){
i++;
x = 0; y =0;
a = 0; b = 0;
Thread t1 = new Thread(new Runnable(){
public void run(){
a = 1;
x = b;
}
});
Thread t2 = new Thread (new Runnable(){
public void run(){
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0){
System.err.println(result);
break;
}else{
System.out.println(result);
}
}
}
}
在main函數(shù)中,我們編寫了一個死循環(huán),循環(huán)中每次都會初始化x,y,a,b變量,且每次創(chuàng)建兩個線程,分別對x,y,a或b賦值,當(dāng)遇到x=0和y=0這種情況時,退出循環(huán)。假設(shè)沒有指令重排這種機制,我們先考慮下程序運行過程中可能出現(xiàn)的賦值情況:
| 線程執(zhí)行情況 | x | y | a | b |
|---|---|---|---|---|
t1執(zhí)行完后t2執(zhí)行 |
0 | 1 | 1 | 1 |
t2執(zhí)行完后t1執(zhí)行 |
1 | 0 | 1 | 1 |
t1執(zhí)行a = 1后t2執(zhí)行b=1,后續(xù)執(zhí)行順序t1先或t2先執(zhí)行完剩余代碼 |
1 | 1 | 1 | 1 |
我們來看下代碼運行結(jié)果:

我們可以發(fā)現(xiàn),出現(xiàn)了在沒有指令重排假設(shè)時的其它情況。那么為什么會出現(xiàn)這種情況呢?我們看下下圖:


- 在線程
t1的線程棧中,有a=1與x=b的字節(jié)碼以及在線程t2中的b=1和y=a的字節(jié)碼,它們經(jīng)過字節(jié)碼執(zhí)行引擎執(zhí)行,再通過JIT及時編譯器編譯成匯編指令,最后由CPU執(zhí)行; - 而JIT由于存在會根據(jù)線程上下文分析按何種順序執(zhí)行指令會達到更高效,所以會存在交換指令先后順序的情況;
- 假設(shè)在到達cpu執(zhí)行階段前,
t1線程中的a=1與x=b的指令未發(fā)生順序交換,而t2線程的執(zhí)行順序同樣未變(即b=1先于y=a)。再假設(shè)t1執(zhí)行了a=1指令后,t2開始執(zhí)行。此時,a =1已經(jīng)加載到cpu的緩存行中,而x = b尚未加載,當(dāng)cpu執(zhí)行b = 1指令時,由于指令y=a此時的a已經(jīng)在緩存中,而b這個變量的值需要再主內(nèi)存中獲取,由于cpu的快速運轉(zhuǎn)特性,這個過程會影響它的執(zhí)行效率,為此,可能會優(yōu)先執(zhí)行y =a的指令,同時在內(nèi)存中拉取變量b的值,達到提高效率的效果。
那我們可能會想,那么既然很有可能發(fā)生指令重排,那么我們寫的代碼是不是就不會按預(yù)期的執(zhí)行了,也就得不到我們想要的正確結(jié)果。答案很明顯,指令重排同樣需要遵循一定的原則,如happens-before、as-if-serial等。以此來保證程序運行的正確性。同樣,可以使用volatile關(guān)鍵字來實現(xiàn)禁止指令重排。
6.總結(jié)
以上只是對多線程并發(fā)編程中簡單概述了關(guān)于java內(nèi)存模型中的工作模型,以及相關(guān)的緩存一致性協(xié)議(MESI)和指令重排機制。初步認(rèn)識了并發(fā)編程的可見性、原子性與有序性。但值得注意的是volatile保證可見性與有序性,但是不保證原子性,保證原子性還需要其它相關(guān)的鎖機制,如重量級鎖synchronized。(如有錯,望指出)