Java 虚拟机在前面的系列文章中有所介绍(https://www.jianshu.com/c/84fea797b420),Android 所使用的虚拟机(Dalvik 和 ART)与 JVM 有所不同,这篇文章来介绍一下。
1. Dalvik 虚拟机
Dalvik 虚拟机(Dalvik Virtual Machine),简称 Dalvik VM 或者 DVM。它是 Google 专门为 Android 平台开发的虚拟机,运行在 Android 运行时库中。DVM 并不是一个 Java 虚拟机,原因在下面介绍。
1.1 DVM 与 JVM 的区别
DVM 之所以不是一个 JVM,主要原因是 DVM 并没有遵循 JVM 规范来实现,与 JVM 主要区别如下:
1. 基于的架构不同
JVM 的执行的指令是基于栈结构,这就意味着需要去栈中读写数据,所需的指令会很多,会导致速度变慢,对于性能有限的移动设备,显然不合适。DVM 是基于寄存器的,没有基于栈的虚拟机在复制数据时使用的大量的出入栈指令,同时指令更紧凑、更简介。但是由于指定了操作数,所以指令会比基于栈的指令大,但是由于指令数量的减少,总的代码不会增加多少。
2. 执行的字节码不同
在 Java SE 程序中,Java 类被编译成一个或多个 .class 文件,并打包成 jar 文件,而后 JVM 会通过相应的 .class 文件和 jar 文件获取对相应的字节码。执行顺序为:.java 文件 → .class 文件 → .jar 文件,而 DVM 会用 dx 工具将所有的 .class 文件转换为一个 .dex 文件,然后 DVM 会从该 .dex 文件读取指令和数据。执行顺序为:.java 文件 → .class 文件 → .dex 文件。
jar 文件和 apk 文件结构如图:
关于 class 文件结构的详细说明,可参考:
当 JVM 加载 .jar 文件的时候,会加载里面所有的 .class 文件,这种加载方式对于性能有限的移动设备不合适。在 .apk 文件中,一般情况下只包含一个 .dex 文件,这个 .dex 文件把所有的 .class 文件信息整合在一起,这样就提升了加载速度。.class 文件种也会存在一些冗余信息,dex 工具会去除冗余信息,并把所有的 .class 文件整合到 .dex 文件种,减少 I/O 操作,加快了类的查找速度。
3. DVM 允许在有限的内存中同时运行多个进程
DVM 经过优化,允许在有限的内存中同时运行多个进程。在 Android 中的每一个应用都运行在一个 DVM 实例中,每一个 DVM 实例都运行在一个独立的进程空间中,独立的进程可以防止在虚拟机崩溃的时候所有的程序都关闭。
4. DVM 由 Zygote 创建和初始化
Zygote 是第一个 DVM 进程,同时也用来创建和初始化 DVM 实例。每当系统需要创建一个应用程序时,Zygote 就会 fork 自身,快速的创建和初始化一个 DVM 实例,用于应用程序的运行。对于一些只读的系统库,所有的 DVM 实例都会和 Zygote 共享一块内存区域,节省了内存开销。
5. DVM 有共享机制
DVM 拥有预加载——共享的机制,不同的应用之间在运行时可以共享相同的类,拥有更高的效率。而 JVM 不存在这种共享机制,不同的程序,打包后彼此独立,即便它们使用了同样的类,运行时也都是单独加载和运行的,无法进行共享。
6. DVM 早期没有使用 JIT 编译器
早期的 DVM 没有使用 JIT 编译器,每次执行代码,都需要通过解释器将 dex 代码编译成机器码,然后执行,效率不高。为了解决这一问题,从 Android 2.2 版本开始 DVM 使用了 JIT 编译器,它会对多次运行的代码(热点代码)进行编译,生成精简的本地机器码(Native Code),这样在下次执行到相同代码时,可以直接使用机器码执行。但是,应用程序每次重新运行时,都需要做 JIT 编译工作。
1.2 DVM 架构
DVM 源码位于 dalvik/ 目录下,Android 8.0 及 9.0 中的 DVM 源码部分目录说明如下:
目录/文件 | 说明 |
---|---|
dexdump | 生成 dex 文件的反编译查看工具,主要用来查看编译出来的代码的正确性和结构 |
dexgen | dex 代码生成器项目 |
docs | DVM 相关帮助文档 |
dx | Java 字节码转换成 DVM 机器码的工具 |
libdex | 生成主机和设备处理 dex 文件的库 |
tools | 一些编译和运行相关的工具 |
Android.mk | 虚拟机编译的 makefile 配置文件 |
MODULE_LICENSE_APACHE2 | APACHE2 版权声明文件 |
NOTICE | 虚拟机源码版权注意事项文件 |
其中,dalvik/libdex 会被编译成 libdex.a 静态库,作为 dex 工具使用;dalvik/dexdump 是 .dex 文件的反编译工具,DVM 架构如图:
首先 Java 编译器编译的 .class 文件经过 dx 工具转换为 .dex 文件,.dex 文件由类加载器处理,接着解释器根据指令集对 Dalvik 字节码进行解释、执行,最后交由 Linux 处理。
1.3 DVM 的运行时堆
DVM 的运行时堆使用标记——清除(Mark-Sweep)算法进行 GC,它由两个 Space 以及多个辅助数据结构组成,两个 Space 分别是 Zygote Space(Zygote Heap)和 Allocation Space(Active Heap)。Zygote Space 用来管理 Zygote 进程在启动过程中预加载和创建的各种对象,Zygote Space 中不会触发 GC,在 Zygote 进程和应用程序进程之间会共享 Zygote Space。在 Zygote 进程 fork 第一个子进程之前会把 Zygote Space 分为两部分,原来的已经被使用的那部分堆仍称为 Zygote Space,而未使用的那部分堆称为 Allocation Space,以后的对象都会在 Allocation Space 上进行分配和释放。Allocation Space 不是进程间共享的。除了这两个 Space,还包含以下数据结构:
- Card Table:用于 DVM Concurrent GC,当第一次进行垃圾回收标记后,记录被标记对象信息。
- Heap Bitmap:有两个 Heap Bitmap,一个用来记录上次 GC 存活的对象,另一个用来记录这次 GC 存活的对象。
- Mark Stack:DVM 的运行时堆使用标记——清除(Mark-Sweep)算法进行 GC,Mark Stack 就是在 GC 的标记阶段使用的,用来遍历存活的对象。
1.4 DVM 的 GC 日志
DVM 种的垃圾收集日志格式为:
D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>, <External_memory_status>, <Pause_time>
可以看出 DVM 的日志共有 5 个信息,其中 GC Reason 有多个。
1. 引起 GC 的原因
<GC_Reason> 就是引起 GC 的原因,有以下几种:
- GC_CONCURRENT:当堆开始填充时,并发 GC 释放内存。
- GC_FOR_MALLOC:当堆已满时,App 尝试分配内存而引起 GC,系统必须停止 App 并回收内存。
- GC_HPROF_DUMP_HEAP:当请求创建 HPROF 文件来分析堆内存时出现 GC。
- GC_EXPLICIT:显式的 GC,例如调用 System.gc()(应避免显式的调用 GC,信任 GC 会在需要时运行)。
- GC_EXTERNAL_ALLOC:仅适用于 API 级别小于等于 10,且用于外部分配内存的 GC。
2. 其它的信息
除了引起 GC 的原因,其它信息如下:
- Amount_freed:本次 GC 释放内存的大小。
- Heap_stats:堆的空闲内存百分比(已用内存)/(堆的总内存)。
- External_memory_stats:API 小于等于级别 10 的内存分配(已分配的内存)/(引起 GC 的阈值)。
- Pause_time:暂停时间,更大的堆会有更长的暂停时间。并发暂停时间会显示两个暂停时间,一个出现在垃圾收集开始时,另一个出现在在垃圾收集快要完成时。
3. 实例分析:
D/dalvikvm: GC_CONCURRENT freed 2011K, 60% free 3200K/8900K, external 4501K/5421K, paused 2ms+2ms
含义如下:
引起 GC 的原因时 GC_CONCURRENT;本次释放的内存为 2011KB;堆的空闲百分比为 60%,已使用内存为 3200KB,堆的总内存为 8900KB;暂停的总时长为 4ms。
2. ART 虚拟机
ART(Android Runtime)虚拟机是 Android 4.4 发布的,用来替换 Dalvik 虚拟机,Android 4.4 默认采用 DVM,但是可以选择使用 ART。在 Android 5.0 版本中默认使用 ART,DVM 从此退出历史舞台。
2.1 ART 与 DVM 的区别
主要有以下 4 点:
- DVM 中的应用每次运行时,字节码搜需要通过 JIT 编译器编译成机器码,这会使得应用程序的运行效率降低。而在 ART 中,系统在安装应用程序时会进行一次 AOT(ahead of time compilation, 预编译),将字节码预先编译成机器码并存储在本地,这样应用程序每次运行时就不需要执行编译了,运行 效率会大大提升,设备的耗电量也会降低。不过采用 AOT 也有缺点,主要有两个:第一个是 AOT 会使得应用程序的安装时间变长,尤其是一些复杂的应用;第二个是字节码预先编译成机器码,机器码需要的存储空间会多一些。为了弥补以上两个缺点,Android 7.0 版本的 ART 加入了即时编译器 JIT,作为 AOT 的一个补充,在应用程序安装时不会将字节码全部编译成机器码,而是在运行种将热点代码编译成机器码,从而缩短了应用程序的安装时间并节省了存储空间。
- DVM 时为 32 位 CPU 设计的,而 ART 支持 64 位并兼容 32 位 CPU,这也是 DVM 被淘汰的主要原因之一。
- ART 对垃圾回收机制进行了改进,比如更频繁地执行并行垃圾收集,将 GC 暂停由 2 次减少为 1 次等。
- ART 的运行时堆空间划分与 DVM 不同。
2.2 ART 的运行时堆
与 DVM 的 GC 不同的是,ART 采用了多种垃圾回收方案,每个方案会运行不同的垃圾收集器,默认采用 CMS(Concurrent Mark-Sweep)方案,该方案主要使用了 sticky-CMS 和 partial-CMS。根据不同的 CMS 方案,ART 的运行时堆的空间也有不同的划分,默认是由 4 个 Space 和多个辅助数据结构组成的,4 个 Space 分别是 Zygote Space、Allocation Space、Image Space 和 Large Object Space。Zygote Space、Allocation Space 和 DVM 中的作用是一样的,Image Space 用来存放一些预加载类,Large Object Space 用来分配一些大对象(默认大小为 12KB),其中 Zygote Space 和 Image Space 是进程间共享的。采用标记——清除算法时的运行时堆空间划分如图:
除了这四个 Space,ART 的 Java 堆中还包括两个 Mod Union Table,一个 Card Table,两个 Heap Bitmap,两个 Object Map,以及三个 Object Stack。
2.3 ART 的 GC 日志
ART 的 GC 日志与 DVM 不同,ART 会为那些主动请求的垃圾回收事件或者认为 GC 速度慢时才会打印 GC 日志。GC 速度慢指的是 GC 暂停超过 5ms 或者 GC 持续时间超过 100ms.如果 App 未处于可察觉的暂停进程状态,那么它的 GC 不会被认为是慢速的。
ART 的 GC 日志具体格式为:
I/art: <GC_Reason> <GC_Name> <Objects_freed>(<Size_freed>) AllocSpace Objects, <Large_objects_freed>(<Large_object_size_freed>) <Heap_stats> LOS objects, <Pause_time(s)>
介绍如下:
1. 引起 GC 的原因
ART 的引起 GC 原因(GC_Reason)要比 DVM 多一些,有以下几种:
- Concurrent:并发 GC,不会使 App 的线程暂停,该 GC 是在后台线程运行的,并不会阻止内存分配。
- Alloc:当堆内存已满时,App 尝试分配内存而引起的 GC,这个 GC 会发生在正在分配内存的线程中。
- Explicit:App 显示的请求垃圾收集,例如调用 System.gc()。与 DVM 一样,最佳做法是应该信任 GC 并避免显式地请求 GC,显示地请求 GC 会阻止分配线程并不必要地浪费 CPU 周期。如果显式地请求 GC 导致其他线程被抢占,那么有可能会导致 jank(App 同一帧画了多次)。
- NativeAlloc:Native 内存分配时,比如为 Bitmaps 或者 RenderScript 分配对象,这会导致 Native 内存压力,从而触发 GC。
- CollectorTransition:由堆转换引起的回收,这是运行时切换 GC 而引起的。收集器转换包括将所有对象从空闲列表空间复制到碰撞指针空间(反之亦然)。当前,收集器转换仅在以下情况出现:在内存较小的设备上,App 将进程状态从可察觉的暂停状态变更为可察觉的非暂停状态(反之亦然)。
- HomogeneousSpaceCompact:齐性空间压缩,指空闲列表到压缩的空闲列表空间,通常发生在当 App 已经移动到可察觉的暂停进程状态时。这样做的主要原因是减少内存使用并对堆内存进行碎片整理。
- DisableMovingGc:不是真正触发 GC 的原因,发生在并发堆压缩时,由于使用了 GetPrimitiveArrayCritical,收集会被阻塞。在一般情况下,强烈建议不要使用 GetPrimitiveArrayCritical,因为它在移动收集器方面有限制。
- HeapTrim:不是触发 GC 的原因,但是需要注意,收集会一直被阻塞,直到堆内存整理完毕。
2. 垃圾收集器名称
GC_Name 指的是垃圾收集器名称,有以下几种:
- Concurrent Mark Sweep(CMS):CMS 收集器是一种以获取最短收集暂停时间为目标的收集器,采用标记——清楚算法实现。它是完整的垃圾收集器,能释放除了 Image Space 外的所有的空间。
- Concurrent Partial Mark Sweep:部分完整的垃圾收集器,能释放除 Image Space 和 Zygote Space 外的所有空间。
- Concurrent Sticky Mark Sweep:粘性收集器,基于分带垃圾收集思想,只能释放上次 GC 以来分配的对象。这个垃圾收集器比一个完整或者部分完整的垃圾收集器扫描的更频繁,因为它更快而且有更短的暂停时间。
- Marksweep + Semispace:非并发的 GC,复制 GC 用于堆转换以及齐性空间压缩(堆碎片整理)。
3. 其它信息
- Object freed:本次 GC 从非 Large Object Space 中回收的对象数量。
- Size_freed:本次 GC 从非 Large Object Space 中回收的字节数。
- Large objects freed:本次 GC 从 Large Object Space 中回收的对象数量。
- Large objects size freed:本次 GC 从 Large Object Space 中回收的字节数。
- Heap stats:堆的空闲内存百分比,即(已用内存)/(堆的总内存)。
- Pause times:暂停时间,暂停时间与在 GC 运行时修改的对象引用数量成比例。目前,ART 的 CMS 收集器仅有一次暂停,它出现在 GC 的结尾附近。有对象移动的垃圾收集器暂停时间会很长,会在大部分垃圾回收期间连续出现。
4. 实例分析
I/art : Explicit concurrent mark sweep GC freed 104710(7MB) AllocSpace objects, 21(416KB) LOS objects, 33% free, 25MB/38MB, paused 1.230ms total 67.21ms
这个 GC 日志的含义为引起 GC 原因是 Explicit; 垃圾收集器为 CMS 收集器;释放对象的数量为 104710个,释放字节数为 7MB;释放大对象的数量为 21 个,释放大对象字节数为 416KB;堆的空闲内存百分比为 33%,已用内存为 25MB,堆的总内存为 38MB;GC 暂停时长为 1.230ms,GC 总时长为 67.21ms。