写点什么

R8 疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

作者:得物技术
  • 2025-09-02
    上海
  • 本文字数:6379 字

    阅读完需:约 21 分钟

R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

一、背景

R8 作为谷歌官方的编译优化工具,在编译阶段会对字节码进行大规模修改,以追求包体优化和性能提升。但是 Android 应用开发者数量太过庞大,无论测试流程多么完善,终究难以避免在一些特定场景下出现问题。


近期我们在升级项目的 AGP,遇到了一个指向系统 SurfaceTexture 类的 native 崩溃问题。经反编译分析发现问题最终指向了 smali 字节码中多余的一行 new-instance 指令。


该指令创建了一个 SurfaceTexture 对象,但是并未调用其<init>方法,这意味着构造方法没有执行,但是这个类重写了 finalize 方法,后续被 gc 回收时会调用其中的 nativeFinalize 这个 JNI 方法,最终在 native 层执行析构函数时触发了 SIGNALL 11 的内存访问错误.


二、复现问题

我们注意到多出来的 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 功能生成了这个类。

我们进到 R8 工程中检索下相关的关键字,再加上 demo 多次尝试,可以确认满足以下条件能够必现该问题:


  1. 使用了高于当前 minSdkVersion 的系统函数/变量(仅限系统类,自己写的无效)

  2. 用 synchronized 或者 try 语句块包裹了该调用,或者给该函数传参时有任何计算行为(除了传局部变量)。例如:new SurfaceTexture( getParmas() )new SurfaceTexture( if(enable) 1 : 2)new SurfaceTexture ( (boolean) enable )

三、问题分析

在确认复现条件之后,我们带着几个问题来逐个分析。

ApiModel 外联是什么?

R8 中的优化大多数跟包体优化有关,代码外联也是其中一种,但是外联的前提是代码重复的次数满足一定阈值,但是 ApiModel 会对所有调用了高版本系统 API 的代码做外联,包括只调用一次的场景。


ApiModel 并非为了包体优化,我们通过 R8 工程的 issueTrackerhttps://issuetracker.google.com/issues/333477035检索到了相关的信息:

译:AGP 新增的 ApiModel 功能是为了防止在低版本设备上不可能执行的代码引起类验证错误,从而降低 App 启动耗时。


从这篇介绍 ART 虚拟机类验证的文档https://chromium.googlesource.com/chromium/src/+/HEAD/build/android/docs/class_verification_failures.md#chromium_s-solution就能够理解上面这句话的含义:


ART 虚拟机会在 APK 安装之后立刻执行 AOT class verification,即对 dex 文件中所有的类进行验证,如果验证成功则后续运行时将不需要再进行验证,反之若失败,则该 class 会被 ART 打上 RetryVerificationAtRuntime 的标记,后续运行时还得重新执行类验证。


同时这些失败的类也将无法被 dex2oat 优化成 oat 格式的优化字节码(oat 字节码的加载和执行速度更快)。

如果是在 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-verification-issues-in-r8-and-d8-7e9e06827dfd


为什么会多生成一个

new-instance 指令?

介绍完 ApiModel 之后,我们已经知道了为什么<init>方法的调用被替换成了一个生成函数的调用,接下来我们再分析下导致崩溃的罪魁祸首 new-instance 指令是如何出现的。


我们先来了解下 java 文件在编译过程中的格式转换过程,因为 ApiModel 是基于 IRCode 格式(R8 自定义的格式)来做外联。

文件转换

javac

javac 将 java 文件编译成 class 文件


值得一提的是 sychronized 语句块在 javac 编译之后会为其内部代码生成 try-catch,这是为了确保在语句块抛异常时能够正常释放锁,因此和问题有关的是 try-catch 语句块,和 synchronized 无关。


D8

目前 R8 已经整合 D8,因此输入 class 文件之后就会先通过 D8 转为 dex 格式,并持有在内存中。


转换之后的指令基本和 class 字节码基本类似。

IRcode

为了做进一步的优化,会将 dex 格式的代码转化成 R8 自定义的 IRcode 格式,其特点是代码分块。


案例:

问题根因

在 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 是前一步中替换的新指令。


经过这一步,字节码会变成这样:

具体替换逻辑如下(可以参考注释理解):

  • 第二次遍历则是对 new-instance 指令的处理:找到 new-instance 指令查询 map,确认<init>方法已完成替换根据 canSkipClInit 方法返回的结果分为两种场景:

  • 无类初始化逻辑:直接移除 new-instance 指令,不影响原代码的语义。

  • 有类初始化逻辑:生成外联函数,只包含该 new-instance 指令,和前一次遍历一样进行指令替换。

具体替换逻辑:

  • 问题重点就在于 canSkipClInit 这个函数的实现。


它会检查 new-intance 指令和 invoke <init>指令之间是否存在任何局部变量声明以外的指令,如果存在,他会认为这些指令是这个类初始化的逻辑,因此为了保留源代码的执行顺序,这种情况下就是需要额外执行一次 new-instance 指令来触发类初始化。

但是实际上,如果在调用这个构造函数传参时执行了任何运算(和类加载无关),都会生成相关的指令插在中间,例如:

从作者留下的 todo 也能看出,后续准备扩展这个方法,实现对这些夹在中间的指令的判断,如果是对类初始化无影响的入参计算逻辑,则也将正常移除 new-intance 指令。

值得一提的是,我们最终 APK 里 new-intance 指令并没有被外联,这是因为 SurfaceTexture 这个类本身在安卓 21 之前的版本就已经存在,只是入参为 bool 类型的构造方法是在安卓 26 新增的,所以他其实是被外联之后又被内联回到了调用处,因此看起来像是没有被外联。


小结

至此,我们就明白了多出来一个看似无用的 new-intance 指令,实际上是为了保全源代码的语义,触发类加载用的,但是作者没有考虑到这些被优化的类可能重写了 finalize 方法来释放一些本就不存在的资源。


而且不局限于调用 native 函数,只要是重写了 finalize,并在里面访问一些在构造函数中初始化的成员变量,一样可能造成 NPE 等崩溃。


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

R83.3 版本开始,它编译时会下载一个.ser 格式的数据库文件,里面记录了所有系统 API、变量与安卓版本号的映射信息,在运行时通过行号和偏移量来寻找各自的版本号。


为什么 try-catch

也会导致该问题?

前面解释了在构造函数入参中添加函数调用等写法导致的字节码异常原因,但是实际上这次我们遇到的崩溃场景是在 sychronized 里 new 了一个 SurfaceTexture。

前文中已经解释过,sychronized 在编译成 class 后会生成 try-catch 语句块,这段代码改成用 try-catch 语句块包裹,一样会复现崩溃,因此我们跟踪 try-catch 在文件转换过程中对字节码的影响即可。


回到 class 文件转 dex 文件的阶段,我们发现 try 语句块中的每一行指令,都会在其后生成一条 FALLTHROUGH 指令。


dex 格式:

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 中。

问题根因


在 R8 4.0.26 版本,IRCode 翻译器新增了对 FALLTHROUGH 指令的处理,即新建一个 block 并生成一条 GOTO 指令指向新的 block。

根据前文的结论,GOTO 指令一样会被认为是类初始化相关的逻辑,因此 try-catch 语句块一样会导致最终多出来一个 new-instance 字节码。


为什么只升级 AGP 会导致

R8 功能出问题?

我们在数个版本之前就已经单独升级了 R8,正好涵盖了 ApiModel 这个变更,但是直到近期才升级了 AGP。


可以看到从 AGP7.3-beta 版本开始,才默认打开 ApiModel 功能,这就解释了为什么升级 AGP 之后才出现此崩溃。


四、解决方案

禁用 ApiModel

ApiModel 通过牺牲些微包体,换来启动阶段类验证耗时,但是从他覆盖的类范围来看,对启动速度的收益微乎其微,因此可以直接通过配置开关关闭整个功能。

System.setProperty("com.android.tools.r8.disableApiModeling", "1")
复制代码

虽说这是个实验中的功能,且逻辑相对独立,但是考虑到后续还有内联优化等操作,贸然关闭整个功能无法评估影响面,潜在的稳定性风险较高。


官方修复

该问题反馈给 R8 团队后,官方提供了临时规避的方案,即确保高版本 API 在单独的函数中调用。


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

随后不久就提了 MR 针对 SurfaceTexture 这个类禁用了 ApiModel,并未彻底解决此问题。https://r8-review.googlesource.com/c/r8/+/109044

官方的修复方案比较权威,且影响面较小,但是并未彻底解决问题。


自行修复

如果要修复此问题,关键是要将多余的 new-instance 指令替换成一个合适的触发类加载的指令,根据 java 官方文档里的介绍,只有 new 对象,访问静态的成员变量或者函数的指令才能安全的触发类加载,比较理想的方案是改成访问静态变量,但是很多类并没有静态变量,比如 SurfaceTexture 就没有。


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

因此我们可以考虑结合 getStatic 指令和扫描 finalize 的方式来解决该问题:

虽说可以通过打印日志来约束此改动的影响面,但毕竟要自行修改并编译 R8 的 jar 包,且需要自行长期维护,整体影响面还是偏大,对稳定性要求高的 App 不建议采用该方案。


业务改造(推荐)

在前文中提到的外联函数生成处打印日志,即可感知到工程中有哪些类受 ApiModel 影响,如果数量不多,分别让业务改造其相关的写法,确保传参时是局部变量且无 try-catch/synchronized 语句块即可。

考虑到 App 整体的稳定性,最终我们采用了业务改造的方式绕过了此问题,并在 R8 异常代码处添加了日志告警来预防后续增量问题,并仿照官方 MR 中的写法补充了类的黑名单,用于应对无法编辑的三方库引入此问题的场景。


五、总结

在 Android 开发中,即使是 AGP、R8 这样的官方工具链升级,也要保持足够的警惕。毕竟 Android 生态太过复杂,再加上开发者们千奇百怪的代码写法,不论多么完善的测试流程都无法规避这类特定场景的 bug。


这次的 ApiModel 外联优化问题就是一个很好的例子——它只在特定条件下才会暴露,但一旦出现就是必现的 native 崩溃。所以对于这种影响面无法评估的重大升级,还是需要经过足够长时间的独立灰度验证,才能合入主干分支。


往期回顾


1. 可扩展系统设计的黄金法则与 Go 语言实践|得物技术

2. 得物新商品审核链路建设分享

3. 营销会场预览直通车实践|得物技术

4. 基于 TinyMce 富文本编辑器的客服自研知识库的技术探索和实践|得物技术

5. AI 质量专项报告自动分析生成|得物技术


文 / 永乐


关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

发布于: 刚刚阅读数: 2
用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术_android_得物技术_InfoQ写作社区