【Android ART】Heap的内存布局

本文分析基于Android 15

Heap内存管理,是ART中一块非常重要的内容。曾经我数次想要征服它,但都以失败告终。它就像一片迷雾森林,让身处其中的人看不清方向。譬如看了许多遍的CC(Concurrent Copying) Collector,直到Android 15上它被弃用了我都还没弄明白。因此这块内容我迟迟不敢动笔。

今年又贼心不死地再次尝试,心想多少要总结些内容,于是有了这篇文章。

Heap内存管理,大体上可以分为三块来阐述:内存布局、内存分配、内存回收。不过这些内容太过庞杂,无法汇总在一篇文章中,因此本文先介绍内存布局。

ART中的Heap并非是一块连续的单一内存,而是由众多不同功能的内存组合而成。这些不同功能的内存块在虚拟机中通过Space来管理,因此了解Heap的内存布局,本质上就是了解这些Space的具体功能。

如下是Heap中所有Space的继承关系。

乍一看有点蒙,我最初看到它的时候也是这个感觉。但随着研究的深入,我发现每种Space都有它存在的独特价值,而且基本都会带来性能的提升。不过这张图还是稍显复杂,下面我们针对Android 15,看看这些Space在内存空间上具体是如何排列的。

上图中,左侧是Space的类别,右侧是它具体的数据结构。main space、large object space、non-moving space之所以和它的数据结构不同名,是因为它们的数据结构不止一种。譬如large object space,就有FreeListSpace和LargeObjectMapSpace两种选择。而image space和zygote space的数据结构唯一,因此同名。

这张图包含了两个重要的信息:

  1. 所有的Space都位于0~4G的地址空间,即便是在64位的进程中。这样所有Java对象的地址(引用)都可以用4个字节来表示,而不是8个字节(64位),节省一半的内存。
  2. Android 15的GC回收策略从CC(Concurrent Copying)改成了CMC(Concurrent Mark-Compact),main space的数据结构也从RegionSpace切换成了BumpPointerSpace。不过变来变去,实际使用的Space只有图中的5种。

下面来具体介绍这5种Space。

Image space

上古时期,天地混沌,故事的开始还要从zygote说起。zygote初始阶段会启动虚拟机,这里面一个关键的环节便是创建Heap。它会通过mmap为每个Space创建地址空间,然后交由各自的数据结构去管理。Heap创建完毕后,这些Space通常都没有数据,但有一个例外:image space。

人们常说,Android App之所以采用fork的方式启动,就是为了让zygote中已经打开的资源可以被App复用,而不用在启动阶段再去加载。那么这些资源当中,最关键的就是一些常用的类。让zygote根据dex文件中的数据去创建自然没有问题,但更好的方式是在系统编译的时候就将这些类、方法、字段都创建好,然后写入一个image文件(后缀为.art)。这样zygote启动时只需要将image文件的内容搬运到内存当中,就可以得到已经创建好的类。而这些image搬运到内存中的位置,正好位于image space。

除了类似于boot.art的image文件外,zygote还会将boot.vdexboot.oat加载到image space。前者包含原始的dex信息,后者包含编译好的machine code。以下是一个真实的App进程的memory maps,从中我们看到boot相关的几个文件在image space中的排列。

bash 复制代码
00000000'6f114000-00000000'6f3e3fff rw-         0    2d0000  [anon:dalvik-/system/framework/boot.art]
...
00000000'705f4000-00000000'7068ffff r--         0     9c000  /system/framework/arm64/boot.oat
00000000'70690000-00000000'708cafff r-x     9c000    23b000  /system/framework/arm64/boot.oat
00000000'708d0000-00000000'708ebfff rw-         0     1c000  /system/framework/boot.vdex
00000000'708ec000-00000000'708ecfff r--    2d8000      1000  /system/framework/arm64/boot.oat
00000000'708f0000-00000000'708f0fff rw-    2dc000      1000  /system/framework/arm64/boot.oat

当然,image space中包含的不仅仅是boot.xxx它还包括boot-core-libart.xxx、boot-framework.xxx等其他文件。

另外基于安全的考虑,image space的起始地址并非固定的0x70000000,而是0x6f000000~0x71000000之间的一个随机地址。

Zygote space

Zygote在启动过程中所创建的对象会位于三个Space:main space(类型为BumpPointerSpace)、large object space(类型为FreeListSpace)和non-moving space(类型为DlMallocSpace)。它们通常都是些重要的对象,因此很难成为垃圾。于是GC干脆假定这些对象常驻内存、无需回收,这样可以节省一些操作。

可是fork出来的App依然会使用这些Space。为了减少彼此间的干扰,zygote在第一次fork前会将自己在main space里分配的对象规整一下,然后拷贝到已经使用的non-moving space后面,一起组合成新的zygote space,里面的对象在以后的日子里将不会被移动和回收。而原来的main space将会清空留给之后的App使用,non-moving space剩下的空间也会成为新的non-moving space。

既然main space和non-moving space里的对象都放到了zygote space里,那large object space为什么不这么操作呢?这是因为large object space中的对象不会引用其他对象,因此是引用关系链的末端。作为末端的节点,它们在三色标记中不会出现灰色的状态,因此可以省去一些中间辅助的数据和环节。换言之,large object space里的对象移动到zygote space中反而会增加无用的标记操作,得不偿失。不过zygote会在创建zygote space的时候将large object space里所有的对象标记上特殊的flag(kFlagZygote),以此告知GC这些对象不用回收。

Non-moving space

Non-moving space里的对象正如它名字所表示的这般:不可移动。不可移动不代表不可回收,这是non-moving space和zygote space最大的区别。现如今的Collector里面,移动代表着整理,不论方式是copy还是compact,它都会将存活的对象重新规整规整,让大家聚拢在一块,这样既能减少碎片化,也能提高局部性。

如此好的举措,为什么non-moving space里的对象非要逃避移动呢?Java世界中,有一类特别的对象,它们需要保证自己的内存不被移动,因为它的地址可能会被传递到native层使用。比如DirectByteBuffer,还有早期的Bitmap,这类对象就会分配在non-moving space中。

Non-moving space和zygote space合起来的大小为64M,所以如果需要分配的non-moving对象超过64M的话,进程也会抛出OutOfMemoryError,即便整体内存没有达到256M的上限。这里我本地做了个实验,以下是实验代码和结果。

java 复制代码
ByteBuffer[] tmp = new ByteBuffer[67109];
for(int i = 0; i < 67109; i++) {
    tmp[i] = ByteBuffer.allocateDirect(1000);
}
vbnet 复制代码
Process: dev.xxx.test, PID: 29216
java.lang.OutOfMemoryError: Failed to allocate a 1019 byte allocation with 100663296 free bytes and 189MB until OOM, target footprint 169903792, growth limit 268435456;
                            failed due to malloc_space fragmentation (largest possible contiguous allocation 944 bytes, space in use 60235640 bytes, capacity = 60706816)
	at dalvik.system.VMRuntime.newNonMovableArray(Native Method)
	at java.nio.DirectByteBuffer$MemoryRef.<init>(DirectByteBuffer.java:73)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:347)
    xxxxxxx

67109乘以1000刚好超过64M,从OutofMemoryError的log可以看出,需要分配的对象大小为1019,之所以比1000要大一些,是因为array有一些header的开销。此时距离256M的heap上限还有189M的空间,但是non-moving space已经分不出内存了。另外non-moving space的capacity为60706816(~57M),之所以不是64M是因为zygote space还占据了7M左右的空间。可能有人会对实验代码提出疑问:直接allocateDirect 64M不行么,为什么要用for循环?原因是大于12K的分配会走large object分支,对象分配出来会位于large object space。

Android 15上,non-moving space使用的具体数据结构为DlMallocSpace,其对于内存的底层管理为dlmalloc,全称为"Doug Lea's Malloc",它是早期标准C库里malloc的具体实现。之所以选用它,其实有着历史的原因。在Dalvik虚拟机的时代,dlmalloc作为主要的分配算法刚好满足了需求(具体参见下方引用)。后续随着GC的发展,分配算法几经更替,dlmalloc早已不用承担重要的工作了。如今non-moving space的使用频率并不高,而dlmalloc作为一个稳定性不错的"遗老",也算是不错的选择。

The high-order bit was that Dalvik needed to have an underlying allocator that was separate from the default malloc-managed heap, so it could have the right kind of control over how allocation happened, knowing that other subsystems wouldn't be interfering.

As it turned out, dlmalloc was a reasonably-mature existing library that provided the isolation and the hooks we needed. The intent (up to the point when I left the team) was that eventually we'd replace it with something more bespoke, but it never became a sufficiently pressing issue to take that particular plunge.

Large object space

Large object space我之前专门写过一篇文章介绍,它用于管理≥12KB的基本类型数组(譬如int[])和字符串对象(java.lang.String),它里面的内存不可以被移动,但可以被回收,具体可以点击链接阅读。想想当年写它的原因也挺搞笑,不是因为它重要,而是它相对Heap里的其他模块独立且简单。

Main space

Main space里的内存既可以被移动,也可以被回收。它是App绝大多数内存分配的地方,也是GC回收的主战场。它的分配和回收(尤其是回收)是Heap内存管理最重要、最精华的部分。因此,关于它的详细介绍会留到后续专门的文章中。

小结

最后做个小结,方便日后查阅。

App进程中的Heap由5个Space构成,它们的区别如下:

  • Image space:用于将boot相关的.art.oat.vdex文件加载到内存中,其中的对象不可移动、不可回收。
  • Zygote space:zygote在第一次fork前会将main space和non-moving space中使用的对象规整到一起,成为zygote space,其中的对象不可移动、不可回收。
  • Non-moving space:DirectByteBuffer和早期的Bitmap,这类对象需要保证自己的内存不被移动,因为它的地址可能会被传递到native层使用。因此这类对象所处的空间称为non-moving space,其中的对象不可移动但可以被回收。
  • Large object space:它用于管理≥12KB的基本类型数组(譬如int[])和字符串对象(java.lang.String)。其中的对象不会引用其他对象,因此是引用关系链的末端。作为末端的节点,它们在三色标记中不会出现灰色的状态,因此可以省去一些中间辅助的数据和环节。其中的对象不可移动但可以被回收。
  • Main space:App堆内存分配的主要场所,其中的对象既可以被移动,也可以被回收。

好了,这篇文章到这里也该结束了。至于剩下的内存分配和回收环节,就留给后面的文章了。

相关推荐
编程、小哥哥20 分钟前
python操作mysql
android·python
Couvrir洪荒猛兽1 小时前
Android实训十 数据存储和访问
android
五味香3 小时前
Java学习,List 元素替换
android·java·开发语言·python·学习·golang·kotlin
十二测试录4 小时前
【自动化测试】—— Appium使用保姆教程
android·经验分享·测试工具·程序人生·adb·appium·自动化
Couvrir洪荒猛兽5 小时前
Android实训九 数据存储和访问
android
aloneboyooo6 小时前
Android Studio安装配置
android·ide·android studio
Jacob程序员6 小时前
leaflet绘制室内平面图
android·开发语言·javascript
2401_897907867 小时前
10天学会flutter DAY2 玩转dart 类
android·flutter
m0_748233647 小时前
【PHP】部署和发布PHP网站到IIS服务器
android·服务器·php
Yeats_Liao8 小时前
Spring 定时任务:@Scheduled 注解四大参数解析
android·java·spring