前面講到使用Account.class作為互斥鎖,來解決銀行業(yè)務(wù)里面的轉(zhuǎn)賬問題,雖然這個方法不存在并發(fā)問題,但是所有賬戶的轉(zhuǎn)賬操作都是串行的.例如賬戶A轉(zhuǎn)賬戶B,賬戶C轉(zhuǎn)帳戶D這兩個操作在現(xiàn)實世界里時可以并行的,但是在這個方案卻被串行化了,這樣的話性能太差了!
在現(xiàn)實世界里找答案.
現(xiàn)實中賬戶轉(zhuǎn)賬操作時支持并發(fā)的.而且絕對是真正的并行.
在古代賬戶的形式就是一個賬本,每個賬戶都有一個賬本.這些賬本統(tǒng)一放在文件架上.銀行柜員在給我們做轉(zhuǎn)賬時,要去文件架上把轉(zhuǎn)出賬本和轉(zhuǎn)入賬本都拿到手,然后做轉(zhuǎn)賬.這個柜員拿賬本可能遇到三種情況:
1.文件架上恰好有轉(zhuǎn)出賬本和轉(zhuǎn)入賬本,那就同時拿走.
2.如果只有兩本中的其中一本,那就先拿一本,同時等其他柜員把另外一本送回來.
3,兩本都沒有,柜員等著兩本都被送回來.
上面的例子在編程世界怎么實現(xiàn)?其實用兩把鎖就可以了.轉(zhuǎn)出賬本一把,轉(zhuǎn)入賬本另一把.在transfer()方法內(nèi)部,首先嘗試鎖定轉(zhuǎn)出賬戶this(先把轉(zhuǎn)出賬本拿到手),然后嘗試鎖定轉(zhuǎn)入賬戶target(再把轉(zhuǎn)入賬本拿到手),只有當(dāng)兩者都成功時,才執(zhí)行轉(zhuǎn)賬操作.如下圖:
這樣賬戶A轉(zhuǎn)帳戶B和賬戶C轉(zhuǎn)帳戶D兩個轉(zhuǎn)賬操作就可以并行了.
沒有免費(fèi)的午餐!
上面看上去很完美,相對于用Account.class作為互斥鎖,鎖定的范圍太大,而鎖定兩個賬戶范圍就小多了,這樣的鎖叫細(xì)粒度鎖.使用細(xì)粒度鎖可以提高并行度哥蔚,是性能優(yōu)化的一個重要手段.
使用細(xì)粒度鎖是有代價的,這個代價就是可能會導(dǎo)致死鎖蚣旱。
特殊場景:如果有客戶找柜員張三做個轉(zhuǎn)賬業(yè)務(wù):賬戶 A 轉(zhuǎn)賬戶 B 100 元,此時另一個客戶找柜員李四也做個轉(zhuǎn)賬業(yè)務(wù):賬戶 B 轉(zhuǎn)賬戶 A100 元岁疼,于是張三和李四同時都去文件架上拿賬本赤炒,這時候有可能湊巧張三拿到了賬本 A蟹略,李四拿到了賬本 B。張三拿到賬本 A 后就等著賬本 B(賬本 B 已經(jīng)被李四拿走)哈打,而李四拿到賬本 B后就等著賬本 A(賬本 A 已經(jīng)被張三拿走)塔逃,他們要等多久呢?他們會永遠(yuǎn)等待下去…因為張三不會把賬本 A 送回去料仗,李四也不會把賬本 B 送回去湾盗。我們姑且稱為死等吧
死鎖:一組互相競爭資源的線程因互相等待,導(dǎo)致“永久”阻塞的現(xiàn)象立轧。
我們假設(shè)線程 T1 執(zhí)行賬戶 A 轉(zhuǎn)賬戶 B 的操作格粪,賬戶A.transfer(賬戶 B);同時線程 T2 執(zhí)行賬戶 B 轉(zhuǎn)賬戶 A 的操作氛改,賬戶 B.transfer(賬戶 A)帐萎。當(dāng) T1和 T2 同時執(zhí)行完①處的代碼時,T1 獲得了賬戶 A 的鎖(對于 T1胜卤,this 是賬戶 A)疆导,而 T2 獲得了賬戶 B 的鎖(對于 T2,this 是賬戶 B)葛躏。之后 T1 和 T2 在執(zhí)行②處的代碼時澈段,T1 試圖獲取賬戶 B 的鎖時,發(fā)現(xiàn)賬戶 B 已經(jīng)被鎖定(被 T2 鎖定)紫新,所以 T1 開始等待均蜜;T2 則試圖獲取賬戶 A 的鎖時李剖,發(fā)現(xiàn)賬戶 A 已經(jīng)被鎖定(被 T1 鎖定)芒率,所以 T2 也開始等待。于是 T1 和 T2 會無期限地等待下去篙顺,也就是我們所說的死鎖了偶芍。
如何預(yù)防死鎖
并發(fā)程序一旦死鎖,一般沒有特別好的方法德玫,很多時候我們只能重啟應(yīng)用匪蟀。因此,解決死鎖問題最好的辦法還是規(guī)避死鎖宰僧。
只有以下四個條件都發(fā)生時才會出現(xiàn)死鎖:
1. 互斥材彪,共享資源 X 和 Y 只能被一個線程占用;
2. 占有且等待,線程 T1 已經(jīng)取得共享資源 X段化,在等待共享資源 Y 的時候嘁捷,不釋放共享資源 X;
3. 不可搶占显熏,其他線程不能強(qiáng)行搶占線程 T1 占有的資源雄嚣;
4. 循環(huán)等待,線程 T1 等待線程 T2 占有的資源喘蟆,線程 T2 等待線程 T1 占有的資源缓升,就是循環(huán)等待
反過來分析,也就是說只要我們破壞其中一個蕴轨,就可以成功避免死鎖的發(fā)生
1.互斥這個條件我們沒有辦法破壞港谊,因為我們用鎖為的就是互斥。
2. 對于“占用且等待”這個條件橙弱,我們可以一次性申請所有的資源封锉,這樣就不存在等待了。
3.對于“不可搶占”這個條件膘螟,占用部分資源的線程進(jìn)一步申請其他資源時成福,如果申請不到,可以主動釋放它占有的資源荆残,這樣不可搶占這個條件就破壞掉了奴艾。
4.對于“循環(huán)等待”這個條件,可以靠按序申請資源來預(yù)防。所謂按序申請,是指資源是有線性順序的兼丰,申請的時候可以先申請資源序號小的尖阔,再申請資源序號大的,這樣線性化后自然就不存在循環(huán)了庶诡。
1. 破壞占用且等待條件
要破壞這個條件,可以一次性申請所有資源〈苏睿可以增加一個賬本管理員,然后只允許賬本管理員從文件架上拿賬本遮婶,也就是說柜員不能直接在文件架上拿賬本蝗碎,必須通過賬本管理員才能拿到想要的賬本。例如旗扑,張三同時申請賬本 A 和 B蹦骑,賬本管理員如果發(fā)現(xiàn)文件架上只有賬本 A,這個時候賬本管理員是不會把賬本 A 拿下來給張三的臀防,只有賬本 A 和 B 都在的時候才會給張三眠菇。這樣就保證了“一次性申請所有資源”边败。
對應(yīng)到編程領(lǐng)域,“同時申請”這個操作是一個臨界區(qū)捎废,我們也需要一個角色(Java 里面的類)來管理這個臨界區(qū)放闺,我們就把這個角色定為 Allocator。它有兩個重要功能缕坎,分別是:同時申請資源 apply() 和同時釋放資源 free()怖侦。賬戶 Account 類里面持有一個 Allocator 的單例(必須是單例,只能由一個人來分配資源)谜叹。當(dāng)賬戶 Account 在執(zhí)行轉(zhuǎn)賬操作的時候匾寝,首先向 Allocator同時申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源,成功后再鎖定這兩個資源荷腊;當(dāng)轉(zhuǎn)賬操作執(zhí)行完艳悔,釋放鎖之后,我們需通知 Allocator 同時釋放轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源女仰。具體的代碼實現(xiàn)如下
2. 破壞不可搶占條件
破壞不可搶占條件看上去很簡單猜年,核心是要能夠主動釋放它占有的資源,這一點 synchronized是做不到的疾忍。原因是 synchronized 申請資源的時候乔外,如果申請不到,線程直接進(jìn)入阻塞狀態(tài)了一罩,而線程進(jìn)入阻塞狀態(tài)杨幼,啥都干不了,也釋放不了線程已經(jīng)占有的資源聂渊。
Java 在語言層次確實沒有解決這個問題差购,不過在 SDK 層面還是解決了的,java.util.concurrent 這個包下面提供的 Lock 是可以輕松解決這個問題的汉嗽。
3. 破壞循環(huán)等待條件
破壞這個條件,需要對資源進(jìn)行排序饼暑,然后按序申請資源稳析。這個實現(xiàn)非常簡單,我們假設(shè)每個賬戶都有不同的屬性 id撵孤,這個 id 可以作為排序字段迈着,申請的時候,我們可以按照從小到大的順序來申請邪码。比如下面代碼中,①~⑥處的代碼對轉(zhuǎn)出賬戶(this)和轉(zhuǎn)入賬戶(target)排序咬清,然后按照序號從小到大的順序鎖定賬戶闭专。這樣就不存在“循環(huán)”等待了奴潘。
總結(jié)
利用現(xiàn)實世界的模型來構(gòu)思解決方案
了用細(xì)粒度鎖來鎖定多個資源時,要注意死鎖的問題影钉。
上面轉(zhuǎn)賬那個例子画髓,我們破壞占用且等待條件的成本就比破壞循環(huán)等待條件的成本高,破壞占用且等待條件平委,我們也是鎖了所有的賬戶奈虾,而且還是用了死循環(huán) while(!actr.apply(this, target));方法,不過好在 apply() 這個方法基本不耗時廉赔。在轉(zhuǎn)賬這個例子中肉微,破壞循環(huán)等待條件就是成本最低的一個方案。所以我們在選擇具體方案的時候蜡塌,還需要評估一下操作成本碉纳,從中選擇一個成本最低的方案