字節(jié)二面 | 26圖揭秘線程安全
想必都知道線程是什么,也知道怎么用了,但是使用線程的時(shí)候總是沒有達(dá)到自己預(yù)期的效果,要么是值不對(duì),要么是無(wú)限等待,為了盡全力的避免這些問題以及更快定位所出現(xiàn)的問題,下面我們看看線程安全的這一系列問題
前言
-
什么是線程安全
-
常見的線程安全問題
-
在哪些場(chǎng)景下需要特別注意線程安全
-
多線程也會(huì)帶來性能問題
-
死鎖的必要條件
-
必要條件的模擬
-
多線程會(huì)涉及哪些性能問題

什么是線程安全
來說說關(guān)于線程安全 what 這一問題,安全對(duì)立面即風(fēng)險(xiǎn),可能存在風(fēng)險(xiǎn)的事兒我們就需要慎重了。之所以會(huì)產(chǎn)生安全問題,無(wú)外乎分為主觀因素和客觀因素。
先來看看大佬們是怎么定義線程安全的。《Java Concurrency In Practice》的作者Brian Goetz對(duì)線程安全是這樣理解的,當(dāng)多個(gè)線程訪問一個(gè)對(duì)象時(shí),如果不用考慮這些線程在運(yùn)行時(shí)環(huán)境下的調(diào)度和交替執(zhí)行問題,也不需要進(jìn)行額外的同步,而調(diào)用這個(gè)對(duì)象的行為都可以獲得正確的結(jié)果,那這個(gè)對(duì)象便是線程安全的。
他所表達(dá)的意思為:如果對(duì)象是線程安全的,那么對(duì)于開發(fā)人員而言,就不需要考慮方法之間的協(xié)調(diào)問題,說白了都不需要考慮不能同時(shí)寫入或讀寫不能并行的問題,更別說使用各種鎖來保證線程安全,所以對(duì)于線程的安全還是相當(dāng)?shù)目量獭?/span>
那么平時(shí)的開發(fā)過程中,通常會(huì)遇到哪些線程安全問題呢
-
運(yùn)行結(jié)果千奇八怪
最典型了莫過于多個(gè)線程操作一個(gè)變量導(dǎo)致的結(jié)果,這是顯然的了

執(zhí)行結(jié)果如下所示

此過程中,你會(huì)發(fā)現(xiàn)結(jié)果幾乎每次都不一樣,這是為啥呢?
這是因?yàn)樵诙嗑€程的情況下,每個(gè)線程都有得到運(yùn)行的機(jī)會(huì),而 CPU 的調(diào)度是以時(shí)間片的方式進(jìn)行分配,意味著每個(gè)線程都可以獲取時(shí)間片,一旦線程的時(shí)間片用完,它將讓出 CPU 資源給其他的線程,這樣就可能出現(xiàn)線程安全問題。
看似 i++ 一行代碼,實(shí)際上操作了很多步
-
讀取數(shù)據(jù)
-
增加數(shù)據(jù)
-
保存

看上面這個(gè)圖,線程 1 先拿到 i=1 的結(jié)果,隨后進(jìn)行 +1 操作,剛操作完還沒有保存,此時(shí)線程 2 插足,CPU開始執(zhí)行線程 2 ,和線程 1 的操作一樣,執(zhí)行 i++ 操作,那對(duì)于線程 2 而言,此時(shí)的 i 是多少呢?其實(shí)還是 1,因?yàn)榫€程 1 雖然操作了,但是沒有保存結(jié)果,所以對(duì)于線程 2 而言,就沒看到修改后的結(jié)果
此時(shí)又切換到線程 1 操作,完成接下來保存結(jié)果 2,隨后再次切換到線程 2 完成 i=2 的保存操作??偵希吹览砦覀儜?yīng)該是得到結(jié)果 3,最后結(jié)果卻為 2 了,這就是典型的線程安全問題了
活躍性問題
說活躍性問題可能比較陌生,那我說死鎖你就知道了,因?yàn)榇_實(shí)太常見,面試官可能都把死鎖嚼碎了吧,不問幾個(gè)死鎖都仿佛自己不是面試官了,隨便拋出來幾個(gè)問題看看
-
死鎖是什么
-
死鎖必要條件
-
如何避免死鎖
-
寫一個(gè)死鎖案例
如果此時(shí)不知道如何回答,當(dāng)大家看完下面的內(nèi)容再回頭應(yīng)該就很清楚,不用死記硬背,理解性的記憶一定是會(huì)更長(zhǎng)久啦。
死鎖是什么
兩個(gè)線程之間相互等待對(duì)方的資源,但又都不互讓,如下代碼所示

死鎖有什么危害
首先我們需要知道,使用鎖是不讓其他線程干擾此次數(shù)據(jù)的操作,如果對(duì)于鎖的操作不當(dāng),就可能導(dǎo)致死鎖。
描述下死鎖
說直白一點(diǎn),占著茅坑不拉屎。死鎖是一種狀態(tài),兩個(gè)或多個(gè)線程相互持有相互的資源而不放手,導(dǎo)致大家都得不到需要的東西。小 A 和 小 B談戀愛,畢業(yè)了,一個(gè)想去北京,一個(gè)想去廣東,互不相讓讓,怎么辦?可想而知,兩者都想挨著家近一點(diǎn)的地方工作,又舍不得如此美好的愛情
再舉一個(gè)生活上的例子
A:有本事你上來啊
B:有本事你下來啊
A:我不下,有本事你上來啊
B:我不上,你有本事下來啊
線程 A 和 線程 B的例子

上圖兩個(gè)線程,線程 A 和 線程 B,線程 A 想要獲取線程 B 的鎖,當(dāng)然獲取不到,因?yàn)榫€程 B 沒有釋放。同樣的線程 B 想要獲取線程 A 也不行,因?yàn)榫€程 A 也沒有釋放,這樣一來,線程 A 和線程 B 就發(fā)生了死鎖,因?yàn)樗鼈兌枷嗷コ钟袑?duì)方想要的資源,卻又不釋放自己手中的資源,形成相互等待,而且會(huì)一直等待下去。
多個(gè)線程導(dǎo)致的死鎖場(chǎng)景
剛才的兩個(gè)線程因?yàn)橄嗷サ却梨i,多個(gè)線程則形成環(huán)導(dǎo)致死鎖。

線程 1、2、3 分別持有 A B C。此時(shí)線程 1 想要獲取鎖 B,當(dāng)然不行,因?yàn)榇藭r(shí)的鎖 B 在線程 2 手上,線程 2 想要去獲取鎖 C,一樣的獲取不到,因?yàn)榇藭r(shí)的鎖 C 在線程 3 手上,然后線程 3 去嘗試獲取鎖 A ,當(dāng)然它也獲取不到,因?yàn)殒i A 現(xiàn)在在線程 1 的手里,這樣線程 A B C 就形成了環(huán),所以多個(gè)線程仍然是可能發(fā)生死鎖的
死鎖會(huì)造成什么后果
死鎖可能發(fā)生在很多不同的場(chǎng)景,下面舉例說幾個(gè)
-
JVM
在 JVM 中發(fā)生死鎖的情況,JVM 不會(huì)自動(dòng)的處理,所以一旦死鎖發(fā)生就會(huì)陷入無(wú)窮的等待中
-
數(shù)據(jù)庫(kù)
數(shù)據(jù)庫(kù)中可能在事務(wù)之間發(fā)生死鎖。假設(shè)此時(shí)事務(wù) A 需要多把鎖,并一直持有這些鎖直到事物完成。事物 A 持有的鎖在其他的事務(wù)中也可能需要,因此這兩個(gè)事務(wù)中就有可能存在死鎖的情況
這樣的話,兩個(gè)事務(wù)將永遠(yuǎn)等待下去,但是對(duì)于數(shù)據(jù)庫(kù)而言,這樣的事兒不能發(fā)生。通常會(huì)選擇放棄某一個(gè)事務(wù),放棄的事務(wù)釋放鎖,從而其他的事務(wù)就可以順利進(jìn)行。
雖然有死鎖發(fā)生的可能性,但并不是 100% 就會(huì)發(fā)生。假設(shè)所有的鎖持有時(shí)間非常短,那么發(fā)生的概率自然就低,但是在高并發(fā)的情況下,這種小的累積就會(huì)被放大。
所以想要提前避免死鎖還是比較麻煩的,你可能會(huì)說上線之前經(jīng)過壓力測(cè)試,但仍不能完全模擬真實(shí)的場(chǎng)景。這樣根據(jù)發(fā)生死鎖的職責(zé)不同,所造成的問題就不一樣。死鎖常常發(fā)生于高并發(fā),高負(fù)載的情況,一旦直接影響到用戶,你懂的!
寫一個(gè)死鎖的例子

上圖的注釋比較詳細(xì)了,但是在這里還是梳理一下。
可以看到,在這段代碼中有一個(gè) int 類型的 level,它是一個(gè)標(biāo)記位,然后我們新建了 o1 和 o2、作為 synchronized 的鎖對(duì)象。
首先定義一個(gè) level,類似于 flag,如果 level 此時(shí)為 1,那么會(huì)先獲取 o1 這把鎖,然后休眠 1000ms 再去獲取 o2 這把鎖并打印出 「線程1獲得兩把鎖」
同樣的,如果 level 為 2,那么會(huì)先獲取 o2 這把鎖,然后休眠 1000ms 再去獲取 o1 這把鎖并打印出「線程1獲得兩把鎖」
然后我們看看 Main 方法,建立兩個(gè)實(shí)例,隨后啟動(dòng)兩個(gè)線程分別去執(zhí)行這兩個(gè) Runnable 對(duì)象并啟動(dòng)。
程序的一種執(zhí)行結(jié)果:

從結(jié)果我們可以發(fā)現(xiàn),程序并沒有停止且一直沒有輸出線程 1 獲得了兩把鎖或“線程 2 獲得了兩把鎖”這樣的語(yǔ)句,此時(shí)這里就發(fā)生了死鎖。
然后我們對(duì)死鎖的情況進(jìn)行分析
下面我們對(duì)上面發(fā)生死鎖的過程進(jìn)行分析:
第一個(gè)線程起來的時(shí)候,由于此時(shí)的 level 值為1,所以會(huì)嘗試獲得 O1 這把鎖,隨后休眠 1000 毫秒

線程 1 啟動(dòng)一會(huì)兒后進(jìn)入休眠狀態(tài),此時(shí)線程 2 啟動(dòng)。由于線程 2 的 level 值為2,所以會(huì)進(jìn)入 level=2 的代碼塊,即線程 2 會(huì)獲取 O2 這把鎖,隨后進(jìn)入1000 毫秒的休眠狀態(tài)。

線程 1 睡醒(休眠)后,還想去嘗試獲取 O2 這把鎖,由于此時(shí)的 02 被線程2使用著,自然線程 1 就無(wú)法獲取 O2。

同樣的,線程 2 睡醒了后,想去嘗試獲取 O1 這把鎖,O1 被線程 1 使用著,線程 2 自然獲取不到 O1 這把鎖。

好了,我們總結(jié)下上面的情況。應(yīng)該是很清晰了,線程 1 拿著 O1的鎖想去獲取 O2 的鎖,線程 2 呢,拿著 O2 的鎖想去獲取 O1 的鎖,這樣一來線程 1 和線程 2 就形成了相互等待的局面,從而形成死鎖。想必大家這次就很清晰的能理解死鎖的基本概念了,這樣以來,要死鎖,它的必要條件是什么呢?ok,我們繼續(xù)往下看。

發(fā)生死鎖的必要條件
-
互斥條件
如果不是互斥條件,那么每個(gè)人都可以拿到想要的資源就不用等待,即不可能發(fā)生死鎖。
-
請(qǐng)求與保持條件
當(dāng)一個(gè)線程請(qǐng)求資源阻塞了,如果不保持,而是釋放了,就不會(huì)發(fā)生死鎖了。所以,指當(dāng)一個(gè)線程因請(qǐng)求資源而阻塞時(shí),則需對(duì)已獲得的資源保持不放
-
不剝奪條件
如果可剝奪,假設(shè)線程 A 需要線程 B 的資源,啪的一下?lián)屵^來,那怎么會(huì)死鎖。所以,要想發(fā)生死鎖,必須滿足不剝奪條件,也就是說當(dāng)現(xiàn)在的線程獲得了某一個(gè)資源后,別人就不能來剝奪這個(gè)資源,這才有可能形成死鎖
-
循環(huán)等待條件
只有若干線程之間形成一種頭尾相接的循環(huán)等待資源關(guān)系時(shí),才有可能形成死鎖,比如在兩個(gè)線程之間,這種“循環(huán)等待”就意味著它們互相持有對(duì)方所需的資源、互相等待;而在三個(gè)或更多線程中,則需要形成環(huán)路,例如依次請(qǐng)求下一個(gè)線程已持有的資源等
案例解析四大必要條件

上面和大家一起走過這個(gè)代碼,相信大家都很清晰了,我也將必要的注釋放在了代碼中,需要的童鞋可以再過一遍。現(xiàn)在我們主要通過這份代碼,來分析分析死鎖的這四個(gè)必要條件
-
第一個(gè)必要條件為互斥條件
在代碼中,很明顯,我們使用 了 synchronized 互斥鎖,它的鎖對(duì)象 O1、O2 只能同時(shí)被一個(gè)線程所獲得,所以是滿足互斥的條件
-
第二個(gè)必要條件為請(qǐng)求與保持條件
不僅要請(qǐng)求還要保持。從代碼我們可以發(fā)現(xiàn),線程 1 獲得 O1 這把鎖后不罷休,還要嘗試獲取 O2 這把鎖,此時(shí)就被阻塞了,阻塞就算了,它也不會(huì)釋放 O1 這把鎖,意味著對(duì)已有的資源保持不放。所以第二個(gè)條件也滿足了。

第 3 個(gè)必要條件是不剝奪條件,在我們這個(gè)代碼程序中,JVM 并不會(huì)主動(dòng)把某一個(gè)線程所持有的鎖剝奪,所以也滿足不剝奪條件。

第 4 個(gè)必要條件是循環(huán)等待條件,在我們的例子中,這兩個(gè)線程都想獲取對(duì)方已持有的資源,也就是說線程 1 持有 o1 去等待 o2,而線程 2 則是持有 o2 去等待 o1,這是一個(gè)環(huán)路,此時(shí)就形成了一個(gè)循環(huán)等待。

這樣通過代碼的形式,更加深刻的了解死鎖問題。所以,在以后再遇到死鎖的問題,只要破壞任意一個(gè)條件就可以消除死鎖,這也是我們后面要講的解決死鎖策略中重點(diǎn)要考慮的內(nèi)容,從這樣幾個(gè)維度去回答是不是更清晰勒。那如果發(fā)生了死鎖該怎么處理呢?
發(fā)生死鎖了怎么處理
既然死鎖已經(jīng)發(fā)生了,那么現(xiàn)在要做的當(dāng)然是止損,最好的辦法為保存當(dāng)前 JVM ,日志等數(shù)據(jù),然后重啟。
為什么要重啟?
我們知道發(fā)生死鎖是有很多前提的,而且通常情況下是在高并發(fā)的情況才會(huì)發(fā)生死鎖,所以重啟后發(fā)生的幾率很小且可以暫時(shí)保證當(dāng)前服務(wù)的可用,隨后根據(jù)保存的信息排查死鎖原因,修改代碼,隨后發(fā)布
有哪些修復(fù)的策略呢
常見的修復(fù)策略有三個(gè),避免策略,檢測(cè)與恢復(fù)策略以及鴕鳥策略。下面分別說說這三種策略
-
避免策略
發(fā)生死鎖的原因無(wú)外乎是采用了相反的順序去獲取鎖,那么就要思考如何將方向掉過來。
下面以轉(zhuǎn)賬的例子來看看死鎖的形成與避免。
在轉(zhuǎn)賬之前,為了保證線程安全通常會(huì)獲取兩把鎖,分別為轉(zhuǎn)出的賬戶與轉(zhuǎn)入的賬戶。說白了,在沒有獲取這兩把鎖的時(shí)候,是不能對(duì)余額做操作的,即只有獲取了這兩把鎖才會(huì)進(jìn)行接下來的轉(zhuǎn)賬操作。看看下面的代碼

執(zhí)行結(jié)果如下

通過之前的代碼分析,再看這個(gè)代碼是不是會(huì)簡(jiǎn)單很多。代碼中,定義 int 類型的 flag,不同的 flag 對(duì)應(yīng)不同的執(zhí)行邏輯,隨后建立了兩個(gè)賬戶 對(duì)象 a 和 對(duì)象 b,兩者賬戶最初都為 1000 元。
再來看 run 方法,如果此時(shí) flag 值為 1 ,那么代表著 a 賬戶會(huì)向 b賬戶轉(zhuǎn)賬 100 元,如果 flag 為 0 則表示 b 賬戶往 a 賬戶轉(zhuǎn)賬 100 元。
再來看 transferMoney 方法,會(huì)嘗試獲取兩把鎖 O1 和 O2,如果獲取成功則判斷當(dāng)前余額是否足以轉(zhuǎn)出,如果不足則會(huì) return。如果余額足夠則會(huì)轉(zhuǎn)出賬戶并減余額,對(duì)應(yīng)的給被轉(zhuǎn)入的賬戶加余額,最后打印成功轉(zhuǎn)賬"XX"元
在代碼中,首先定義了 int 類型的 flag,它是一個(gè)標(biāo)記位,用于控制不同線程執(zhí)行不同邏輯。然后建了兩個(gè) Account 對(duì)象 a 和 b,代表賬戶,它們最初都有 1000 元的余額。
再看主函數(shù),分別創(chuàng)建兩個(gè)對(duì)象,并設(shè)置 flag 值,傳入兩個(gè)線程并啟動(dòng),結(jié)果如下

呀哈,結(jié)果完全正確,符合正常邏輯。那是因?yàn)榇藭r(shí)對(duì)鎖的持有時(shí)間比較短,釋放也快,所以在低并發(fā)的情況下不容易發(fā)生死鎖,下面我們將代碼做下調(diào)整。
我在兩個(gè) synchonized 之間加上一個(gè)休眠 Thread.sleep(1000),就反復(fù)模擬銀行轉(zhuǎn)賬的網(wǎng)絡(luò)延遲現(xiàn)象。所以此時(shí)的 transferMoney 方法變?yōu)檫@樣

可以看到 的變化就在于,在兩個(gè) synchronized 之間,也就是獲取到第一把鎖后、獲取到第二把鎖前,我們加了睡眠 1000 毫秒的語(yǔ)句。此時(shí)再運(yùn)行程序,會(huì)有很大的概率發(fā)生死鎖,從而導(dǎo)致控制臺(tái)中不打印任何語(yǔ)句,而且程序也不會(huì)停止。
為什么加了一句睡眠時(shí)間就可能出現(xiàn)死鎖呢。原因就在于有了這個(gè)休息時(shí)間,讓其他的線程有了得逞的機(jī)會(huì),想一想什么時(shí)候是追下女票最快的方式,哈哈哈哈。
這樣,兩個(gè)線程獲取鎖的方式是相反的,意味著第一個(gè)線程的“轉(zhuǎn)出賬戶”正是第二個(gè)線程的“轉(zhuǎn)入賬戶”,所以我們就可以從這個(gè)“相反順序”的角度出發(fā),來解決死鎖問題。,
既然是相反順序,那我們就想辦法控制線程間的執(zhí)行順序,這里可以使用 HashCode 的方式,來保證線程安全
修復(fù)之后的 transferMoney 方法如下:

上面代碼,首先計(jì)算出 兩個(gè) Account 的 HashCode,隨后根據(jù) HashCode 的大小來決定獲取鎖的順序。所以,不管哪個(gè)線程先執(zhí)行,也無(wú)論是轉(zhuǎn)出和轉(zhuǎn)入,獲取鎖的順序都會(huì)嚴(yán)格按照 HashCode大小來決定,也就不會(huì)出現(xiàn)獲取鎖順序相反的情況,也就避免了死鎖。
除了使用 HashCode 的方式?jīng)Q定鎖獲取順序以外 ,不過我們知道還是會(huì)存在 HashCode 沖突的情況。所以在實(shí)際生產(chǎn)中,排序會(huì)使用一個(gè)實(shí)體類,這個(gè)實(shí)體類有一個(gè)主鍵 ID,既然是主鍵,則有唯一,不重復(fù)的特點(diǎn),所以也就沒必要再去計(jì)算 HashCode,這樣也更加方便,直接使用它主鍵 ID 進(jìn)行排序,由主鍵 ID 大小來決定獲取鎖的順序,從而確保避免死鎖。
其實(shí),使用 HashCode 方式有個(gè)問題,如果出現(xiàn) Hash 沖突還有有點(diǎn)麻煩,雖然概率比較低。在實(shí)際生產(chǎn)上,通常會(huì)排序一個(gè)實(shí)體類,這個(gè)實(shí)體類有一個(gè)主鍵 ID,既然是主鍵 ID,也就有唯一,不重復(fù)的特點(diǎn),所以所以如果我們這個(gè)類包含主鍵屬性的話就方便多了,我們也沒必要去計(jì)算 HashCode,直接使用它的主鍵 ID 來進(jìn)行排序,由主鍵 ID 大小來決定獲取鎖的順序,就可以確保避免死鎖。
以上我們介紹了死鎖的避免策略。
檢測(cè)與恢復(fù)策略
檢測(cè)與恢復(fù)策略,從名字可以猜出,大體意思為可以先讓死鎖發(fā)生,只不過會(huì)每次調(diào)用鎖的時(shí)候,記錄下調(diào)用信息并形成鎖的調(diào)用鏈路圖,然后每隔一段時(shí)間就用死鎖檢測(cè)算法檢測(cè)下,看看這個(gè)圖中是否存在環(huán)路,如果存在即發(fā)生了死鎖,就可以使用死鎖恢復(fù)機(jī)制,比如剝奪某個(gè)資源來解開死鎖并進(jìn)行恢復(fù)。
那到底如何解除死鎖呢?
-
線程終止
第一種解開死鎖的方式比較直接,直接讓線程或進(jìn)程終止,這樣的話,系統(tǒng)會(huì)終止已經(jīng)陷入死鎖的線程,線程終止,釋放資源,這樣死鎖就會(huì)解開
當(dāng)然終止也是要講究順序的,不是隨便隨時(shí)終止
第一個(gè)考量?jī)?yōu)先級(jí):
當(dāng)進(jìn)程線程終止的時(shí)候,會(huì)終止優(yōu)先級(jí)比較低的線程。如果是前臺(tái)線程,那么直接影響到界面的顯示,這對(duì)用戶而言是無(wú)法接受的,所以通常來說前臺(tái)線程優(yōu)先級(jí)會(huì)高于后臺(tái)線程。
第二個(gè)考量已占有資源,還需要資源:
如果一個(gè)線程占有的很多資源,只差百分之一的資源就可以完成任務(wù),那么這個(gè)時(shí)候系統(tǒng)可能就不會(huì)終止這樣的線程額,而是會(huì)選擇終止其他的線程來有限促進(jìn)該線程的完成
第三個(gè)考量已經(jīng)運(yùn)行的時(shí)間:
如果一個(gè)線程運(yùn)行很長(zhǎng)的時(shí)間了,很快就要完成任務(wù),那么突然終止這樣的一個(gè)線程也不是一個(gè)明智的選擇,我們可以讓那些剛剛開始運(yùn)行的線程終止,并在之后把它們重新啟動(dòng)起來,這樣成本更低。
這里會(huì)有各種各樣的算法和策略,我們根據(jù)實(shí)際業(yè)務(wù)去進(jìn)行調(diào)整就可以了。
-
方法2——資源搶占
其實(shí)第一種方式太暴力了,我們只需要把它已經(jīng)獲得的資源進(jìn)行剝奪,比如讓線程回退幾步、 釋放資源,這樣一來就不用終止掉整個(gè)線程了,這樣造成的后果會(huì)比剛才終止整個(gè)線程的后果更小一些,成本更低。
不過這樣還是有個(gè)缺點(diǎn),如果我們搶占的線程一直是同一個(gè)線程,那么線程也扛不住會(huì)出現(xiàn)線程饑餓的情況,這個(gè)線程一直被剝奪已經(jīng)得到的資源,那它將長(zhǎng)期得不到運(yùn)行。
鴕鳥策略
還是從名字出發(fā),鴕鳥嘛,有啥特點(diǎn)?就是當(dāng)遇到危險(xiǎn)的時(shí)候就會(huì)將頭埋到沙子里,這樣就看不到危險(xiǎn)了。
在低并發(fā)的情況下,比如很多內(nèi)部系統(tǒng),發(fā)生死鎖的概率很低,如果即使發(fā)生了也不會(huì)特別嚴(yán)重,那還花這么多心思去處理它,完全沒有必要。
哪些場(chǎng)景需要額外注意線程安全問題?
-
訪問共享變量或資源
上面最開始說的 i++ 就是這樣的情況,訪問共享變量和共享資源,共享緩存等。這些信息被多個(gè)線程操作就可以出現(xiàn)并發(fā)讀寫的情況。
-
依賴時(shí)序的操作
如果在開發(fā)的過程中,相應(yīng)的需求或場(chǎng)景依賴于時(shí)序的關(guān)系,那么在多線程又不能保障執(zhí)行順序和預(yù)期一致,這個(gè)時(shí)候依然要考慮線程安全的問題。如下簡(jiǎn)單代碼

這樣先檢查再執(zhí)行的操作不是原子性操作,中間任意一個(gè)環(huán)節(jié)都有可能被打斷,檢查后的結(jié)果可能出現(xiàn)無(wú)效,過期的情況,所以,想要獲取正確的結(jié)果可能取決于時(shí)序,所以這種情況需要通過枷鎖等方式保護(hù)保障操作的原子性。
-
對(duì)方?jīng)]有聲明自己是線程安全的
因?yàn)橛泻芏鄡?nèi)置的庫(kù)函數(shù),比如集合中的 ArrayList,本身就不是線程安全的,如果多個(gè)線程同時(shí)對(duì) ArrayList 進(jìn)行并發(fā)的讀寫,那就自然有可能出現(xiàn)線程安全問題,從而造成數(shù)據(jù)出錯(cuò),這個(gè)責(zé)任不在于 ArrayList,而是因?yàn)樗旧砭筒皇遣l(fā)安全的。我們也可以看看源碼中的注釋

描述的也很清晰,如果我們要使用 ArrayList 在多線程的場(chǎng)景,請(qǐng)?jiān)谕獠渴褂?synchronized 等保證并發(fā)安全。
多線程會(huì)有哪些性能問題
我們經(jīng)常聽到的是通過多線程來提升效率,多個(gè)線程同時(shí)工作,加快程序運(yùn)行速度,而這里想說的是多線程會(huì)帶來哪些問題。單線程是個(gè)單身漢兒,啥時(shí)候自己干,也不和別人牽扯,可多線程不一樣,需要和別人協(xié)同辦公,既然要協(xié)同辦公,那就涉及到溝通的成本,這樣的調(diào)度和合作就會(huì)帶來性能開銷。
哪些可能會(huì)有性能開銷?
性能開銷多種多樣,其表現(xiàn)形式也多樣。常見的響應(yīng)慢,內(nèi)存占用過多都屬于性能問題。我們通過購(gòu)買服務(wù)器來橫向提升服務(wù)器的處理能力,通過購(gòu)買更大的帶寬提升網(wǎng)絡(luò)處理能力,總是用戶是上帝,我們需要想盡一切辦法讓用戶有更好的體驗(yàn),不卸載,勤分享。
多線程帶來哪些開銷
第一個(gè)就是上面說的信息交互涉及的上下文切換,通常我們會(huì)指定一定數(shù)量的線程,但是 CPU 的核心又比線程數(shù)少,所以無(wú)法同時(shí)照顧到所有的線程,自然就有一部分線程在某個(gè)時(shí)間點(diǎn)處于等待的狀態(tài)
操作系統(tǒng)就會(huì)按照一定的調(diào)度算法,給每個(gè)線程分配時(shí)間片,讓每個(gè)線程都有機(jī)會(huì)得到運(yùn)行。而在進(jìn)行調(diào)度時(shí)就會(huì)引起上下文切換,上下文切換會(huì)掛起當(dāng)前正在執(zhí)行的線程并保存當(dāng)前的狀態(tài),然后尋找下一處即將恢復(fù)執(zhí)行的代碼,喚醒下一個(gè)線程,以此類推,反復(fù)執(zhí)行。但上下文切換帶來的開銷是比較大的,假設(shè)我們的任務(wù)內(nèi)容非常短,比如只進(jìn)行簡(jiǎn)單的計(jì)算,那么就有可能發(fā)生我們上下文切換帶來的性能開銷比執(zhí)行線程本身內(nèi)容帶來的開銷還要大的情況。
第二個(gè)帶來的問題是緩存失效。對(duì)了,如果我們把經(jīng)常使用的比如數(shù)據(jù)線等物品放在固定的地方,下次需要的時(shí)候就不會(huì)驚慌失措,浪費(fèi)時(shí)間了。同樣的,我們把經(jīng)常訪問的數(shù)據(jù)緩存起來,下次需要的時(shí)候直接取就好了。常見的數(shù)據(jù)庫(kù)的連接池,線程池等都有類似的思想。
如果沒有緩存,一旦進(jìn)行了線程調(diào)度,切換到其他的線程,CPU 就會(huì)去執(zhí)行其他代碼,這時(shí)候就可能出現(xiàn)緩存失效了,一旦失效,就要重新緩存新的數(shù)據(jù),從而引起開銷。所以線程調(diào)度器為了避免頻繁地發(fā)生上下文切換,通常會(huì)給被調(diào)度到的線程設(shè)置最小的執(zhí)行時(shí)間,也就是只有執(zhí)行完這段時(shí)間之后,才可能進(jìn)行下一次的調(diào)度,由此減少上下文切換的次數(shù)。
更可怕的是,如果多線程頻繁的競(jìng)爭(zhēng)鎖或者 IO 讀寫,就有可能出現(xiàn)大量的阻塞,此時(shí)就可能需要更多的上下文切換,即更大的開銷
線程協(xié)作帶來的開銷
很多時(shí)候多個(gè)線程需要共享數(shù)據(jù),為了保證線程安全,就會(huì)禁止編譯器和 CPU 對(duì)其進(jìn)行重排序等優(yōu)化,正是因?yàn)橐剑枰磸?fù)把線程工作內(nèi)存的數(shù)據(jù) flush 到主存中,隨后將主存的內(nèi)容 refresh 到其他線程的工作內(nèi)存中,等等。
這些問題在單線程中并不存在,但在多線程中為了確保數(shù)據(jù)的正確性,就不得不采取上述方法,因?yàn)榫€程安全的優(yōu)先級(jí)要比性能優(yōu)先級(jí)更高,這也間接降低了我們的性能。
總結(jié)
在本篇文章中,我們首先介紹了什么是死鎖,接著介紹了死鎖在生活中、兩個(gè)線程中以及多個(gè)線程中的例子。然后我們分析了死鎖的影響,在 JVM 中如果發(fā)生死鎖,可能會(huì)導(dǎo)致程序部分甚至全部無(wú)法繼續(xù)向下執(zhí)行的情況,所以死鎖在 JVM 中所帶來的危害和影響是比較大的,我們需要盡量避免。最后舉了一個(gè)必然會(huì)發(fā)生死鎖的例子代碼,并且對(duì)此代碼進(jìn)行了詳細(xì)的分析。
另外也學(xué)習(xí)了解決死鎖的策略。從線程發(fā)生死鎖,到保存重要數(shù)據(jù),恢復(fù)線上服務(wù)。最后也提到了三種修復(fù)的策略。
一是避免策略,其主要思路就是去改變鎖的獲取順序,防止相反順序獲取鎖這種情況的發(fā)生;二是檢測(cè)與恢復(fù)策略,它是允許死鎖發(fā)生,但是一旦發(fā)生之后它有解決方案;三是鴕鳥策略。
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問題,請(qǐng)聯(lián)系我們,謝謝!