????最近Java的網(wǎng)課上到“concurrency”的部分,花了三天仔細(xì)地看完了整個(gè)章節(jié),除了最后幾節(jié)涉及到j(luò)avaFX的內(nèi)容,其他大部分代碼都跟著敲了,跑了,challenge也基本做了。之前對(duì)多線程這一塊很不熟悉,現(xiàn)在這樣一趟下來(lái),感覺(jué)收獲很多。也可以說(shuō)是比較系統(tǒng)地學(xué)習(xí)了一下這一部分的內(nèi)容。因此覺(jué)得有必要寫(xiě)篇文章概括總結(jié)一下,也方便今后翻閱。
? ? 在說(shuō)多線程之前,先要簡(jiǎn)單說(shuō)一下進(jìn)程和線程的概念。首先線程是包涵在進(jìn)程里的,一個(gè)java程序就對(duì)應(yīng)了一個(gè)進(jìn)程。一旦啟動(dòng),計(jì)算機(jī)就要為它分配內(nèi)存等資源。而進(jìn)程里可以有多個(gè)線程(如果一個(gè)進(jìn)程沒(méi)有單獨(dú)開(kāi)辟線程,則視為是單線程)。計(jì)算機(jī)是不會(huì)專門(mén)為線程分配資源的(除了cpu時(shí)間片)。同一個(gè)進(jìn)程下的各個(gè)線程會(huì)共享同一塊內(nèi)存區(qū)域,也會(huì)有各自的一塊工作區(qū),線程工作時(shí),就從共享內(nèi)存中讀取變量等數(shù)據(jù)到自己的工作區(qū)中進(jìn)行操作,然后寫(xiě)回共享內(nèi)存。線程之間的工作區(qū)是互不可見(jiàn)的。那么多線程的好處是什么呢?最大的好處就在于提高效率。舉個(gè)例子,我們準(zhǔn)備晚餐的時(shí)候,既需要炒菜,也需要煮飯。我們一般都是先把電飯煲打開(kāi)開(kāi)始煮飯,10分鐘后它自動(dòng)就好了,那這中間10分鐘我們一直干等著飯煮好再去炒菜嗎?肯定不會(huì),我們肯定是電飯煲按鈕按下后就開(kāi)始炒菜了。然后10分鐘后菜好了,飯也好了,就可以吃了。而對(duì)應(yīng)到計(jì)算機(jī)中,也許就是它需要一邊去數(shù)據(jù)庫(kù)讀取數(shù)據(jù),一邊在前端頁(yè)面響應(yīng)你的請(qǐng)求等,這就是多線程的優(yōu)勢(shì)了。知道了多線程的優(yōu)勢(shì),那么怎么使用多線程呢,或者說(shuō),怎么讓我的程序擁有多線程呢?
? ? 多線程是需要手動(dòng)創(chuàng)建的,換言之,我們平時(shí)leetcode或者自己寫(xiě)的小程序里往往都是單線程的。創(chuàng)建線程的方法有3,4種,但是主流的是兩種,一種是創(chuàng)建一個(gè)類繼承Thread父類,一種是創(chuàng)建一個(gè)類實(shí)現(xiàn)runnable接口。然后override其中的run()方法,在這里面指定這個(gè)線程需要執(zhí)行的命令。可以是打印一條語(yǔ)句,也可以是執(zhí)行某個(gè)函數(shù)(不過(guò)似乎是不能傳入?yún)?shù))。我看了一下網(wǎng)上,說(shuō)是第二種用的比較多,因?yàn)閖ava是單繼承,局限比較大。那么線程創(chuàng)建出來(lái)之后,它就處于就緒的狀態(tài)了。一旦我們調(diào)用start()方法,就會(huì)把它加入到隊(duì)列里等待執(zhí)行。然后操作系統(tǒng)會(huì)綜合考慮各種因素,決定按什么順序執(zhí)行它們。值得一提的是,我們可以給thread設(shè)置優(yōu)先級(jí),但是這個(gè)優(yōu)先級(jí)更像是一種對(duì)操作系統(tǒng)的建議,并不能真正決定它的被執(zhí)行順序,而且從實(shí)際效果看來(lái),這個(gè)建議的效果相當(dāng)有限。還有就是,操作系統(tǒng)執(zhí)行線程的順序存在很大的隨機(jī)性,所以在寫(xiě)多線程的程序時(shí),同樣的代碼運(yùn)行三次,看到三個(gè)不同的結(jié)果,也是不足為奇的。
? ? 多線程的好處我們說(shuō)了,接下來(lái)說(shuō)說(shuō)它的隱患。它的隱患就是thread interference。正如前面所提到的,線程之間是不可見(jiàn)的,那么如果線程以某種不恰當(dāng)?shù)捻樞驁?zhí)行的話,則可能出現(xiàn)錯(cuò)誤的結(jié)果甚至導(dǎo)致程序崩潰。舉兩個(gè)簡(jiǎn)單的例子:
第一個(gè)例子: count ++; 這個(gè)簡(jiǎn)單的語(yǔ)句其實(shí)并不是原子性的,當(dāng)它被線程執(zhí)行時(shí),其實(shí)包含了三步操作:第一步,從共享內(nèi)存中讀取count的值到線程的工作內(nèi)存中。第二步,在工作內(nèi)存中對(duì)count的值進(jìn)行加一。第三步,將值寫(xiě)回共享內(nèi)存中。那么如果兩個(gè)線程同時(shí)在執(zhí)行這個(gè)語(yǔ)句的話,會(huì)發(fā)生什么問(wèn)題呢?假設(shè)現(xiàn)在有線程A,線程B,Candidate類里有個(gè)屬性count,count值為0。
1. 線程A從共享內(nèi)存中讀取了count = 0,在自己的工作區(qū)中對(duì)它進(jìn)行加一,count值變成了1,然后線程A被pending了?
2.線程B被執(zhí)行,線程B也從共享內(nèi)存中過(guò)讀取了count = 0,也在自己的工作區(qū)中對(duì)它進(jìn)行了加一,count值變成了1,然后被pending了
3. 此時(shí)線程A恢復(fù)運(yùn)行,并將它工作區(qū)里count為1的值寫(xiě)入共享內(nèi)存,此時(shí)內(nèi)存中count的值為1
4.然后線程B恢復(fù)運(yùn)行,并將它工作區(qū)里count為1的值寫(xiě)入共享內(nèi)存,此時(shí)內(nèi)存中count的值仍為1
所以這就出問(wèn)題了,count的值本該為2,但是最后卻變成了1。這就是值錯(cuò)誤,接下來(lái)還有更嚴(yán)重的例子。假設(shè)有兩個(gè)函數(shù):function A 和 function B, 對(duì)同一個(gè)list 進(jìn)行操作。function A 里判斷if (! list.isEmpty()) int x = list.get(0); function B 里if (! list.isEmpty()) int x = list.remove(0);假設(shè)有線程A和線程B,list里有一個(gè)元素,那么可能出現(xiàn)如下情況:
1. 線程A執(zhí)行判斷語(yǔ)句,發(fā)現(xiàn)list不為空,然后此時(shí)線程A被pending,線程B被執(zhí)行。
2. 線程B執(zhí)行判斷語(yǔ)句,發(fā)現(xiàn)list不為空,于是remove掉了list中的唯一元素。
3.線程A被恢復(fù)執(zhí)行,試圖從空l(shuí)ist中取元素,報(bào)錯(cuò)。
????通過(guò)這兩個(gè)例子,大家應(yīng)該能隱隱到感覺(jué)到問(wèn)題的根源所在:線程執(zhí)行期間隨時(shí)可能被打斷,這讓我們的判斷語(yǔ)句失去意義,即使你現(xiàn)在判斷l(xiāng)ist是非空的,到你去取數(shù)據(jù)的時(shí)候,它可能已經(jīng)空了??胺Q計(jì)算機(jī)版的“你永遠(yuǎn)不知道明天和意外哪一個(gè)先到來(lái)?!?或者是讀了一個(gè)已經(jīng)過(guò)時(shí)的值,之后所有努力都不過(guò)是在錯(cuò)誤的道路上漸行漸遠(yuǎn)。
? ? 曾經(jīng)有這么一個(gè)段子:一個(gè)年輕人去面試,面試官說(shuō):你簡(jiǎn)歷上說(shuō)你心算很快?那我考考你。42 * 37等于多少,年輕人脫口而出5211。面試官一按計(jì)算器,不禁大跌眼鏡,說(shuō)你這差的也太遠(yuǎn)了吧?年輕人回答到:我說(shuō)我心算快,沒(méi)說(shuō)我心算準(zhǔn)。
? ? 所以說(shuō)程序的效率固然重要,但是如果完全拋棄了安全性,可靠性的話,那就是舍本逐末了。那么怎樣避免線程間的干擾,提高結(jié)果的可靠性呢?方法有很多,這里主要介紹三種,分別可以用三個(gè)關(guān)鍵詞代表:synchronized, reentrantlock, volatile。
????先說(shuō)synchronized,這是一個(gè)java 關(guān)鍵字,可以用來(lái)修飾一個(gè)方法,也可以用來(lái)修飾方法中的某一塊代碼。本質(zhì)上說(shuō),synchronized的實(shí)現(xiàn)機(jī)制就是鎖,是一種非公平鎖,在此不做展開(kāi)?,F(xiàn)階段你所需要知道的就是synchronized的作用就是它可以保證被它修飾的方法或者代碼塊在任意時(shí)刻只能有一個(gè)線程執(zhí)行。舉個(gè)例子(假設(shè)在那個(gè)例子中線程A和線程B都是對(duì)同一個(gè)object進(jìn)行操作,假設(shè)object是lion),如果我們對(duì)count ++ 所在的函數(shù)使用了synchronized,那么就能避免線程的干涉,因?yàn)楫?dāng)線程A開(kāi)始執(zhí)行時(shí),會(huì)獲得lion的鎖,這是個(gè)互斥鎖,線程B就不能再隨意去對(duì)lion進(jìn)行操作了,它必須等待,直到線程A釋放了鎖。需要注意的是,synchronized是會(huì)降低程序效率的,因?yàn)榈讓訉?shí)現(xiàn)過(guò)程中會(huì)有很多上下文切換的開(kāi)銷等,所以要視情況使用,如果一個(gè)函數(shù)很大,其中只有一部分代碼需要synchronized的話,我們也可以直接synchronized那部分代碼塊,而不是整個(gè)函數(shù)?;蛘哂行r(shí)候直接用更輕量級(jí)的volatile代替。synchoronized方便好用,但使用時(shí)需要注意隨之而來(lái)的兩大隱患:deallock或starvation。這里簡(jiǎn)單說(shuō)下這兩種情況:
場(chǎng)景一: 假設(shè)有一個(gè)Person類,類里有兩個(gè)方法:sayHello(), sayHelloBack():
public synchronized void sayHello(Person person){
System.out.format("%s: %s" +" has said hello to me!%n", this.name, person.getName());
? ? person.sayHelloBack(this);
}
public synchronized void sayHelloBack(Person person){
System.out.format("%s: %s" +" has said hello back to me!%n", this.name, person.getName());
}
兩個(gè)function都被synchronized修飾。此時(shí),實(shí)例化兩個(gè)Person,neo和jane,新建兩個(gè)線程,分別執(zhí)行neo.sayHello(Jane),jane.sayHello(neo)。那么就會(huì)出現(xiàn)這種情況:
1. 線程A啟動(dòng),得到了neo的鎖,然后輸出 “jane has said hello to me!”,然后suspend。
2. 線程B啟動(dòng),得到了jane的鎖,然后輸出 “neo has said hello to me!”,然后suspend。
3. 線程A恢復(fù)執(zhí)行,試圖調(diào)用jane的sayHelloBack(),但是jane的鎖被線程B占據(jù)著,所以它只能等線程B釋放鎖。
4.線程B恢復(fù)執(zhí)行,試圖調(diào)用neo的sayHelloBack(),但是neo的鎖被線程A占據(jù)著,所以它只能等線程A釋放鎖。這么一來(lái),deadlock就出現(xiàn)了。所以在使用鎖時(shí),需要注意鎖的使用順序。
其實(shí)這里還有一個(gè)問(wèn)題我是比較疑惑的。這個(gè)例子我是跑過(guò)的,確實(shí)deadlock了,那就是說(shuō)synchronized會(huì)鎖住整個(gè)對(duì)象嗎?前面雖然調(diào)用的是sayHello的方法,但是別的線程不但不能調(diào)用它的sayHello,連sayHelloBack也調(diào)用不了了。這么看來(lái)感覺(jué)效率影響很大,而且如果它會(huì)影響到所有funciton的話,synchronized function還有什么意義呢?不知道是不是在這一塊理解有什么偏差。這里先打個(gè)問(wèn)號(hào)吧。
? ? deadlock是多線程系統(tǒng)中的重要話題,但是沒(méi)有deadlock不代表系統(tǒng)就是完好的,還需要看有沒(méi)有starvation。這就要提到公平鎖和不公平鎖,這里的公平是什么意思呢?使用公平鎖時(shí),線程需要去隊(duì)列里排隊(duì),永遠(yuǎn)都是隊(duì)列中第一個(gè)線程獲得鎖。大家排好隊(duì),一個(gè)個(gè)來(lái),這樣能夠讓每個(gè)線程都能得到鎖。而另一種就像是叢林法則了,有一個(gè)搶占鎖的過(guò)程,只有搶不到了,才回到隊(duì)列。這樣的機(jī)制下,就可能出現(xiàn)有的線程盡管來(lái)的早,卻一直搶不到鎖,也就會(huì)出現(xiàn)starvation的現(xiàn)象。雖然非公平鎖聽(tīng)上去很野蠻,但是卻效率更高,因?yàn)閏pu不必喚醒所有進(jìn)程,減小了開(kāi)銷。包括我們上面提到的synchronized和接下來(lái)要說(shuō)的reentrantlock都是非公平鎖(默認(rèn)情況下)。寬泛地說(shuō),公平鎖和非公平鎖各有優(yōu)缺點(diǎn),需要視情況而定。比如說(shuō)如果線程占用時(shí)間遠(yuǎn)長(zhǎng)于線程等待時(shí)間,那么用非公平鎖就優(yōu)于用公平鎖。這里就不做過(guò)多的展開(kāi)了。
? ? 接下來(lái)說(shuō)說(shuō)reentrantlock,在用synchronized的時(shí)候,有一個(gè)局限性,就是只能對(duì)某個(gè)或function中的一部分進(jìn)行加鎖,無(wú)法跨function。而reentrantlock就解決了這一問(wèn)題,雖然synchronized和reentrantlock都是鎖,但是在用retrantlock的時(shí)候我們需要手動(dòng)的加鎖,解鎖。這也讓它的使用變得更加靈活,不局限于某個(gè)function。reentrantlock的用法很簡(jiǎn)單,new一個(gè)新的reentrantlock對(duì)象,然后在需要加鎖的代碼前使用lock()方法(經(jīng)常會(huì)使用trylock()來(lái)提高效率),再在之后unlock()。reentrantlock方便,好用,也有多種選擇(可通過(guò)重載函數(shù)的途徑創(chuàng)建不同類型的鎖)。
? ? 最后說(shuō)說(shuō)volatile,volatile是一個(gè)用來(lái)修飾變量的Java關(guān)鍵字,它主要是為了避免臟讀現(xiàn)象。什么是臟讀呢?就是假設(shè)有兩個(gè)線程對(duì)同一個(gè)變量進(jìn)行操作,線程A剛在自己的工作區(qū)對(duì)變量值進(jìn)行了修改,但還沒(méi)來(lái)及寫(xiě)回共享內(nèi)存,線程B就從共享內(nèi)存中讀取了一個(gè)過(guò)時(shí)的值。這就是臟讀。那么volatile是怎么解決這個(gè)問(wèn)題的呢?使用volatile修飾的變量會(huì)強(qiáng)制將修改的值立即寫(xiě)入共享內(nèi)存,而共享內(nèi)存中值的更新會(huì)使緩存中的值失效,這樣別的線程就不會(huì)讀到過(guò)時(shí)的值了。
? ? 那么這幾天來(lái)學(xué)的多線程的基本知識(shí)點(diǎn)到這也就差不多了。它里面涉及鎖是一個(gè)很大的話題,有很多不同類型的鎖,有不同的優(yōu)劣和適用場(chǎng)景。但是我現(xiàn)在也沒(méi)怎么去深入了解,這個(gè)話題就留待以后有興趣和機(jī)會(huì)的時(shí)候再深入吧?,F(xiàn)在就先說(shuō)這么多。