
如果你跟一位只写服务端 Java 的同学讲 Android 的构建流程最容易被问懵的一个问题就是明明都是 Java为什么还要折腾出一个 Dex 文件更让人说不清的是这个 Dex 文件最后又是怎么“跑”起来的——是从 JVM 里加载吗是双击一下就执行了吗我最初也只是照着教程知道javac之后有个dx/d8后来为了查一个崩溃日志里“为什么总是找不到某个类”的问题才不得不把这条链路完整抠了一遍.java - .class - .dex - oat/vdex - ART 运行时。这条链路的每一段都藏着一个能解释实际问题的答案。这篇文章就把我整理后的完整链路写出来希望对你在排查包体、做热修复、理解安装速度甚至准备面试时都有点用。1. 先给整条链路线索Java代码到Dex再到执行分了几个阶段“Java 文件编译成 Dex 文件然后执行 Dex 文件”这句话把两件完全不同的事拼在了一起前半句是构建期发生的事情后半句是运行期才发生的事情。中间隔着的不是一道命令而是从javac到D8再到dex2oat、类加载器、ART 虚拟机的一整条流水线。很多人的困惑来自这里他们在构建日志里看到D8输出了classes.dex以为这就是最终产物又在系统目录里看到.oat、.vdex、.art文件就彻底分不清了。其实这些都指向同一个东西为 ART 准备的、可以被高效加载和执行的“代码载体”。只是载体在不同阶段换了名字换了形态。阶段核心动作主要产物发生时机源码编译javac 将 .java 编译成 JVM 字节码.class 文件开发期/CI 构建期Dex 生成D8/R8 将多个 .class 合并、去重、翻译为 dex 字节码classes.dexAPK 打包前打包dex 与资源、so 一起打进 APKAPK构建期最后几步预编译/优化安装时或首次运行时二次处理 dexoat / vdex / art安装期/系统启动期加载执行ART 装载 dex 并解释/JIT/AOT 执行方法运行进程内存用户点击图标那一刻需要特别注意的是安装到手机上的 APK 里classes.dex是一个完整文件但它里面已经没有.class的“类文本文档”味道了而是一套带有全局索引表的紧凑字节码。ART 拿到这套字节码之后也不会傻傻地每次从头解释执行——它有一整套解释器、JIT、AOT 配合的机制这部分是后面章节的重点。1.1 为什么 .class 不能在 Android 上直接跑先从最根本的问题说起为什么 Android 不直接用标准 JVM 加载.class原因是 Android 从设计之初就没有用标准 JVM而选择了自己定制的运行时。早期是 Dalvik 虚拟机后来是 ART。.class文件是按照《Java 虚拟机规范》生成的里面依赖 JVM 的常量池、符号引用、栈式字节码。而 Android 的 runtime 是一个寄存器式架构的运行时两者对“方法体怎么存、指令怎么编、常量怎么引用”的假设完全不同。往更深一层说标准 JVM 面向的是通用服务器/桌面场景一个应用可能就几十个 class每个 class 自带一份常量池无伤大雅。可在移动端一个 APK 动辄几万、几十万个类如果还采用 JVM 那种“每个类一份常量池、全符号引用、指令基于栈操作”的设计内存占用和启动开销都扛不住。Dex 的设计目标之一就是“跨类共享常量池”让同样的字符串、方法名、类型描述符只保存一份用索引去指。所以.class不是不能跑而是“让 Android 跑.class性价比太低”。早期的 Android 团队需要的是更紧凑、更适合移动设备的中间格式于是就有了 Dex。1.2 构建期、安装期、运行期三个阶段别混在一起我见过不少同学在排查问题时把构建期的报错和运行期的报错混为一谈。比如构建日志里有“Dexing”字样就以为代码已经在生成 dex 了或者运行崩溃里有ClassNotFoundException还往javac报错上找。更好的心智模型是构建期javac把你写的Hello.java编成Hello.class紧接着构建工具链里的 D8或者 R8 D8读取所有 class输出一个或多个 dex 文件。打包期dex 和其他资源一起被打进 APK。这个阶段严格说还是构建的一部分但它只负责“装车”不负责“生产”。安装期用户点击安装系统会运行dex2oat等相关服务按策略对 dex 做预校验、优化、甚至提前编译成机器码产物就是/data/dalvik-cache目录下那堆.oat、.vdex文件。运行期应用进程启动ART 通过类加载器找到 dex把方法加载进来然后决定用解释器还是 JIT 还是已经编译好的 AOT 机器码来执行。把这四个阶段在脑子里摆清楚再看标题里的两个问题第一部分“Java 文件如何编译成 Dex”主要对应前两个阶段第二部分“如何执行 Dex”主要对应后两个阶段。下面我按这个顺序逐段展开。2. javac 并不多余class 文件里埋的是 JVM 的伏笔现在的大多数 Android 开发者其实很少直接敲javac因为构建工具把这一步吞掉了。但如果不理解 class 文件长什么样就很难理解 D8 到底做了哪些工作。尤其是符号引用symbolic reference这个概念它是连接 Java 源码、class 文件、dex 文件之间的一座桥。2.1 class 文件的“名片”魔数、常量池、方法表标准.class文件开头是四个字节的魔数CAFEBABE接着是次版本号、主版本号然后才是真正的大头——常量池。你可以用 IDE 打开一个Hello.class的十六进制数据看看或者在命令行跑一句javap -v Hello.class输出里会有大量#1、#2这样的索引项后面跟着Methodref、Fieldref、Utf8等结构。这些索引项都属于常量池。更关键的是javac在编译时不会真的把System.out.println这个调用“指向”某个内存地址它只会在当前类的常量池里登记一条符号引用方法所属的类、方法名、方法描述符。这种设计是 JVM 世界里“晚绑定”思想的体现类文件可以在多处移动哪份是真正被加载的实现类要等运行期由虚拟机去决定。class 文件里的方法体也不是机器码而是一组 JVM 字节码。比如int a 1; int b 2; int c a b;在 class 里大致会变成iconst_1 istore_1 iconst_2 istore_2 iload_1 iload_2 iadd istore_3可以看到JVM 字节码没有显式的“寄存器编号”所有操作都在操作数栈上完成。取变量、算加法、存回去都需要先把值压栈再弹出操作。指令本身短小紧凑但一条运算会被拆成好几条栈操作。2.2 栈式字节码和寄存器式字节码根本是两套思维为什么 Android 不沿用 JVM 的栈式字节码原因有三层第一寄存器式字节码对“指令条数”更友好。比如上面那个加法在 Dex 里可能只需要三条指令const/4 v0, 0x1 const/4 v1, 0x2 add-int/2addr v0, v1三条指令直接基于寄存器完成没有压栈、弹栈的浪费。每一条指令虽然比 JVM 指令稍长要携带寄存器编号但在真实程序里整体体积反而更小解释执行时也少了很多栈操作。第二移动设备早期内存很紧张。寄存器式指令配合 D8 做好的寄存器分配能让 dex 文件更紧凑加载到内存后的解析成本也更低。第三Dalvik 和 ART 的 JIT/AOT 编译器需要面向一张“确定性更强”的指令表。寄存器式指令天然贴近后端机器码做代码生成时不用先把字节码翻译成 SSA 再做一遍寄存器分配省了一道工序。所以从开发者的角度看javac编译出的 class 文件并不是“可以直接被 ui 就能执行的成品”它更像一份 JVM 世界的草稿。D8 要做的事就是把这堆草稿翻译成 ART 世界能读的“正稿”。2.3 javac 之后的符号引用会被 D8 如何消化还不止指令层面。class 文件里的符号引用在 Dex 里也会被重新登记。例如两个类都调用了System.out.println它们在各自的 class 文件中可能各有一条Methodref常量内容相同。如果直接把两个 class 塞进 dex这个重复就没有消除掉D8 会把这些重复的字段、方法、类型引用去重统一放进 Dex 的全局索引表里。这个“统一登记”的过程恰恰就是下一章最核心的部分。换句话说javac是按“一个类一张表”的思维工作的D8 则按“一个 dex 一张总表”的思维工作。理解了这种思维转换你就理解了为什么 dex 一定比多个 class 加起来更省空间。3. D8/R8 合并 class 的现场Dex 文件真正在这里诞生Dex 文件的“真正出生地”不是javac而是 D8老一代是 dx。这一步输入的是一堆.class文件和第三方库的.jar输出的是一到多个.dex。3.1 为什么 dx 被 D8 取代R8 又是什么早期 Android SDK 里用的转换工具是dx它可以把.jar里的 class 打成classes.dex。这个工具用了很多年问题也不少不支持 Java 8 语法脱糖、多 dex 配置繁琐、优化能力弱。后来 Android 官方逐步用D8取代了dx从 Android Studio 3.x 时代开始D8 成了默认执行器。D8 的核心职能有两个把 JVM 字节码翻译成 dex 字节码包括寄存器分配、指令重排等对 dex 做基础优化比如字符串合并、布局优化、部分指令快速化。R8 则是在 D8 前面的“压缩器和优化器”。它先把未使用的类和方法裁剪掉做内联、常量折叠再做脱糖、再交给 dexing 部分生成 dex。简单说R8 负责“减重”D8 负责“成型”。在现在的 Android Gradle Plugin 里release 构建默认会打开 R8然后走到 D8 生成 dexdebug 构建则主要走 D8。这一步最容易被忽略的还有“脱糖”desugaring。Java 8 的 lambda、String.join、接口默认方法class 文件里用的是 JVM 语法ART 不一定直接支持。D8 会在翻译时把这些语法翻译成 Android runtime 能吃的旧式写法。所以我们在构建脚本里配置--min-api不是随便填的不同 API 级别下脱糖的策略和最终产物体积都不一样。3.2 合并常量池和十六进制索引Dex 文件内部有一个全局索引体系。你可以把 dex 想象成一本电话簿string_ids所有字符串的目录比如类名、方法名、字段名type_ids所有类型的目录用descriptor_idx指向字符串method_ids所有方法的目录记录类、方法名、参数/返回类型描述field_ids所有字段的目录。多个 class 编译进同一个 dex 时这些表会去重合并。比如 100 个类都引用了同一个类描述符Ljava/lang/String;在最终的 dex 里这个描述符大概率只占type_ids中的一条记录。正因为这种全局唯一化dex 的文件体才能比原始 class 总和还小很多。对应的dex 内部大量使用 16 位索引早先和可变长编码LEB128来保存这些编号。你会看到很多工具反汇编结果显示0x0000、0x0001这样的下标比如invoke-virtual {v0}, method0x0004。这里method0x0004不是内存地址而是method_ids表的第 4 项。真正的实现细节要运行期由 ART 去解析。3.3 65536 方法数限制与 multidex 的来龙去脉既然用的是 16 位索引单个 dex 里方法表最多就只能装 65536 个条目。一旦超过D8 会直接报错提示到达方法引用上限。这时候就需要用 multidex把类拆到多个 dex 里。但注意拆分的粒度是“类”而不是“方法”。一个类不能横跨两个 dex。D8 会尽量均匀分配类把引用关系密集的类放一起减少跨 dex 依赖。所以你的用户会在 APK 里看到classes.dex、classes2.dex、classes3.dex。在minSdk21的工程里系统原生支持加载多个 dex不需要额外兼容库但如果minSdk低于 21就必须使用 multidex 支持库由它把二级 dex 在应用启动早期加载进来。之所以有这个问题是因为早年 Dalvik 只认一个classes.dex次 dex 需要绕过系统限制自己加载。现在新项目基本都minSdk21了但面试时这个历史包袱还是经常被问到。3.4 手动 D8 一次把 Hello.class 变成 classes.dex光看理论容易飘手动跑一遍最踏实。假设你有一个最简单的类public class Hello { public static void main(String[] args) { System.out.println(Hello Dex); } }前提是本机装了 Android SDK并且在环境变量里配好ANDROID_HOME。按下面几步执行javac Hello.java $ANDROID_HOME/build-tools/34.0.0/d8 --release \ --lib $ANDROID_HOME/platforms/android-34/android.jar \ --min-api 21 \ --output . Hello.class $ANDROID_HOME/build-tools/34.0.0/dexdump -d classes.dex--lib指定 android.jar是为了让 D8 在链接时知道系统方法引用的归宿不传它System.out.println这种外部符号可能解析不完整。--min-api 21则告诉 D8 按 Android 5.0 以后的能力去选择 dex 版本和脱糖策略。dexdump -d输出里你能看到这样的内容Class descriptor : LHello; ... #0 : (line 3) invoke-direct {v0}, Ljava/lang/Object;.init:()V看到这里class 文件里的pop、dup、栈操作基本消失了取而代之的是注册器和invoke-*指令。从这一刻起JVM 字节码正式变成了 ART 认识的 Dex 字节码。4. 拆开 Dex 看内部六张索引表如何互相引用很多前端或者逆向同学第一次打开 dex 文件的时候都会觉得它就是一坨二进制不知道从哪看起。其实 Dex 文件有非常清晰的结构前面是header后面跟着若干索引区再后面是一个大的data区。整体设计思路就是“用索引代替指针用偏移量代替绝对地址”。4.1 header112 字节里记录了什么Dex 文件最开头的header固定是 0x70 字节112 字节里面依次包括magic8 字节前四个是dex\n后四个是版本号常见有035\0、037\0、038\0、039\0checksum4 字节对除magic和checksum之外的整个文件做 Adler32 校验signature20 字节是文件的 SHA-1 摘要file_size整个 dex 文件的大小header_sizeheader 自身大小固定 0x70endian_tag大小端标记通常为0x12345678map_off指向 map_list 的偏移后面是一串xxx_ids_size和xxx_ids_off记录六张索引表各自的大小和位置。所以拿到一个 dex想验证它有没有被篡改先算Adler32(文件内容去掉前 12 字节)再和checksum字段比对就行。这也是很多加固、脱壳工具校验原始 dex 时常用的方式。4.2 六张表和偏移的引用关系Dex 里的核心索引表有六张string_ids、type_ids、proto_ids、field_ids、method_ids、class_defs。它们之间不是孤立的而是层层引用表存什么关键引用string_ids字符串在 data 区的偏移通过偏移取出实际 UTF-8 字符串type_ids类型描述符descriptor_idx指向 string_idsproto_ids方法签名参数和返回值shorty_idx、return_type_idx、parameters_offfield_ids字段信息class_idx指向 typename_idx指向 stringmethod_ids方法信息class_idx、proto_idx、name_idxclass_defs类定义class_idx、superclass_idx、class_data_off真正的方法体在class_defs里通过class_data_off指向一段叫class_data_item的结构里面记录了字段列表、直接方法、虚方法。每个方法的code_item又包含registers_size、insns_size和insns。insns是 uint16 数组这才是 dex 字节码指令的存放地。前面那份dexdump输出的方法反汇编读的就是这块区域。可以这样理解整个结构六张表是“门户”告诉你有哪些字符串、类型、方法、字段data区是“房间”真正的方法指令都在里面索引表里记录的off就是门牌号。所有引用关系最终都能通过“偏移量”串起来。4.3 从 Dex 里找关心的类dexdump、baksmali 各干各的实操里我一般这样选工具只是快速看类是否存在、方法签名对不对用dexdump -d classes.dex就够无依赖命令输出直观要做精细修改把 dex 转成 smali用baksmali改完再用smali回编译成 dex要整体读业务逻辑直接拖进jadx它能反编译 dex 到近 Java 源码的级别。有一次定位一个混淆导致的 NoSuchMethodError我就是先在dexdump里搜目标类的方法表确认该方法已经被混淆器改名或裁剪然后才确定是 R8 规则有问题。如果你也有类似经历会发现能直接读 dex 的索引表和反汇编比纯靠堆栈猜快很多。5. ART 执行阶段dex2oat、JIT 与解释器在争什么Dex 文件生成并被装进 APK只完成了一半使命。真正要“跑起来”得看 Android 运行时怎么处理它。这一章重点讲 ART 时代的三板斧dex2oat、JIT、解释器。5.1 安装期到底编译了什么从 odex 到 oat 再到 vdex先讲历史有助于理解现在为什么有这么多文件后缀。Dalvik 时代应用安装时会跑一次dexopt产出.odex文件。这个 odex 不是机器码而是“优化过的 dex”主要做指令修正、内联一些数据、把类和方法的位置预先计算好让它更适合 Dalvik 的解释器。ART 在 Android 5.0 上推出后采用了一套更激进的方案dex2oat。它会直接把 dex 里的方法编译成当前设备 CPU 架构比如 arm64的机器码输出 OAT 文件。OAT 本质是 ELF 文件里面既保留了原始 dex 数据也包含编译好的 native 方法。这样应用首次冷启动就能直接执行机器码速度飞快。对 CPU 专注、性能要求高的应用来说这是很大的提升但代价是安装时间明显拉长磁盘占用也大。后来 Android 团队发现“全量 AOT”并不划算因为不是每个方法都会被频繁调用。全量编译等于在安装时做了一堆无用功。于是演进方向变成了“少编译、晚编译、按需编译”。到了 Android 8.0系统引入了.vdex和.art等辅助文件把 dex 原始数据、验证信息、AOT 代码分离得更加精细。Android 10 以后还有 CompactDex 等更紧凑的 dex 容器格式。普通应用开发者其实不需要记住这些细节只需要理解一个核心矛盾AOT 越充分运行越快但安装越慢、体积越大JIT 越激进安装越轻但首次运行需要边解释边编译。系统一直在找平衡。5.2 解释执行、JIT 和 AOT 怎么配合现代 Android 设备上一个方法被执行的完整路径大致是这样的应用冷启动ART 加载 dex 中的类首个方法先走解释器执行同时记录执行次数某个方法执行得足够频繁达到 JIT 阈值ART 的 JIT 编译器会把该方法编译成机器码放进内存里的 code cacheprofile 机制会把热点方法、热点类记录下来应用在空闲时系统再调用 dex2oat用speed-profile模式把 profile 里的热点方法做 AOT 编译存进 oat下次启动时这些热点方法直接加载机器码非热点方法继续解释执行或临时 JIT。这个过程对普通开发者基本透明。但了解它有两个好处你做冷启动性能优化时不要只看“第一次安装后的冷启动”时间因为此时很多方法还没被 profile 命中解释执行占大头慢是正常的。优化目标应该是帮助 profile 尽快收敛热点。判断一个异常是“字节码问题”还是“AOT 机器码问题”时可以先去/data/dalvik-cache看相应的 oat/vdex 文件是否存在或损坏有时候清一下缓存能解决很多诡异问题。5.3 compiler-filter看懂它就懂了为什么安装速度不一样dex2oat有一个compiler-filter参数它决定编译策略。常见取值如下取值行为verify只做验证不编译成机器码quicken对 dex 做指令快速化但不生成 native 代码space-profile按 profile 编译编译时优先省空间space全量编译优先省空间speed-profile按 profile 编译优先执行速度现代安卓常用speed全量编译优先执行速度everything所有方法都编译最激进你可以在开发者选项里或者通过 adb 强制指定编译模式比如adb shell cmd package compile -m speed -f com.example.yourapp这会强制对某个应用做全量 speed 编译适合用来测 AOT 效果但不建议默认这么做。这也是为什么同样是安装一个大型应用有的设备要等半天有的很快就完成了——系统的 dexopt 策略不一样。从玩家的角度看所有“系统加速”、“安装后强制编译”的骚操作本质都是调整compiler-filter。理解了这层机制你就不容易被那些玄学脚本忽悠了。6. 类加载顺序决定了“执行哪个Dex”从DexClassLoader到app_processDex 字节码编译得再好也得有一个“入口”把它交给运行时。这个入口就是类加载器。6.1 ClassLoader 查找顺序parent 优先dexElements 数组谁在前谁先被 loadAndroid 应用进程里默认类加载器通常是PathClassLoader它只能加载已安装 APK 里的 dex。要想加载外部 dex 文件会用DexClassLoader。但这两个类其实都是BaseDexClassLoader的子类核心逻辑都集中在DexPathList里。DexPathList内部维护了一个Element[] dexElements数组。当代码里执行Class.forName(com.example.Foo)时findClass大致会做这样的事for (Element element : dexElements) { Class clazz element.dexFile.loadClassBinaryName(name, this, suppressed); if (clazz ! null) { return clazz; } } throw new ClassNotFoundException(name);从第 0 个 element 开始找找到就返回找不到就找下一个。也就是说同名类谁排在数组前面谁就会被加载后面即使有同样的类也不会再有机会。类加载这种“先到先得”的规则是所有热修复方案的理论地基。我们后面说的把修复 dex 插到数组最前面用的就是这个机制。6.2 热修复为什么要把修复 Dex 放在数组最前面但未必总是成功传统的热修复方案比如类加载替换型思路很简单生成一个包含修复类的 dex把它插到dexElements数组的头部。下次应用冷启动加载目标类时ART 会命中头部修复 dex 里的新类于是旧类被“覆盖”了。但它有三个前提也是三个坑类一旦被加载当前进程内已经没法替换。很多方案要求重启生效原因就在这里系统类和 boot classpath 里的类不能通过应用自身的 ClassLoader 篡改你改不了String从 Android 9 开始系统加强了 ClassLoader 的匹配校验外部插入的 dex 如果和原 dex 的 ClassLoader 类型不匹配可能直接被拒绝加载。所以热修复不是万能的能修的只是“还没有被加载过的业务类”而且实现时还要适配各版本的系统限制。我在项目里吃过一次亏只记得把 dex 插到最前面没注意走的 ClassLoader 不是同一个结果修复包完全没生效。后来排查才发现是系统在findClass阶段做了额外校验拒绝了这个“外来人口”。6.3 在设备上直接“跑”一个 Dexapp_process 小实验讲完类加载原理最后分享一个我经常用来验证 dex 执行链路的小实验用app_process在设备上直接运行一个 dex 文件绕开 APK。假设还是前面那个Hello类已经用 D8 生成了classes.dex。把它推到设备上adb push classes.dex /data/local/tmp adb shell export CLASSPATH/data/local/tmp/classes.dex app_process -Djava.class.path/data/local/tmp/classes.dex /system/bin com.example.Helloapp_process是 Android 启动 Zygote 和加载系统 Java 代码的入口。通过指定CLASSPATH和java.class.path它可以直接加载我们外置的 dex并调用指定类的main方法。这相当于在设备上“裸执行”一个 dex。要提醒两点System.out.println在 Android 上不一定直接出现在终端输出里。你可以改用Log.e(Test, Hello from dex)然后去 logcat 里看或者让 main 方法写一个文件验证效果部分 ROM 对/data/local/tmp有 SELinux 限制推不进去或执行被拒时可以换个可写目录或者用模拟器做实验。我自己做这个实验后的最大体会是dex 不是终点它更像是 ART 和 Java 代码之间的“译稿”。构建工具负责翻译运行时负责择时编译与加载。遇到问题时不用只盯着一句报错先判断它发生在 dex 生成之前还是类加载之后很多排查思路一下子就清晰了。