本文将按照时间顺序讲述Android垃圾回收器(Garbage Collector)的演进,从最早版本的Android到最新版本。
纵观整个演变的历程,关于GC的变动,主要可以分为两大类:
1. GC工作模式的改动。即Android的开发团队对GC的整体逻辑进行了大的修改,改动包括 分配/释放 内存的算法,以及搜索可达对象和确定可用内存的逻辑等。
2. 优化。即整体逻辑不变的基础上对算法进行优化。
对改动的分类是有意义的,因为在某些版本,GC有明显的改动。我们需要明确这些改动是重新实现还是优化,这样可以更好的梳理出一些Android GC发展历程中的关键节点。
整体来看,我们可以把Android的GC演变总结为四个阶段:
1. Dalvik GC : 第一阶段的GC,对应系统版本为Android KitKat之前。Dalvik GC使用了比较旧的"stop the world"设计,在垃圾回收期间会暂停虚拟机上的所有线程。
2. ART GC (Lollipop & Marshamllow) : GC发展历程中最大的一次改动,ART/Dalvik的开发团队重写了整个GC。这一阶段的GC也被叫做“分代GC”,因为对象会因存活时间不同而具有不同的“代”(或者叫年龄),另外还有其他方面的大的改进,比如分配内存的方式等。
3. ART GC(Nougat) : ART/Dalvik团队用汇编重写了整个内存分配的过程。
4. ART GC(Oreo) : 对第一版ART GC进行了优化,最明显的改进是把垃圾回收过程改为并发执行。这一代的GC也叫“并发复制垃圾回收器(Concurrent Copying Garbage Collector)”,同时在其他方面也做了不少优化。
Dalvik
Android在KitKat之前一直使用Dalvik运行环境,在KitKat的时候谷歌引入了新开发的ART,和Dalvik混合发布,目的是进行一些前期的测试以及从开发人员那里获取一些反馈。在Lollipop发布时就用ART全面替换掉了Dalvik。
Dalvik时期,对象的内存分配和回收都比较慢。因为Dalvik使用的GC是单线程的,在垃圾回收期间要暂停虚拟机上的其他所有线程。所以这个时期的开发建议就是尽可能不要分配内存(GC=卡顿,所以不分配,就不用回收了。。)
Dalvik GC采用了并发标记-清除(CMS)算法。标记-清除算法的特点是,不用的对象不会马上回收,而是等到可用内存用尽之后才回收。这样导致了很多对象在用完之后还会在内存中存活较长时间,不能及时释放。
而且Dalvik GC只对后台的App进行内存整理(压缩),导致(前台App)堆中出现大量内存碎片,这些碎片往往都比较小,无法被分配给新对象,因为新的对象需要大块的连续内存空间。造成了严重的内存浪费。
回收完对象后不进行压缩,导致出现的内存碎片
触发GC的时机:
1. 当试图分配内存,但失败的时候
2. 当堆空间的大小达到了某些阈值的时候
3. 程序主动请求GC
DalvikGC的具体回收过程,总共分四个阶段:
1.查找所有Root Sets(简单理解为Root对象):GC会暂停所有线程,然后开始查找Root sets,Root sets一般指局部变量,thread对象,静态变量等。或者说,Root sets就是你的应用可访问到的,活着的对象。这一步会比较耗时,而且期间你的App会暂停运行。
2.标记可达对象(一):这个阶段,GC会把刚找到的Root sets标记为可达(reachable),剩余的就是不可达对象,等待后续阶段回收。标记可达的这一阶段是并发执行的,也就是在这期间你的App会恢复运行。但是这个并发带来了新问题,因为标记的同时又在给新的对象分配内存。
3.标记可达对象(二):给新的对象分配内存的时候,GC又要重新查找Root sets,执行第1步操作,暂停你的App。这个时候你的App会显得卡顿,感觉上像是手机在执行什么高负荷的任务。
4.回收:GC把所有没有被标记为可达的对象进行回收。这一阶段是并发执行的。
简单示意
还记得KitKat那时候logcat打出来的那些GC_FOR_ALLOC信息吗?那些日志就是在给新对象分配内存失败的时候,GC启动垃圾回收时打印的信息,如果完成了垃圾回收,发现内存还是不够分配,可能会发生2种情况:
1.堆内存变大(如果当前还没到最大值的话)
2.发生OOM
通常在给大对象分配内存的时候会发生这个现象,比如Bitmap。
ART
从Lollipop这个版本开始,谷歌用ART彻底替换了Dalvik。ART大幅提升了性能,包括采用预编译(Ahead-Of-Time Compilation)机制,移除整个JIT编译器和解释器这样的革命性改变。同时当然也对GC做了很多的改进。
Lollipop&Marshmallow
内存分配/回收的算法依然是CMS(并发标记-清除),但是优化了很多地方。
1. 最明显的改动,就是用RosAlloc代替旧的dlmalloc作为内存分配的算法。在native代码中调用malloc或在Java/C++中使用new关键字创建对象时都会用到RosAlloc算法。该算法的优点在于可以针对特定线程进行内存分配,这个优点给之后Oreo版本的进一步优化提供了基础。
2. 第二个明显的提升,是把小块的内存分配进行分组合并,并在分配大块内存时进行页对齐。之所以能做到按页对齐,是因为ART没有对内存进行限制。不像Dalvik最多允许一个进程申请36MB的内存(不同设备的具体数值可能不同),ART对单个进程的内存上限没有做限制。
3. 另一个进步是GC终于支持了前台App的堆内存压缩。这样前台的App在需要的时候也可以释放部分内存,降低OOM的概率,减少内存碎片,减少因内存碎片而导致的可用内存不足引发的GC,大幅提升了整体性能。
4. 这一版的GC使用的更细粒度的锁,改善了在最终回收前的那一次停顿(细粒度锁,阻塞的代码更少了)。同时也把第一步的查找Root sets改为并发执行,这样整体上,把运行一次标记-清除的回收操作的耗时从10ms左右减少到3ms左右。
5. 引入分代回收的机制。每次major gc完成后,垃圾回收器会追踪之后每一个分配的对象,按照各自的存活时间和大小,对这些对象进行“分代”,对象一开始会被放到年轻代区域,存活时间够长的话会被转移到老年代,更“大龄”的放到永久代区域。这个优化对性能的提升是最明显的。堆内存中每个“代”区域都有各自的内存上限,当达到上限的时候,系统会在这个区域发起一次gc,gc的耗时取决于该区域是哪一代,以及区域内有多少活跃的对象。
分代回收的引入,节省了内存分配(失败)过程的耗时,因为在内存不足的情况下,会针对不同的区域,只在必要的情况下进行内存回收,比如如果在年轻代可以回收到足够的内存,就直接完成分配,而不需要关心进一步去看老年代和永久代的情况,节省时间。
Nougat
这个版本主要的改动,就是用汇编语言重写了内存分配过程,内存分配整体性能对比KitKat时期提升10倍。
Oreo
里程碑式的革新,重写了整个垃圾回收器。新的GC名叫“并发堆压缩回收器(Concurrent Heap Compaction Collector)”。
新版本的GC采用了“激进式碎片整理(always-defragmentation)”的策略,来解决堆压缩的问题。即使App在前台,也尽可能及时地触发内存压缩。这个策略对整个设备的总的内存占用都有明显的改善,因为无论是前台的应用包括系统服务,系统UI,媒体服务等,或是后台的其他服务,都可以更频繁的进行堆内存压缩(整理)。根据谷歌的说法,采用激进式的碎片整理策略能够节省30%的总内存占用。
新GC的并发压缩(Concurrent Compaction)同样是一个很大的进步。Oreo版本的GC将堆内存分割为一个一个的桶(bucket),大小为256K,用来给对象分配内存。当某个线程需要内存的时候,GC会给该线程分配一个桶大小的内存区域,这样该线程就可以在这个桶的区域内单独进行分配和回收,而不需要锁住整个系统。
同时,桶机制引出了另外一个改进之处:每当对一个桶的区域进行内存压缩后,如果发现内存占用率不足70%或75%,这个桶会被直接回收掉,桶内的对象会被移动到另外一个桶里。
另外,前面提到的Lollipop版本引入的RosAlloc分配算法,结合Oreo版本引入的指针碰撞(bump-the-pointer)技术,形成了一个新的机制——线程局部缓冲分配器(thread local bump-allocator)。这项技术是的Oreo版本的GC能够在内存分配性能上领先Dalvik18倍之多,相比Nougat版本也提升了70%的性能。怎么做到的?
采用指针碰撞技术,在每个桶内有一个指针,指示了当前桶内最后一个元素的地址。在给一个新的对象分配内存的时候,GC会先给对象所在线程分配一个桶,然后通过桶的指针,判断当前桶是否还有足够的空间分给这个新的对象,如果有的话,直接完成内存分配,并将指针更新为这个新对象的地址。
但是Oreo版本的GC有一个退步的地方,就是GC的分代管理机制被去掉了。
Android 10(Q)
Android Q带来了比以往更高性能的GC,同时分代GC回归,并且保留了Oreo GC的所有特性。
总结
可以看到,经过多个版本的迭代,Android GC已经变得更成熟更健壮,内存的分配算法相比Dalvik时代更加的高校,内存回收也做到了更细粒度,回收期间仅需要锁住当前的线程,而不是整个VM。
在Oreo版本,Android引入的激进式内存压缩策略,通过优化那些常驻前台,很少进入后台的进程的内存占用,使得整个设备的内存使用情况有了大幅的改善。
原文: