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

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

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

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

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

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
























