前言
米娜桑,是时候揭开DEX的面纱了!我们都知道multidex,都知道65535方法数超标,那DEX到底是个什么东西呢?或许又有些同学知道DEX会优化为ODEX,那ODEX又是什么鬼,优化了什么呢?为什么ClassLoader热补丁方案插入构造函数导致CLASS_ISPREVERIFIED为false后,会对性能造成影响,和ODEX又有什么关系呢?
我们又知道5.0以上Android虚拟机变成了Art,那DEX在art上变成了什么呢?为什么安装特别耗时间?有时候我看着我的Nexus6安装一个应用在那进度条读啊读的好像卡住了,有一种想砸了它的想法,所以当我拿到Nexus 5测试机的时候,第一件事就是刷到4.4,不然每次安装的效率实在不能忍(捂脸)。
DEX是什么
直接把apk当成zip打开后,第一级目录你就会看见有classes.dex,这就是我们要揭开面纱的东西了。
Why DEX
为什么需要DEX,jar不行吗?相应地,为什么需要Dalvik虚拟机,JVM不行吗?
Dalvik虚拟机是专门为了Android移动平台设计的。目标系统的RAM有限,数据存储在缓慢的内部闪存上,而且性能和上个世纪的周免系统相当。它们运行Linux,来提供虚拟内存,进程和线程,以及基于UID的安全机制。
这些特征和限制使我们聚焦在这些目标上:
类数据,尤其是字节码,必须被多个进程共享,以最小化系统内存使用。
启动一个新app的开销必须最小化,来保证设备的可响应。
在独立的文件存储类数据可能导致很多冗余,尤其是字符串。为了保证磁盘空间,我们需要把这些因子提出来。
解析类数据的fields在类加载的时候增加了很多不必要的开销。把数据值直接当成C类型(比如整数或字符串)使用会更好。
字节码验证是必要的,却也是缓慢的。所以我们想在app执行外尽量验证更多,以便不要影响app本身体验。
字节码优化(加速指令,精简方法)对速度和电池生命很重要。
为了安全原因,进程不能编辑共享代码。
典型的虚拟机执行从压缩文件解压独立的类,然后把它们存到heap上。这就导致了每个类可能在每个进程有独立的拷贝,从而使得应用启动变慢,因为代码必须被解压(或者至少需要从磁盘的很多小片段去读取)。另一方面,在本地heap放置字节码简化了首次使用时的指令重写,从而可能导致一些不同的优化。
这些目标指引了一些基本决定:
多个类被聚集到一个单个的DEX文件。
DEX文件被映射为只读,并且在进程间共享。
针对本地系统调整字节码顺序和词对齐。
字节码验证对所有类都是强制的,但我们想要对一切可能的进行”预验证(pre-verify)”。
需要重写字节码的优化必须提前执行。
而Dalvik虚拟机和DEX也就应运而生。
Hello DEX
让我们手动来生成一个java,编译成javac,然后转换为dex看看:
1 2 3 4 5 6 7 | echo 'class Foo {'\ 'public static void main(String[] args) {'\ 'System.out.println("Hello, world"); }}' > Foo.java javac Foo.java dx --dex --output=foo.jar Foo.class adb push foo.jar /sdcard/ adb shell dalvikvm -cp /sdcard/foo.jar Foo |
当我们在dx命令的output中指定输出文件后缀为.jar,.zip,或者.apk,名为classes.dex的文件就会被创建并保存在压缩包内。解开Foo.jar你就会看到classes.dex和META-INF文件夹(里面只有一个MANIFEST.MF文件)。
我们创建完该jar后直接push到设备上,并通过shell直接让dalvik虚拟机去运行它,如果操作无误,会看到命令行的反馈 - Hello, world。
DEX in file system
DEX in memory
为什么DEX不能被内存映射,或者说,不能直接从zip去执行呢?因为数据是压缩的,文件头也不保证是词对齐的。这些问题可以通过不压缩直接保存为classes.dex和填充zip文件来解决,但会导致数据网络间传输的包体积变大。
我们需要在使用前把zip包里的classes.dex解压。当我们拿到文件的时候,我们可能还会做些之前提到的其他操作(对齐、优化、验证)。这又引出了另一个问题:谁去负责做这些,我们又该把输出放在哪儿?
ODEX是什么
ODEX,全名Optimized DEX,即优化过的DEX。
有至少3种方法去创建一个“准备好的”DEX文件,即ODEX:
虚拟机“即时(just in time)”执行。输出会跑到一个特殊的dalvik-cache目录。这只在一些特殊的桌面和工程机的设备上使用(这些机器的build中,dalvik-cache目录的权限不是严格的)。在生产机器上这是不被允许的。
系统的安装器在程序首次安装时候执行,它有写dalvik-cache的权限。
构建(build)系统预先执行。相关的 jar / apk 文件还在,但classes.dex被剥离出来了。ODEX和原来的zip包保存在一起,不在dalvik-cache,而是系统镜像的一部分。
dalvik-cache目录更准确地说是$ANDROID_DATA/data/dalvik-cache。里面的文件的名字来源于源DEX的完整路径。在设备上该目录被system所拥有,而system拥有0771权限,保存在那里的ODEX被系统和应用的组所拥有,权限为0644。数字权限保护的应用会使用640权限来防止其他应用去检测它们。底线是你可以读取自己的与其他大部分应用的DEX文件,但你不能创建、修改,或删除它们。
前两种方法的执行分为以下三个步骤:
首先,dalvik-cache文件被创建。这必须在一个有恰当权限的进程进行,所以在“系统安装器”的场景,是在运行为root的installd进程执行的。
接着,classes.dex从zip包中解压出来。文件头部留出一小块空间给ODEX header。
最后,文件被内存映射以便访问,并被为当前系统使用进行调整。这包括了字节交换(byte-swapping),结构重新排列(structure realigning),但并没有对DEX文件做有意义的改变。还做了一些其他的基本结构检查,比如确保文件偏移量和数据索引落在有效范围内。
构建系统不在桌面上运行工具,而宁愿去启动模拟器,强制所有相关DEX文件的即时优化,然后从dalvik-cache把结果提取出来。这样做的原因,在解释完优化后会变得更显而易见。
一旦代码被字节替换和对齐,我们就可以继续了。我们添加了一些预计算的数据,在文件头填写ODEX header,然后开始执行。然而,如果我们对验证和优化有兴趣,就需要在初始准备后再插入一个步骤。
dexopt的魔法
在Android 2.3版本以前,系统源码中提供了生成odex的工具dexopt-wrapper,位于Android 2.2系统源码的 build/tools/dexpreopt/dexopt-wrapper/ 目录下,查看DexOptWrapper.cpp
文件会发现实际调用的是 /system/bin/dexopt 程序。在5.0及以上版本的设备上,你可能已经再也找不到dexopt了,取而代之的是dex2oat。
我们想要验证和优化DEX文件里的所有类。最简单和安全的方法就是把所有类加载到虚拟机,然后跑一遍。任何加载失败的就是验证/优化失败的。不幸的是,这可能导致一些资源的分配难以释放(比如native共享库的加载),所以我们不想执行在应用运行的虚拟机里。
解决方案就是起一个叫做dexopt的程序(事实上就是虚拟机的后门)。它会执行一个简短的虚拟机初始化,从引导的类路径加载0个或多个DEX文件,然后开始做一切从目标DEX可以做的验证和优化。结束后,进程退出,释放所有资源。
因为多个虚拟机可能同时需求同一个DEX文件,文件锁被用来确保dexopt仅被执行一次。
验证
字节码验证过程包含了扫描DEX文件中每一个类每个方法的指令。目的是为了识别非法指令序列以便不会在运行时才发现它们。涉及到的很多运算对“准确的”GC也是必要的。更多信息见Dalvik字节码验证器笔记。
为了性能原因,(下节描述的)优化器假设验证器已经运行成功,还会做一些其他可能不安全的假设。默认地,Dalvik会坚持验证所有类,并只优化那些被验证过的类。可以使用命令行flags去禁用验证器。怎么在Android应用框架中控制这些功能的指令见控制嵌入式虚拟机。
验证失败的报告是一个复杂的问题。例如,在不同的package中,调用一个package内可见的方法是非法的,会被验证器捕捉到。但我们未必想要在验证期报告它 —— 事实上我们想要在试图调用方法的时候抛出异常。在每个方法调用上检查这些访问flags也是很昂贵的,Dalvik字节码验证器笔记提到了这个问题。
成功被验证的类在ODEX有一个flag被设置了,在加载的时候就不会被重新验证。ODEX文件有一个32位的checksum,但那是主要是用来快速检查数据损坏的。
优化
虚拟机解释器通常会在一段代码被首次使用的时候执行某些优化。常量池引用被指向内部数据结构的指针所替代,总是成功的操作或是那些总会以某种方式工作的,会被更简单的形式所替代。这些的一部分需要仅在运行时可用的信息,另一部分在某些特定假设下可以被静态推论出。
Dalvik优化器做了这些:
对于虚方法调用,把方法索引替换为vtable索引。
对于实例变量(field)的get/put,把变量索引替换为字节偏移。另外,把 boolean / byte / char / short 基本变量(variants)合并到单个的32位形式(解释器里更少的代码意味着CPU I-cache里更少的空间)。
替换一些高频次调用,比如把 String.length() 替换成”内联“的。这可以跳过一些常见的方法调用消耗,直接从解释器切换到native实现。
删除空方法。最简单的例子就是Object.
,啥都没干,但却必须在任何对象被分配的时候执行。指令会被替换为一个新版本的空指令(no-op)形式,除非调试器被attach上去了。附加预计算数据。例如,虚拟机想要一个类名的哈希表以便查找。不同于在加载DEX文件时候去计算这个,我们可以先计算,以节省堆(heap)空间和所有加载该DEX文件的虚拟机的计算时间。
大部分的优化显然都会更好。
Hello ODEX
我们继续玩耍之前生成的dex,来做一个odex:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | adb push dexopt-wrapper /sdcard/ adb shell # 不然没权限去/data/local su chmod 777 dexopt-wrapper # 直接在sdcard执行会提示权限错误 cp dexopt-wrapper /data/local/ cp foo.jar /data/local/ cd /data/local /dexopt-wrapper foo.jar foo.odex --- BEGIN 'foo.jar' (bootstrap=0) --- --- waiting for verify+opt, pid=5220 --- would reduce privs here --- END 'foo.jar' (success) --- cp foo.odex /sdcard exit exit adb pull /sdcard/foo.odex . |
这样子就拿到了优化后的odex,赶紧把手机还给同事。