閱讀484 返回首頁    go 阿裏雲 go 技術社區[雲棲]


假笨說-類初始化死鎖導致線程被打爆!打爆!爆!

640?wx_fmt=jpeg&tp=webp&wxfrom=5

概述

之前寫過關於類加載死鎖的文章,消失的死鎖,說的是類加載過程中發生的死鎖,我們從線程dump裏完全看不出死鎖的跡象,但是確實發生了死鎖,沒了解的建議看看我前麵的那篇文章

本文要說的是另外一個問題,最近在生產環境上碰到,是類初始化導致的死鎖,恩,你沒看錯,確實是類初始化導致的死鎖,我之前寫過一篇文章,不可逆的類初始化過程,這篇文章可以助你了解類的初始化過程,另外也寫過一篇JDK的sql設計不合理導致的驅動類初始化死鎖問題,也是關於初始化死鎖的,原因其實差不多,不過本文將這個問題描述的場景更加通用化了

我們線上的現象是發現非常多的線程都卡死在同一個地方,也不是在做類加載,如果是死循環,那cpu肯定上去了,但是cpu並沒有上去,因此比較詭異

PS:有人經常給我公眾號發消息谘詢問題,可消息最多隻能保存最近5天的,而且隻能回複最近2天的,有時候忘記回了想起要回的時候就不能再回複了,如果比較緊急,問題可以發到我郵箱裏,我會抽時間看這些問題並回答,不過無法保證所有的問題都會回答,因為問的人確實有點多,精力也有限。。。

Demo

嚴格意義上說,這個Demo裏提到的情況是其中一個簡單的場景,和我們線上碰到的場景會有點出入,比這個會更複雜點,我後麵也會提到那個場景

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=

為了讓問題能重現,我選擇了一個最簡單的辦法,就是debug,一般情況下,並發導致的問題,通過debug都可以模擬出來,並發無非就是控製代碼執行的先後順序,debug顯然可以做到這一點

我們上麵定義了A,B兩個類,他們相互依賴,並且都有一個靜態塊,在靜態塊裏相互調用對方的某個靜態方法,我們的測試類ABTest就是用兩個線程分別取調用兩個類的靜態方法,那我們在A和B兩個類的靜態塊裏調用對方靜態方法之前設置一個斷點,比如說都在System.out.println()那裏設置斷點,當兩個線程都停到斷點處的時候,我們再過掉兩個斷點,你會發現一個奇怪的現象,這個進程並沒有退出,也就是那兩個線程都沒有執行完,你看到堆棧如下:

hWAAAAAElFTkSuQmCC這裏你看下Thread狀態是RUNNABLE,但是又是卡在Object.wait()處的,這裏確實隻能說是JVM裏的一個bug吧,狀態不一致,我之前在InfoQ上發過一篇文章JVM Bug:多個線程持有一把鎖,解釋了這個狀態不一致的問題。

Object.wait是哪裏調的

從線程dump的線程棧來看完全看不出是調用了Object.wait,但是從線程輸出來看確實有Object.wait,為了找出哪裏調用了它,我們可以通過jstack -m <pid>來看,看到輸出之後,你會覺得不可思議,確實有wait的邏輯

hWAAAAAElFTkSuQmCC那這個邏輯從名字上來不難猜到是正在做類的初始化,那我們先來了解下類的初始化過程

類的初始化過程

當我們第一次主動調用某個類的靜態方法就會觸發這個類的初始化,當然還有其他的觸發情況,類的初始化說白了就是在類加載起來之後,在某個合適的時機執行這個類的clinit方法,clinit方法是什麼?比如我們在類裏聲明一段static代碼塊,或者有靜態屬性,javac會將這些代碼都統一放到一個叫做clinit的方法裏,在類初始化的時候來執行這個方法,但是JVM必須要保證這個方法隻能被執行一次,如果有其他線程並發調用觸發了這個類的多次初始化,那隻能讓一個線程真正執行clinit方法,其他線程都必須等待,當clinit方法執行完之後,然後再喚醒其他等待這裏的線程繼續操作,當然不會再讓它們有機會再執行clinit方法,因為每個類都有一個狀態,這個狀態可以保證這一點

hWAAAAAElFTkSuQmCC當有個線程正在執行這個類的clinit方法的時候,就會設置這個類的狀態為being_initialized,當正常執行完之後就馬上設置為fully_initialized,然後才喚醒其他也在等著對其做初始化的線程繼續往下走,在繼續走下去之前,會先判斷這個類的狀態,如果已經是fully_initialized了說明有線程已經執行完了clinit方法,因此不會再執行clinit方法了

hWAAAAAElFTkSuQmCC當然如果執行clinit失敗了,那我之前那篇不可逆的類初始化過程文章就著重講了這種情況,可以去看看。

看到這裏是否能解釋了我們線上為什麼會有那麼多線程會卡在某一個地方了?因為這個類的狀態是being_initialized,所以隻能等啦

Demo現象解釋

我們Demo裏的那兩個線程,從dump來看確實是死鎖了,那這個場景當時是怎麼發生的呢?線程1首先執行B.test(),於是會對B類做初始化,設置B的類狀態為being_initialized,接著去執行B的clinit方法,但是在clinit方法裏要去調用A.test方法,理論上此時會對A做初始化並調用其test方法,但是就在設置完B的類狀態之後,執行其clinit裏的A.test方法之前,線程2卻執行了A.test方法,此時線程2會優先負責對A的初始化工作,即設置A類的狀態為being_initialized,然後再去執行A的clinit方法,此時線程1發現A的類狀態是being_initialized了,那線程1就認為有線程對A類正在做初始化,於是就等待了,而線程2同樣發現B的類狀態也是being_initialized,於是也開始等待,這樣就形成了互等的情況,造成了類死鎖的現象。

更隱蔽的初始化死鎖現象

這裏提到的場景其實是我們線上的場景,這個情況不是很好模擬,比較難控製,當然debug jvm還是可以的

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=

上述代碼不一定能重現,不過我可以跟大家解釋下可能死鎖的情況,代碼裏我們主要定義了

  • Iterator接口:這個接口裏有個static屬性,static方法,還有個default方法,這意味著這個Iterator接口有個clinit方法,裏麵主要是對這個static屬性賦值

  • AbstractIterator抽象類:沒啥東西,就是實現Iterator接口罷了

  • Test測試類:起了兩個線程,分別new了一個AbstractIterator匿名子類實例以及調用Iterator的靜態方法

ok,到此我要描述一個特殊的場景了,線程1執行會創建一個AbstractIterator匿名子類實例,此時會觸發AbstractIterator的初始化,同時因為其實現了Iterator接口,而Iterator接口含有defalut方法,因此這個類會被標記是一個含有default方法的類,於是在設置完AbstractIterator的類狀態為being_initialized之後,會遞歸遍曆其父接口,如果某個接口有default方法,比如Iterator,那就先觸發Iterator類的初始化動作,但是在觸發這個動作之前,線程2執行Iterator.empty靜態方法了,於是會觸發對Iterator類的初始化動作,於是設置Iterator的類狀態為being_initialized,然後開始執行其clinit方法,而在clinit方法裏有創建AbstractIterator匿名子類的實例,於是就會想觸發AbstractIterator的初始化,但是AbstractIterator已經被線程1設置為being_initialized了,於是就隻能等了,同理,線程1因為要等Iterator的初始化完成而必須等待了,從而互鎖現象再次形成

相比我們最早Demo裏的場景最大的不同是我們看線程棧,隻能看到一個線程在執行clinit方法,另外一個線程並還沒有在支持clinit方法,因此這個線程卡在了初始化其父接口初始化的路上了,還沒拿到執行clinit的機會。

總結

類加載的死鎖很隱蔽了,但是類初始化的死鎖更隱蔽,所以大家要謹記在類的初始化代碼裏產生循環依賴,另外對於jdk8的defalut特性也要謹慎,因為這會直接觸發接口的初始化導致更隱蔽的循環依賴。

最後更新:2017-04-11 19:32:01

  上一篇:go 假笨說-從一起GC血案談到反射原理
  下一篇:go angularJS 獨立作用域