R8疑難雜癥分析實戰(zhàn):外聯(lián)優(yōu)化設(shè)計缺陷引起的崩潰
一、背景
二、復(fù)現(xiàn)問題
三、問題分析
1. ApiModel外聯(lián)是什么?
2. 為什么會多生成一個new-instance指令?
3. R8是如何計算出API的版本?
4. 為什么try-catch也會導(dǎo)致該問題?
5. 為什么只升級AGP會導(dǎo)致R8功能出問題?
四、解決方案
1. 禁用ApiModel
2. 官方修復(fù)
3. 自行修復(fù)
4. 業(yè)務(wù)改造(推薦)
五、總結(jié)
一、背景
R8作為谷歌官方的編譯優(yōu)化工具,在編譯階段會對字節(jié)碼進行大規(guī)模修改,以追求包體優(yōu)化和性能提升。但是Android應(yīng)用開發(fā)者數(shù)量太過龐大,無論測試流程多么完善,終究難以避免在一些特定場景下出現(xiàn)問題。
近期我們在升級項目的AGP,遇到了一個指向系統(tǒng)SurfaceTexture類的native崩潰問題。經(jīng)反編譯分析發(fā)現(xiàn)問題最終指向了smali字節(jié)碼中多余的一行new-instance指令。
圖片
圖片
該指令創(chuàng)建了一個SurfaceTexture對象,但是并未調(diào)用其<init>方法,這意味著構(gòu)造方法沒有執(zhí)行,但是這個類重寫了finalize方法,后續(xù)被gc回收時會調(diào)用其中的nativeFinalize這個JNI方法,最終在native層執(zhí)行析構(gòu)函數(shù)時觸發(fā)了SIGNALL 11的內(nèi)存訪問錯誤.
圖片
圖片
二、復(fù)現(xiàn)問題
我們注意到多出來的new-instance指令下面緊接著的是對a0.e 類中的靜態(tài)方法 i() 的調(diào)用,其內(nèi)部實現(xiàn)就是SurfaceTexture的構(gòu)造方法。這是典型的代碼外聯(lián)操作,即一段相同的代碼在工程中多次出現(xiàn),則會被抽出來單獨作為一個靜態(tài)函數(shù),原先的調(diào)用點則替換成該函數(shù)的調(diào)用,這樣可以減小代碼體積,是常見的編碼思路。
例如:
class Activity{
void onCreate(){
// ...
String a = xx.xxx();
String b = xx.xxx();
Log.e("log",a+b);
//...
}
void onReusme(){
// ...
String a = xx.xxx();
String b = xx.xxx();
Log.e("log",a+b);
//...
}
}class Activity{
void onCreate(){
// ...
Activity$Outline.log();
//...
}
void onReusme(){
// ...
Activity$Outline.log();
//...
}
}
//外聯(lián)生成的類
class Activity$Outline{
public static void log(){
String a = xx.xxx();
String b = xx.xxx();
Log.e("log",a+b);
}
}我們根據(jù)這個生成類的類名可以知道是R8中ApiModelOutline功能生成了這個類。
圖片
我們進到R8工程中檢索下相關(guān)的關(guān)鍵字,再加上demo多次嘗試,可以確認滿足以下條件能夠必現(xiàn)該問題:
- 使用了高于當(dāng)前minSdkVersion的系統(tǒng)函數(shù)/變量(僅限系統(tǒng)類,自己寫的無效)
- 用synchronized或者try語句塊包裹了該調(diào)用,或者給該函數(shù)傳參時有任何計算行為(除了傳局部變量)。例如:
- new SurfaceTexture( getParmas() )
- new SurfaceTexture( if(enable) 1 : 2)
- new SurfaceTexture ( (boolean) enable )
三、問題分析
在確認復(fù)現(xiàn)條件之后,我們帶著幾個問題來逐個分析。
ApiModel外聯(lián)是什么?
R8中的優(yōu)化大多數(shù)跟包體優(yōu)化有關(guān),代碼外聯(lián)也是其中一種,但是外聯(lián)的前提是代碼重復(fù)的次數(shù)滿足一定閾值,但是ApiModel會對所有調(diào)用了高版本系統(tǒng)API的代碼做外聯(lián),包括只調(diào)用一次的場景。
ApiModel并非為了包體優(yōu)化,我們通過R8工程的issueTracker(https://issuetracker.google.com/issues/333477035)檢索到了相關(guān)的信息:
圖片
譯:AGP新增的ApiModel功能是為了防止在低版本設(shè)備上不可能執(zhí)行的代碼引起類驗證錯誤,從而降低App啟動耗時。
從這篇介紹ART虛擬機類驗證的文檔(https://chromium.googlesource.com/chromium/src/+/HEAD/build/android/docs/class_verification_failures.md#chromium_s-solution)就能夠理解上面這句話的含義:
ART虛擬機會在APK安裝之后立刻執(zhí)行 AOT class verification,即對dex文件中所有的類進行驗證,如果驗證成功則后續(xù)運行時將不需要再進行驗證,反之若失敗,則該class會被ART打上RetryVerificationAtRuntime的標(biāo)記,后續(xù)運行時還得重新執(zhí)行類驗證。
同時這些失敗的類也將無法被dex2oat優(yōu)化成oat格式的優(yōu)化字節(jié)碼(oat字節(jié)碼的加載和執(zhí)行速度更快)。
圖片
如果是在MainActivity,啟動任務(wù)中使用了這些高版本API,那么在低版本設(shè)備App啟動時就必須額外執(zhí)行一次類驗證(比較耗時,有的類能到8ms https://issues.chromium.org/issues/40574431),而ApiModel外聯(lián)則是相當(dāng)于將這些肯定驗證失敗的函數(shù)的調(diào)用單獨抽到一個生成類中,這樣運行時就能將類驗證失敗問題徹底隔離在生成類中,從而規(guī)避運行時的類驗證耗時。
//安裝apk后驗證失敗,運行時驗證失敗,但是能正常執(zhí)行
class MainActivity{
void onCreate(){
if(android.sdk > 26){
new SurfaceTexture(false);
}
}
}ApiModel后
class MainActivity{
void onCreate(){
if(android.sdk > 26){
a0.b(); //這樣類驗證就能成功
}
}
}
//生成的外聯(lián)類,類驗證會失敗,但是運行時不可能走到,不影響
class a0{
public static void b(){
new SurfaceTexture(false);
}
}更多關(guān)于ApiModel的詳細介紹,見這篇文章:https://medium.com/androiddevelopers/mitigating-soft-verification-issues-in-r8-and-d8-7e9e06827dfd
為什么會多生成一個new-instance指令?
介紹完ApiModel之后,我們已經(jīng)知道了為什么<init>方法的調(diào)用被替換成了一個生成函數(shù)的調(diào)用,接下來我們再分析下導(dǎo)致崩潰的罪魁禍?zhǔn)?new-instance 指令是如何出現(xiàn)的。
我們先來了解下java文件在編譯過程中的格式轉(zhuǎn)換過程,因為ApiModel是基于IRCode格式(R8自定義的格式)來做外聯(lián)。
文件轉(zhuǎn)換
javac
javac將java文件編譯成class文件
值得一提的是sychronized語句塊在javac編譯之后會為其內(nèi)部代碼生成try-catch,這是為了確保在語句塊拋異常時能夠正常釋放鎖,因此和問題有關(guān)的是try-catch語句塊,和synchronized無關(guān)。
圖片
D8
目前R8已經(jīng)整合D8,因此輸入class文件之后就會先通過D8轉(zhuǎn)為dex格式,并持有在內(nèi)存中。
轉(zhuǎn)換之后的指令基本和class字節(jié)碼基本類似。

IRcode
為了做進一步的優(yōu)化,會將dex格式的代碼轉(zhuǎn)化成R8自定義的IRcode格式,其特點是代碼分塊。
案例:
圖片
問題根因
在R8工程里檢索ApiModel關(guān)鍵字,最終定位到針對構(gòu)造函數(shù)生成外聯(lián)函數(shù)和指令替換的代碼:
InstanceInitializerOutliner->rewriteCode
執(zhí)行此方法之前的指令如下:
java:
new SurfaceTexture(false);dex:
: -1: NewInstance v1 <- android.graphics.SurfaceTexture
: -1: ConstNumber v2(0) <- 0 (INT)
: -1: Invoke-Direct v1, v2(0); method: void android.graphics.SurfaceTexture.<init>(boolean)- 對整個方法中所有的指令從上往下進行遍歷,第一次遍歷主要是:
檢索 <init>方法調(diào)用的指令
判斷該方法的androidApiLevel是否高于minSDK
生成包含完整構(gòu)造函數(shù)指令的外聯(lián)函數(shù),并替換<init>函數(shù)調(diào)用為外聯(lián)函數(shù)調(diào)用。
執(zhí)行完替換邏輯,就記錄信息到map中,key是<init>對應(yīng)的new-instance指令,value是前一步中替換的新指令。
經(jīng)過這一步,字節(jié)碼會變成這樣:
圖片
具體替換邏輯如下(可以參考注釋理解):
圖片
- 第二次遍歷則是對new-instance指令的處理:
找到new-instance指令
查詢map,確認<init>方法已完成替換
根據(jù)canSkipClInit方法返回的結(jié)果分為兩種場景:
無類初始化邏輯:直接移除new-instance指令,不影響原代碼的語義。
圖片
- 有類初始化邏輯:生成外聯(lián)函數(shù),只包含該new-instance指令,和前一次遍歷一樣進行指令替換。
圖片
具體替換邏輯:
圖片
- 問題重點就在于canSkipClInit這個函數(shù)的實現(xiàn)。
它會檢查 new-intance指令和invoke <init>指令之間是否存在任何局部變量聲明以外的指令,如果存在,他會認為這些指令是這個類初始化的邏輯,因此為了保留源代碼的執(zhí)行順序,這種情況下就是需要額外執(zhí)行一次new-instance指令來觸發(fā)類初始化。
圖片
但是實際上,如果在調(diào)用這個構(gòu)造函數(shù)傳參時執(zhí)行了任何運算(和類加載無關(guān)),都會生成相關(guān)的指令插在中間,例如:
java寫法 | new-intance和invoke <init>指令之間的指令 |
new SurfaceTexture( getParmas() ) | invoke-virtual v2 <-; method: void xx.xx.xx |
new SurfaceTexture( if(enable) 1 : 2) | StaticGet v3 <- ; field: boolean xxx.xxx.xx |
new SurfaceTexture ( (boolean) enable ) | : -1: CheckCast v5 <- v3; java.lang.Boolean : -1: Invoke-Virtual v6 <- v5; method: boolean java.lang.Boolean.booleanValue() |
從作者留下的todo也能看出,后續(xù)準(zhǔn)備擴展這個方法,實現(xiàn)對這些夾在中間的指令的判斷,如果是對類初始化無影響的入?yún)⒂嬎氵壿嫞瑒t也將正常移除new-intance指令。
圖片
值得一提的是,我們最終APK里 new-intance指令并沒有被外聯(lián),這是因為SurfaceTexture這個類本身在安卓21之前的版本就已經(jīng)存在,只是入?yún)閎ool類型的構(gòu)造方法是在安卓26新增的,所以他其實是被外聯(lián)之后又被內(nèi)聯(lián)回到了調(diào)用處,因此看起來像是沒有被外聯(lián)。
圖片
小結(jié)
至此,我們就明白了多出來一個看似無用的new-intance指令,實際上是為了保全源代碼的語義,觸發(fā)類加載用的,但是作者沒有考慮到這些被優(yōu)化的類可能重寫了finalize方法來釋放一些本就不存在的資源。
而且不局限于調(diào)用native函數(shù),只要是重寫了finalize,并在里面訪問一些在構(gòu)造函數(shù)中初始化的成員變量,一樣可能造成NPE等崩潰。
R8是如何計算出API的版本?
圖片
R83.3版本開始,它編譯時會下載一個.ser格式的數(shù)據(jù)庫文件,里面記錄了所有系統(tǒng)API、變量與安卓版本號的映射信息,在運行時通過行號和偏移量來尋找各自的版本號。
圖片
為什么try-catch也會導(dǎo)致該問題?
前面解釋了在構(gòu)造函數(shù)入?yún)⒅刑砑雍瘮?shù)調(diào)用等寫法導(dǎo)致的字節(jié)碼異常原因,但是實際上這次我們遇到的崩潰場景是在sychronized里new了一個SurfaceTexture。
圖片
前文中已經(jīng)解釋過,sychronized在編譯成class后會生成try-catch語句塊,這段代碼改成用try-catch語句塊包裹,一樣會復(fù)現(xiàn)崩潰,因此我們跟蹤try-catch在文件轉(zhuǎn)換過程中對字節(jié)碼的影響即可。
回到class文件轉(zhuǎn)dex文件的階段,我們發(fā)現(xiàn)try語句塊中的每一行指令,都會在其后生成一條FALLTHROUGH指令。
dex格式:
圖片
FALLTHROUGH是什么指令,他是做什么的?
FALLTHROUGH指令表示指令自然流轉(zhuǎn),沒有實際含義,它主要是為了幫助優(yōu)化器識別哪些指令是可達的。
例如下面這種寫法,case1沒有寫break,這樣會接著執(zhí)行case2的代碼:
switch (value) {
case 1:
System.out.println("One");
// 故意不寫break
case 2:
System.out.println("Two");
break;
case 3:
System.out.println("Three");
break;
}其字節(jié)碼如下:
正常有break的話,會對應(yīng)一條GOTO 指令跳轉(zhuǎn)到switch語句塊最后一行,但是沒寫break的話,就會出現(xiàn):
在12行執(zhí)行 goto 13 跳轉(zhuǎn)到13行的指令,這種指令毫無意義,且運行時會消耗性能,因此可以替換成FALLTHROUGH指令,這樣最終在生成dex文件時會被移除掉,從而避免浪費性能。
public static void switchWithFallthrough(int);
Code:
stack=2, locals=1, args_size=1
// 加載參數(shù)
0: iload_0
// 檢查case 1
1: iconst_1
2: if_icmpne 13 // 如果不等于1,跳轉(zhuǎn)到case 2
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #3 // String One
10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: goto 13
// case 2 (fallthrough目標(biāo))
13: iconst_2
14: if_icmpne 28 // 如果不等于2,跳轉(zhuǎn)到case 3
17: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
20: ldc #5 // String Two
22: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: goto 40 // 跳轉(zhuǎn)到switch結(jié)束
// case 3
28: iconst_3
29: if_icmpne 40 // 如果不等于3,跳轉(zhuǎn)到結(jié)束
32: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
35: ldc #6 // String Three
37: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// switch結(jié)束
40: return既然沒用為什么還要加這個指令?
class文件是通過Exception table來指定異常處理的指令范圍,而dex文件則是通過為每一行可能產(chǎn)生throwable的指令后面添加FALLTHROUGH指令來實現(xiàn)try-catch。
這里會把每一行可能崩潰的指令都鏈接到catch指令所在的block中,確保任意位置的崩潰都能正常走到catch中。

問題根因
在R8 4.0.26版本,IRCode翻譯器新增了對FALLTHROUGH指令的處理,即新建一個block并生成一條GOTO指令指向新的block。
圖片
根據(jù)前文的結(jié)論,GOTO指令一樣會被認為是類初始化相關(guān)的邏輯,因此try-catch語句塊一樣會導(dǎo)致最終多出來一個new-instance字節(jié)碼。
為什么只升級AGP會導(dǎo)致R8功能出問題?
我們在數(shù)個版本之前就已經(jīng)單獨升級了R8,正好涵蓋了ApiModel這個變更,但是直到近期才升級了AGP。
可以看到從AGP7.3-beta版本開始,才默認打開ApiModel功能,這就解釋了為什么升級AGP之后才出現(xiàn)此崩潰。

四、解決方案
禁用ApiModel
ApiModel通過犧牲些微包體,換來啟動階段類驗證耗時,但是從他覆蓋的類范圍來看,對啟動速度的收益微乎其微,因此可以直接通過配置開關(guān)關(guān)閉整個功能。
System.setProperty("com.android.tools.r8.disableApiModeling", "1")雖說這是個實驗中的功能,且邏輯相對獨立,但是考慮到后續(xù)還有內(nèi)聯(lián)優(yōu)化等操作,貿(mào)然關(guān)閉整個功能無法評估影響面,潛在的穩(wěn)定性風(fēng)險較高。
官方修復(fù)
該問題反饋給R8團隊后,官方提供了臨時規(guī)避的方案,即確保高版本API在單獨的函數(shù)中調(diào)用。
https://issuetracker.google.com/issues/441137561
圖片
隨后不久就提了MR針對SurfaceTexture這個類禁用了ApiModel,并未徹底解決此問題。https://r8-review.googlesource.com/c/r8/+/109044
圖片
官方的修復(fù)方案比較權(quán)威,且影響面較小,但是并未徹底解決問題。
自行修復(fù)
如果要修復(fù)此問題,關(guān)鍵是要將多余的new-instance指令替換成一個合適的觸發(fā)類加載的指令,根據(jù)java官方文檔里的介紹,只有new對象,訪問靜態(tài)的成員變量或者函數(shù)的指令才能安全的觸發(fā)類加載,比較理想的方案是改成訪問靜態(tài)變量,但是很多類并沒有靜態(tài)變量,比如SurfaceTexture就沒有。
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.5

因此我們可以考慮結(jié)合getStatic指令和掃描finalize的方式來解決該問題:
圖片
雖說可以通過打印日志來約束此改動的影響面,但畢竟要自行修改并編譯R8的jar包,且需要自行長期維護,整體影響面還是偏大,對穩(wěn)定性要求高的App不建議采用該方案。
業(yè)務(wù)改造(推薦)
在前文中提到的外聯(lián)函數(shù)生成處打印日志,即可感知到工程中有哪些類受ApiModel影響,如果數(shù)量不多,分別讓業(yè)務(wù)改造其相關(guān)的寫法,確保傳參時是局部變量且無try-catch/synchronized語句塊即可。

考慮到App整體的穩(wěn)定性,最終我們采用了業(yè)務(wù)改造的方式繞過了此問題,并在R8異常代碼處添加了日志告警來預(yù)防后續(xù)增量問題,并仿照官方MR中的寫法補充了類的黑名單,用于應(yīng)對無法編輯的三方庫引入此問題的場景。
五、總結(jié)
在Android開發(fā)中,即使是AGP、R8這樣的官方工具鏈升級,也要保持足夠的警惕。畢竟Android生態(tài)太過復(fù)雜,再加上開發(fā)者們千奇百怪的代碼寫法,不論多么完善的測試流程都無法規(guī)避這類特定場景的bug。
這次的ApiModel外聯(lián)優(yōu)化問題就是一個很好的例子——它只在特定條件下才會暴露,但一旦出現(xiàn)就是必現(xiàn)的native崩潰。所以對于這種影響面無法評估的重大升級,還是需要經(jīng)過足夠長時間的獨立灰度驗證,才能合入主干分支。
























