该篇内容会基于 JVM 运行时数据区(栈和堆) 和 JVM GC 两篇文章的理论知识上讲解,所以建议看完理解后再学习该篇文章会对知识的学习比较有帮助。
在 JVM 运行时数据区(栈和堆) 有简单提到,Hotspot 虚拟机是基于 JVM 标准开发的虚拟机,而 Android 并不是使用的 Hotspot 虚拟机,而是使用了 Dalvik 虚拟机,在 Android 5.0 后被替换为 ART 虚拟机。Dalvik 是一款不是 JVM 的 JVM 虚拟机,本质上它没有遵循 JVM 规范。
Android 为什么不使用 Hotspot 虚拟机,而是自己定制了 Dalvik 虚拟机呢?这就涉及到 Java 体系和 Android 体系的对比区别。
在 Java 体系使用 Java 语言,是为了能更好的推广 Java 语言,能够兼顾到更广的应用范围,注重的是能够跨平台,能够在各种各样的设备上移植,不需要硬件设备对 Java 语言的支撑。
在 Android 体系因为 设备目标明确 就是使用在 Android 手机上,也不需要依赖于硬件,因为 硬件体系规范明确。
Dalvik 虚拟机与 Hotspot 虚拟机的区别:
不直接运行 class 字节码文件,执行的是编译后的 dex 字节码文件
它的结构基于寄存器指令集结构,而不是 JVM 的栈指令集结构
JVM 运行时数据区:
Android 内存模型:
上图是 Android 的内存模型,相比 JVM,Android 将内存分为用户空间和内存空间,且用户空间是进程隔离的。
JVM 堆内存初始大小是 运行内存大小 / 64,最大内存大小是 运行内存 / 4,大小可能上好几个 GB,Android 每一个应用分配的内存并不大,只有 128-256 MB,当然这个数值不同的厂商定制化设定的数值也不同。
接下来同样的也会对比 JVM 栈和堆讲解 Android 的堆栈。
每个线程都会开辟一个虚拟机栈,每个方法对应一个栈帧,无论是 JVM 还是 Dalvik&ART 虚拟机在栈结构上都是这样的。栈区就是管理方法的运行。
那怎么理解 JVM 使用的栈指令集结构,Dalvik&ART 使用的寄存器指令集结构?
栈指令集结构在 JVM 主要代指的是栈帧中的局部变量表和操作数栈:
可以看到上图中,一个栈帧中无论是局部变量表还是操作数栈,都是基于栈结构。指令执行的最小单位是一个字节 8 位,这样能兼容到所有设备;方法运行时局部变量表和操作数栈会不断的入栈出栈。这种设计能够体现跨平台的优势。
在 Android 为了能够一次处理更多的指令有更快的运行速度,Dalvik&ART 虚拟机参考 CPU 结构,在栈帧内部将局部变量表和操作数栈的概念去除了,换为寄存器结构(也可以理解这里的寄存器就是局部变量表+操作数栈),寄存器能够一次操作 2/4/6 个字节单位的指令,用空间换时间。
在 CPU 中寄存器是其中一个组成部分,它能够有限的暂存指令、数据和位址:
相比 JVM 栈指令集结构计算需要多次入栈出栈,用寄存器结构计算时,就是将两个内存地址的数据通过 ALU 算数逻辑单元计算后,将结果存到另一块内存空间。与 JVM 相比这样的设计在指令数和数据移动次数上明显减少了。
需要注意的是,无论是基于寄存器指令集结构还是基于栈指令集结构,它们都是有使用栈结构,一个线程还是一个虚拟机栈,还是用的栈帧,区别是栈帧内部结构的不同,JVM 栈帧内部是局部变量表和操作数栈,而 Dalvik&ART 虚拟机是寄存器。
在讲解 Dalvik&ART 虚拟机堆区前,因为会涉及到 AOT 预先编译机制的内容,所以先提前了解下什么是 AOT。
Dalvik 虚拟机执行的是 dex 字节码文件,dex 字节码文件的生成经过 将 Java 文件编译为 class 字节码文件 -> class 字节码文件通过 dx 工具编译为 dex 字节码文件 -> 应用安装时 dex 字节码文件通过 dexopt 工具优化为 odex 字节码文件。
从 Android 2.2 开始支持 JIT(Just In Time)即时编译,即在程序运行过程中选择经常执行的代码进行编译或优化。
ART(Android Runtime)是在 Android 4.4 中引入的一个开发者选项,在 Android 5.0 及更高版本默认使用的 Android 运行时,ART 虚拟机执行的是本地机器码。Android 运行时从 Dalvik 虚拟机替换成 ART 虚拟机,并不要求开发者将自己的应用直接编译成目标机器码,apk 仍然是一个包含 dex 字节码的文件。
那 ART 执行的本地机器码是哪里来的?这就涉及到 dex2oat。
每一个 apk 应用都是一个 Dalvik 虚拟机,在应用安装时会进行一次优化,将 dex 字节码优化生成 odex 文件。ART 虚拟机将应用的 dex 字节码翻译成本地机器码的最恰当 AOT 预先编译时机也是发生在应用安装的时候。在安装时,ART 使用设备自带的 dex2oat 工具编译应用,dex 中的字节码将被编译成本地机器码。
虽然 AOT 能在应用运行时更快的运行,但这带来的问题就是应用安装会比较慢。所以在 Android 更高系统版本也提供了优化方案:
在 Android. 7.0 及更高版本混合使用了 AOT 预先编译、解释和 JIT。
最初安装应用时不进行任何 AOT 预先编译(安装变快了),运行过程中解释执行,对经常执行的方法进行 JIT,经过 JIT 编译的方法会记录到 Profile 配置文件中
当设备闲置和充电时,编译守护进程 BackgroundDexOptService 会运行,根据 Profile 文件对常用代码进行 AOT 编译成 base.art,待下次运行时直接使用
Android 因为数据种类比较多,有 Java、C/C++、Bitmap、预编译好的代码等,所以相比 JVM 只有一块堆内存,Android 根据存储的数据类型不同,它将堆拆分成了四个部分:Image Space、Zygote Space、Allocation Space 和 LargeObject Space。
Image Space 是用于存放系统预加载类,这些系统预加载类是通过 AOT 预先编译好并存放在 oat 文件中,每次开机启动时会把这些类映射到 Image Space。类比 JVM 堆区可以理解为是方法区。这块内存不进行垃圾回收。
Zygote Space 是存放管理 Zygote 进程在启动过程中预加载和创建的各种对象和资源,会进行垃圾回收。
Allocation Space 是我们主要关注的一块内存空间,它是 Java 或 C/C++ 代码的对象内存分配。类比 JVM 堆区就是年青代和老年代两个内存区域:
Allocation Space 大小是有限制的,一般是 12MB/24MB/36MB/48MB,不同的厂商定制化这块内存的空间各有不同,大多是 12MB/24MB。这个内存大小并不大,也因为有这个大小限制,如果对象过多就会频繁 GC。
LargeObject Space 是用于分配大对象的内存。类比 JVM 堆区就是老年代。
当满足以下一个条件时对象内存会在 LargeObject Space 分配,否则在 Allocation Space 上分配:
对象需要分配的内存大小 >= 12KB(内存一页 page 是 4 KB,12 KB 也就是三页 page)
对象是基本数据类型数组,即 byte[]、int[]、boolean[] 等;图片也是 byte[] 所以也是会在这里分配
JVM 会有 Minor GC、Major GC 和 Full GC 三种策略:
Eden 区满了,触发 Minor GC
old 区空间不足了,先触发 Minor GC,如果内存还不够则触发 Major GC
System.gc()、方法区空间不足、old 区空间不足、从年青代推到老年代的对象 > old 区可用内存,触发 Full GC
Dalvik 同样有三种 GC 策略:
Sticky GC:只回收上一次 GC 到本地 GC 之间申请的内存。其实就是 CMS 回收浮游垃圾
Partial GC:局部垃圾回收,会回收 Allocation Space 和 LargeObject Space 的内存垃圾
Full GC:全局垃圾回收,回收除了 Image Space 之外的内存垃圾
性能指标 | GC 策略对比 |
---|---|
GC 暂停时间 | Sticky GC < Partial GC < Full GC |
回收垃圾的效率 | Sticky GC > Partial GC > Full GC |
GC 时间越长对应用影响越大,因为 GC 伴随着 STW(Stop The World),所以也就会出现卡顿现象。
GC 回收也涉及到对应的引用类型,Java 有四种引用类型:强引用、软引用、弱引用、虚引用。
强引用:普通 new 对象就是使用强引用,强引用必须是对象不可达情况下才会回收
软引用:当内存不足时,软引用会被回收;系统内存不足时,就算可达也会被回收
弱引用:只要遇到垃圾回收,就会被回收掉
虚引用:如果一个对象仅持有虚引用,那么它就和没有引用一样,在任何时候都可能被 GC 回收
JVM 对象分配流程如下:
new 出来的对象进 Eden(大对象直接进老年代)
Eden 区放满了,再放就会开启一个 GC 线程(Minor GC)来回收垃圾
把 Eden 区中非垃圾对象复制到 Survivor 的 From 区,剩下的直接全部清除
继续 new 对象时,如果 Eden 区又满了,GC 来了就把 Eden 和 From 区存活的对象复制到 To 区,对象的分代年龄 +1
每次 Eden 满的时候,就在 Eden+From 和 Eden+To 中来回复制
对象年龄到老年代阈值,就进入老年代
老年代放满了,就会发生 Full GC,对堆进行全面 GC
Dalvik 对象分配流程如下:
首先会进行一次 Sticky GC,GC 完成后尝试分配;如果分配失败,则选择 Partial GC,GC 完成后再尝试分配;还是分配失败,则选择 Full GC,GC 完成后再尝试分配;如果还是不能分配,进入下一阶段
允许堆进行增长的情况下进行对象分配(可能一开始是 128 MB,最大是 256 MB,堆内存增长后再尝试分配)
经过两个阶段还不能分配,进行一次允许回收软引用的 GC 进行对象分配;如果还不能分配,OOM
将上面的三个阶段可以简单理解为:
内存是否足够,不够,GC 一次回收浮游垃圾
还不够,GC 一次局部回收(Allocation Space 和 LargeObject Space 的内存回收)
还不够,Full GC 一次(除 Image Space 之外的内存回收)
还不够,堆扩容
还不够,软引用回收
还不够,OOM
可以发现 Android 相比 JVM,在对象分配流程上会最大限度的保证不出现 OOM 的情况下,充分利用每一份空间,但是 当堆内存只要达到 85% 左右就会出现频繁 GC。