引言
在之前對于并發(fā)編程這個模塊的內(nèi)容已經(jīng)闡述了很多篇章了,而本章的目的則是為了對前述的內(nèi)容做個補充评姨,重點會圍繞著鎖的狀態(tài)與并行處理的方式進行展開論述难述。
一、進程吐句、線程胁后、纖程、協(xié)程嗦枢、管程概念理解
在現(xiàn)在你可能會經(jīng)撑市荆看到進程、線程净宵、纖程敲才、協(xié)程裹纳、管程择葡、微線程、綠色線程....一大堆xx程的概念剃氧,其實這些本質(zhì)上都是為了滿足并行執(zhí)行敏储、異步執(zhí)行而出現(xiàn)的一些概念。
因為隨著如今的科技越來越發(fā)達朋鞍,計算機目前多以多核機器為主已添,所以之前單線程的串行執(zhí)行方式注定無法100%程度發(fā)揮出硬件該有的性能。同時滥酥,為了滿足互聯(lián)網(wǎng)時代中日益漸增的用戶基數(shù)更舞,我們開發(fā)的程序往往需要更優(yōu)異的性能,更快的執(zhí)行效率坎吻,更大的吞吐量才可缆蝉。
為了方便理解,我們可以先把操作系統(tǒng)抽象為了一個帝國瘦真。并且為了方便理解這些概念刊头,下面也不會太過官方死板的做概念介紹。
1.1诸尽、進程(Progress)
進程也就是平時所說的程序原杂,比如在操作系統(tǒng)上運行一個谷歌瀏覽器,那么就代表著谷歌瀏覽器就是一個進程您机。進程是操作系統(tǒng)中能夠獨立運行的個體穿肄,并且也作為資源分配的基本單位年局,由指令、數(shù)據(jù)咸产、堆棧等結(jié)構(gòu)組成某宪。
安裝好一個程序之后,在程序未曾運行之前也僅是一些文件存儲在磁盤上锐朴,當啟動程序時會向操作系統(tǒng)申請一定的資源兴喂,如CPU、存儲空間和I/O設(shè)備等焚志,OS為其分配資源后衣迷,會真正的出現(xiàn)在內(nèi)存中成為一個抽象的概念:進程。
其實操作系統(tǒng)這個帝國之上酱酬,在運行時往往有著很多個進程存在壶谒,你可以把這些進程理解成一個個的工廠,根據(jù)各自的代碼實現(xiàn)各司其職膳沽。如通過Java編寫一個程序后運行在操作系統(tǒng)上汗菜,那么就相當于在OS帝國上注冊了一家工廠,該工廠具體的工作則由Java代碼的業(yè)務(wù)屬性決定挑社。
隨著計算機硬件技術(shù)的不斷進步陨界,慢慢的CPU架構(gòu)更多都是以多核的身份出現(xiàn)在市面上,所以對于程序而言痛阻,CPU利用率的要求會更高菌瘪。但是進程的調(diào)度開銷是比較大的,并且在并發(fā)中切換過程效率也很低阱当,所以為了更高效的調(diào)度和滿足日益復雜的程序需求俏扩,最終發(fā)明了線程。
1.2弊添、線程(Thread)
在操作系統(tǒng)早期的時候其實并沒有線程的概念录淡,到了后來為了滿足并發(fā)處理才推出的一種方案,線程作為程序執(zhí)行的最小單位油坝,一個進程中可以擁有多條線程嫉戚,所有線程可以共享進程的內(nèi)存區(qū)域,線程通常在運行時也需要一組寄存器免钻、內(nèi)存彼水、棧等資源的支撐。現(xiàn)如今极舔,程序之所以可以運行起來的根本原因就是因為內(nèi)部一條條的線程在不斷的執(zhí)行對應(yīng)的代碼邏輯凤覆。
假設(shè)進程現(xiàn)在是OS帝國中的一個工廠,那么線程就是工廠中一個個工位上的工人拆魏。工廠之所以能夠運轉(zhuǎn)的根本原因就在于:內(nèi)部每個工位上的工人都各司其職的處理自己分配到的工作盯桦。
多核CPU中慈俯,一個核心往往在同一時刻只能支持一個內(nèi)核線程的運行,所以如果你的機器為八核CPU拥峦,那么理論上代表著同一時刻最多支持八條內(nèi)核線程同時并發(fā)執(zhí)行贴膘。當然,現(xiàn)在也采用了超線程的技術(shù)略号,把一個物理芯片模擬成兩個邏輯處理核心刑峡,讓單個處理器都能使用線程級并行計算,進而兼容多線程操作系統(tǒng)和軟件玄柠,減少了CPU的閑置時間突梦,提高的CPU的運行效率。比如四核八線程的CPU羽利,在同一時刻也支持最大八條線程并發(fā)執(zhí)行宫患。
在OS中,程序一般不會去直接申請內(nèi)核線程進行操作这弧,而是去使用內(nèi)核線程提供的一種名為LWP
的輕量級進程(Lightweight Process
)進行操作娃闲,這個LWP
也就是平時所謂的線程,也被成為用戶級線程匾浪。
1.2.1皇帮、線程模型
在如今的操作系統(tǒng)中,用戶線程與內(nèi)核線程主要存在三種模型:一對一模型户矢、多對一模型以及多對多模型玲献。而Java中使用的則是一對一模型,在之前分析Java內(nèi)存模型JMM時曾詳細分析過梯浪。
一對一模型
一對一模型是指一條用戶線程對應(yīng)著內(nèi)核中的一條線程,而Java中采用的就是這種模型瓢娜,如下:
一對一模型是真正意義上的并行執(zhí)行挂洛,因為這種模型下,創(chuàng)建一條Java的
Thread
線程是真正的在內(nèi)核中創(chuàng)建并映射了一條內(nèi)核線程的眠砾,執(zhí)行過程中虏劲,一條線程不會因為另外一條線程的原因而發(fā)生阻塞等情況。不過因為是直接映射內(nèi)核線程的模式褒颈,所以數(shù)量會存在上限柒巫。并且同一個核心中,多條線程的執(zhí)行需要頻繁的發(fā)生上下文切換以及內(nèi)核態(tài)與用戶態(tài)之間的切換谷丸,所以如果線程數(shù)量過多菲盾,切換過于頻繁會導致線程執(zhí)行效率下降锦茁。
多對一模型
顧名思義,多對一模型是指多條用戶線程映射同一條內(nèi)核線程的情況孤荣,對于用戶線程而言,它們的執(zhí)行都由用戶態(tài)的代碼完成切換驴娃。
這種模式優(yōu)點很明顯,一方面可以節(jié)省內(nèi)核態(tài)到用戶態(tài)切換的開銷,第二方面線程的數(shù)量不會受到內(nèi)核線程的限制扮休。但是缺點也很明顯,因為線程切換的工作是由用戶態(tài)的代碼完成的拴鸵,所以如果當一條線程發(fā)生阻塞時玷坠,與該內(nèi)核線程對應(yīng)的其他用戶線程也會一起陷入阻塞。
多對多模型
多對多模型就可以避免上面一對一和多對一模型帶來的弊端劲藐,也就是多條用戶線程映射多條內(nèi)核線程侨糟,這樣即可以避免一對一模型的切換效率問題和數(shù)量限制問題,也可以避免多對一的阻塞問題瘩燥,如下:
1.3秕重、協(xié)程(Coroutines)
協(xié)程是一種基于線程之上,但又比線程更加輕量級的存在厉膀,這種由程序管理的輕量級線程也被稱為用戶空間線程溶耘,對于內(nèi)核而言是不可見的。正如同進程中存在多條線程一樣服鹅,線程中也可以存在多個協(xié)程凳兵。
協(xié)程在運行時也有自己的寄存器、上下文和棧企软,協(xié)程的調(diào)度完全由用戶控制庐扫,協(xié)程調(diào)度切換時,會將寄存器上下文和棧保存到分配的私有內(nèi)存區(qū)域中仗哨,在切回來的時候形庭,恢復先前保存的寄存器上下文和棧,直接操作棧則基本沒有內(nèi)核切換的開銷厌漂,可以不加鎖的訪問全局變量萨醒,所以上下文的切換非常快苇倡。
前面把線程比作了工廠工位上的固定工人富纸,那么協(xié)程更多的就可以理解為:工廠中固定工位上的不固定工人。一個固定工位上允許有多個不同的工人旨椒,當輪到某個工人工作時晓褪,就把上一個工人的換下來,把這個要工作的工人換上去综慎』练拢或者當前工人在工作時要上廁所,那么就會先把當前工作的工人撤下去寥粹,換另一個工人上來变过,等這個工人上完廁所回來了埃元,會再恢復它的工作。
協(xié)程有些類似于線程的多對一模型媚狰。
1.4岛杀、纖程(Fiber)
纖程(Fiber
)是Microsoft
組織為了幫助企業(yè)程序的更好移植到Windows
系統(tǒng),而在操做系統(tǒng)中增加的一個概念崭孤,由操作系統(tǒng)內(nèi)核根據(jù)對應(yīng)的調(diào)度算法進行控制类嗤,也是一種輕量級的線程。
纖程和協(xié)程的概念一致辨宠,都是線程的多對一模型遗锣,但有些地方會區(qū)分開來,但從協(xié)程的本質(zhì)概念上來談:纖程嗤形、綠色線程精偿、微線程這些概念都屬于協(xié)程的范圍。纖程和協(xié)程的區(qū)別在于:
- 纖程是OS級別的實現(xiàn)赋兵,而協(xié)程是語言級別的實現(xiàn)笔咽,纖程被OS內(nèi)核控制,協(xié)程對于內(nèi)核而言不可見霹期。
1.5叶组、管程(Monitors)
管程(Monitors
)提供了一種機制,線程可以臨時放棄互斥訪問历造,等待某些條件得到滿足后甩十,重新獲得執(zhí)行權(quán)恢復它的互斥訪問。相信這個概念對于熟悉多線程編程的Java程序員而言并不是陌生吭产,因為在Java中Synchronized
關(guān)鍵字就是基于它實現(xiàn)的侣监,不太了解的可以去看之前的文章:全面剖析Synchronized關(guān)鍵字。
1.6垮刹、XX程小結(jié)
先如今各種程出現(xiàn)的根本原因是由于多核機器的流行达吞,所以程序?qū)崿F(xiàn)中也需要最大程度上考慮并行、并發(fā)荒典、異步執(zhí)行,在最大程序上去將硬件機器應(yīng)有的性能發(fā)揮出來吞鸭。以Java而言寺董,本身多線程的方式是已經(jīng)可以滿足這些需求的,但Java中的線程資源比較昂貴刻剥,是直接與內(nèi)核線程映射的遮咖,所以在上下文切換、內(nèi)核態(tài)和用戶態(tài)轉(zhuǎn)換上都需要浪費很多的資源開銷造虏,同時也受到操作系統(tǒng)的限制御吞,允許一個Java程序中創(chuàng)建的纖程數(shù)量是有限的麦箍。所以對于這種一對一的線程模型有些無法滿足需求了,最終才出現(xiàn)了各種程的概念陶珠。
從實現(xiàn)級別上來看:進程挟裂、線程、纖程是OS級別的實現(xiàn)揍诽,而綠色線程诀蓉、協(xié)程這些則是語言級別上的實現(xiàn)。
從調(diào)度方式上而言:進程暑脆、線程渠啤、綠色線程屬于搶占式執(zhí)行,而纖程添吗、協(xié)程則屬于合作式調(diào)度沥曹。
從包含關(guān)系上來說:一個OS中可以有多個進程,一個進程中可以有多條線程碟联,而一條線程中則可以有多個協(xié)程妓美、纖程、微線程等玄帕。
二部脚、死鎖、活鎖與鎖饑餓概念理解
在多核時代中裤纹,多線程委刘、多進程的程序雖然大大提高了系統(tǒng)資源的利用率以及系統(tǒng)的吞吐量,但并發(fā)執(zhí)行也帶來了新的一系列問題:死鎖鹰椒、活鎖與鎖饑餓锡移。
死鎖、活鎖與鎖饑餓都是程序運行過程中的一種狀態(tài)漆际,而其中死鎖與活鎖狀態(tài)在進程中也是可能存在這種情況的淆珊,接下來先簡單闡述一下這些狀態(tài)的含義。
2.1奸汇、何謂死鎖(DeadLock)施符?
死鎖是指兩個或兩個以上的線程(或進程)在運行過程中,因為資源競爭而造成相互等待的現(xiàn)象擂找,若無外力作用則不會解除等待狀態(tài)戳吝,它們之間的執(zhí)行都將無法繼續(xù)下去。舉個栗子:
某一天竹子和熊貓在森林里撿到一把玩具弓箭贯涎,竹子和熊貓都想玩听哭,原本說好一人玩一次的來,但是后面竹子耍賴,想再玩一次陆盘,所以就把弓一直拿在自己手上普筹,而本應(yīng)該輪到熊貓玩的,所以熊貓跑去撿起了竹子前面剛剛射出去的箭隘马,然后跑回來之后便發(fā)生了如下狀況:
熊貓道:竹子太防,快把你手里的弓給我,該輪到我玩了....
竹子說:不祟霍,你先把你手里的箭給我杏头,我再玩一次就給你....
最終導致熊貓等著竹子的弓,竹子等著熊貓的箭沸呐,雙方都不肯退步醇王,結(jié)果陷入僵局場面....。
相信這個場景各位小伙伴多多少少都在自己小時候發(fā)生過崭添,這個情況在程序中發(fā)生時就被稱為死鎖狀況寓娩,如果出現(xiàn)后則必須外力介入,然后破壞掉死鎖狀態(tài)后推進程序繼續(xù)執(zhí)行呼渣。如上述的案例中棘伴,此時就必須第三者介入,把“違反約定”的竹子手中的弓拿過去給熊貓......
當然屁置,類似于這樣的死鎖案例還有很多現(xiàn)實中的例子焊夸,比如:哲學家進餐等。
2.2蓝角、活鎖(LiveLock)是什么阱穗?
活鎖是指正在執(zhí)行的線程或進程沒有發(fā)生阻塞,但由于某些條件沒有滿足使鹅,導致反復重試-失敗-重試-失敗的過程揪阶。與死鎖最大的區(qū)別在于:活鎖狀態(tài)的線程或進程是一直處于運行狀態(tài)的,在失敗中不斷重試患朱,重試中不斷失敗鲁僚,一直處于所謂的“活”態(tài),不會停止裁厅。而發(fā)生死鎖的線程則是相互等待冰沙,雙方之間的狀態(tài)是不會發(fā)生改變的,處于所謂的“死”態(tài)执虹。
死鎖沒有外力介入是無法自行解除的倦淀,而活鎖狀態(tài)有一定幾率自行解除。
其實本質(zhì)上來說声畏,活鎖狀態(tài)就是指兩個線程雖然在反復的執(zhí)行,但是卻沒有任何效率。正如生活中那句名言:“雖然你看起來很努力插龄,但結(jié)果卻沒有因為你的努力而發(fā)生任何改變”愿棋,也是所謂的做無用功。同樣舉個生活中的栗子理解:
生活中大家也都遇見過的一個事情:在一條走廊上兩個人低頭玩手機往前走均牢,突然雙方一起抬頭都發(fā)現(xiàn)面對面快撞上了糠雨,然后雙方同時往左側(cè)跨了一步讓開路,然后兩個人都發(fā)現(xiàn)對方也到左邊來了徘跪,兩個人想著再回到右邊去給對方讓路甘邀,然后同時又向右邊跨了一步,然后不斷重復這個過程垮庐,再同時左邊跨松邪、右邊跨、左邊跨........
這個栗子中哨查,雖然雙方都在不斷的移動逗抑,但是做的卻是無用功,如果一直這樣重復下去寒亥,可能從太陽高照到滿天繁星的時候邮府,雙方還是沒有走出這個困境。
這個狀態(tài)又該如何打破呢溉奕?主要有兩種方案褂傀,一種是單方的,其中有一方打破“同步”的頻率加勤。另一種方案則是雙方之間先溝通好仙辟,制定好約定之后再讓路,比如其中一方開口說:你等會兒走我這邊胸竞,我往那邊走欺嗤。而另一方則說:好。
在程序中卫枝,如果兩條線程發(fā)生了某些條件的碰撞后重新執(zhí)行煎饼,那么如果再次嘗試后依然發(fā)生了碰撞,長此下去就有可能發(fā)生如上案例中的情況校赤,這種情況就被稱為協(xié)同導致的活鎖吆玖。
比如同時往某處位置寫入數(shù)據(jù),但同時只能允許一條線程寫入數(shù)據(jù)马篮,所以在寫入之前會檢測是否有其他線程存在沾乘,如果有則放棄本次寫入,過一段時間之后再重試浑测。而此時正好有兩條線程同時寫入又相互檢測到了對方翅阵,然后都放棄了寫入歪玲,而重試的時間間隔都為1s,結(jié)果1s后這兩條線程又碰頭了掷匠,然后來回重復這個過程.....
當然滥崩,在程序中除開上述這種多線程之間協(xié)調(diào)導致的活鎖情況外,單線程也會導致活鎖產(chǎn)生讹语,比如遠程RPC調(diào)用中就經(jīng)常出現(xiàn)钙皮,A調(diào)用B的RPC接口,需要B的數(shù)據(jù)返回顽决,結(jié)果B所在的機器網(wǎng)絡(luò)出問題了短条,A就不斷的重試,最終導致反復調(diào)用才菠,不斷失敗茸时。
活鎖解決方案
活鎖狀態(tài)是有可能自行解除的,但時間會久一點鸠儿,不過在編寫程序時屹蚊,我們可以盡量避免活鎖情況發(fā)生,一方面可以在重試次數(shù)上加上限制进每,第二個方面也可以把重試的間隔時間加點隨機數(shù)汹粤,第三個則是前面所說的,多線程協(xié)同式工作時則可以先在全局內(nèi)約定好重試機制田晚,盡量避免線程沖突發(fā)生嘱兼。
2.3、啥又叫鎖饑餓(LockStarving)贤徒?
鎖饑餓是指一條長時間等待的線程無法獲取到鎖資源或執(zhí)行所需的資源芹壕,而后面來的新線程反而“插隊”先獲取了資源執(zhí)行,最終導致這條長時間等待的線程出現(xiàn)饑餓接奈。
ReetrantLock
的非公平鎖就有可能導致線程饑餓的情況出現(xiàn)踢涌,因為線程到來的先后順序無法決定鎖的獲取,可能第二條到來的線程在第十八條線程獲取鎖成功后序宦,它也不一定能夠成功獲取鎖睁壁。
鎖饑餓這種問題可以采用公平鎖的方式解決,這樣可以確保線程獲取鎖的順序是按照請求鎖的先后順序進行的互捌。但實際開發(fā)過程中潘明,從性能角度而言,非公平鎖的性能會遠遠超出公平鎖秕噪,非公平鎖的吞吐量會比公平鎖更高钳降。
當然,如果你使用了多線程編程腌巾,但是在分配纖程組時沒有合理的設(shè)置線程優(yōu)先級遂填,導致高優(yōu)先級的線程一直吞噬低優(yōu)先級的資源铲觉,導致低優(yōu)先級的線程一直無法獲取到資源執(zhí)行,最終也會使低優(yōu)先級的線程產(chǎn)生饑餓城菊。
三备燃、死鎖產(chǎn)生原因/如何避免死鎖、排查死鎖詳解
關(guān)于鎖饑餓和活鎖前面闡述的內(nèi)容便已足夠了凌唬,不過對于死鎖這塊的內(nèi)容,無論在面試過程中漏麦,還是在實際開發(fā)場景下都比較常見客税,所以再單獨拿出來分析一個段落。
在前面提及過死鎖的概念:死鎖是指兩個或兩個以上的線程(或進程)在運行過程中撕贞,因為資源競爭而造成相互等待的現(xiàn)象更耻。而此時可以進一步拆解這句話,可以得出死鎖如下結(jié)論:
- ①參與的執(zhí)行實體(線程或進程)必須要為兩個或兩個以上捏膨。
- ②參與的執(zhí)行實體都需要等待資源方可執(zhí)行秧均。
- ③參與的執(zhí)行實體都均已占據(jù)對方等待的資源。
- ④死鎖情況下會占用大量資源而不工作号涯,如果發(fā)生大面積的死鎖情況可能會導致程序或系統(tǒng)崩潰目胡。
3.1、死鎖產(chǎn)生的四個必要條件
而誘發(fā)死鎖的根本從前面的分析中可以得知:是因為競爭資源引起的链快。當然誉己,產(chǎn)生死鎖存在四個必要條件,如下:
- ①互斥條件:指分配到的資源具備排他使用性域蜗,即在一段時間內(nèi)某資源只能由一個執(zhí)行實體使用巨双。如果此時還有其它執(zhí)行實體請求資源,則請求者只能等待霉祸,直至占有資源的執(zhí)行實體使用完成后釋放才行筑累。
- ②不可剝奪條件:指執(zhí)行實體已持有的資源,在未使用完之前丝蹭,不能被剝奪慢宗,只能在使用完時由自己釋放。
- ③請求與保持條件:指運行過程中半夷,執(zhí)行實體已經(jīng)獲取了至少一個資源婆廊,但又提出了新的資源請求,而該資源已被其它實體占用巫橄,此時當前請求資源的實體阻塞淘邻,但在阻塞時卻不釋放自己已獲得的其它資源,一直保持著對其他資源的占用湘换。
-
④環(huán)狀等待條件:指在發(fā)生死鎖時宾舅,必然存在一個執(zhí)行實體的資源環(huán)形鏈统阿。比如:線程
T1
等待T2
占用的一個資源,線程T2
在等待線程T3
占用的一個資源筹我,而線程T3
則在等待T1
占用的一個資源扶平,最終形成了一個環(huán)狀的資源等待鏈。
以上是死鎖發(fā)生的四個必要條件蔬蕊,只要系統(tǒng)或程序內(nèi)發(fā)生死鎖情況结澄,那么這四個條件必然成立,只要上述中任意一條不符合岸夯,那么就不會發(fā)生死鎖麻献。
3.2、系統(tǒng)資源的分類
操作系統(tǒng)以及硬件平臺上存在各種各樣不同的資源猜扮,而資源的種類大體可以分為永久性資源勉吻、臨時性資源、可搶占式資源以及不可搶占式資源旅赢。
3.2.1齿桃、永久性資源
永久性資源也被稱為可重復性資源,即代表著一個資源可以被執(zhí)行實體(線程/進程)重復性使用煮盼,它們不會因為執(zhí)行實體的生命周期改變而發(fā)生變化短纵。比如所有的硬件資源就是典型的永久性資源,這些資源的數(shù)量是固定的孕似,執(zhí)行實體在運行時即不能創(chuàng)建踩娘,也不能銷毀,要使用這些資源時必須要按照請求資源喉祭、使用資源养渴、釋放資源這樣的順序操作。
3.2.2泛烙、臨時性資源
臨時性資源也被稱為消耗性資源理卑,這些資源是由執(zhí)行實體在運行過程中動態(tài)的創(chuàng)建和銷毀的,如硬件中斷信號蔽氨、緩沖區(qū)內(nèi)的消息藐唠、隊列中的任務(wù)等,這些都屬于臨時性資源鹉究,通常是由一個執(zhí)行實體創(chuàng)建出來之后宇立,被另外的執(zhí)行實體處理后銷毀。比如典型的一些消息中間件的使用自赔,也就是生產(chǎn)者-消費者模型妈嘹。
3.2.3、可搶占式資源
可搶占式資源也被稱為可剝奪性資源绍妨,是指一個執(zhí)行實體在獲取到某個資源之后润脸,該資源是有可能被其他實體或系統(tǒng)剝奪走的柬脸。可剝奪性資源在程序中也比較常見毙驯,如:
- 進程級別:CPU倒堕、主內(nèi)存等資源都屬于可剝奪性資源,系統(tǒng)將這些資源分配給一個進程之后爆价,系統(tǒng)是可以將這些資源剝奪后轉(zhuǎn)交給其他進程使用的垦巴。
- 線程級別:比如Java中的
ForkJoin
框架中的任務(wù),分配給一個線程的任務(wù)是有可能被其他線程竊取的允坚。
可剝奪性資源還有很多魂那,諸如上述過程中的一些類似的資源都可以被稱為可剝奪性資源。
3.2.4稠项、不可搶占式資源
同樣,不可搶占式資源也被稱為不可剝奪性資源鲜结,不可剝奪性是指把一個執(zhí)行實體獲取到資源之后展运,系統(tǒng)或程序不能強行收回,只能在實體使用完后自行釋放精刷。如:
- 進程級別:磁帶機拗胜、打印機等資源,分配給進程之后只能由進程使用完后自行釋放怒允。
- 線程級別:鎖資源就是典型的線程級別的不可剝奪性資源埂软,當一條線程獲取到鎖資源后,其他線程不能剝奪該資源纫事,只能由獲取到鎖的線程自行釋放勘畔。
3.2.5、資源引發(fā)的死鎖問題
前面曾提到過一句丽惶,死鎖情況的發(fā)生必然是因為資源問題引起的炫七,而在上述資源中,競爭臨時性資源和不可剝奪性資源都可能引起死鎖發(fā)生钾唬,也包括如果資源請求順序不當也會誘發(fā)死鎖問題万哪,如兩條并發(fā)線程同時執(zhí)行,T1
持有資源M1
抡秆,線程T2
持有M2
奕巍,而T2
又在請求M1
,T1
又在請求M2
儒士,兩者都會因為所需資源被占用而阻塞的止,最終造成死鎖。
當然乍桂,也并非只有資源搶占會導致死鎖出現(xiàn)冲杀,有時候沒有發(fā)生資源搶占效床,就單純的資源等待也會造成死鎖場面,如:服務(wù)A
在等待服務(wù)B
的信號权谁,而服務(wù)B
恰巧也在等待服務(wù)A
的信號剩檀,結(jié)果也會導致雙方之間無法繼續(xù)向前推進執(zhí)行。不過從這里可以看出:A和B不是因為競爭同一資源旺芽,而是在等待對方的資源導致死鎖沪猴。
對于這個例子有人可能會疑惑,這不是活鎖情況嗎采章?
答案并非如此运嗜,因為活鎖情況講究的是一個“活”字,而上述這個案例悯舟,雙方之間都是處于相互等待的“死”態(tài)担租。
3.3、死鎖案例分析
上述對于死鎖的理論進行了大概闡述抵怎,下來來個簡單例子感受一下死鎖情景:
public class DeadLock implements Runnable {
public boolean flag = true;
// 靜態(tài)成員屬于class奋救,是所有實例對象可共享的
private static Object o1 = new Object(), o2 = new Object();
public DeadLock(boolean flag){
this.flag = flag;
}
@Override
public void run() {
if (flag) {
synchronized (o1) {
System.out.println("線程:" + Thread.currentThread()
.getName() + "持有o1....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("線程:" + Thread.currentThread()
.getName() + "等待o2....");
synchronized (o2) {
System.out.println("true");
}
}
}
if (!flag) {
synchronized (o2) {
System.out.println("線程:" + Thread.currentThread()
.getName() + "持有o2....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("線程:" + Thread.currentThread()
.getName() + "等待o1....");
synchronized (o1) {
System.out.println("false");
}
}
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new DeadLock(true),"T1");
Thread t2 = new Thread(new DeadLock(false),"T2");
// 因為線程調(diào)度是按時間片切換決定的,
// 所以先執(zhí)行哪個線程是不確定的反惕,也就代表著:
// 后面的t1.run()可能在t2.run()之前運行
t1.start();
t2.start();
}
}
// 運行結(jié)果如下:
/*
線程:T1持有o1....
線程:T2持有o2....
線程:T2等待o1....
線程:T1等待o2....
*/
如上是一個簡單的死鎖案例尝艘,在該代碼中:
- 當
flag==true
時,先獲取對象o1
的鎖姿染,獲取成功之后休眠500ms
背亥,而發(fā)生這個動作的必然是t1
,因為在main
方法中悬赏,我們將t1
任務(wù)的flag
顯式的置為了true
狡汉。 - 而當
t1
線程睡眠時,t2
線程啟動舷嗡,此時t2
任務(wù)的flag=false
轴猎,所以會去獲取對象o2
的鎖資源,然后獲取成功之后休眠500ms
进萄。 - 此時
t1
線程睡眠時間結(jié)束捻脖,t1
線程被喚醒后會繼續(xù)往下執(zhí)行,然后需要獲取o2
對象的鎖資源中鼠,但此時o2
已經(jīng)被t2
持有可婶,此時t1
會阻塞等待。 - 而此刻
t2
線程也從睡眠中被喚醒會繼續(xù)往下執(zhí)行援雇,然后需要獲取o1
對象的鎖資源矛渴,但此時o1
已經(jīng)被t1
持有,此時t2
會阻塞等待。 - 最終導致線程
t1具温、t2
相互等待對象的資源蚕涤,都需要獲取對方持有的資源之后才可繼續(xù)往下執(zhí)行蹋笼,最終導致死鎖產(chǎn)生嚎幸。
3.4、死鎖處理
對于死鎖的情況一旦出現(xiàn)都是比較麻煩的柏肪,但這也是設(shè)計并發(fā)程序避免不了的問題达皿,當你想要通過多線程編程技術(shù)提升你的程序處理速度和整體吞吐量時天吓,對于死鎖的問題也是必須要考慮的一項,而處理死鎖問題總的歸納來說可以從如下四個角度出發(fā):
- ①預防死鎖:通過代碼設(shè)計或更改配置來破壞掉死鎖產(chǎn)生的四個條件其中之一峦椰,以此達到預防死鎖的目的龄寞。
- ②避免死鎖:在資源分配的過程中,盡量保證資源請求的順序性汤功,防止推進順序不當引起死鎖問題產(chǎn)生物邑。
- ③檢測死鎖:允許系統(tǒng)在運行過程中發(fā)生死鎖情況,但可設(shè)置檢測機制及時檢測死鎖的發(fā)生滔金,并采取適當措施加以清除拂封。
- ④解除死鎖:當檢測出死鎖后,便采取適當措施將進程從死鎖狀態(tài)中解脫出來鹦蠕。
3.4.1、預防死鎖
前面提過在抛,預防死鎖的手段是通過破壞死鎖產(chǎn)生的四個必要條件中的一個或多個钟病,以此達到預防死鎖的目的。
破壞“互斥”條件
在程序中將所有“互斥”的邏輯移除刚梭,如果一個資源不能被獨占使用時肠阱,那么死鎖情況必然不會發(fā)生。但一般來說在所列的四個條件中朴读,“互斥”條件是不能破壞的屹徘,因為程序設(shè)計中必須要考慮線程安全問題,所以“互斥”條件是必需的衅金。因此噪伊,在死鎖預防里主要是破壞其他幾個必要條件,不會去破壞“互斥”條件氮唯。
破壞“不可剝奪”條件
破壞“不可剝奪性”條件的含義是指取消資源獨占性鉴吹,一個執(zhí)行實體獲取到的資源可以被別的實體或系統(tǒng)強制剝奪,在程序中可以這樣設(shè)計:
- ①如果占用資源的實體下一步資源請求失敗惩琉,那么則釋放掉之前獲取到的所有資源豆励,后續(xù)再重新請求這些資源和另外的資源(和分布式事務(wù)的概念有些類似)。
- ②如果一個實體需要請求的資源已經(jīng)被另一個實體持有瞒渠,那么則由程序或系統(tǒng)將該資源釋放良蒸,然后讓給當前實體獲取執(zhí)行技扼。這種方式在Java中也有實現(xiàn),就是設(shè)置線程的優(yōu)先級嫩痰,優(yōu)先級高的線程是可以搶占優(yōu)先級低的資源先執(zhí)行的剿吻。
破壞“請求與保持”條件
破壞“請求與保持”條件的意思是:系統(tǒng)或程序中不允許出現(xiàn)一個執(zhí)行實體在獲取到資源的情況下再去申請其他資源,主要有兩種方案:
- ①一次性分配方案:對于執(zhí)行實體所需的資源始赎,系統(tǒng)或程序要么一次性全部給它和橙,要么什么都不給。
- ②要求每個執(zhí)行實體提出新的資源申請前造垛,釋放它所占有的資源魔招。
但總歸來說,這種情況也比較難滿足五辽,因為程序中難免會有些情況下要占用多個資源后才能一起操作办斑,就比如最簡單的數(shù)據(jù)庫寫入操作,在Java程序這邊需要先獲取到鎖資源后才能通過連接對象進行操作杆逗,但獲取到的連接對象在往DB表中寫入數(shù)據(jù)的時候還需要再和DB中其他連接一起競爭DB那邊的鎖資源方可真正寫表乡翅。
破壞“環(huán)狀等待鏈”條件
破壞“環(huán)狀等待鏈”條件實際上就是要求控制資源的請求順序性,防止請求順序不當導致的環(huán)狀等待鏈閉環(huán)出現(xiàn)罪郊。
這個點主要是在編碼的時候要注意蠕蚜,對于一些鎖資源的獲取、連接池悔橄、RPC調(diào)用靶累、MQ消費等邏輯,盡量保證資源請求順序合理癣疟,避免由于順序性不當引起死鎖問題出現(xiàn)挣柬。
預防死鎖小結(jié)
因為預防死鎖的策略需要實現(xiàn)會太過苛刻,所以如果真正的在程序設(shè)計時考慮這些方面睛挚,可能會導致系統(tǒng)資源利用率下降邪蛔,也可能會導致系統(tǒng)/程序整體吞吐量降低。
總的來說扎狱,預防死鎖只需要在系統(tǒng)設(shè)計侧到、進程調(diào)度、線程調(diào)度委乌、業(yè)務(wù)編碼等方面刻意關(guān)注一下:如何讓死鎖的四個必要條件不成立即可床牧。
3.4.2、避免死鎖
避免死鎖是指系統(tǒng)或程序?qū)τ诿總€能滿足的執(zhí)行實體的資源請求進行動態(tài)檢查遭贸,并且根據(jù)檢查結(jié)果決定是否分配資源戈咳,如果分配后系統(tǒng)可能發(fā)生死鎖,則不予分配,反之則給予資源分配著蛙,這是一種保證系統(tǒng)不進入死鎖狀態(tài)的動態(tài)策略删铃。
避免死鎖的常用算法
- ①有序資源分配法:這種方式大多數(shù)被操作系統(tǒng)應(yīng)用于進程資源分配。假設(shè)此時有兩個進程
P1踏堡、P2
猎唁,進程P1
需要請求資源順序為R1、R2
顷蟆,而進程P2
使用資源的順序則為R2诫隅、R1
。如果這個情況下兩個進程并發(fā)執(zhí)行帐偎,采用動態(tài)分配法的情況下是有一定幾率發(fā)生死鎖的逐纬,所以可以采用有序資源分配法,把資源分配的順序改為如下情況削樊,從而做到破壞環(huán)路條件豁生,避免死鎖發(fā)生。- P1:R1漫贞,R2
- P2:R1甸箱,R2
- ②銀行家算法:銀行家算法顧名思義是來源于銀行的借貸業(yè)務(wù),有限的本金要應(yīng)多個客戶的借貸周轉(zhuǎn)迅脐,為了防止銀行家資金無法周轉(zhuǎn)而倒閉芍殖,對每一筆貸款,必須考察其借貸者是否能按期歸還谴蔑。在操作系統(tǒng)中研究資源分配策略時也有類似問題围小,系統(tǒng)中有限的資源要供多個進程使用,必須保證得到的資源的進程能在有限的時間內(nèi)歸還資源树碱,以供其他進程使用資源,確保整個操作系統(tǒng)能夠正常運轉(zhuǎn)变秦。如果資源分配不得到就會發(fā)生進程之間環(huán)狀等待資源成榜,則進程都無法繼續(xù)執(zhí)行下去,最終造成死鎖現(xiàn)象蹦玫。
- OS實現(xiàn):把一個進程需要的赎婚、已占有的資源情況記錄在進程控制塊中,假定進程控制塊PCB其中“狀態(tài)”有就緒態(tài)樱溉、等待態(tài)和完成態(tài)挣输。當進程在處于等待態(tài)時,表示系統(tǒng)不能滿足該進程當前的資源申請福贞×媒溃“資源需求總量”表示進程在整個執(zhí)行過程中總共要申請的資源量。顯然,每個進程的資源需求總量不能超過系統(tǒng)擁有的資源總數(shù)完丽,通過銀行家算法進行資源分配可以避免死鎖恋技。
上述的兩種算法更多情況下是操作系統(tǒng)層面對進程級別的資源分配算法,而在程序開發(fā)中又該如何編碼才能盡量避免死鎖呢逻族?大概有如下兩種方式:
- ①順序加鎖
- ②超時加鎖
對于上述中的兩種方式從字面意思就可以理解出:前者是保證鎖資源的請求順序性蜻底,防止請求順序不當引起資源相互等待,最終造成死鎖發(fā)生聘鳞。而后者則是獲取鎖超時中斷的意思薄辅,在JDK級別的鎖,如ReetrantLock抠璃、Redisson
等站楚,都支持該方式,也就是在指定時間內(nèi)未獲取到鎖資源則放棄獲取鎖資源鸡典。
3.4.3源请、檢測死鎖
檢測死鎖這塊也分為兩個方向來談,也就是分別從進程和線程兩個角度出發(fā)彻况。進程級別來說谁尸,操作系統(tǒng)在設(shè)計的時候就考慮到了進程并行執(zhí)行的情況,所以有專門設(shè)計死鎖的檢測機制纽甘,該機制能夠檢測到死鎖發(fā)生的位置和原因良蛮,如果檢測到死鎖時會暴力破壞死鎖條件,從而使得并發(fā)進程從死鎖狀態(tài)中恢復悍赢。
而對于Java程序員而言决瞳,如果在線上程序運行中發(fā)生了死鎖又該如何排查檢測呢?我們接著來進行詳細分析左权。
Java線上排查死鎖問題實戰(zhàn)
先借用前面3.3
階段的DeadLock
死鎖案例代碼皮胡,操作如下:
D:\> javac -encoding utf-8 DeadLock.java
D:\> java DeadLock
線程:T1持有o1....
線程:T2持有o2....
線程:T2等待o1....
線程:T1等待o2....
在前面3.3
案例中,實際上T1
永遠獲取不到o1
赏迟,而T2
永遠也獲取不到o2
屡贺,所以此時發(fā)生了死鎖情況。那假設(shè)如果在線上我們并不清楚死鎖是發(fā)生在那處代碼呢锌杀?其實可以通過多種方式定位問題:
- ①通過
jps+jstack
工具排查甩栈。 - ②通過
jconsole
工具排查。 - ③通過
jvisualvm
工具排查糕再。 - PS:當然你也可以通過其他一些第三方工具排查問題量没,但前面兩種都是JDK自帶的工具。
先來看看
jps+jstack
的方式突想,此時保持原先的cmd/shell
窗口不關(guān)閉殴蹄,再新開一個窗口究抓,輸入jps
指令:
D:\> jps
19552 Jps
2892 DeadLock
jps是JDK安裝位置bin
目錄下自帶的工具,其作用是顯示當前系統(tǒng)的Java進程情況及其進程ID
饶套,可以從上述結(jié)果中看出:ID
為2892
的進程是剛剛前面產(chǎn)生死鎖的Java程序漩蟆,此時我們可以拿著這個ID
再通過jstack
工具查看該進程的dump
日志,如下:
D:\> jstack -l 2892
顯示結(jié)果如下:
可以從dump
日志中明顯看出妓蛮,jstack
工具從該進程中檢測到了一個死鎖問題怠李,是由線程名為T1、T2
的線程引起的蛤克,而死鎖問題的誘發(fā)原因可能是DeadLock.java:41捺癞、DeadLock.java:25
行代碼引起的。而到這一步之后其實就已經(jīng)確定了死鎖發(fā)生的位置构挤,我們就可以跟進代碼繼續(xù)去排查程序中的問題髓介,優(yōu)化代碼之后就可以確保死鎖不再發(fā)生。
再來看看
jconsole
的方式筋现,首先按win+r
調(diào)出運行窗口唐础,然后輸入JConsole
命令,緊接著會得到一個如下界面:
然后緊接著可以雙擊本地進程中PID為
2892
的Java程序矾飞,進入之后選擇導航欄中的線程選項一膨,如下:最后再點擊底部的“檢測死鎖”的選項即可,最終就能非常方便快捷的檢測到程序中的死鎖情況洒沦,如下:
通過
JConsole
這個工具能夠更加方便的檢測死鎖問題豹绪,并且還帶有可視化的圖形界面,相對比之前的jps+jstack
方式來說申眼,更加友好瞒津。
再來看看
jvisualvm
工具的方式,同樣的在開一個命令行窗口括尸,然后在其內(nèi)輸入:jvisualvm
巷蚪,如下:
D:\> jvisualvm
然后同樣的可以得到一個可視化的圖像界面:
然后可以在左側(cè)本地的
DeadLock
進程上右鍵→選擇“打開”,最終可以得到如下界面:從界面中的提示可以明確看出:當前Java進程中檢測到了死鎖濒翻,發(fā)生死鎖的線程為
T1钓辆、T2
,然后點擊右側(cè)的“線程Dump”按鈕肴焊,同樣可以查看具體跟蹤日志,如下:從線程
Dump
日志中可以清晰看見定位到的死鎖相關(guān)信息功戚,以及死鎖發(fā)生的位置等娶眷。
3.4.4、解除死鎖
當排查到死鎖的具體發(fā)生原因和發(fā)生位置之后啸臀,就應(yīng)立即釆取對應(yīng)的措施解除死鎖届宠,避免長時間的資源占用導致最終拖垮程序或系統(tǒng)烁落。
而一般操作系統(tǒng)處理進程級別的死鎖問題主要用三種方式:
- ①資源剝奪法。掛起某些死鎖進程豌注,并剝奪它的資源伤塌,將這些資源分配給其他的死鎖進程。但應(yīng)當合理處置被掛起的進程轧铁,防止進程長時間掛起而得不到資源每聪,一直處于資源匱乏的狀態(tài)。
- ②撤銷進程法齿风。強制撤銷部分药薯、甚至全部死鎖進程并剝奪這些進程的資源。撤銷的原則可以按進程優(yōu)先級救斑、進程重要性和撤銷進程代價的高低進行童本。
- ③進程回退法。讓一個或多個進程回退到足以避免死鎖發(fā)生的位置脸候,進程回退時自己釋放資源而不是被剝奪穷娱。要求系統(tǒng)保持進程的歷史信息,設(shè)置還原點运沦。
當然泵额,這些對于非底層開發(fā)程序員而言不必太過關(guān)注,重點我們還是放在線程級別的死鎖問題解決上面茶袒,比如經(jīng)過上一個階段之后梯刚,我們已經(jīng)成功定位死鎖發(fā)生位置又該如何處理死鎖問題呢?一般而言在Java程序中只能修改代碼后重新上線程序薪寓,因為大部分的死鎖都是由于代碼編寫不當導致的亡资,所以將代碼改善后重新部署即可。
其實在數(shù)據(jù)庫中是這樣處理死鎖問題的向叉,數(shù)據(jù)庫系統(tǒng)中考慮了檢測死鎖和從死鎖中恢復锥腻。當DB檢測到死鎖時,將會選擇一個線程(客戶端那邊的連接對象)犧牲者并放棄這個事務(wù)母谎,作為犧牲者的事務(wù)會放棄它占用的所有資源瘦黑,從而使其他事務(wù)繼續(xù)執(zhí)行,最終當其他死鎖線程執(zhí)行完畢后奇唤,再重新執(zhí)行被強制終止的事務(wù)幸斥。
而你的項目如果在短時間內(nèi)也不能重啟,那么只能寫一個與DB類似的死鎖檢測器+處理器咬扇,然后通過自定義一個類加載器將該類動態(tài)加載到JVM中(需提前設(shè)計)甲葬,然后在運行時通過你編寫的死鎖處理機制,強制性的掐斷死鎖問題懈贺。
但對于這種方式我并不太建議使用经窖,因為強制掐斷線程執(zhí)行坡垫,可能會導致業(yè)務(wù)出現(xiàn)問題,所以對于Java程序的死鎖問題解決画侣,更多的還是需要從根源:代碼上著手解決冰悠,因為只有當代碼正確了才能根治死鎖問題。
四配乱、總結(jié)
本篇重點是對于之前篇章中未提及的一些概念和問題做個補充溉卓,主要敘述了一些如今出現(xiàn)的新概念,以及對于一些并發(fā)執(zhí)行時會出現(xiàn)的其他問題進行了分析宪卿,至此《并發(fā)編程》系列大致完結(jié)的诵。