本文分析基于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的数据结构唯一,因此同名。
这张图包含了两个重要的信息:
- 所有的Space都位于0~4G的地址空间,即便是在64位的进程中。这样所有Java对象的地址(引用)都可以用4个字节来表示,而不是8个字节(64位),节省一半的内存。
- 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.vdex
和boot.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堆内存分配的主要场所,其中的对象既可以被移动,也可以被回收。
好了,这篇文章到这里也该结束了。至于剩下的内存分配和回收环节,就留给后面的文章了。