假笨说-类初始化死锁导致线程被打爆!打爆!爆!
概述
之前寫過關于類加載死鎖的文章,消失的死鎖,說的是類加載過程中發生的死鎖,我們從線程dump里完全看不出死鎖的跡象,但是確實發生了死鎖,沒了解的建議看看我前面的那篇文章
本文要說的是另外一個問題,最近在生產環境上碰到,是類初始化導致的死鎖,恩,你沒看錯,確實是類初始化導致的死鎖,我之前寫過一篇文章,不可逆的類初始化過程,這篇文章可以助你了解類的初始化過程,另外也寫過一篇JDK的sql設計不合理導致的驅動類初始化死鎖問題,也是關于初始化死鎖的,原因其實差不多,不過本文將這個問題描述的場景更加通用化了
我們線上的現象是發現非常多的線程都卡死在同一個地方,也不是在做類加載,如果是死循環,那cpu肯定上去了,但是cpu并沒有上去,因此比較詭異
PS:有人經常給我公眾號發消息咨詢問題,可消息最多只能保存最近5天的,而且只能回復最近2天的,有時候忘記回了想起要回的時候就不能再回復了,如果比較緊急,問題可以發到我郵箱里,我會抽時間看這些問題并回答,不過無法保證所有的問題都會回答,因為問的人確實有點多,精力也有限。。。
Demo
嚴格意義上說,這個Demo里提到的情況是其中一個簡單的場景,和我們線上碰到的場景會有點出入,比這個會更復雜點,我后面也會提到那個場景
為了讓問題能重現,我選擇了一個最簡單的辦法,就是debug,一般情況下,并發導致的問題,通過debug都可以模擬出來,并發無非就是控制代碼執行的先后順序,debug顯然可以做到這一點
我們上面定義了A,B兩個類,他們相互依賴,并且都有一個靜態塊,在靜態塊里相互調用對方的某個靜態方法,我們的測試類ABTest就是用兩個線程分別取調用兩個類的靜態方法,那我們在A和B兩個類的靜態塊里調用對方靜態方法之前設置一個斷點,比如說都在System.out.println()那里設置斷點,當兩個線程都停到斷點處的時候,我們再過掉兩個斷點,你會發現一個奇怪的現象,這個進程并沒有退出,也就是那兩個線程都沒有執行完,你看到堆棧如下:
這里你看下Thread狀態是RUNNABLE,但是又是卡在Object.wait()處的,這里確實只能說是JVM里的一個bug吧,狀態不一致,我之前在InfoQ上發過一篇文章JVM Bug:多個線程持有一把鎖,解釋了這個狀態不一致的問題。
Object.wait是哪里調的
從線程dump的線程棧來看完全看不出是調用了Object.wait,但是從線程輸出來看確實有Object.wait,為了找出哪里調用了它,我們可以通過jstack -m <pid>來看,看到輸出之后,你會覺得不可思議,確實有wait的邏輯
那這個邏輯從名字上來不難猜到是正在做類的初始化,那我們先來了解下類的初始化過程
類的初始化過程
當我們第一次主動調用某個類的靜態方法就會觸發這個類的初始化,當然還有其他的觸發情況,類的初始化說白了就是在類加載起來之后,在某個合適的時機執行這個類的clinit方法,clinit方法是什么?比如我們在類里聲明一段static代碼塊,或者有靜態屬性,javac會將這些代碼都統一放到一個叫做clinit的方法里,在類初始化的時候來執行這個方法,但是JVM必須要保證這個方法只能被執行一次,如果有其他線程并發調用觸發了這個類的多次初始化,那只能讓一個線程真正執行clinit方法,其他線程都必須等待,當clinit方法執行完之后,然后再喚醒其他等待這里的線程繼續操作,當然不會再讓它們有機會再執行clinit方法,因為每個類都有一個狀態,這個狀態可以保證這一點
當有個線程正在執行這個類的clinit方法的時候,就會設置這個類的狀態為being_initialized,當正常執行完之后就馬上設置為fully_initialized,然后才喚醒其他也在等著對其做初始化的線程繼續往下走,在繼續走下去之前,會先判斷這個類的狀態,如果已經是fully_initialized了說明有線程已經執行完了clinit方法,因此不會再執行clinit方法了
當然如果執行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還是可以的
上述代碼不一定能重現,不過我可以跟大家解釋下可能死鎖的情況,代碼里我們主要定義了
-
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特性也要謹慎,因為這會直接觸發接口的初始化導致更隱蔽的循環依賴。
總結
以上是生活随笔為你收集整理的假笨说-类初始化死锁导致线程被打爆!打爆!爆!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: linux 小白启航之路-搭建linux
- 下一篇: node.js