(拉钩)Android工程师进阶34讲-12:DVM以及ART是如何对JVM进行优化的?

1. 什么是Dalvik

Dalvik是谷歌自己设计用于Android平台的Java虚拟机,Android工程师编写的Java或Kotlin代码最终都在这台虚拟机中执行。在Android 5.0之前叫DVM,5.0以后改叫ART(Android Runtime)。

在整个Android操作系统体系中,ART位于以下位置:

其实成DMV/ART为Android版的Java虚拟机是不准确的。虚拟机必须符合Java虚拟机规范,即通过JCM(Java Compliance Kit)的测试并获得授权,但是DVM/ART并没有得到授权。

DVM大多数实现与传统的JVM相同,但是因为Android最初是被设计用于手机端的,对内存空间要求比较高,并且起初Dalvik目标只是运行ARM架构的CPU上。针对这几种情况,Android DVM有了自己独有的优化措施。

2. Dex文件

传统class文件是由一个Java文件编译成的.class文件,而Android是把所有class文件进行合并优化,然后生成一个最终的class.dex文件。dex文件中去除了class文件中的冗余信息(如重复字符常量),并且结构更加紧凑,因此在dex文件解析阶段,可以减少I/O操作,提高类的查找速度。

比如在course12目录下,分别创建Dex1.java和Dex2.java,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Dex1 {
private int num = 1;

public int add(int i, int j) {
return i + j;
}
}

public class Dex2 {
private int count = 0;
private int num = 2;

public int add(int i) {
return num + i;
}
}

分别对它们进行编译:

1
2
javac Dex1.java  ->  Dex1.class
javac Dex2.java -> Dex2.class

然后使用jar cvf AllDex.jar Dex1.class Dex2.class将Dex1.class和Dex2.class打包到一个jar文件中。

这时会生成一个名为AllDex.jar的文件。

最后使用dx命令对AllDex.jar进行优化,生成AllDex.dex文件。

1
dx --dex --output AllDex.dex AllDex.jars

正常情况下,无法通过反编译工具查看其源码,可以通过Android SDK中的工具dexdump查看其字节码。

1
dexdump -d -l plain AllDex.dex

上述命令将Dex1和Dex2优化后的字节码显示到控制台,内容较多,部分结果如下:

可以看出Dex1和Dex2的信息都在此.dex文件中。

实际上,dex文件在App安装过程中还会被进一步优化为odex(optimized dex),此过程还会在后面介绍安装过程再次提到。

注意:这里的优化也伴随着一些副作用,最经典的就是Android 65535问题。出现这个问题的根本原因是在DVM源码中的MemberIdsSection.java类中,有如下一段代码:

如果items个数超过DexFormat.MAX_MEMBER_IDX,则会报错,DexFormat.MAX_MEMBER_IDX的值为65535,items代表dex文件中方法的个数、属性个数、以及类的个数。也就是说理论上不止方法数,在java文件中声明的变量,或者创建的类超过65535个,同样会编译失败,Android提供了MultiDex来解决这个问题。

3. 架构基于寄存器&基于栈堆结构

前面已经介绍过,JVM的指令集是基于栈结构来执行的;而Android字节码和Java字节码完全不同,Androdid的字节码(smali)更多的是二地址指令和三地址指令,具体Dalvik指令可以参考Dalvik 字节码

具体看一下Dalvik和JVM字节码的区别,在上文中提到的Dex1.java,在Dex1中有add()方法。

经过编译为Dex1.class之后,查看其字节码如下:

add()方法会使用4行指令来完成。而通过dx将其优化为.dex之后,再次查看它的Dalvik字节码如下:

说明:

  • add_int指令需要三个寄存器参数:v0v2v3。这个指令会将v2v3进行相加运算,然后将结果保存到寄存器v0中。
  • return指令将结果返回。

可以看出,Dalvik字节码只需要2行指令。基于寄存器的指令明显会比基于栈的指令少,虽然增加了指令长度但却缩减了指令的数量,执行也更迅速。

用一张表来对比基于栈和基于寄存器的实现方式:

栈式VS寄存器式 对比
指令条数 栈式 > 寄存器式
指令长度 栈式 < 寄存器式
移植性 栈式由于寄存器式
指令优化 栈式更不易优化
解释器执行速度 栈式解释器速度稍慢
代码生成难度 栈式简单

4. 内存管理与回收

DVM与JVM另一个比较显著的不同是内存结构的区别,主要体现在对“堆”内存的管理。Dalvik虚拟机中的堆被划分为两个部分:Active Heap和Zygote Heap。如下如:

图中的Card Table以及两个Heap Bitmap主要用来记录垃圾收集过程中对象的引用情况,以便实现Concurrent GC。

5. 为什么要分Zygote和Active两个部分?

Android系统中的第一个Dalvik虚拟机是由Zygote进程创建的,而应用程序进程是由Zygote进程fork出来的。

Zygote进程是系统启动时产生的,它会完成虚拟机的初始化,库的加载,预置类的加载以及初始化等操作,而在系统需要一个新的虚拟机实例时,Zygote通过复制自身,最快速的提供一个进程;另外,对于一些只读的系统库,所有虚拟机实例都和Zygote共享一块内存区域,大大节省内存开销。如下图:

说明:

当启动一个应用时,Android操作系统需要为应用程序创建新的进程,而这一步操作是通过一种写时拷贝技术(COW)直接复制Zygote进程而来。这意味着在开始的时候,应用程序进程和Zygote进程共享了同一个用来分配对象的堆。然而,当Zygote进程或者应用程序进程对该堆进行写操作时,内核就会执行真正额拷贝操作,使得Zygote进程和应用程序进程分别拥有自己的一份拷贝。拷贝是一件费时费力额的事情。因此,为了尽量避免拷贝,Dalvik虚拟机将自己的堆栈划分为两部分。

事实上,Dalvik虚拟机的堆最初只有一个,也就是Zygote进程在启动过程创建Dalvik虚拟机时,只有一个堆。但是当Zygote进程在fork第一个应用程序进程之前,会将已经使用的那部分堆内存划分为一部分,把还没有使用的堆内部内划分为另外一部分。前者称为Zygote堆,后者称为 Active堆。以后无论Zygote进程,还是应用程序进程,当它们需要分配对象时,都在Active上进行。这样就可以使得Zygote堆尽可能少地被执行写操作,因为就可以减少执行写时拷贝的操作时间。

6. Dalvik虚拟机堆

在Dalvik虚拟机中,堆实际上就是一块匿名共享内存。Dalvik虚拟机并不是直接管理这块匿名共享内存,而是将它封装成一个mspace,交给C库来管理,为什么呢?因为内存碎片问题实际是一个通用的问题,不只是Dalvik虚拟机在Java堆为对象分配内存时会遇到,C库的malloc函数在分配内存时也会遇到。

Android系统使用的C库bionic使用了Doug Lea写的dlmalloc内存分配器,也就是说,调用函数malloc时,使用的是dlmalloc内存分配器来分配的内存。这是一个成熟的内存分配器,可以很好的解决内存碎片问题。

关于dlmalloc内存分配器的设计,可以参考:A Memory Allocator

7. 拓展阅读

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信