二十萬分之一幾率:if語句變do-while卡死問題分析
一、背景
某次灰度發布之后沒多久就收到線上ANR告警,經排查定位到是某個頁面onCreate方法執行太久導致,而火焰圖中的耗時堆棧指向了我們用于監控頁面啟動速度的一段插樁代碼,反編譯Apk之后發現本該是if語句的代碼竟變成了一個do-while語句,形成了死循環最終導致主線程卡死。
此后每構建二、三十次都會復現一次該問題,且每次的異常頁面,異常方法完全隨機。
圖片
二、問題分析
if和do-while兩個完全不相干的語句為什么出現互相轉化的情況?在jadx反編譯而來的smali代碼中不難看出,if語句對應的標簽正常情況下應該指向的是return語句,和Java源碼中if語句塊后面緊跟著return語句對應。而異常情況下標簽跑到了整個函數的開頭,故被jadx翻譯成了do-while,因此問題的關鍵就在這個label上面。
圖片
初步分析
出現此問題的這段插樁代碼出自我們的APM頁面啟動監控,原本是插樁在Activity和Fragment的onCreate等關鍵生命周期中用于耗時統計。其所在的類是由我們自定義的插樁plugin weaver所生成(基于byteX開發的一個plugin,支持插入,代理和替換等自定義的插樁行為)。
因此我們要對從該plugin所在的byteX transform開始,直到最終產出dex文件的R8 transform結束這期間的所有transform挨個分析。
由于問題偶現,且每次異常的類和方法完全隨機,說明大概率是一個多線程并發讀寫的問題,因此我們在分析過程中會需要重點關注涉及并發讀寫的邏輯。
分析R8
我們在輸入給R8的jar包中找到了這個異常類的class文件,這里可以看到jadx反編譯這個方法會失敗,看class字節碼中if語句跳轉指向的標簽L29,但是函數中并沒有定義L29指向的是哪里,并且smali視圖下查看可以看到if語句指向的標簽在整個函數體中也沒有聲明,但是前面反編譯DEX文件得到的結論是標簽有聲明但是在函數體的第一行,兩者不一致,說明R8可能在執行過程中編輯了字節碼導致異常。
(這里我們早期誤以為標簽丟失并不會導致語句變化這種程度的錯誤,因此直接將范圍鎖定在了R8,雖然后續證明了此問題與R8無關,但這段分析也為最終解開謎底提供了關鍵線索)
圖片
圖片
環境準備
R8目前已經不再單獨提供jar包,而是一同打包在AGP中,且開啟了混淆,因此想要調試/修改代碼就需要自行clone源碼,切到自己項目AGP版本對應的git tag來構建R8.jar并指定,具體操作可以參考R8的git倉庫中描述:https://r8.googlesource.com/r8
階段產物分析
目前的R8是由早期的D8融合了一系列的包體/性能優化的操作而來,dx負責將jar包整合壓縮成DEX文件,它相對于后來新增的編譯優化操作來說出現問題的概率更低,因此我們優先關注R8中涉及對字節碼進行編輯的優化功能。
由于R8在輸入了jar包之后一直在內存中進行操作,并無中間產物,因此我們需要在相關功能的開始結束點手動將內存中所有由自定義weaver plugin生成的class(有統一的后綴名)寫到文件并保存。
圖片
圖片
在多次打包復現問題之后,對階段產物進行分析并未發現異常方法的字節碼有任何變動,直到dx這一步,我們發現if語句在class字節碼中跳轉到指定標簽的行為,在dex文件的smali字節碼中被編譯成了跳轉到指定的函數偏移量。
而之前class字節碼中if語句指向的label找不到聲明的問題,在smali中表現為直接將函數偏移量設為默認值0X00,正好是函數體的第一行,和一開始反編譯apk得到的結果吻合,這也就解釋了為什么if語句最終會變成一個do-while語句。
圖片
小結
至此,我們已經知曉為什么if語句會變成毫不相干的do-while語句,同時也排除了R8的嫌疑,接下來就是要繼續回溯transform,排查為什么class字節碼中if語句指向的標簽的聲明會丟失。
分析weaver
在回溯排查完所有途徑transform的產物之后確認這個異常的方法在一開始weaver生成他時就已經是異常狀態,因此問題范圍鎖定到此plugin。
在繼續分析問題之前我們來了解下weaver的插樁原理:
weaver插樁原理
weaver基于byteX實現了一些自定義的插樁行為,這次出問題的是insert行為,也就是在目標函數開頭插入代碼的模式,其實現原理是預先寫好要插樁的代碼,在plugin執行期間會用ASM的classNode讀取這個類,并將其中的方法復制到一個新建的內部類中,這個內部類會被添加到在注解中指定的目標類中,再在目標類的生命周期函數中調用這個內部類對應的方法即可完成對生命周期的插樁。
走碼分析
雖然我們已經確認是weaver在生成內部類中方法時出現異常,但是生成的過程是從0到1,此時再去加日志打印class字節碼分析中間產物已經沒有意義,并且由于其極低的復現概率,我們也無法在本地做調試分析。
遂走碼分析,最后發現在從舊方法中復制方法提供給內部類的過程中,出現了ASM版本不一致的問題,由于整個byteX組件全局指定了ASM的版本是9,但是weaver中使用了ASM9的methodNode去clone出一個指定為ASM5的methodNode,但是很遺憾這并不是根因,在修正版本后依舊會復現問題。
圖片
圖片
我們目前已知的只有class字節碼中if語句指向的label沒有聲明,遂猜測是methodNode的指令鏈表中丟失了labelNode,但添加了相應的檢測邏輯之后并未命中,故排除labelNode丟失的可能。
關鍵線索缺失
前文中提到過推測這個問題和多線程有關,因此理論上在本地固定輸入輸出,并用大量線程并發死循環跑是能夠復現問題從而debug找到根因的,但是苦于沒有明確的檢測邏輯,即不知道這個methodNode在什么狀態下才算異常,哪怕問題復現了也無法斷點。
逆向分析異常字節碼
當務之急是找到合適的異常字節碼檢測手段,但是在常規思路都碰壁時,不妨用逆向思維試試,于是把異常的class文件直接用ASM的classNode類讀取到內存,仔細觀察異常方法和正常方法的指令鏈表中labelNode是否有什么不一樣。最終發現異常methodNode的指令鏈表中,jumpNode持有的labelNode和鏈表中的labelNode不是同一個對象(正常情況下是)。
帶著這個逆向得到的結論,再正向去驗證他,即編碼實現主動將某個方法的labelNode給替換成新的對象,再輸出為class文件,發現和前面得到的異常class完全一致,至此我們就得到了一個準確的異常檢測邏輯。
圖片
帶著前面得到的精準檢測邏輯,我們在本地寫demo開16線程并發,瞬間就復現了此問題,隨后順著這個線索走碼也找到了問題根因。
這里使用我們正常運行時使用的forkjoinpool,并發死循環執行前面提到的methodNode復制過程,模擬正常構建過程的并發度,最終得出結果是大約每執行20w次可以復現一次問題,除以我們App中相關方法的量級,正好和之前約每20次~30次構建復現一次的頻率吻合。
圖片
圖片
小結
至此我們已經定位到了引起問題的代碼,也通過多種手段驗證了根因就是多線程復制methodNode,但穩妥起見還是要刨根問底弄明白并發復制到底是怎么引起的labelNode對象被替換,防止還有更深層次的問題被掩蓋。
揭露謎底
ASM方法復制原理
methodNode復制流程圖如下:
圖片
ASM的methodNode類,通過其accept方法可以將這個方法復制給一個methodVIsitor,通常情況下只會使用一次,如果有1次以上的復制行為,就會在復制之前將指令鏈表中的labelNode中記錄跳轉地址的label對象置為null。
(clone方法理應是創建一個全新的對象,不應該和舊對象有任何共用的數據,ASM這里的處理沒問題,但是沒有適配多線程的情況)
圖片
圖片
隨后在指令復制的過程中,在遍歷到jump指令(通過持有labelNode來形成指向關系)時,會通過getLabel方法將剛剛被置null的label對象重新new出來,同時再從新的label對象中new一個新的LabelNode交給新的JumpNode。
圖片
圖片
圖片
圖片
等遍歷到對應的LabelNode時,此時getLabel拿到的是剛剛new出來的新Label,同樣的鏈路再走一遍,此時無需再new新的,并且新方法中的JumpNode持有的labelNode也和當前是一個對象。
圖片
圖片
多線程問題根因
至此我們能得知在復制methodNode的過程中,針對labelNode有多次讀寫操作。而weaver為了加快執行速度,對每一個class都單獨安排了一個task,全都提交給一個forkJoinPool來執行,并且按照前面介紹的weaver插樁原理,提前寫好的這個類里的方法,總計會復制成千上萬次,提供給每一個Activity的內部類。因此在多線程高并發執行時就會出現以下順序:
圖片
這樣最終就會出現jumpNode持有的LabelNode和指令鏈表中的LabelNode不一致的問題。
三、修復方案
ASM為了規避同一個methodNode在多次復制時,復制出來的新methodNode的labelNode全都指向同一個對象的問題,加了這個resetLabel的標簽重置邏輯,但是并沒有考慮到多線程并發執行的場景,因此該問題最終加一個類鎖即可解決,放那已上線驗證有效。
圖片
四、總結
這類多線程引起的字節碼異常問題潛伏期可達到數年之久,例如本文遇到的問題在App的頁面量級較低時幾乎不會觸發,但隨著App的業務規模增長,又或是打包機器的一次升級換代,問題就會悄然出現,而他極低的復現概率和隨機性又很容易使其被忽視。
字節碼異常問題在互聯網鮮有參考資料,倘若字節碼損壞直接崩潰還則罷了,遇到這種恰巧能被當成其他語句繼續執行的情況分析起來著實麻煩。因此開發插樁這類涉及代碼編輯操作的plugin,針對"寫”操作務必要慎重開發,重點測試下極端并發的場景。這類問題如果是發生在定時大量推送的活動頁或者熱修sdk之類穩定性兜底的功能,其危害可想而知。