动态

详情 返回 返回

R8疑難雜症分析實戰:外聯優化設計缺陷引起的崩潰|得物技術 - 动态 详情

一、背景

R8作為谷歌官方的編譯優化工具,在編譯階段會對字節碼進行大規模修改,以追求包體優化和性能提升。但是Android應用開發者數量太過龐大,無論測試流程多麼完善,終究難以避免在一些特定場景下出現問題。

近期我們在升級項目的AGP,遇到了一個指向系統SurfaceTexture類的native崩潰問題。經反編譯分析發現問題最終指向了smali字節碼中多餘的一行new-instance指令。

image.png

image.png

該指令創建了一個SurfaceTexture對象,但是並未調用其<init>方法,這意味着構造方法沒有執行,但是這個類重寫了finalize方法,後續被gc回收時會調用其中的nativeFinalize這個JNI方法,最終在native層執行析構函數時觸發了SIGNALL 11的內存訪問錯誤.

image.png

二、復現問題

我們注意到多出來的new-instance指令下面緊接着的是對a0.e 類中的靜態方法 i() 的調用,其內部實現就是SurfaceTexture的構造方法。這是典型的代碼外聯操作,即一段相同的代碼在工程中多次出現,則會被抽出來單獨作為一個靜態函數,原先的調用點則替換成該函數的調用,這樣可以減小代碼體積,是常見的編碼思路。

例如:

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();
        //...
    }
}
//外聯生成的類
class Activity$Outline{
    public static void log(){
        String a = xx.xxx();
        String b = xx.xxx();
        Log.e("log",a+b);
    }
}

我們根據這個生成類的類名可以知道是R8中ApiModelOutline功能生成了這個類。

image.png

我們進到R8工程中檢索下相關的關鍵字,再加上demo多次嘗試,可以確認滿足以下條件能夠必現該問題:

  1. 使用了高於當前minSdkVersion的系統函數/變量(僅限系統類,自己寫的無效)
  2. 用synchronized或者try語句塊包裹了該調用,或者給該函數傳參時有任何計算行為(除了傳局部變量)。例如:
    1. new SurfaceTexture( getParmas() )
    2. new SurfaceTexture( if(enable) 1 : 2)
    3. new SurfaceTexture ( (boolean) enable )

三、問題分析

在確認復現條件之後,我們帶着幾個問題來逐個分析。

ApiModel外聯是什麼?

R8中的優化大多數跟包體優化有關,代碼外聯也是其中一種,但是外聯的前提是代碼重複的次數滿足一定閾值,但是ApiModel會對所有調用了高版本系統API的代碼做外聯,包括只調用一次的場景。

ApiModel並非為了包體優化,我們通過R8工程的issueTracker https://issuetracker.google.com/issues/333477035 檢索到了相關的信息:

image.png
譯:AGP新增的ApiModel功能是為了防止在低版本設備上不可能執行的代碼引起類驗證錯誤,從而降低App啓動耗時。

從這篇介紹ART虛擬機類驗證的文檔 https://chromium.googlesource.com/chromium/src/+/HEAD/build/a... 就能夠理解上面這句話的含義:

ART虛擬機會在APK安裝之後立刻執行 AOT class verification,即對dex文件中所有的類進行驗證,如果驗證成功則後續運行時將不需要再進行驗證,反之若失敗,則該class會被ART打上RetryVerificationAtRuntime的標記,後續運行時還得重新執行類驗證。

同時這些失敗的類也將無法被dex2oat優化成oat格式的優化字節碼(oat字節碼的加載和執行速度更快)。

image.png

如果是在MainActivity,啓動任務中使用了這些高版本API,那麼在低版本設備App啓動時就必須額外執行一次類驗證(比較耗時,有的類能到8ms https://issues.chromium.org/issues/40574431),而ApiModel外聯則是相當於將這些肯定驗證失敗的函數的調用單獨抽到一個生成類中,這樣運行時就能將類驗證失敗問題徹底隔離在生成類中,從而規避運行時的類驗證耗時。

//安裝apk後驗證失敗,運行時驗證失敗,但是能正常執行
class MainActivity{
    void onCreate(){
        if(android.sdk > 26){
            new SurfaceTexture(false);
        }
    }
}

ApiModel後

class MainActivity{
    void onCreate(){
        if(android.sdk > 26){
            a0.b(); //這樣類驗證就能成功
        }
    }
}
//生成的外聯類,類驗證會失敗,但是運行時不可能走到,不影響
class a0{
    public static void b(){
        new SurfaceTexture(false);
    }
}

更多關於ApiModel的詳細介紹,見這篇文章:https://medium.com/androiddevelopers/mitigating-soft-verifica...

為什麼會多生成一個

new-instance指令?

介紹完ApiModel之後,我們已經知道了為什麼<init>方法的調用被替換成了一個生成函數的調用,接下來我們再分析下導致崩潰的罪魁禍首 new-instance 指令是如何出現的。

我們先來了解下java文件在編譯過程中的格式轉換過程,因為ApiModel是基於IRCode格式(R8自定義的格式)來做外聯。

文件轉換

javac

javac將java文件編譯成class文件

值得一提的是sychronized語句塊在javac編譯之後會為其內部代碼生成try-catch,這是為了確保在語句塊拋異常時能夠正常釋放鎖,因此和問題有關的是try-catch語句塊,和synchronized無關。

image.png

D8

目前R8已經整合D8,因此輸入class文件之後就會先通過D8轉為dex格式,並持有在內存中。

轉換之後的指令基本和class字節碼基本類似。

image.png

IRcode

為了做進一步的優化,會將dex格式的代碼轉化成R8自定義的IRcode格式,其特點是代碼分塊。

案例:

image.png

問題根因

在R8工程裏檢索ApiModel關鍵字,最終定位到針對構造函數生成外聯函數和指令替換的代碼:

InstanceInitializerOutliner->rewriteCode

執行此方法之前的指令如下:

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>方法調用的指令
    • 判斷該方法的androidApiLevel是否高於minSDK
    • 生成包含完整構造函數指令的外聯函數,並替換<init>函數調用為外聯函數調用。
    • 執行完替換邏輯,就記錄信息到map中,key是<init>對應的new-instance指令,value是前一步中替換的新指令。

經過這一步,字節碼會變成這樣:

image.png

具體替換邏輯如下(可以參考註釋理解):

image.png

  • 第二次遍歷則是對new-instance指令的處理:
    • 找到new-instance指令
    • 查詢map,確認<init>方法已完成替換
    • 根據canSkipClInit方法返回的結果分為兩種場景:

<!---->

      • 無類初始化邏輯:直接移除new-instance指令,不影響原代碼的語義。

image.png

      • 有類初始化邏輯:生成外聯函數,只包含該new-instance指令,和前一次遍歷一樣進行指令替換。

image.png

具體替換邏輯:

image.png

  • 問題重點就在於canSkipClInit這個函數的實現。

它會檢查 new-intance指令和invoke <init>指令之間是否存在任何局部變量聲明以外的指令,如果存在,他會認為這些指令是這個類初始化的邏輯,因此為了保留源代碼的執行順序,這種情況下就是需要額外執行一次new-instance指令來觸發類初始化。

但是實際上,如果在調用這個構造函數傳參時執行了任何運算(和類加載無關),都會生成相關的指令插在中間,例如:

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也能看出,後續準備擴展這個方法,實現對這些夾在中間的指令的判斷,如果是對類初始化無影響的入參計算邏輯,則也將正常移除new-intance指令。

image.png

值得一提的是,我們最終APK裏 new-intance指令並沒有被外聯,這是因為SurfaceTexture這個類本身在安卓21之前的版本就已經存在,只是入參為bool類型的構造方法是在安卓26新增的,所以他其實是被外聯之後又被內聯回到了調用處,因此看起來像是沒有被外聯。

image.png

小結

至此,我們就明白了多出來一個看似無用的new-intance指令,實際上是為了保全源代碼的語義,觸發類加載用的,但是作者沒有考慮到這些被優化的類可能重寫了finalize方法來釋放一些本就不存在的資源。

而且不侷限於調用native函數,只要是重寫了finalize,並在裏面訪問一些在構造函數中初始化的成員變量,一樣可能造成NPE等崩潰。

R8是如何計算出API的版本?

image.png

R83.3版本開始,它編譯時會下載一個.ser格式的數據庫文件,裏面記錄了所有系統API、變量與安卓版本號的映射信息,在運行時通過行號和偏移量來尋找各自的版本號。

image.png

為什麼try-catch

也會導致該問題?

前面解釋了在構造函數入參中添加函數調用等寫法導致的字節碼異常原因,但是實際上這次我們遇到的崩潰場景是在sychronized裏new了一個SurfaceTexture。

image.png

前文中已經解釋過,sychronized在編譯成class後會生成try-catch語句塊,這段代碼改成用try-catch語句塊包裹,一樣會復現崩潰,因此我們跟蹤try-catch在文件轉換過程中對字節碼的影響即可。

回到class文件轉dex文件的階段,我們發現try語句塊中的每一行指令,都會在其後生成一條FALLTHROUGH指令。

dex格式:

image.png

FALLTHROUGH是什麼指令,他是做什麼的?

FALLTHROUGH指令表示指令自然流轉,沒有實際含義,它主要是為了幫助優化器識別哪些指令是可達的。

例如下面這種寫法,case1沒有寫break,這樣會接着執行case2的代碼:

switch (value) {
            case 1:
                System.out.println("One");
                // 故意不寫break
            case 2:
                System.out.println("Two");
                break;
            case 3:
                System.out.println("Three");
                break;
        }

其字節碼如下:

正常有break的話,會對應一條GOTO 指令跳轉到switch語句塊最後一行,但是沒寫break的話,就會出現:

在12行執行 goto 13 跳轉到13行的指令,這種指令毫無意義,且運行時會消耗性能,因此可以替換成FALLTHROUGH指令,這樣最終在生成dex文件時會被移除掉,從而避免浪費性能。

public static void switchWithFallthrough(int);
  Code:
    stack=2, locals=1, args_size=1


    // 加載參數
    0: iload_0


    // 檢查case 1
    1: iconst_1
    2: if_icmpne 13    // 如果不等於1,跳轉到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目標)
    13: iconst_2
    14: if_icmpne 28   // 如果不等於2,跳轉到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        // 跳轉到switch結束


    // case 3
    28: iconst_3
    29: if_icmpne 40   // 如果不等於3,跳轉到結束
    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結束
    40: return

既然沒用為什麼還要加這個指令?

class文件是通過Exception table來指定異常處理的指令範圍,而dex文件則是通過為每一行可能產生throwable的指令後面添加FALLTHROUGH指令來實現try-catch。

這裏會把每一行可能崩潰的指令都鏈接到catch指令所在的block中,確保任意位置的崩潰都能正常走到catch中。

image.png

問題根因

在R8 4.0.26版本,IRCode翻譯器新增了對FALLTHROUGH指令的處理,即新建一個block並生成一條GOTO指令指向新的block。

image.png

根據前文的結論,GOTO指令一樣會被認為是類初始化相關的邏輯,因此try-catch語句塊一樣會導致最終多出來一個new-instance字節碼。

為什麼只升級AGP會導致

R8功能出問題?

我們在數個版本之前就已經單獨升級了R8,正好涵蓋了ApiModel這個變更,但是直到近期才升級了AGP。

可以看到從AGP7.3-beta版本開始,才默認打開ApiModel功能,這就解釋了為什麼升級AGP之後才出現此崩潰。

image.png

四、解決方案

禁用ApiModel

ApiModel通過犧牲些微包體,換來啓動階段類驗證耗時,但是從他覆蓋的類範圍來看,對啓動速度的收益微乎其微,因此可以直接通過配置開關關閉整個功能。

System.setProperty("com.android.tools.r8.disableApiModeling", "1")

雖説這是個實驗中的功能,且邏輯相對獨立,但是考慮到後續還有內聯優化等操作,貿然關閉整個功能無法評估影響面,潛在的穩定性風險較高。

官方修復

該問題反饋給R8團隊後,官方提供了臨時規避的方案,即確保高版本API在單獨的函數中調用。

https://issuetracker.google.com/issues/441137561

image.png

隨後不久就提了MR針對SurfaceTexture這個類禁用了ApiModel,並未徹底解決此問題。https://r8-review.googlesource.com/c/r8/+/109044

image.png

官方的修復方案比較權威,且影響面較小,但是並未徹底解決問題。

自行修復

如果要修復此問題,關鍵是要將多餘的new-instance指令替換成一個合適的觸發類加載的指令,根據java官方文檔裏的介紹,只有new對象,訪問靜態的成員變量或者函數的指令才能安全的觸發類加載,比較理想的方案是改成訪問靜態變量,但是很多類並沒有靜態變量,比如SurfaceTexture就沒有。

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.htm...

image.png

因此我們可以考慮結合getStatic指令和掃描finalize的方式來解決該問題:

image.png

雖説可以通過打印日誌來約束此改動的影響面,但畢竟要自行修改並編譯R8的jar包,且需要自行長期維護,整體影響面還是偏大,對穩定性要求高的App不建議採用該方案。

業務改造(推薦)

在前文中提到的外聯函數生成處打印日誌,即可感知到工程中有哪些類受ApiModel影響,如果數量不多,分別讓業務改造其相關的寫法,確保傳參時是局部變量且無try-catch/synchronized語句塊即可。

image.png

考慮到App整體的穩定性,最終我們採用了業務改造的方式繞過了此問題,並在R8異常代碼處添加了日誌告警來預防後續增量問題,並仿照官方MR中的寫法補充了類的黑名單,用於應對無法編輯的三方庫引入此問題的場景。

五、總結

在Android開發中,即使是AGP、R8這樣的官方工具鏈升級,也要保持足夠的警惕。畢竟Android生態太過複雜,再加上開發者們千奇百怪的代碼寫法,不論多麼完善的測試流程都無法規避這類特定場景的bug。

這次的ApiModel外聯優化問題就是一個很好的例子——它只在特定條件下才會暴露,但一旦出現就是必現的native崩潰。所以對於這種影響面無法評估的重大升級,還是需要經過足夠長時間的獨立灰度驗證,才能合入主幹分支。

往期回顧

1. 可擴展系統設計的黃金法則與Go語言實踐|得物技術

2. 得物新商品審核鏈路建設分享

3. 營銷會場預覽直通車實踐|得物技術

4. 基於TinyMce富文本編輯器的客服自研知識庫的技術探索和實踐|得物技術

5. AI質量專項報告自動分析生成|得物技術

文 / 永樂

關注得物技術,每週更新技術乾貨

要是覺得文章對你有幫助的話,歡迎評論轉發點贊~

未經得物技術許可嚴禁轉載,否則依法追究法律責任。

user avatar tech 头像 hejing-michael 头像 uwatechnologies 头像 immerse 头像 meituanjishutuandui 头像 matrixorigin 头像 johanazhu 头像 alisecued 头像 chai_huo 头像 jeecg 头像 ruyadekabuqinuo 头像 jingmingdewudongmian_dscnyw 头像
点赞 20 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.