在并發(fā)編程中尺碰,多個線程之間采取什么機制進行通信(信息交換),什么機制進行數(shù)據(jù)的同步洛心?
在Java語言中皂甘,采用的是共享內(nèi)存模型來實現(xiàn)多線程之間的信息交換和數(shù)據(jù)同步的悼凑。
線程之間通過共享程序公共的狀態(tài)璧瞬,通過讀-寫內(nèi)存中公共狀態(tài)的方式來進行隱式的通信嗤锉。同步指的是程序在控制多個線程之間執(zhí)行程序的相對順序的機制墓塌,在共享內(nèi)存模型中,同步是顯式的访诱,程序員必須顯式指定某個方法/代碼塊需要在多線程之間互斥執(zhí)行触菜。
在說Java內(nèi)存模型之前哀峻,我們先說一下Java的內(nèi)存結(jié)構(gòu),也就是運行時的數(shù)據(jù)區(qū)域:
Java虛擬機在執(zhí)行Java程序的過程中催蝗,會把它管理的內(nèi)存劃分為幾個不同的數(shù)據(jù)區(qū)域育特,這些區(qū)域都有各自的用途且预、創(chuàng)建時間、銷毀時間。
Java運行時數(shù)據(jù)區(qū)分為下面幾個內(nèi)存區(qū)域:
1.PC寄存器/程序計數(shù)器:
嚴格來說是一個數(shù)據(jù)結(jié)構(gòu)涮拗,用于保存當前正在執(zhí)行的程序的內(nèi)存地址迂苛,由于Java是支持多線程執(zhí)行的三幻,所以程序執(zhí)行的軌跡不可能一直都是線性執(zhí)行。當有多個線程交叉執(zhí)行時抑堡,被中斷的線程的程序當前執(zhí)行到哪條內(nèi)存地址必然要保存下來,以便用于被中斷的線程恢復執(zhí)行時再按照被中斷時的指令地址繼續(xù)執(zhí)行下去偎漫。為了線程切換后能恢復到正確的執(zhí)行位置有缆,每個線程都需要有一個獨立的程序計數(shù)器棚壁,各個線程之間計數(shù)器互不影響,獨立存儲史隆,我們稱這類內(nèi)存區(qū)域為“線程私有”的內(nèi)存,這在某種程度上有點類似于“ThreadLocal”在刺,是線程安全的蚣驼。
2.Java棧 Java Stack:
Java棧總是與線程關聯(lián)在一起的纯陨,每當創(chuàng)建一個線程留储,JVM就會為該線程創(chuàng)建對應的Java棧获讳,在這個Java棧中又會包含多個棧幀(Stack Frame),這些棧幀是與每個方法關聯(lián)起來的量愧,每運行一個方法就創(chuàng)建一個棧幀帅矗,每個棧幀會含有一些局部變量浑此、操作棧和方法返回值等信息。每當一個方法執(zhí)行完成時紊馏,該棧幀就會彈出棧幀的元素作為這個方法的返回值,并且清除這個棧幀稀火,Java棧的棧頂?shù)臈褪钱斍罢趫?zhí)行的活動棧赌朋,也就是當前正在執(zhí)行的方法沛慢,PC寄存器也會指向該地址。只有這個活動的棧幀的本地變量可以被操作棧使用逾冬,當在這個棧幀中調(diào)用另外一個方法時躺苦,與之對應的一個新的棧幀被創(chuàng)建匹厘,這個新創(chuàng)建的棧幀被放到Java棧的棧頂,變?yōu)楫斍暗幕顒訔K础M瑯蝇F(xiàn)在只有這個棧的本地變量才能被使用炕柔,當這個棧幀中所有指令都完成時匕累,這個棧幀被移除Java棧,剛才的那個棧幀變?yōu)榛顒訔诎裕懊鏃姆祷刂底優(yōu)檫@個棧幀的操作棧的一個操作數(shù)际插。
由于Java棧是與線程對應起來的框弛,Java棧數(shù)據(jù)不是線程共有的捕捂,所以不需要關心其數(shù)據(jù)一致性,也不會存在同步鎖的問題慷妙。
在Java虛擬機規(guī)范中膝擂,對這個區(qū)域規(guī)定了兩種異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常狞山;如果虛擬機可以動態(tài)擴展萍启,如果擴展時無法申請到足夠的內(nèi)存屏鳍,就會拋出OutOfMemoryError異常钓瞭。在Hot Spot虛擬機中,可以使用-Xss參數(shù)來設置棧的大小超埋。棧的大小直接決定了函數(shù)調(diào)用的可達深度佳鳖。
3.堆 Heap:
堆是JVM所管理的內(nèi)存中國最大的一塊系吩,是被所有Java線程鎖共享的穿挨,不是線程安全的,在JVM啟動時創(chuàng)建帽衙。堆是存儲Java對象的地方贞绵,這一點Java虛擬機規(guī)范中描述是:所有的對象實例以及數(shù)組都要在堆上分配。Java堆是GC管理的主要區(qū)域章母,從內(nèi)存回收的角度來看翩剪,由于現(xiàn)在GC基本都采用分代收集算法,所以Java堆還可以細分為:新生代和老年代蚪缀;新生代再細致一點有Eden空間椿胯、From Survivor空間剃根、To Survivor空間等狈醉。
4.方法區(qū)Method Area:
方法區(qū)存放了要加載的類的信息(名稱、修飾符等)抒线、類中的靜態(tài)常量渣慕、類中定義為final類型的常量逊桦、類中的Field信息强经、類中的方法信息,當在程序中通過Class對象的getName.isInterface等方法來獲取信息時兰迫,這些數(shù)據(jù)都來源于方法區(qū)炬称。方法區(qū)是被Java線程鎖共享的玲躯,不像Java堆中其他部分一樣會頻繁被GC回收鲸伴,它存儲的信息相對比較穩(wěn)定,在一定條件下會被GC赡译,當方法區(qū)要使用的內(nèi)存超過其允許的大小時不铆,會拋出OutOfMemory的錯誤信息誓斥。方法區(qū)也是堆中的一部分,就是我們通常所說的Java堆中的永久區(qū) Permanet Generation毕谴,大小可以通過參數(shù)來設置,可以通過-XX:PermSize指定初始值距芬,-XX:MaxPermSize指定最大值框仔。
5.常量池Constant Pool:
常量池本身是方法區(qū)中的一個數(shù)據(jù)結(jié)構(gòu)。常量池中存儲了如字符串银舱、final變量值寻馏、類名和方法名常量茄袖。常量池在編譯期間就被確定宪祥,并保存在已編譯的.class文件中蝗羊。一般分為兩類:字面量和應用量。字面量就是字符串翔悠、final變量等蓄愁。類名和方法名屬于引用量。引用量最常見的是在調(diào)用方法的時候妇斤,根據(jù)方法名找到方法的引用站超,并以此定為到函數(shù)體進行函數(shù)代碼的執(zhí)行乖酬。引用量包含:類和接口的權(quán)限定名咬像、字段的名稱和描述符,方法的名稱和描述符钮惠。
6.本地方法棧Native Method Stack:
本地方法棧和Java棧所發(fā)揮的作用非常相似素挽,區(qū)別不過是Java棧為JVM執(zhí)行Java方法服務狸驳,而本地方法棧為JVM執(zhí)行Native方法服務耙箍。本地方法棧也會拋出StackOverflowError和OutOfMemoryError異常辩昆。
主內(nèi)存和工作內(nèi)存:
Java內(nèi)存模型的主要目標是定義程序中各個變量的訪問規(guī)則,即在JVM中將變量存儲到內(nèi)存和從內(nèi)存中取出變量這樣的底層細節(jié)术辐。此處的變量與Java編程里面的變量有所不同步辉词,它包含了實例字段猾骡、靜態(tài)字段和構(gòu)成數(shù)組對象的元素,但不包含局部變量和方法參數(shù)赡勘,因為后者是線程私有的嘱么,不會共享曼振,當然不存在數(shù)據(jù)競爭問題(如果局部變量是一個reference引用類型冰评,它引用的對象在Java堆中可被各個線程共享木羹,但是reference引用本身在Java棧的局部變量表中坑填,是線程私有的)脐瑰。為了獲得較高的執(zhí)行效能,Java內(nèi)存模型并沒有限制執(zhí)行引起使用處理器的特定寄存器或者緩存來和主內(nèi)存進行交互绝页,也沒有限制即時編譯器進行調(diào)整代碼執(zhí)行順序這類優(yōu)化措施续誉。
JMM規(guī)定了所有的變量都存儲在主內(nèi)存(Main Memory)中初肉。每個線程還有自己的工作內(nèi)存(Working Memory),線程的工作內(nèi)存中保存了該線程使用到的變量的主內(nèi)存的副本拷貝牙咏,線程對變量的所有操作(讀取眠寿、賦值等)都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存中的變量(volatile變量仍然有工作內(nèi)存的拷貝盒发,但是由于它特殊的操作順序性規(guī)定宁舰,所以看起來如同直接在主內(nèi)存中讀寫訪問一般)拼卵。不同的線程之間也無法直接訪問對方工作內(nèi)存中的變量,線程之間值的傳遞都需要通過主內(nèi)存來完成蛮艰。
線程1和線程2要想進行數(shù)據(jù)的交換一般要經(jīng)歷下面的步驟:
1.線程1把工作內(nèi)存1中的更新過的共享變量刷新到主內(nèi)存中去腋腮。
2.線程2到主內(nèi)存中去讀取線程1刷新過的共享變量,然后copy一份到工作內(nèi)存2中去壤蚜。
Java內(nèi)存模型是圍繞著并發(fā)編程中原子性即寡、可見性、有序性這三個特征來建立的袜刷,那我們依次看一下這三個特征:
原子性(Atomicity):一個操作不能被打斷,要么全部執(zhí)行完畢著蟹,要么不執(zhí)行墩蔓。在這點上有點類似于事務操作,要么全部執(zhí)行成功萧豆,要么回退到執(zhí)行該操作之前的狀態(tài)奸披。
基本類型數(shù)據(jù)的訪問大都是原子操作,long 和double類型的變量是64位涮雷,但是在32位JVM中阵面,32位的JVM會將64位數(shù)據(jù)的讀寫操作分為2次32位的讀寫操作來進行,這就導致了long份殿、double類型的變量在32位虛擬機中是非原子操作膜钓,數(shù)據(jù)有可能會被破壞,也就意味著多個線程在并發(fā)訪問的時候是線程非安全的卿嘲。
下面我們來演示這個32位JVM下颂斜,對64位long類型的數(shù)據(jù)的訪問的問題:
public class NotAtomicity {
//靜態(tài)變量t
public static long t = 0;
//靜態(tài)變量t的get方法
public static long getT() {
return t;
}
//靜態(tài)變量t的set方法
public static void setT(long t) {
NotAtomicity.t = t;
}
//改變變量t的線程
public static class ChangeT implements Runnable{
private long to;
public ChangeT(long to) {
this.to = to;
}
public void run() {
//不斷的將long變量設值到 t中
while (true) {
NotAtomicity.setT(to);
//將當前線程的執(zhí)行時間片段讓出去,以便由線程調(diào)度機制重新決定哪個線程可以執(zhí)行
Thread.yield();
}
}
}
//讀取變量t的線程拾枣,若讀取的值和設置的值不一致沃疮,說明變量t的數(shù)據(jù)被破壞了,即線程不安全
public static class ReadT implements Runnable{?
public void run() {
//不斷的讀取NotAtomicity的t的值
while (true) {
long tmp = NotAtomicity.getT();
//比較是否是自己設值的其中一個
if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {
//程序若執(zhí)行到這里梅肤,說明long類型變量t司蔬,其數(shù)據(jù)已經(jīng)被破壞了
System.out.println(tmp);
}
////將當前線程的執(zhí)行時間片段讓出去,以便由線程調(diào)度機制重新決定哪個線程可以執(zhí)行
Thread.yield();
}
}
}
public static void main(String[] args) {
new Thread(new ChangeT(100L)).start();
new Thread(new ChangeT(200L)).start();
new Thread(new ChangeT(-300L)).start();
new Thread(new ChangeT(-400L)).start();
new Thread(new ReadT()).start();
}
}
我們創(chuàng)建了4個線程來對long類型的變量t進行賦值姨蝴,賦值分別為100,200俊啼,-300,-400左医,有一個線程負責讀取變量t,如果正常的話授帕,讀取到的t的值應該是我們賦值中的一個同木,但是在32的JVM中,事情會出乎預料跛十。如果程序正常的話彤路,我們控制臺不會有任何的輸出,可實際上芥映,程序一運行洲尊,控制臺就輸出了下面的信息:
-4294967096
4294966896
-4294967096
-4294967096
4294966896
之所以會出現(xiàn)上面的情況,是因為在32位JVM中奈偏,64位的long數(shù)據(jù)的讀和寫都不是原子操作坞嘀,即不具有原子性,并發(fā)的時候相互干擾了惊来。
32位的JVM中姆吭,要想保證對long、double類型數(shù)據(jù)的操作的原子性唁盏,可以對訪問該數(shù)據(jù)的方法進行同步,就像下面的:
public class Atomicity {
//靜態(tài)變量t
public static long t = 0;
//靜態(tài)變量t的get方法,同步方法
public synchronized static long getT() {
return t;
}
//靜態(tài)變量t的set方法检眯,同步方法
public synchronized static void setT(long t) {
Atomicity.t = t;
}
//改變變量t的線程
public static class ChangeT implements Runnable{
private long to;
public ChangeT(long to) {
this.to = to;
}
public void run() {
//不斷的將long變量設值到 t中
while (true) {
Atomicity.setT(to);
//將當前線程的執(zhí)行時間片段讓出去厘擂,以便由線程調(diào)度機制重新決定哪個線程可以執(zhí)行
Thread.yield();
}
}
}
//讀取變量t的線程,若讀取的值和設置的值不一致锰瘸,說明變量t的數(shù)據(jù)被破壞了刽严,即線程不安全
public static class ReadT implements Runnable{
public void run() {
//不斷的讀取NotAtomicity的t的值
while (true) {
long tmp = Atomicity.getT();
//比較是否是自己設值的其中一個
if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {
//程序若執(zhí)行到這里,說明long類型變量t避凝,其數(shù)據(jù)已經(jīng)被破壞了
System.out.println(tmp);
}
////將當前線程的執(zhí)行時間片段讓出去舞萄,以便由線程調(diào)度機制重新決定哪個線程可以執(zhí)行
Thread.yield();
}
}
}
public static void main(String[] args) {
new Thread(new ChangeT(100L)).start();
new Thread(new ChangeT(200L)).start();
new Thread(new ChangeT(-300L)).start();
new Thread(new ChangeT(-400L)).start();
new Thread(new ReadT()).start();
}
這樣做的話,可以保證對64位數(shù)據(jù)操作的原子性管削。
可見性:一個線程對共享變量做了修改之后倒脓,其他的線程立即能夠看到(感知到)該變量這種修改(變化)。
Java內(nèi)存模型是通過將在工作內(nèi)存中的變量修改后的值同步到主內(nèi)存含思,在讀取變量前從主內(nèi)存刷新最新值到工作內(nèi)存中崎弃,這種依賴主內(nèi)存的方式來實現(xiàn)可見性的。
無論是普通變量還是volatile變量都是如此含潘,區(qū)別在于:volatile的特殊規(guī)則保證了volatile變量值修改后的新值立刻同步到主內(nèi)存饲做,每次使用volatile變量前立即從主內(nèi)存中刷新,因此volatile保證了多線程之間的操作變量的可見性遏弱,而普通變量則不能保證這一點盆均。
除了volatile關鍵字能實現(xiàn)可見性之外,還有synchronized,Lock漱逸,final也是可以的泪姨。
使用synchronized關鍵字游沿,在同步方法/同步塊開始時(Monitor Enter),使用共享變量時會從主內(nèi)存中刷新變量值到工作內(nèi)存中(即從主內(nèi)存中讀取最新值到線程私有的工作內(nèi)存中),在同步方法/同步塊結(jié)束時(Monitor Exit),會將工作內(nèi)存中的變量值同步到主內(nèi)存中去(即將線程私有的工作內(nèi)存中的值寫入到主內(nèi)存進行同步)驴娃。
使用Lock接口的最常用的實現(xiàn)ReentrantLock(重入鎖)來實現(xiàn)可見性:當我們在方法的開始位置執(zhí)行l(wèi)ock.lock()方法奏候,這和synchronized開始位置(Monitor Enter)有相同的語義,即使用共享變量時會從主內(nèi)存中刷新變量值到工作內(nèi)存中(即從主內(nèi)存中讀取最新值到線程私有的工作內(nèi)存中)唇敞,在方法的最后finally塊里執(zhí)行l(wèi)ock.unlock()方法蔗草,和synchronized結(jié)束位置(Monitor Exit)有相同的語義,即會將工作內(nèi)存中的變量值同步到主內(nèi)存中去(即將線程私有的工作內(nèi)存中的值寫入到主內(nèi)存進行同步)。
final關鍵字的可見性是指:被final修飾的變量疆柔,在構(gòu)造函數(shù)數(shù)一旦初始化完成咒精,并且在構(gòu)造函數(shù)中并沒有把“this”的引用傳遞出去(“this”引用逃逸是很危險的,其他的線程很可能通過該引用訪問到只“初始化一半”的對象)旷档,那么其他線程就可以看到final變量的值模叙。
有序性:對于一個線程的代碼而言,我們總是以為代碼的執(zhí)行是從前往后的鞋屈,依次執(zhí)行的范咨。這么說不能說完全不對,在單線程程序里厂庇,確實會這樣執(zhí)行渠啊;但是在多線程并發(fā)時,程序的執(zhí)行就有可能出現(xiàn)亂序权旷。用一句話可以總結(jié)為:在本線程內(nèi)觀察替蛉,操作都是有序的;如果在一個線程中觀察另外一個線程拄氯,所有的操作都是無序的躲查。前半句是指“線程內(nèi)表現(xiàn)為串行語義(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”現(xiàn)象和“工作內(nèi)存和主內(nèi)存同步延遲”現(xiàn)象。
Java提供了兩個關鍵字volatile和synchronized來保證多線程之間操作的有序性,volatile關鍵字本身通過加入內(nèi)存屏障來禁止指令的重排序译柏,而synchronized關鍵字通過一個變量在同一時間只允許有一個線程對其進行加鎖的規(guī)則來實現(xiàn)镣煮,
在單線程程序中,不會發(fā)生“指令重排”和“工作內(nèi)存和主內(nèi)存同步延遲”現(xiàn)象艇纺,只在多線程程序中出現(xiàn)怎静。
happens-before原則:
Java內(nèi)存模型中定義的兩項操作之間的次序關系,如果說操作A先行發(fā)生于操作B黔衡,操作A產(chǎn)生的影響能被操作B觀察到蚓聘,“影響”包含了修改了內(nèi)存中共享變量的值、發(fā)送了消息盟劫、調(diào)用了方法等夜牡。
下面是Java內(nèi)存模型下一些”天然的“happens-before關系,這些happens-before關系無須任何同步器協(xié)助就已經(jīng)存在,可以在編碼中直接使用塘装。如果兩個操作之間的關系不在此列急迂,并且無法從下列規(guī)則推導出來的話,它們就沒有順序性保障蹦肴,虛擬機可以對它們進行隨意地重排序僚碎。
a.程序次序規(guī)則(Pragram Order Rule):在一個線程內(nèi),按照程序代碼順序阴幌,書寫在前面的操作先行發(fā)生于書寫在后面的操作勺阐。準確地說應該是控制流順序而不是程序代碼順序,因為要考慮分支矛双、循環(huán)結(jié)構(gòu)渊抽。
b.管程鎖定規(guī)則(Monitor Lock Rule):一個unlock操作先行發(fā)生于后面對同一個鎖的lock操作。這里必須強調(diào)的是同一個鎖议忽,而”后面“是指時間上的先后順序懒闷。
c.volatile變量規(guī)則(Volatile Variable Rule):對一個volatile變量的寫操作先行發(fā)生于后面對這個變量的讀取操作,這里的”后面“同樣指時間上的先后順序栈幸。
d.線程啟動規(guī)則(Thread Start Rule):Thread對象的start()方法先行發(fā)生于此線程的每一個動作愤估。
e.線程終于規(guī)則(Thread Termination Rule):線程中的所有操作都先行發(fā)生于對此線程的終止檢測,我們可以通過Thread.join()方法結(jié)束速址,Thread.isAlive()的返回值等作段檢測到線程已經(jīng)終止執(zhí)行灵疮。
f.線程中斷規(guī)則(Thread Interruption Rule):對線程interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過Thread.interrupted()方法檢測是否有中斷發(fā)生壳繁。
g.對象終結(jié)規(guī)則(Finalizer Rule):一個對象初始化完成(構(gòu)造方法執(zhí)行完成)先行發(fā)生于它的finalize()方法的開始。
g.傳遞性(Transitivity):如果操作A先行發(fā)生于操作B荔棉,操作B先行發(fā)生于操作C闹炉,那就可以得出操作A先行發(fā)生于操作C的結(jié)論。
一個操作”時間上的先發(fā)生“不代表這個操作會是”先行發(fā)生“润樱,那如果一個操作”先行發(fā)生“是否就能推導出這個操作必定是”時間上的先發(fā)生 “呢渣触?也是不成立的,一個典型的例子就是指令重排序壹若。所以時間上的先后順序與happens-before原則之間基本沒有什么關系嗅钻,所以衡量并發(fā)安全問題一切必須以happens-before 原則為準。