寫在前面
最近因為自己畢業(yè)的一些事情斷更了簡書汁展,一轉(zhuǎn)眼已經(jīng)有兩個月了析孽,是時候給自己充一波電了律歼。本篇主要 復(fù)習(xí) 總結(jié)一些多線程中的基礎(chǔ)知識虏等,開篇打算理清一些概念性的東西弄唧,如有理解錯誤的地方,歡迎各位指正霍衫。
什么是并發(fā)候引,為什么要用并發(fā)?
并發(fā)與并行是一對相似而又有區(qū)別的的兩個概念敦跌。并行是指兩個或多個事件在同一時刻發(fā)生澄干,只有在多CPU環(huán)境下才有可能發(fā)生逛揩。并發(fā)是指在一段時間內(nèi)宏觀上有多個程序在同時運行,但實際上每個程序只是在CPU分配的時間片內(nèi)運行麸俘,每一時刻也只能由一道程序執(zhí)行辩稽。
使用并發(fā)編程的目的是為了讓程序運行的更快,但是从媚,并不是啟動更多的線程就能讓程序運行的更快逞泄,這取決于代碼的質(zhì)量和應(yīng)用場景。拋開并發(fā)代碼的質(zhì)量不談拜效,如果應(yīng)用場景不得當(dāng)喷众,并發(fā)也不一定比串行的程序快,因為線程有創(chuàng)建和上下文切換的開銷紧憾。這里不再深入到千,如果感興趣可以《Java并發(fā)編程藝術(shù)》第一章中找到例子。
Thread和一些問題
首先上一段最基本的對于線程的使用:
public class ThreadTest {
public static void main(String... args){
new Thread(()-> System.out.println(Thread.currentThread().getName()))
.start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}).start();
}
}
// 輸出:
// Thread-0
// Thread-1
上面的例子創(chuàng)建了兩個線程赴穗,分別打印了兩條線程的名字憔四。Thread的構(gòu)造函數(shù)可以接受一個Runable參數(shù),這個Runnable主要就是執(zhí)行用戶任務(wù)的代碼望抽。代碼很簡單加矛,沒什么好多說的。接下來還是來看一段代碼和輸出結(jié)果煤篙,來引入并發(fā)可能引發(fā)的問題斟览。
public class SaleTicket implements Runnable {
public static final int TICKET_TOTAL = 100;
private int tickets = 100;
@Override
public void run() {
while (tickets > 0) {
try {
sale();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void sale() throws InterruptedException {
if (tickets > 0) {
tickets--;
System.out.println(Thread.currentThread().getName() +
"賣出 第" + (TICKET_TOTAL - tickets) + "張票");
Thread.sleep(100);
}
}
public static void main(String... args) {
SaleTicket saleTicket = new SaleTicket();
new Thread(saleTicket, "一號窗口").start();
new Thread(saleTicket, "二號窗口").start();
new Thread(saleTicket, "三號窗口").start();
}
}
代碼比較簡單,模擬賣票辑奈,只要有余票就可以繼續(xù)賣苛茂。可以看到當(dāng)我使用三個線程模擬三個窗口賣票時出現(xiàn)了一個奇怪的現(xiàn)象鸠窗,就是有一個時刻三個窗口都賣出了第八張票妓羊,這顯然不符合正常的預(yù)期。聰明的你肯定能想到是我代碼寫的不對稍计,的確躁绸,我的代碼并不是線程安全的代碼。關(guān)于線程安全臣嚣,有很多定義净刮,在維基百科上是這么說的:指某個函數(shù)、函數(shù)庫在多線程環(huán)境中被調(diào)用時硅则,能夠正確地處理多個線程之間的共享變量淹父,使程序功能正確完成。非常正確怎虫,然而看完什么信息也沒得到的定義……不過沒事暑认,現(xiàn)在就算放一個信息量巨大的定義給你困介,你也看不懂,先了解下就好蘸际。
接下來需要思考一些問題座哩,為什么上面的代碼會不安全?這個鍋我們可以輕易的丟給多線程捡鱼、并發(fā)八回,“因為多線程并發(fā)訪問修改tickets變量,導(dǎo)致結(jié)果不可預(yù)期”驾诈,這么說也沒錯缠诅,不過太籠統(tǒng)了。這段代碼是在我的電腦上跑的乍迄,我的電腦只有一個CPU管引,雖說是并發(fā),但是到CPU層面上闯两,也是串行執(zhí)行的褥伴,那么為什么會出現(xiàn)上面圖片中奇怪的情況呢?
在Java中線程有自己的工作內(nèi)存漾狼,工作內(nèi)存中保存了被該線程使用到的變量的主內(nèi)存副本重慢,所以對該副本的操作并不能直接影響到主內(nèi)存,還需要將這個操作的結(jié)果同步到主內(nèi)存中逊躁,其他線程才能“感知到”似踱。
cpu在執(zhí)行指令的過程中很有由于這個線程時間片耗盡而切換線程。雖然tickets--只是一行代碼稽煤,但是核芽,** 他并非原子操作 **。所以很有可能出現(xiàn)的情況就是這個操作雖然讓這個線程里的i減了1酵熙,但是還沒有來得及同步到主內(nèi)存中轧简,CPU便切換線程導(dǎo)致下一個線程中的i還是未減1的值。
上面兩點的信息量還是比較大的匾二,要理解上面兩點哮独,首先得了解一下Java的內(nèi)存模型。
Java內(nèi)存模型
關(guān)于Java的內(nèi)存模型察藐,我只會簡單的介紹一下借嗽,不會當(dāng)然暫時也沒那個能力深入……
計算機的存儲設(shè)備與處理器的運算速度有幾個數(shù)量級的差距,所以現(xiàn)代計算機系統(tǒng)都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存來作為內(nèi)存與處理器之間的緩沖转培。基于高速緩存的存儲交互很好的解決了處理器與內(nèi)存的速度矛盾浆竭,但是也為計算機系統(tǒng)帶來了更高的復(fù)雜度浸须,因為它引入了一個新的問題:緩存一致性惨寿。
在多處理器系統(tǒng)中,每個處理器的運算任務(wù)都有自己的高速緩存删窒,而它們又共享同一塊主內(nèi)存裂垦。當(dāng)多個處理器的運算任務(wù)都涉及同一塊主內(nèi)存區(qū)域時,將可能導(dǎo)致各自的緩存數(shù)據(jù)不一致肌索,如果真的發(fā)生這種情況蕉拢,那同步回到主內(nèi)存時以誰為準(zhǔn)?
所以就出現(xiàn)了緩存一致性協(xié)議诚亚。最出名的就是Intel 的MESI協(xié)議晕换,MESI協(xié)議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當(dāng)CPU寫數(shù)據(jù)時站宗,如果發(fā)現(xiàn)操作的變量是共享變量闸准,即在其他CPU中也存在該變量的副本,會發(fā)出信號通知其他CPU將該變量的緩存行置為無效狀態(tài)梢灭,因此當(dāng)其他CPU需要讀取這個變量時夷家,發(fā)現(xiàn)自己緩存中緩存該變量的緩存行是無效的,那么它就會從內(nèi)存重新讀取敏释。
原子操作與volatile關(guān)鍵字
之前提到i--并不是原子操作库快,下面一段代碼和輸出可以證明i++并非原子操作:
public class AtomicTest implements Runnable {
private int serialNum = 0;
public int getSerialNum() {
return serialNum++;
}
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + getSerialNum());
}
public static void main(String... args){
AtomicTest atomicTest = new AtomicTest();
for (int i = 0; i < 10; i++) {
new Thread(atomicTest).start();
}
}
}
這里的輸出出現(xiàn)了同樣的serialNum,這說明在A線程進(jìn)行serialNum++的讀改操作钥顽,而尚未寫入時义屏,另一個線程B進(jìn)行了讀改操作,由此證明i++的讀改寫操作不具有原子性耳鸯。
說了這么多原子性湿蛔,還沒有正式的說一下原子性的概念:原子操作是不可分割的,在執(zhí)行完畢之前不會被任何其它任務(wù)或事件中斷县爬。
原子性對于并發(fā)來說非常有意義阳啥,涉及到線程安全就會考慮到他。對于線程安全還有兩點非常重要:可見性和有序性财喳。
- 可見性:可見性是指當(dāng)多個線程訪問同一個變量時察迟,一個線程修改了這個變量的值,其他線程能夠立即看到修改值耳高。
關(guān)于可見性扎瓶,之前簡單介紹過Java內(nèi)存模型結(jié)合我之前的例子就能理解,在買車票時泌枪,3個線程共享tickets變量概荷,但由于每個線程都在自己的工作內(nèi)存中操作變量,可能有沒有及時同步到主內(nèi)存的碌燕,所以有的線程沒有讀取到最新的值误证,導(dǎo)致操作和我們的預(yù)期不一樣继薛。
Java中,使用volatile關(guān)鍵字可以保證變量的“可見性”愈捅。但是使用volatile可以解決我上面賣車票的問題嗎遏考?答案是否定的,volatile可以解決變量可見性的問題蓝谨,但是sale方法是非原子操作的問題還是存在的灌具。volatile的另一個作用就是禁止指令重排序優(yōu)化。那么什么是指令重排呢譬巫?
指令重排是指CPU采用了允許將多條指令不按程序規(guī)定的順序分開發(fā)送給各相應(yīng)電路單元處理咖楣。當(dāng)然這種重排肯定是按照一定規(guī)則進(jìn)行的,不然毫無規(guī)則的重排缕题,也沒誰能編程了截歉。以下是一些處理器的重排規(guī)則:
指令重排這里就不再深入的探討了,有序性是指指令重排不會影響到單線程程序的執(zhí)行烟零,但多線程并發(fā)執(zhí)行的正確性會受到影響敦间。
在平時寫非并發(fā)代碼時我們從未考慮過指令重排這事卑笨,所以前半句我們可以根據(jù)自己的經(jīng)驗認(rèn)為他是對的,那么后半句該怎么理解呢?在《深入理解Java虛擬機》是這么說的:如果在一個線程中觀察另一個線程仲器,所有的操作都是無序的开呐。下面以一個簡單的例子說明:
//線程1:
context = loadContext(); //語句1
inited = true; //語句2
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
語句1和2沒有數(shù)據(jù)依賴穿仪,是可以被重排的障癌,如果發(fā)生了重排,對于線程1來說沒關(guān)系末早,操作都是有序的烟馅。而對于線程2來說則可能是觀測到inited為true,但是因為指令重排的關(guān)系然磷,context還沒有被初始化郑趁,而導(dǎo)致使用了錯誤的context。
綜上姿搜,可知對于線程安全來說寡润,需要綜合考慮原子性、可見性以及有序性舅柜。在Java內(nèi)存模型(Java Memory Model梭纹,簡稱JMM)中有一個很重要的概念:happens-before,一般翻譯為“先行先發(fā)生”致份,如果一個動作happens-before另一個動作变抽,則第一個動作對第二個動作可見,且第一個動作的執(zhí)行順序排在第二個操作之前。但是如果沖排序之后的執(zhí)行結(jié)果與按happens-before關(guān)系來執(zhí)行的結(jié)果一直绍载,那么這種重排序并不非法(允許這種重排)太伊。關(guān)于這事不難理解,作為應(yīng)用開發(fā)者逛钻,我們并不關(guān)心底層到底怎么實現(xiàn),只要保證程序運行時的語義不發(fā)生變化就可以了锰提。happens-before是判斷是否存在數(shù)據(jù)競爭曙痘、線程是否安全的主要依據(jù)。
在JSR133文檔中介紹了以下幾種包含了happens-before的操作/規(guī)則:
- 一個線程中的每個操作立肘,happens-before于該線程中的任意后續(xù)操作边坤。
- 對一個鎖的解鎖happens-before后續(xù)的加鎖。
- 對某個volatile字段的寫操作happens-before任意后續(xù)對字段的讀操作谅年。
- 在某個線程對象上調(diào)用start()方法happens-before該啟動了的線程中的任意動作茧痒。
- 如果A線程執(zhí)行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功的返回融蹂。
- 如果A happens-before B旺订,且B happens-before C,那么A happens-before C超燃。
關(guān)于happens-before也是可以大寫特寫的区拳,但是我這里就不多寫了,等我有更多的實踐再來吹一番意乓。
本次學(xué)習(xí)總結(jié)樱调,感覺還是學(xué)習(xí)了不少東西,剛開始寫的時候幾乎堅持不下去届良,因為我發(fā)現(xiàn)基本上就只能寫各種資料上的東西笆凌,也因為我?guī)缀鯖]有實踐經(jīng)驗。不過怎么說呢士葫,有些東西光閱讀大量的資料乞而,自己理解了多少其實還是未知,但是如果你能把這些寫出來(不是無腦抄)为障,那一定經(jīng)過了自己的思考晦闰,可以吸收更多的東西。在Java中的并發(fā)還是比較復(fù)雜的鳍怨,關(guān)于并發(fā)介紹的比較清楚的當(dāng)屬《Java并發(fā)編程的藝術(shù)》呻右,如果有能力的話可以直接去閱讀JSR133文檔。我都沒有讀完鞋喇,如果以后真的有使用的場景声滥,我會更深入的去學(xué)習(xí)的。
參考資料
- 《Java核心》:并發(fā)相關(guān)章節(jié)
- 《Java編程思想》:并發(fā)相關(guān)章節(jié)
- 《Java并發(fā)編程藝術(shù)》:部分章節(jié)
- 《深入理解Java虛擬機》:并發(fā)相關(guān)章節(jié)
- 《計算機操作系統(tǒng)》
- Java并發(fā)編程:volatile關(guān)鍵字解析
- i++是否是原子操作