計(jì)算機(jī)中為什么會(huì)出現(xiàn)線程不安全的問(wèn)題
volatile既然是與線程安全有關(guān)的問(wèn)題蜗搔,那我們先來(lái)了解一下計(jì)算機(jī)在處理數(shù)據(jù)的過(guò)程中為什么會(huì)出現(xiàn)線程不安全的問(wèn)題八堡。
大家都知道,計(jì)算機(jī)在執(zhí)行程序時(shí)缝龄,每條指令都是在CPU中執(zhí)行的溶耘,而執(zhí)行指令過(guò)程中會(huì)涉及到數(shù)據(jù)的讀取和寫(xiě)入。由于程序運(yùn)行過(guò)程中的臨時(shí)數(shù)據(jù)是存放在主存(物理內(nèi)存)當(dāng)中的百新,這時(shí)就存在一個(gè)問(wèn)題庐扫,由于CPU執(zhí)行速度很快仗哨,而從內(nèi)存讀取數(shù)據(jù)和向內(nèi)存寫(xiě)入數(shù)據(jù)的過(guò)程跟CPU執(zhí)行指令的速度比起來(lái)要慢的多厌漂,因此如果任何時(shí)候?qū)?shù)據(jù)的操作都要通過(guò)和內(nèi)存的交互來(lái)進(jìn)行斟珊,會(huì)大大降低指令執(zhí)行的速度。
為了處理這個(gè)問(wèn)題旨椒,在CPU里面就有了高速緩存(Cache)的概念堵漱。當(dāng)程序在運(yùn)行過(guò)程中,會(huì)將運(yùn)算需要的數(shù)據(jù)從主存復(fù)制一份到CPU的高速緩存當(dāng)中示惊,那么CPU進(jìn)行計(jì)算時(shí)就可以直接從它的高速緩存讀取數(shù)據(jù)和向其中寫(xiě)入數(shù)據(jù)愉镰,當(dāng)運(yùn)算結(jié)束之后岛杀,再將高速緩存中的數(shù)據(jù)刷新到主存當(dāng)中。
我舉個(gè)簡(jiǎn)單的例子类嗤,比如cpu在執(zhí)行下面這段代碼的時(shí)候,
t = t + 1;
會(huì)先從高速緩存中查看是否有t的值遗锣,如果有,則直接拿來(lái)使用弧圆,如果沒(méi)有笔咽,則會(huì)從主存中讀取,讀取之后會(huì)復(fù)制一份存放在高速緩存中方便下次使用拯田。之后cup進(jìn)行對(duì)t加1操作甩十,然后把數(shù)據(jù)寫(xiě)入高速緩存吭产,最后會(huì)把高速緩存中的數(shù)據(jù)刷新到主存中臣淤。
這一過(guò)程在單線程運(yùn)行是沒(méi)有問(wèn)題的窃爷,但是在多線程中運(yùn)行就會(huì)有問(wèn)題了。在多核CPU中寺董,每條線程可能運(yùn)行于不同的CPU中刻剥,因此每個(gè)線程運(yùn)行時(shí)有自己的高速緩存(對(duì)單核CPU來(lái)說(shuō)滩字,其實(shí)也會(huì)出現(xiàn)這種問(wèn)題麦箍,只不過(guò)是以線程調(diào)度的形式來(lái)分別執(zhí)行的,本次講解以多核cup為主)享钞。這時(shí)就會(huì)出現(xiàn)同一個(gè)變量在兩個(gè)高速緩存中的值不一致問(wèn)題了诀蓉。
例如:
兩個(gè)線程分別讀取了t的值,假設(shè)此時(shí)t的值為0狐肢,并且把t的值存到了各自的高速緩存中沥曹,然后線程1對(duì)t進(jìn)行了加1操作,此時(shí)t的值為1僵腺,并且把t的值寫(xiě)回到主存中壶栋。但是線程2中高速緩存的值還是0委刘,進(jìn)行加1操作之后鹰椒,t的值還是為1呕童,然后再把t的值寫(xiě)回主存。
此時(shí)奸汇,就出現(xiàn)了線程不安全問(wèn)題了往声。
Java中的線程安全問(wèn)題
上面那種線程安全問(wèn)題浩销,可能對(duì)于不同的操作系統(tǒng)會(huì)有不同的處理機(jī)制,例如Windows操作系統(tǒng)和Linux的操作系統(tǒng)的處理方法可能會(huì)不同塘雳。
我們都知道普筹,Java是一種夸平臺(tái)的語(yǔ)言,因此Java這種語(yǔ)言在處理線程安全問(wèn)題的時(shí)候妻顶,會(huì)有自己的處理機(jī)制蜒车,例如volatile關(guān)鍵字,synchronized關(guān)鍵字呢燥,并且這種機(jī)制適用于各種平臺(tái)寓娩。
Java內(nèi)存模型規(guī)定所有的變量都是存在主存當(dāng)中(類(lèi)似于前面說(shuō)的物理內(nèi)存)棘伴,每個(gè)線程都有自己的工作內(nèi)存(類(lèi)似于前面的高速緩存)。線程對(duì)變量的所有操作都必須在工作內(nèi)存中進(jìn)行仁连,而不能直接對(duì)主存進(jìn)行操作。并且每個(gè)線程不能訪問(wèn)其他線程的工作內(nèi)存使鹅。
由于java中的每個(gè)線程有自己的工作空間昌抠,這種工作空間相當(dāng)于上面所說(shuō)的高速緩存,因此多個(gè)線程在處理一個(gè)共享變量的時(shí)候裁厅,就會(huì)出現(xiàn)線程安全問(wèn)題侨艾。
這里簡(jiǎn)單解釋下共享變量唠梨,上面我們所說(shuō)的t就是一個(gè)共享變量,也就是說(shuō)插龄,能夠被多個(gè)線程訪問(wèn)到的變量科展,我們稱(chēng)之為共享變量糠雨。在java中共享變量包括實(shí)例變量甘邀,靜態(tài)變量,數(shù)組元素坞琴。他們都被存放在堆內(nèi)存中逗抑。
volatile關(guān)鍵字
上面扯了一大堆,都沒(méi)提到volatile關(guān)鍵字的作用荧关,下面開(kāi)始講解volatile關(guān)鍵字是如何保證線程安全問(wèn)題的褂傀。
可見(jiàn)性
什么是可見(jiàn)性仙辟?
意思就是說(shuō)鳄梅,在多線程環(huán)境下未檩,某個(gè)共享變量如果被其中一個(gè)線程給修改了讹挎,其他線程能夠立即知道這個(gè)共享變量已經(jīng)被修改了,當(dāng)其他線程要讀取這個(gè)變量的時(shí)候马篮,最終會(huì)去內(nèi)存中讀取怜奖,而不是從自己的工作空間中讀取。
例如我們上面說(shuō)的迁央,當(dāng)線程1對(duì)t進(jìn)行了加1操作并把數(shù)據(jù)寫(xiě)回到主存之后滥崩,線程2就會(huì)知道它自己工作空間內(nèi)的t已經(jīng)被修改了钙皮,當(dāng)它要執(zhí)行加1操作之后,就會(huì)去主存中讀取导匣。這樣茸时,兩邊的數(shù)據(jù)就能一致了。
假如一個(gè)變量被聲明為volatile缓待,那么這個(gè)變量就具有了可見(jiàn)性的性質(zhì)了命斧。這就是volatile關(guān)鍵的作用之一了嘱兼。
volatile保證變量可見(jiàn)性的原理
當(dāng)一個(gè)變量被聲明為volatile時(shí),在編譯成會(huì)變指令的時(shí)候汇四,會(huì)多出下面一行:
0x00bbacde: lock add1 $0x0,(%esp);
這句指令的意思就是在寄存器執(zhí)行一個(gè)加0的空操作。不過(guò)這條指令的前面有一個(gè)lock(鎖)前綴序宦。
當(dāng)處理器在處理?yè)碛衛(wèi)ock前綴的指令時(shí):
在之前的處理中互捌,lock會(huì)導(dǎo)致傳輸數(shù)據(jù)的總線被鎖定行剂,其他處理器都不能訪問(wèn)總線,從而保證處理lock指令的處理器能夠獨(dú)享操作數(shù)據(jù)所在的內(nèi)存區(qū)域腌巾,而不會(huì)被其他處理所干擾铲觉。
但由于總線被鎖住撵幽,其他處理器都會(huì)被堵住,從而影響了多處理器的執(zhí)行效率漏麦。為了解決這個(gè)問(wèn)題况褪,在后來(lái)的處理器中测垛,處理器遇到lock指令時(shí)不會(huì)再鎖住總線秧均,而是會(huì)檢查數(shù)據(jù)所在的內(nèi)存區(qū)域,如果該數(shù)據(jù)是在處理器的內(nèi)部緩存中锯七,則會(huì)鎖定此緩存區(qū)域誉己,處理完后把緩存寫(xiě)回到主存中,并且會(huì)利用緩存一致性協(xié)議來(lái)保證其他處理器中的緩存數(shù)據(jù)的一致性霉祸。
緩存一致性協(xié)議
剛才我在說(shuō)可見(jiàn)性的時(shí)候袱蜡,說(shuō)“如果一個(gè)共享變量被一個(gè)線程修改了之后,當(dāng)其他線程要讀取這個(gè)變量的時(shí)候奔穿,最終會(huì)去內(nèi)存中讀取贱田,而不是從自己的工作空間中讀取”茵典,實(shí)際上是這樣的:
線程中的處理器會(huì)一直在總線上嗅探其內(nèi)部緩存中的內(nèi)存地址在其他處理器的操作情況,一旦嗅探到某處處理器打算修改其內(nèi)存地址中的值彩倚,而該內(nèi)存地址剛好也在自己的內(nèi)部緩存中帆离,那么處理器就會(huì)強(qiáng)制讓自己對(duì)該緩存地址的無(wú)效结澄。所以當(dāng)該處理器要訪問(wèn)該數(shù)據(jù)的時(shí)候麻献,由于發(fā)現(xiàn)自己緩存的數(shù)據(jù)無(wú)效了,就會(huì)去主存中訪問(wèn)监婶。
有序性
實(shí)際上齿桃,當(dāng)我們把代碼寫(xiě)好之后,虛擬機(jī)不一定會(huì)按照我們寫(xiě)的代碼的順序來(lái)執(zhí)行带污。例如對(duì)于下面的兩句代碼:
int a = 1;
int b = 2;
對(duì)于這兩句代碼鱼冀,你會(huì)發(fā)現(xiàn)無(wú)論是先執(zhí)行a = 1還是執(zhí)行b = 2,都不會(huì)對(duì)a,b最終的值造成影響泛烙。所以虛擬機(jī)在編譯的時(shí)候翘紊,是有可能把他們進(jìn)行重排序的帆疟。
為什么要進(jìn)行重排序呢?
你想啊自赔,假如執(zhí)行 int a = 1這句代碼需要100ms的時(shí)間柳琢,但執(zhí)行int b = 2這句代碼需要1ms的時(shí)間绍妨,并且先執(zhí)行哪句代碼并不會(huì)對(duì)a,b最終的值造成影響。那當(dāng)然是先執(zhí)行int b = 2這句代碼了柬脸。
所以他去,虛擬機(jī)在進(jìn)行代碼編譯優(yōu)化的時(shí)候,對(duì)于那些改變順序之后不會(huì)對(duì)最終變量的值造成影響的代碼倒堕,是有可能將他們進(jìn)行重排序的灾测。
更多代碼編譯優(yōu)化可以看我寫(xiě)的另一篇文章:
虛擬機(jī)在運(yùn)行期對(duì)代碼的優(yōu)化策略
那么重排序之后真的不會(huì)對(duì)代碼造成影響嗎?
實(shí)際上垦巴,對(duì)于有些代碼進(jìn)行重排序之后媳搪,雖然對(duì)變量的值沒(méi)有造成影響,但有可能會(huì)出現(xiàn)線程安全問(wèn)題的秦爆。具體請(qǐng)看下面的代碼
public class NoVisibility{
private static boolean ready;
private static int number;
private static class Reader extends Thread{
public void run(){
while(!ready){
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args){
new Reader().start();
number = 42;
ready = true;
}
}
這段代碼最終打印的一定是42嗎?如果沒(méi)有重排序的話憔披,打印的確實(shí)會(huì)是42鲜结,但如果number = 42和ready = true被進(jìn)行了重排序,顛倒了順序活逆,那么就有可能打印出0了,而不是42拗胜。(因?yàn)閚umber的初始值會(huì)是0).
因此蔗候,重排序是有可能導(dǎo)致線程安全問(wèn)題的。
如果一個(gè)變量被聲明volatile的話埂软,那么這個(gè)變量不會(huì)被進(jìn)行重排序锈遥,也就是說(shuō)纫事,虛擬機(jī)會(huì)保證這個(gè)變量之前的代碼一定會(huì)比它先執(zhí)行,而之后的代碼一定會(huì)比它慢執(zhí)行所灸。
例如把上面中的number聲明為volatile丽惶,那么number = 42一定會(huì)比ready = true先執(zhí)行。
不過(guò)這里需要注意的是爬立,虛擬機(jī)只是保證這個(gè)變量之前的代碼一定比它先執(zhí)行钾唬,但并沒(méi)有保證這個(gè)變量之前的代碼不可以重排序。之后的也一樣侠驯。
volatile關(guān)鍵字能夠保證代碼的有序性抡秆,這個(gè)也是volatile關(guān)鍵字的作用。
總結(jié)一下吟策,一個(gè)被volatile聲明的變量主要有以下兩種特性保證保證線程安全儒士。
可見(jiàn)性。
有序性檩坚。
volatile真的能完全保證一個(gè)變量的線程安全嗎着撩?
我們通過(guò)上面的講解,發(fā)現(xiàn)volatile關(guān)鍵字還是挺有用的匾委,不但能夠保證變量的可見(jiàn)性拖叙,還能保證代碼的有序性。
那么剩檀,它真的能夠保證一個(gè)變量在多線程環(huán)境下都能被正確的使用嗎憋沿?
答案是否定的。原因是因?yàn)镴ava里面的運(yùn)算并非是原子操作沪猴。
原子操作
原子操作:即一個(gè)操作或者多個(gè)操作 要么全部執(zhí)行并且執(zhí)行的過(guò)程不會(huì)被任何因素打斷辐啄,要么就都不執(zhí)行。
也就是說(shuō)运嗜,處理器要嘛把這組操作全部執(zhí)行完壶辜,中間不允許被其他操作所打斷,要嘛這組操作不要執(zhí)行担租。
剛才說(shuō)Java里面的運(yùn)行并非是原子操作砸民。我舉個(gè)例子,例如這句代碼
int a = b + 1;
處理器在處理代碼的時(shí)候奋救,需要處理以下三個(gè)操作:
從內(nèi)存中讀取b的值岭参。
進(jìn)行a = b + 1這個(gè)運(yùn)算
把a(bǔ)的值寫(xiě)回到內(nèi)存中
而這三個(gè)操作處理器是不一定就會(huì)連續(xù)執(zhí)行的,有可能執(zhí)行了第一個(gè)操作之后尝艘,處理器就跑去執(zhí)行別的操作的演侯。
證明volatile無(wú)法保證線程安全的例子
由于Java中的運(yùn)算并非是原子操作,所以導(dǎo)致volatile聲明的變量無(wú)法保證線程安全背亥。
對(duì)于這句話秒际,我給大家舉個(gè)例子悬赏。代碼如下:
public class Test{
public static volatile int t = 0;
public static void main(String[] args){
Thread[] threads = new Thread[10];
for(int i = 0; i < 10; i++){
//每個(gè)線程對(duì)t進(jìn)行1000次加1的操作
threads[i] new Thread(new Runnable(){
@Override
public void run(){
for(int j = 0; j < 1000; j++){
t = t + 1;
}
}
});
threads[i].start();
}
//等待所有累加線程都結(jié)束
while(Thread.activeCount() > 1){
Thread.yield();
}
//打印t的值
System.out.println(t);
}
}
最終的打印結(jié)果會(huì)是1000 * 10 = 10000嗎?答案是否定的娄徊。
問(wèn)題就出現(xiàn)在t = t + 1這句代碼中闽颇。我們來(lái)分析一下
例如:
線程1讀取了t的值,假如t = 0寄锐。之后線程2讀取了t的值兵多,此時(shí)t = 0。
然后線程1執(zhí)行了加1的操作锐峭,此時(shí)t = 1中鼠。但是這個(gè)時(shí)候,處理器還沒(méi)有把t = 1的值寫(xiě)回主存中沿癞。這個(gè)時(shí)候處理器跑去執(zhí)行線程2援雇,注意,剛才線程2已經(jīng)讀取了t的值椎扬,所以這個(gè)時(shí)候并不會(huì)再去讀取t的值了惫搏,所以此時(shí)t的值還是0,然后線程2執(zhí)行了對(duì)t的加1操作蚕涤,此時(shí)t =1 筐赔。
這個(gè)時(shí)候,就出現(xiàn)了線程安全問(wèn)題了揖铜,兩個(gè)線程都對(duì)t執(zhí)行了加1操作茴丰,但t的值卻是1。所以說(shuō)天吓,volatile關(guān)鍵字并不一定能夠保證變量的安全性贿肩。
什么情況下volatile能夠保證線程安全
剛才雖然說(shuō),volatile關(guān)鍵字不一定能夠保證線程安全的問(wèn)題龄寞,其實(shí)汰规,在大多數(shù)情況下volatile還是可以保證變量的線程安全問(wèn)題的。所以物邑,在滿(mǎn)足以下兩個(gè)條件的情況下溜哮,volatile就能保證變量的線程安全問(wèn)題:
運(yùn)算結(jié)果并不依賴(lài)變量的當(dāng)前值,或者能夠確保只有單一的線程修改變量的值色解。
變量不需要與其他狀態(tài)變量共同參與不變約束茂嗓。