- 硬件內(nèi)存模型
- Java內(nèi)存模型
- 線程之間通信
- 同步性原則
- 可能出現(xiàn)的問題
- 可見性
- 原子性
- 有序性
硬件內(nèi)存模型
工程師為了追求橫向的拓展,就是在單臺計算機(jī)中使用更多的處理器。
眾所周知,目前CPU的處理器速度與內(nèi)存速度的讀寫速度不在一個數(shù)量級,所以需要在CPU和內(nèi)存之間加上緩存來進(jìn)行提速,這樣的就呈現(xiàn)了一種CPU-寄存器-緩存-主存的訪問結(jié)構(gòu)。
cpu:包含運(yùn)算器和控制器。根據(jù)馮諾依曼體系,CPU的工作分為以下 5 個階段:取指令階段、指令譯碼階段、執(zhí)行指令階段、訪存取數(shù)和結(jié)果寫回。
寄存器: 指令寄存器、程序計數(shù)器等
結(jié)構(gòu): CPU-寄存器-緩存-主存
這種結(jié)構(gòu)在單CPU時期運(yùn)行的很好,但是當(dāng)一臺計算機(jī)中引入了多個CPU時,出現(xiàn)了一個棘手的問題,假如CPU A將數(shù)據(jù)D從主存讀取到獨(dú)占的緩存內(nèi),通過計算之后修改了數(shù)據(jù)D,變?yōu)镈1,但是還沒有刷新回到主存,此時CPU B將數(shù)據(jù)D從主存讀取到獨(dú)占緩存內(nèi),也對D進(jìn)行計算變?yōu)镈2,顯而易見這時候的數(shù)據(jù)產(chǎn)生了不同步。
到底是以D1為準(zhǔn)還是以D2為準(zhǔn),針對這個問題,科學(xué)家們設(shè)計了緩存一致性協(xié)議。

主要就是為了解決多個CPU緩存之間的同步問題,CPU緩存一致性協(xié)議有很多,大致可以分為兩類。
窺探型和基于目錄型,當(dāng)CPU緩存想要訪問主存時,需要經(jīng)過一致性協(xié)議這種軟件層面的措施來保證數(shù)據(jù)的一致性,協(xié)議本事的實(shí)現(xiàn)細(xì)節(jié),可以猜想的是,其中的內(nèi)容一定是一些和數(shù)據(jù)同步相關(guān)的操作,既然要進(jìn)行數(shù)據(jù)同步,很可能出現(xiàn)等待喚醒這樣的措施,這將可能導(dǎo)致性能問題,尤其是對于CPU這種運(yùn)算速度極快的組件來說,絲毫的等待都是極大的浪費(fèi),比如CPU B想要讀取數(shù)據(jù) D的時候,還需要等待CPU A將D寫回主存,這種行為是難以忍受的,因此,計算機(jī)科學(xué)家們做出了一些優(yōu)化,整體思路上就是將同步改為異步,比如CPU B要讀取數(shù)據(jù)D時,發(fā)現(xiàn)D正在被其他的CPU修改,那么此時CPU B 可以注冊一個讀取D的消息,自己能回頭去做其他事情,其他CPU寫會數(shù)據(jù)D后,響應(yīng)了這個注冊消息,此時CPU B發(fā)現(xiàn)消息被響應(yīng)后,再去讀取D 這樣的就能夠提升效率。但是對于CPU B來說,程序看上去就不是順序執(zhí)行了,可能會出現(xiàn)先運(yùn)行后面的指令,再回頭去運(yùn)行前面的指令,這一種行為就體現(xiàn)出了一種指令重排序。雖然指令被重排了,但CPU依然需要保證程序執(zhí)行結(jié)果的正確性,就是說無論指令怎么重排,最后的執(zhí)行結(jié)果一定要和順序執(zhí)行的結(jié)果是一樣的,這具體是如何實(shí)現(xiàn)的呢?可以做一個擴(kuò)展
指令重排相關(guān)知識點(diǎn)(了解)
1.Store Buffer
2.Store Forwarding
3.Invalid Queue
4.寫屏障
5.內(nèi)存屏障
硬件內(nèi)存模型的目標(biāo)是為了讓匯編代碼能夠運(yùn)行在一個具有一致性的內(nèi)存視圖上。隨著高級語言的流行。工程師們開始設(shè)計編程語言級別的內(nèi)存模型,這是為了能夠使用該語言編程的也能擁有一個一致性的內(nèi)存視圖。
一致性的內(nèi)存視圖。各種硬件內(nèi)存模型抽象出相同的內(nèi)存視圖。
Java內(nèi)存模型
于是在硬件模型之上,還存在著為編程語言設(shè)計的內(nèi)存模型,比如Java內(nèi)存模型 JMM (Java Memory Model)就屏蔽了各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,實(shí)現(xiàn)了讓Java程序能夠在各種硬件平臺下,都能夠按照預(yù)期的方式來運(yùn)行。
他的抽象,如圖,

概括來說每個工作流程都擁有獨(dú)占的本地內(nèi)存,本地內(nèi)存中的存儲的是私有變量以及共享變量的副本,并且使用一定機(jī)制來控制本地內(nèi)存和主存之間讀寫數(shù)據(jù)時的同步問題,更加具體一點(diǎn),我們將工作線程和本地內(nèi)存具象為 thread stack 將主存具象為heap 。

Thread stack中有兩種類型的變量。其中原始類型的變量,總是存儲在線程棧上,對象類型的變量 引用或者說指針本身是存儲在線程棧上,而引用指向的對象的是存儲在堆上的。在Heap中存儲對象本身,持有對象引用的線程就都能夠訪問該對象,heap本身他不關(guān)心哪個線程正在訪問對象
我們可以這么理解 Java線程模型中的thread stack 和heap都是對物理內(nèi)存的一種抽象。這樣開發(fā)者只需要關(guān)心自己寫的程序使用到了thread stack/heap ,而不需要關(guān)心更下層的寄存器 cpu緩存 主存。可以猜測,線程在工作時的大部分情況下都在讀寫thread stack中的本地內(nèi)存,也就是說本地內(nèi)存對速度的要求更高,那么他可能大部分都是使用寄存器和CPU緩存來實(shí)現(xiàn)的,而heap中需要存儲大量的對象,需要更大的容量。那么他可能大部分都是使用主存來實(shí)現(xiàn)的。

線程之間通信
這樣想來,大概就能理解Java內(nèi)存模型與硬件內(nèi)存模型之間這種模糊的內(nèi)容映射關(guān)系了。上面我們提到了Java內(nèi)存模型需要設(shè)計一些機(jī)制,來實(shí)現(xiàn)主存與工作內(nèi)存之間的數(shù)據(jù)傳輸與同步,這種數(shù)據(jù)的傳遞,正式線程之間的通信方式。
主存和工作內(nèi)存之間通過這八個指令來實(shí)現(xiàn)數(shù)據(jù)的讀寫與同步,按照作用域分別分為兩類:
一類是作用于主存,一類是作用于工作內(nèi)存。下圖是一個通信的例子:

比如說線程A現(xiàn)在調(diào)用lock指令,將x變量標(biāo)記獨(dú)占狀態(tài),接下來他assign/store兩個指令來對x進(jìn)行賦值,使他變?yōu)? 再繼續(xù)調(diào)用write指令,將x這個變量寫入主存,此時A的操作已經(jīng)完成,并進(jìn)行解鎖,于是調(diào)用了unlock指令釋放x鎖定狀態(tài) 這時候呢,線程B要讀取變量X,于是他調(diào)用了read指令來讀取了x這個變量,調(diào)用load將變量加載到自己的本地內(nèi)存中,最后他再調(diào)用use指令來讓計算資源對這個變量進(jìn)行操作。這一套下來就實(shí)現(xiàn)了線程A和線程B之間的通信。不過這張圖上演示的是一種比較理想的狀態(tài)。而實(shí)際的線程通信中還存在著一些問題需要解決。
可能出現(xiàn)的問題

第一個問題:
假如本地內(nèi)存A和本地內(nèi)存B中存在x副本且值都是1,當(dāng)線程A將x修改為2并且寫入主存后,此時線程B想要讀取x,默認(rèn)會從本地內(nèi)存B中讀取,而本地內(nèi)存B中的x依然是等于1的,換言之,線程A刷新了主存中的x,線程B如何才能讀取到最新的值,那么這個問題被稱為一種可見性的問題。
第二個問題:
加入線程A和B都從主存中讀取了變量x,此時x=1,分別在各自的本地內(nèi)存中自增1,x變?yōu)榱?,然后再刷新回主存,這里就有一個問題,實(shí)際上自增了兩次,x應(yīng)該變?yōu)?,但是主存中的x 卻為2。那么這種問題被稱為一種原子性的問題。
上面所說的兩個問題其實(shí)就是反應(yīng)了線程通信之間的同步問題。當(dāng)多個線程在并發(fā)操作共享數(shù)據(jù)時,可能回引發(fā)各種各樣的問題。這些問題,被總結(jié)為三個要素。 可見性 原子性 有序性
上面所說的兩個問題呢,分別對應(yīng)可見性和原子性,這三個要素事實(shí)上并不是完全割裂的,尤其是可見性和有序性。
可見性
可見性指的是:當(dāng)一個線程修改共享變量的值,其他線程需要能夠立刻得知這個修改。
這句話其實(shí)有兩層含義:
1.線程A修改了數(shù)據(jù)D,線程B需要督導(dǎo)修改后最新的D。(由刷新主存的時機(jī)引起的)
對應(yīng)到Java內(nèi)存模型中,當(dāng)一個線程在自己的工作內(nèi)存中修改了某個變量,應(yīng)該把該變量立即刷新到主存中,并讓其他線程知道。
對應(yīng)的代碼:
static int a = 1;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
while (a != 2) {
// do nothing
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
a = 2;
}
});
thread1.start();
Thread.sleep(1000);
thread2.start();
}
當(dāng)我們執(zhí)行main方法時,首先線程1會啟動,由于a的值為1,線程1將會執(zhí)行死循環(huán), 一秒后線程2啟動。線程2將a的值改為2,此時如果線程1能夠讀到a的值被修改為2的話,將會跳出死循環(huán),但是你會發(fā)現(xiàn)事實(shí)上并沒有跳出,死循環(huán)將一致執(zhí)行下去。說明變量a的修改并沒有被線程1讀到,那么說明a此時不滿足可見性,針對此種情況,如何解決呢?
當(dāng)某個線程修改了變量,其他線程如何才能立刻獲取到最新值,這里主要由兩種解決辦法。
第一種:利用volataile關(guān)鍵字。volatile關(guān)鍵字的下層實(shí)現(xiàn)保證了,若一個被volatile寫volatile修飾的變量被修改,那么總會主動寫入主存,若要讀取一個volatile變量,那么總是從主存中讀取。這樣的話,相當(dāng)于操作volatile變量都是直接去讀寫主存。這樣就能夠解決上面的可見性問題。
第二種:利用Synchronized關(guān)鍵字,Synchronized關(guān)鍵字實(shí)現(xiàn)的一個特性。在同步代碼塊中,monitor的基礎(chǔ)上,讀寫變量時,將會隱式地執(zhí)行上文提到的內(nèi)存lock指令,并清空工作內(nèi)存中該變量的值,需要使用該變量時必須從主存中讀取。同理,也會隱式的執(zhí)行內(nèi)存unlock指令,將修改過的變量刷新回主存。這樣也能夠解決可見性問題。
對應(yīng)的代碼:
static int a = 1;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
while (a != 2) {
synchronized (this) {
int b = a + 1;
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
a = 2;
}
});
thread1.start();
Thread.sleep(1000);
thread2.start();
}
2.第二種可見性問題:線程B需要讀到被修改的變量D,線程A應(yīng)該修改,但是因為重排序?qū)е戮€程A沒有及時修改變量D。(由指令重排引起的)
代碼:
static int a = 0;
static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
a = 1; // 1
flag = true; // 2
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
if (flag) { // 3
int i = a; // 4
}
}
});
thread1.start();
thread2.start();
}
如果代碼執(zhí)行到注釋4這行時,變量i是否一定等于1,答案是否定的,我們在最上面提到過,硬件內(nèi)存模型中存在指令重排序機(jī)制,Java內(nèi)存模型中也存在指令重排,他們的作用和約束都是一樣的,第一是為了更高的執(zhí)行效率,第二個在單線程中指令重排后能夠保證程序執(zhí)行結(jié)果的正確性,就是說和順序執(zhí)行的結(jié)果是一樣的。所以線程一中的代碼一和代碼二完全有可能在編譯后被重排,出現(xiàn)了下面這樣的執(zhí)行順序 代碼2->代碼3->代碼4->代碼1。在這種情況下,變量i還是等于0,但是從程序順序執(zhí)行的邏輯上看,似乎只要執(zhí)行到代碼4,變量i的值就一定是1,這里就出現(xiàn)了可見性問題,說明變量a此時不滿足可見性。
同樣的,我們也可以通過volatile和sync這兩個關(guān)鍵字來解決這種可見性問題。第一種,使用volatile關(guān)鍵字,volatile關(guān)鍵字禁止當(dāng)前變量與之前的代碼語句進(jìn)行重排序,可以這么理解,當(dāng)程序執(zhí)行到volatile變量的讀寫時(還未執(zhí)行),之前的代碼語句的執(zhí)行結(jié)果是滿足可見性的。當(dāng)執(zhí)行volatile的讀寫時,上文講過變量將會與主存進(jìn)行同步,所以volatile變量保證了可見性。
在這個例子中,我們只要給付那個變量加上volatile修飾,那么就能夠禁止代碼1和代碼2的重排,因為代碼2中的變量是被volatile修飾的,根據(jù)上一段所說,就能夠保證代碼1的可見性。線程2中的代碼4就能夠成功的讀到a的值為1,synchronized關(guān)鍵字,我們再看上面的這個例子導(dǎo)致可見性問題的根源就是代碼1和代碼2被重排了,并且在執(zhí)行期間線程2讀到了線程1的中間狀態(tài),那么如果代碼1和代碼2變成了一個不可分割的代碼塊,這時無論其內(nèi)部如何進(jìn)行重排,外部都只能讀到最終結(jié)果,所以也就避免了可見性的問題。
特別提醒:Java的指令重排有兩次,第一次發(fā)生在將字節(jié)碼編譯成機(jī)器碼的階段,第二次發(fā)生在CPU執(zhí)行的時候,也會適當(dāng)?shù)倪M(jìn)行指令重排。
關(guān)于指令重排的的復(fù)現(xiàn)代碼:
package com.example.demo0413.test;
public class VolatileReOrderSample {
//定義四個靜態(tài)變量
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;
while (true){
i++;
x=0;y=0;a=0;b=0;
//開兩個線程,第一個線程執(zhí)行a=1;x=b;第二個線程執(zhí)行b=1;y=a
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
//線程1會比線程2先執(zhí)行,因此用nanoTime讓線程1等待線程2 0.01毫秒
shortWait(10000);
a=1;
x=b;
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
b=1;
y=a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
//等兩個線程都執(zhí)行完畢后拼接結(jié)果
String result="第"+i+"次執(zhí)行x="+x+"y="+y;
//如果x=0且y=0,則跳出循環(huán)
if (x==0&&y==0){
System.out.println(result);
break;
}else{
System.out.println(result);
}
}
}
//等待interval納秒
private static void shortWait(long interval) {
long start=System.nanoTime();
long end;
do {
end=System.nanoTime();
}while (start+interval>=end);
}
}
happens-before原則
設(shè)計內(nèi)存的前輩們?yōu)槲覀兘鉀Q了這些事,他們定義了一組原則,被稱為 happens-before原則,這組原則規(guī)定了對于兩個操作A和B 這兩個操作可以在不同的線程中執(zhí)行。如果A happens-before B 那么可以保證 當(dāng)A操作執(zhí)行完后,A操作的執(zhí)行結(jié)果對 B操作是可見的。事實(shí)上,之所以很多人在日常開發(fā)中對可見性問題沒有太多的感知,那是因為在不知不覺中就已經(jīng)滿足了happens-before原則之一。
這個原則有八條:
程序順序規(guī)則
鎖定規(guī)則
volatile變量規(guī)則
線程啟動規(guī)則
線程結(jié)束規(guī)則
中斷規(guī)則
終結(jié)期規(guī)則
傳遞性規(guī)則
前面比較重要的三條,
程序順序原則,
在一個線程的內(nèi)部按照程序代碼的書寫順序,書寫在前面的代碼操作 happens-before于書寫在后面的代碼操作。因為在單個線程中程序員編寫的代碼在語義上是需要穿行順序地執(zhí)行,即使在編譯后的代碼可能會進(jìn)行重排,但是內(nèi)存模型會保證程序執(zhí)行結(jié)果的正確性。也就是說,無論他的內(nèi)部怎么重排,他最終的執(zhí)行結(jié)果和順序執(zhí)行的結(jié)果是一致的。這也是大部分程序員在執(zhí)行自己所寫的代碼時沒有出現(xiàn)可見性問題的主要原因。
鎖定規(guī)則,
對于一個鎖的解鎖,總是happens-before這個鎖的加鎖。synchronized保證可見性的主要原理就是刷新儲存和原子化多個操作。
volatile規(guī)則,
對于一個volatile變量的寫,總是happens-before 于后續(xù)對這個volatile變量的讀,其中的原理主要是刷新主存和禁止重排序。
原子性
原子性指的是 一個操作是不可中斷的,要么全部執(zhí)行成功,要么全部執(zhí)行失敗。原子操作我按照自己的理解分為兩種,一種是單指令原子操作,單指令原子操作指的是,當(dāng)你執(zhí)行單個指令,要么成功要么失敗,比如工作內(nèi)存和主存之間進(jìn)行讀寫的8個指令,這些指令是不可再分的,每個指令都是原子操作。第二種利用鎖的組合指令原子操作,有時候開發(fā)者想讓一組操作要么執(zhí)行成功,要么執(zhí)行失敗,也就是想要保證一組指令的原子性,這時候就要用到鎖,比如8個內(nèi)存指令中就有l(wèi)ock和unlock這兩個和鎖有關(guān)的指令。利用他們,可以支持一組指令的原子性,反應(yīng)到上層就是synchronized。
有序性
無論是從硬件內(nèi)存模型還是Java內(nèi)存模型來看,都支持指令重排這種優(yōu)化操作,在單線程中雖然指令可能會被重排,但是在單線程中內(nèi)存模型能夠保證執(zhí)行結(jié)果的準(zhǔn)確。也就是說在單線程中無論指令如何重排,他最終的執(zhí)行結(jié)果和順序執(zhí)行的結(jié)果是一樣的,但是在多線程環(huán)境下就可能因為指令重排而導(dǎo)致一些問題。
有序性和可見性是不能完全分開講的,指令重排引起的亂序最有可能導(dǎo)致的就是可見性問題。我們之前又說happens-before原則來解決部分由于重排而導(dǎo)致的可見性問題,并且針對volatile原則和鎖原則,他們?yōu)槭裁纯梢詫?shí)現(xiàn)內(nèi)部可見性的原理。