- 硬件內(nèi)存模型
- Java內(nèi)存模型
- 線程之間通信
- 同步性原則
- 可能出現(xiàn)的問題
- 可見性
- 原子性
- 有序性
硬件內(nèi)存模型
工程師為了追求橫向的拓展庐冯,就是在單臺(tái)計(jì)算機(jī)中使用更多的處理器孽亲。
眾所周知,目前CPU的處理器速度與內(nèi)存速度的讀寫速度不在一個(gè)數(shù)量級(jí)展父,所以需要在CPU和內(nèi)存之間加上緩存來進(jìn)行提速返劲,這樣的就呈現(xiàn)了一種CPU-寄存器-緩存-主存的訪問結(jié)構(gòu)。
cpu:包含運(yùn)算器和控制器栖茉。根據(jù)馮諾依曼體系篮绿,CPU的工作分為以下 5 個(gè)階段:取指令階段、指令譯碼階段吕漂、執(zhí)行指令階段亲配、訪存取數(shù)和結(jié)果寫回。
寄存器: 指令寄存器痰娱、程序計(jì)數(shù)器等
結(jié)構(gòu): CPU-寄存器-緩存-主存
這種結(jié)構(gòu)在單CPU時(shí)期運(yùn)行的很好弃榨,但是當(dāng)一臺(tái)計(jì)算機(jī)中引入了多個(gè)CPU時(shí),出現(xiàn)了一個(gè)棘手的問題梨睁,假如CPU A將數(shù)據(jù)D從主存讀取到獨(dú)占的緩存內(nèi)鲸睛,通過計(jì)算之后修改了數(shù)據(jù)D,變?yōu)镈1坡贺,但是還沒有刷新回到主存官辈,此時(shí)CPU B將數(shù)據(jù)D從主存讀取到獨(dú)占緩存內(nèi),也對D進(jìn)行計(jì)算變?yōu)镈2遍坟,顯而易見這時(shí)候的數(shù)據(jù)產(chǎn)生了不同步拳亿。
到底是以D1為準(zhǔn)還是以D2為準(zhǔn),針對這個(gè)問題愿伴,科學(xué)家們設(shè)計(jì)了緩存一致性協(xié)議肺魁。
主要就是為了解決多個(gè)CPU緩存之間的同步問題,CPU緩存一致性協(xié)議有很多隔节,大致可以分為兩類鹅经。
窺探型和基于目錄型寂呛,當(dāng)CPU緩存想要訪問主存時(shí),需要經(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的時(shí)候哪廓,還需要等待CPU A將D寫回主存狗唉,這種行為是難以忍受的,因此涡真,計(jì)算機(jī)科學(xué)家們做出了一些優(yōu)化分俯,整體思路上就是將同步改為異步,比如CPU B要讀取數(shù)據(jù)D時(shí)哆料,發(fā)現(xiàn)D正在被其他的CPU修改缸剪,那么此時(shí)CPU B 可以注冊一個(gè)讀取D的消息,自己能回頭去做其他事情东亦,其他CPU寫會(huì)數(shù)據(jù)D后杏节,響應(yīng)了這個(gè)注冊消息,此時(shí)CPU B發(fā)現(xiàn)消息被響應(yīng)后典阵,再去讀取D 這樣的就能夠提升效率奋渔。但是對于CPU B來說,程序看上去就不是順序執(zhí)行了壮啊,可能會(huì)出現(xiàn)先運(yùn)行后面的指令嫉鲸,再回頭去運(yùn)行前面的指令,這一種行為就體現(xiàn)出了一種指令重排序歹啼。雖然指令被重排了玄渗,但CPU依然需要保證程序執(zhí)行結(jié)果的正確性,就是說無論指令怎么重排狸眼,最后的執(zhí)行結(jié)果一定要和順序執(zhí)行的結(jié)果是一樣的藤树,這具體是如何實(shí)現(xiàn)的呢?可以做一個(gè)擴(kuò)展
指令重排相關(guān)知識(shí)點(diǎn)(了解)
1.Store Buffer
2.Store Forwarding
3.Invalid Queue
4.寫屏障
5.內(nèi)存屏障
硬件內(nèi)存模型的目標(biāo)是為了讓匯編代碼能夠運(yùn)行在一個(gè)具有一致性的內(nèi)存視圖上拓萌。隨著高級(jí)語言的流行岁钓。工程師們開始設(shè)計(jì)編程語言級(jí)別的內(nèi)存模型,這是為了能夠使用該語言編程的也能擁有一個(gè)一致性的內(nèi)存視圖。
一致性的內(nèi)存視圖甜紫。各種硬件內(nèi)存模型抽象出相同的內(nèi)存視圖降宅。
Java內(nèi)存模型
于是在硬件模型之上,還存在著為編程語言設(shè)計(jì)的內(nèi)存模型囚霸,比如Java內(nèi)存模型 JMM (Java Memory Model)就屏蔽了各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,實(shí)現(xiàn)了讓Java程序能夠在各種硬件平臺(tái)下激才,都能夠按照預(yù)期的方式來運(yùn)行拓型。
他的抽象,如圖瘸恼,
概括來說每個(gè)工作流程都擁有獨(dú)占的本地內(nèi)存劣挫,本地內(nèi)存中的存儲(chǔ)的是私有變量以及共享變量的副本,并且使用一定機(jī)制來控制本地內(nèi)存和主存之間讀寫數(shù)據(jù)時(shí)的同步問題东帅,更加具體一點(diǎn)压固,我們將工作線程和本地內(nèi)存具象為 thread stack 將主存具象為heap 。
Thread stack中有兩種類型的變量靠闭。其中原始類型的變量帐我,總是存儲(chǔ)在線程棧上,對象類型的變量 引用或者說指針本身是存儲(chǔ)在線程棧上愧膀,而引用指向的對象的是存儲(chǔ)在堆上的拦键。在Heap中存儲(chǔ)對象本身,持有對象引用的線程就都能夠訪問該對象檩淋,heap本身他不關(guān)心哪個(gè)線程正在訪問對象
我們可以這么理解 Java線程模型中的thread stack 和heap都是對物理內(nèi)存的一種抽象芬为。這樣開發(fā)者只需要關(guān)心自己寫的程序使用到了thread stack/heap ,而不需要關(guān)心更下層的寄存器 cpu緩存 主存蟀悦∶碾可以猜測,線程在工作時(shí)的大部分情況下都在讀寫thread stack中的本地內(nèi)存日戈,也就是說本地內(nèi)存對速度的要求更高询张,那么他可能大部分都是使用寄存器和CPU緩存來實(shí)現(xiàn)的,而heap中需要存儲(chǔ)大量的對象涎拉,需要更大的容量瑞侮。那么他可能大部分都是使用主存來實(shí)現(xiàn)的。
線程之間通信
這樣想來鼓拧,大概就能理解Java內(nèi)存模型與硬件內(nèi)存模型之間這種模糊的內(nèi)容映射關(guān)系了半火。上面我們提到了Java內(nèi)存模型需要設(shè)計(jì)一些機(jī)制,來實(shí)現(xiàn)主存與工作內(nèi)存之間的數(shù)據(jù)傳輸與同步季俩,這種數(shù)據(jù)的傳遞钮糖,正式線程之間的通信方式。
主存和工作內(nèi)存之間通過這八個(gè)指令來實(shí)現(xiàn)數(shù)據(jù)的讀寫與同步,按照作用域分別分為兩類:
一類是作用于主存店归,一類是作用于工作內(nèi)存阎抒。下圖是一個(gè)通信的例子:
比如說線程A現(xiàn)在調(diào)用lock指令,將x變量標(biāo)記獨(dú)占狀態(tài)消痛,接下來他assign/store兩個(gè)指令來對x進(jìn)行賦值且叁,使他變?yōu)? 再繼續(xù)調(diào)用write指令,將x這個(gè)變量寫入主存秩伞,此時(shí)A的操作已經(jīng)完成逞带,并進(jìn)行解鎖,于是調(diào)用了unlock指令釋放x鎖定狀態(tài) 這時(shí)候呢纱新,線程B要讀取變量X展氓,于是他調(diào)用了read指令來讀取了x這個(gè)變量,調(diào)用load將變量加載到自己的本地內(nèi)存中脸爱,最后他再調(diào)用use指令來讓計(jì)算資源對這個(gè)變量進(jìn)行操作遇汞。這一套下來就實(shí)現(xiàn)了線程A和線程B之間的通信。不過這張圖上演示的是一種比較理想的狀態(tài)簿废。而實(shí)際的線程通信中還存在著一些問題需要解決空入。
可能出現(xiàn)的問題
第一個(gè)問題:
假如本地內(nèi)存A和本地內(nèi)存B中存在x副本且值都是1,當(dāng)線程A將x修改為2并且寫入主存后捏鱼,此時(shí)線程B想要讀取x执庐,默認(rèn)會(huì)從本地內(nèi)存B中讀取,而本地內(nèi)存B中的x依然是等于1的导梆,換言之轨淌,線程A刷新了主存中的x,線程B如何才能讀取到最新的值看尼,那么這個(gè)問題被稱為一種可見性的問題递鹉。
第二個(gè)問題:
加入線程A和B都從主存中讀取了變量x,此時(shí)x=1藏斩,分別在各自的本地內(nèi)存中自增1,x變?yōu)榱?躏结,然后再刷新回主存,這里就有一個(gè)問題狰域,實(shí)際上自增了兩次媳拴,x應(yīng)該變?yōu)?,但是主存中的x 卻為2兆览。那么這種問題被稱為一種原子性的問題屈溉。
上面所說的兩個(gè)問題其實(shí)就是反應(yīng)了線程通信之間的同步問題。當(dāng)多個(gè)線程在并發(fā)操作共享數(shù)據(jù)時(shí)抬探,可能回引發(fā)各種各樣的問題子巾。這些問題,被總結(jié)為三個(gè)要素。 可見性 原子性 有序性
上面所說的兩個(gè)問題呢线梗,分別對應(yīng)可見性和原子性椰于,這三個(gè)要素事實(shí)上并不是完全割裂的,尤其是可見性和有序性仪搔。
可見性
可見性指的是:當(dāng)一個(gè)線程修改共享變量的值瘾婿,其他線程需要能夠立刻得知這個(gè)修改。
這句話其實(shí)有兩層含義:
1.線程A修改了數(shù)據(jù)D僻造,線程B需要督導(dǎo)修改后最新的D憋他。(由刷新主存的時(shí)機(jī)引起的)
對應(yīng)到Java內(nèi)存模型中,當(dāng)一個(gè)線程在自己的工作內(nèi)存中修改了某個(gè)變量髓削,應(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方法時(shí)立膛,首先線程1會(huì)啟動(dòng),由于a的值為1梯码,線程1將會(huì)執(zhí)行死循環(huán)宝泵, 一秒后線程2啟動(dòng)。線程2將a的值改為2轩娶,此時(shí)如果線程1能夠讀到a的值被修改為2的話儿奶,將會(huì)跳出死循環(huán),但是你會(huì)發(fā)現(xiàn)事實(shí)上并沒有跳出鳄抒,死循環(huán)將一致執(zhí)行下去闯捎。說明變量a的修改并沒有被線程1讀到,那么說明a此時(shí)不滿足可見性许溅,針對此種情況瓤鼻,如何解決呢?
當(dāng)某個(gè)線程修改了變量贤重,其他線程如何才能立刻獲取到最新值茬祷,這里主要由兩種解決辦法。
第一種:利用volataile關(guān)鍵字并蝗。volatile關(guān)鍵字的下層實(shí)現(xiàn)保證了祭犯,若一個(gè)被volatile寫volatile修飾的變量被修改,那么總會(huì)主動(dòng)寫入主存滚停,若要讀取一個(gè)volatile變量沃粗,那么總是從主存中讀取。這樣的話铐刘,相當(dāng)于操作volatile變量都是直接去讀寫主存陪每。這樣就能夠解決上面的可見性問題。
第二種:利用Synchronized關(guān)鍵字,Synchronized關(guān)鍵字實(shí)現(xiàn)的一個(gè)特性檩禾。在同步代碼塊中挂签,monitor的基礎(chǔ)上,讀寫變量時(shí)盼产,將會(huì)隱式地執(zhí)行上文提到的內(nèi)存lock指令饵婆,并清空工作內(nèi)存中該變量的值,需要使用該變量時(shí)必須從主存中讀取戏售。同理侨核,也會(huì)隱式的執(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)該修改锋喜,但是因?yàn)橹嘏判驅(qū)е戮€程A沒有及時(shí)修改變量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這行時(shí),變量i是否一定等于1嘿般,答案是否定的段标,我們在最上面提到過,硬件內(nèi)存模型中存在指令重排序機(jī)制炉奴,Java內(nèi)存模型中也存在指令重排逼庞,他們的作用和約束都是一樣的,第一是為了更高的執(zhí)行效率瞻赶,第二個(gè)在單線程中指令重排后能夠保證程序執(zhí)行結(jié)果的正確性赛糟,就是說和順序執(zhí)行的結(jié)果是一樣的。所以線程一中的代碼一和代碼二完全有可能在編譯后被重排共耍,出現(xiàn)了下面這樣的執(zhí)行順序 代碼2->代碼3->代碼4->代碼1虑灰。在這種情況下,變量i還是等于0痹兜,但是從程序順序執(zhí)行的邏輯上看穆咐,似乎只要執(zhí)行到代碼4,變量i的值就一定是1字旭,這里就出現(xiàn)了可見性問題对湃,說明變量a此時(shí)不滿足可見性。
同樣的遗淳,我們也可以通過volatile和sync這兩個(gè)關(guān)鍵字來解決這種可見性問題拍柒。第一種,使用volatile關(guān)鍵字屈暗,volatile關(guān)鍵字禁止當(dāng)前變量與之前的代碼語句進(jìn)行重排序拆讯,可以這么理解脂男,當(dāng)程序執(zhí)行到volatile變量的讀寫時(shí)(還未執(zhí)行),之前的代碼語句的執(zhí)行結(jié)果是滿足可見性的种呐。當(dāng)執(zhí)行volatile的讀寫時(shí)宰翅,上文講過變量將會(huì)與主存進(jìn)行同步,所以volatile變量保證了可見性爽室。
在這個(gè)例子中汁讼,我們只要給付那個(gè)變量加上volatile修飾,那么就能夠禁止代碼1和代碼2的重排阔墩,因?yàn)榇a2中的變量是被volatile修飾的嘿架,根據(jù)上一段所說,就能夠保證代碼1的可見性啸箫。線程2中的代碼4就能夠成功的讀到a的值為1耸彪,synchronized關(guān)鍵字,我們再看上面的這個(gè)例子導(dǎo)致可見性問題的根源就是代碼1和代碼2被重排了忘苛,并且在執(zhí)行期間線程2讀到了線程1的中間狀態(tài)搜囱,那么如果代碼1和代碼2變成了一個(gè)不可分割的代碼塊,這時(shí)無論其內(nèi)部如何進(jìn)行重排柑土,外部都只能讀到最終結(jié)果,所以也就避免了可見性的問題绊汹。
特別提醒:Java的指令重排有兩次稽屏,第一次發(fā)生在將字節(jié)碼編譯成機(jī)器碼的階段,第二次發(fā)生在CPU執(zhí)行的時(shí)候西乖,也會(huì)適當(dāng)?shù)倪M(jìn)行指令重排狐榔。
關(guān)于指令重排的的復(fù)現(xiàn)代碼:
package com.example.demo0413.test;
public class VolatileReOrderSample {
//定義四個(gè)靜態(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;
//開兩個(gè)線程,第一個(gè)線程執(zhí)行a=1;x=b;第二個(gè)線程執(zhí)行b=1;y=a
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
//線程1會(huì)比線程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();
//等兩個(gè)線程都執(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è)計(jì)內(nèi)存的前輩們?yōu)槲覀兘鉀Q了這些事,他們定義了一組原則届案,被稱為 happens-before原則庵楷,這組原則規(guī)定了對于兩個(gè)操作A和B 這兩個(gè)操作可以在不同的線程中執(zhí)行。如果A happens-before B 那么可以保證 當(dāng)A操作執(zhí)行完后楣颠,A操作的執(zhí)行結(jié)果對 B操作是可見的尽纽。事實(shí)上,之所以很多人在日常開發(fā)中對可見性問題沒有太多的感知童漩,那是因?yàn)樵诓恢挥X中就已經(jīng)滿足了happens-before原則之一弄贿。
這個(gè)原則有八條:
程序順序規(guī)則
鎖定規(guī)則
volatile變量規(guī)則
線程啟動(dòng)規(guī)則
線程結(jié)束規(guī)則
中斷規(guī)則
終結(jié)期規(guī)則
傳遞性規(guī)則
前面比較重要的三條,
程序順序原則矫膨,
在一個(gè)線程的內(nèi)部按照程序代碼的書寫順序差凹,書寫在前面的代碼操作 happens-before于書寫在后面的代碼操作期奔。因?yàn)樵趩蝹€(gè)線程中程序員編寫的代碼在語義上是需要穿行順序地執(zhí)行,即使在編譯后的代碼可能會(huì)進(jìn)行重排危尿,但是內(nèi)存模型會(huì)保證程序執(zhí)行結(jié)果的正確性呐萌。也就是說,無論他的內(nèi)部怎么重排脚线,他最終的執(zhí)行結(jié)果和順序執(zhí)行的結(jié)果是一致的搁胆。這也是大部分程序員在執(zhí)行自己所寫的代碼時(shí)沒有出現(xiàn)可見性問題的主要原因。
鎖定規(guī)則邮绿,
對于一個(gè)鎖的解鎖渠旁,總是happens-before這個(gè)鎖的加鎖。synchronized保證可見性的主要原理就是刷新儲(chǔ)存和原子化多個(gè)操作船逮。
volatile規(guī)則顾腊,
對于一個(gè)volatile變量的寫,總是happens-before 于后續(xù)對這個(gè)volatile變量的讀挖胃,其中的原理主要是刷新主存和禁止重排序杂靶。
原子性
原子性指的是 一個(gè)操作是不可中斷的,要么全部執(zhí)行成功酱鸭,要么全部執(zhí)行失敗吗垮。原子操作我按照自己的理解分為兩種,一種是單指令原子操作凹髓,單指令原子操作指的是烁登,當(dāng)你執(zhí)行單個(gè)指令,要么成功要么失敗蔚舀,比如工作內(nèi)存和主存之間進(jìn)行讀寫的8個(gè)指令饵沧,這些指令是不可再分的,每個(gè)指令都是原子操作赌躺。第二種利用鎖的組合指令原子操作狼牺,有時(shí)候開發(fā)者想讓一組操作要么執(zhí)行成功,要么執(zhí)行失敗礼患,也就是想要保證一組指令的原子性是钥,這時(shí)候就要用到鎖,比如8個(gè)內(nèi)存指令中就有l(wèi)ock和unlock這兩個(gè)和鎖有關(guān)的指令讶泰。利用他們咏瑟,可以支持一組指令的原子性,反應(yīng)到上層就是synchronized痪署。
有序性
無論是從硬件內(nèi)存模型還是Java內(nèi)存模型來看码泞,都支持指令重排這種優(yōu)化操作,在單線程中雖然指令可能會(huì)被重排狼犯,但是在單線程中內(nèi)存模型能夠保證執(zhí)行結(jié)果的準(zhǔn)確余寥。也就是說在單線程中無論指令如何重排领铐,他最終的執(zhí)行結(jié)果和順序執(zhí)行的結(jié)果是一樣的,但是在多線程環(huán)境下就可能因?yàn)橹噶钪嘏哦鴮?dǎo)致一些問題宋舷。
有序性和可見性是不能完全分開講的绪撵,指令重排引起的亂序最有可能導(dǎo)致的就是可見性問題。我們之前又說happens-before原則來解決部分由于重排而導(dǎo)致的可見性問題祝蝠,并且針對volatile原則和鎖原則音诈,他們?yōu)槭裁纯梢詫?shí)現(xiàn)內(nèi)部可見性的原理。