参考:罗升阳的相关博客
https://blog.csdn.net/Luoshengyang/article/details/41338251
- 目录
BiBi - Android VM -0- 开篇
BiBi - Android VM -1- Dalvik
BiBi - Android VM -2- ART
BiBi - Android VM -3- Compacting GC
1. 简介
Dalvik虚拟机把堆划分为两部分:Active Heap 和 Zygote Heap。
-
Android系统的第一个Dalvik虚拟机由Zygote进程创建
应用程序进程是由Zygote进程fork出来的。也就是说,应用程序进程使用了一种写时拷贝技术来复制了Zygote进程的地址空间。这意味着一开始的时候,应用程序进程和Zygote进程共享了同一个用来分配对象的堆【Zygote堆?】。然后,当Zygote进程或者应用程序进程对该堆进行写操作时,内核就会执行真正的拷贝操作,使得Zygote进程和应用程序进程分别拥有自己的一份拷贝。
-
为什么要把堆划分为两部分?
由于拷贝是一件费时费力的事情。因此为了尽量地避免拷贝,Dalvik虚拟机将自己的堆划分为两部分。事实上,Dalvik虚拟机的堆最初是只有一个的,也就是Zygote进程在启动过程中创建Dalvik虚拟机的时候,只有一个堆。另外这样可以使得Zygote进程和其子进程最大限度地共享Zygote堆所占用的内存。
-
如何划分Active Heap 和 Zygote Heap?
当Zygote进程在fork第一个应用程序进程之前,会将已经使用了的那部分堆内存划分为一部分,还没有使用的堆内存划分为另外一部分。前者就称为Zygote堆,后者就称为Active堆。以后无论是Zygote进程,还是应用程序进程,当它们需要分配对象的时候,都在Active堆上进行。这样就可以使得Zygote堆尽可能少地被执行写操作,因而就可以减少执行写时拷贝的操作。
-
Zygote堆中存放的内容
Zygote堆里面分配的对象主要就是Zygote进程在启动过程中预加载的类、资源和对象。这意味着这些预加载的类、资源和对象可以在Zygote进程和应用程序进程中做到长期共享。这样既能减少拷贝操作,还能减少对内存的需求。
-
Mark-Sweep垃圾收集算法中通过什么数据结构来描述对象有没有被引用?
通过Heap Bitmap使用位图来标记对象是否被使用。如果一个对象被引用,那么在Bitmap中与它对应的那一位就会被设置为1。否则的话,就设置为0。Dalvik使用了两个Bitmap来描述堆的对象:Live Bitmap 和 Mark Bitmap。Live Bitmap用来标记上一次GC时被引用的对象,也就是没有被回收的对象,而Mark Bitmap用来标记当前GC时被引用的对象。有了这两个信息之后,我们就可以很容易地知道哪些对象是需要被回收的,即在Live Bitmap在标记为1,但是在Mark Bitmap中标记为0的对象。
-
Mark重新标记
在第二个子阶段执行的过程中,如果一个线程修改了一个对象,那么该对象必须要记录起来,因为它很有可能引用了新的对象,或者引用了之前未引用过的对象,【如在finalize()方法中重新让其它变量引用该对象】。如果不这样做的话,那么就会导致被引用对象还在使用然而却被回收。这种情况出现在只进行部分垃圾收集的情况,Card Table的作用就是用来记录【非垃圾收集堆对象】对垃圾收集堆对象的引用。
Dalvik虚拟机进行部分垃圾收集时,实际上就是只收集在Active堆上分配的对象。因此对Dalvik虚拟机来说,Card Table就是用来记录在Zygote堆上分配的对象在垃圾收集执行过程中对在Active堆上分配的对象的引用。
-
Card Table
在Mark过程中,为了减少内存消耗,使用另外一种技术来标记Mark第二子阶段被修改的对象【并行标记过程修改的对象】,这种技术使用了一种称为Card Table的数据结构。
-
Mark Stack【非递归函数调用的递归标记算法】
在Mark阶段,Dalvik虚拟机通过递归方式来标记对象。但是,这不是通过函数的递归调用来实现的,而是借助一个称为Mark Stack的栈来实现的。具体来说,当我们标记完成根集对象之后,就按照它们的地址从小到大的顺序标记它们所引用的其它对象。
假设有A、B、C、D四个对象,它的地址大小关系为A < B < C < D,其中,B和D是根集对象,A被D引用,C没有被B和D引用。那么我们将依次遍历B和D。当遍历到B的时候,没有发现它引用其它对象,然后就继续向前遍历D对象。发现它引用了A对象。按照递归的算法,这时候除了标记A对象是正在使用之外,还应该去检查A对象有没有引用其它对象,然后又再检查它引用的对象有没有又引用其它的对象,一直这样遍历下去,这样就跟函数递归一样。
更好的做法是将对象A记录在一个Mark Stack中,然后继续检查根集合地址值比对象D大的其它对象。对于地址值比对象D大的其它对象,如果它们引用了一个地址值比它们小的其它对象,那么这些其它对象同样要记录在Mark Stack中。等到该轮检查结束之后,再回过头来检查记录在Mark Stack里面的对象。然后又重复上述过程,直到Mark Stack等于空为止。
-
根集包含的对象
主要包含两大类对象:
一类是Dalvik虚拟机内部使用的全局对象,另一类是应用程序正在使用的对象。前者维护在内部的一些数据结构中,例如Hash表;后者维护在调用栈中。对这些根集对象进行标记都是通过函数dvmVisitRoots和回调函数rootMarkObjectVisitor进行的。
此外,我们还需要将不在堆回收范围内的对象也看作是根集对象,以便后面可以使用统一的方法来遍历这两类对象所引用的其它对象。标记不在堆回收范围内的对象是通过函数dvmMarkImmuneObjects来实现的,具体做法就是分别遍历Active堆和Zygote堆,如果它们处于不回范围中,那么就对里面的对象在Live Bitmap中对应的内存块拷贝到Mark Bitmap的对应位置去。
-
总结
Card Table、Live Heap Bitmap、Mark Heap Bitmap、Mark Stack
为了管理Java堆,Dalvik虚拟机需要一些辅助数据结构,包括一个Card Table、两个Heap Bitmap和一个Mark Stack。Card Table是为了记录在垃圾收集过程中对象的引用情况,以便可以实现Concurrent GC。两个Heap Bitmap,一个称为Live Heap Bitmap,用来记录上次GC之后,还存活的对象;另一个称为Mark Heap Bitmap,用来记录当前GC中还存活的对象。这样,上次GC后存活的但是当前GC不存活的对象,就是需要释放的对象。Davlk虚拟机使用标记-清除【Mark-Sweep】算法进行GC。在标记阶段,通过一个Mark Stack来实现递归检查被引用的对象,即在当前GC中存活的对象。有了这个Mark Stack,就可以通过循环来模拟函数递归调用。
2. Dalvik虚拟机Java堆的创建
-
Java堆的起始大小【Starting Size】、最大值【Maximum Size】、增长上限值【Growth Limit】
Java堆的起始大小指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小。后面再根据需要逐渐向系统申请更多的物理内存,直到达到最大值为止。这是一种按需要分配策略,可以避免内存浪费。在默认情况下,Java堆的起始大小和最大值等于4M和16M。但是厂商会通过dalvik.vm.heapstartsize和dalvik.vm.heapsize这两个属性将它们设置为合适设备的值的。
但是,Dalvik虚拟机又希望能够动态地调整Java堆的可用最大值,于是就出现了一个称为增长上限的值。这个增长上限值,我们可以认为它是Java堆大小的软限制,而前面所描述的最大值是Java堆大小的硬限制。通过动态地调整增长上限值,就可以实现动态调整Java堆的可用最大值,但是这个增长上限值必须要小于等于最大值。如果没有指定Java堆的增长上限的值,那么它的值就等于Java堆的最大值。
注意,虽然Java堆使用的物理内存是按需要分配的,但是它使用的虚拟内存的总大小却是需要在Dalvik启动的时候就确定的,这个虚拟内存的大小就等于Java堆的最大值。
想象一下,如果不这样做的话,会出现什么情况。假设开始时创建的虚拟内存小于Java堆的最大值,由于实际情况是允许虚拟内存的大小是达到Java堆的最大值的,因此,当开始时创建的虚拟内存无法满足需求时,那么就需要重新创建另外一块更大的虚拟内存。这样就需要将之前的虚拟内存的内容拷贝到新创建的更大的虚拟内存去,并且还要相应地修改各种辅助数据结构。这样太麻烦了,而且效率也太低了。因此就在一开始的时候,就创建一块与Java堆的最大值相等的虚拟内存。
-
Dalvik虚拟机在启动的时候,实际上只创建了一个Heap。这个Heap是Active堆,它开始的时候管理整个Java堆。但Java堆实际上还包含有一个Zygote堆,那么这个Zygote堆是怎么来的呢?
Zygote进程在fork子进程之前,它会先调用函数trimHeaps来将Java堆中没有使用到的内存归还给系统,接着再调用函数addNewHeap来创建一个新的Heap,这个新的Heap就是Zygote堆。
Zygote进程只会在fork第一个子进程的时候,才会将Java堆一分为二来管理。
-
在Dalvik虚拟机中,Card Table和Heap Bitmap的作用类似,区别在于:
1) Card Table不是使用一个bit来描述一个对象,而是用一个byte来描述GC_CARD_SIZE个对象;
在Card Table中,用一个byte来描述128个对象。每当一个对象在Concurrent GC的过程中被修改时,典型的情景就是我们通过函数dvmSetFieldObje修改了该对象的引用类型的成员变量。在这种情况下,该对象在Card Table中对应的字节会被设置为GC_CARD_DIRTY。相反,如果一个对象在Concurrent GC的过程中没有被修改,那么它在Card Table中对应的字节会保持为GC_CARD_CLEAN。
2) Card Table不是用来描述对象的存活,而是用来描述在Concurrent GC的过程中被修改的对象,这些对象需要进行特殊处理。
3. Dalvik虚拟机的内存分配
-
碎片问题
Dalvik虚拟机很机智地利用C库里面的dlmalloc内存分配器来解决内存碎片问题!Dalvik虚拟机的Java堆的底层实现是一块匿名共享内存,并且将其抽象为C库的一个mspace。
-
锁
在Java堆分配内存前后,要对Java堆进行加锁和解锁,避免多个线程同时对Java堆进行操作。这分别是通过函数dvmLockHeap和dvmunlockHeap来实现的。真正执行内存分配的操作是通过调用另外一个函数tryMalloc来完成的。
最后,如果分配内存成功,并且参数flags的ALLOC_DONT_TRACK位设置为0,那么需要将新创建的对象增加到Dalvik虚拟机内部的一个引用表去。保存在这个内部引用表的对象在执行GC时,会添加到根集去,以便可以正确地判断对象的存活。
-
对象申请内存的过程
1)调用函数dvmHeapSourceAlloc在Java堆上分配指定大小的内存。如果分配成功,那么就将分配得到的地址直接返回给调用者了。函数dvmHeapSourceAlloc在不改变Java堆当前大小的前提下进行内存分配,这是属于轻量级的内存分配动作。
2)如果上一步内存分配失败,这时候就需要执行一次GC了。不过如果GC线程已经在运行中,即gDvm.gcHeap->gcRunning的值等于true,那么就直接调用函数dvmWaitForConcurrentGcToComplete等到GC执行完成就是了。否则的话,就需要调用函数gcForMalloc来执行一次GC了,参数false表示不要回收软引用对象引用的对象。
3)GC执行完毕后,再次调用函数dvmHeapSourceAlloc尝试轻量级的内存分配操作。如果分配成功,那么就将分配得到的地址直接返回给调用者了。
4)如果上一步内存分配失败,这时候就得考虑先将Java堆的当前大小设置为Dalvik虚拟机启动时指定的Java堆最大值,再进行内存分配了。这是通过调用函数dvmHeapSourceAllocAndGrow来实现的。【该过程需要考虑是否设置了Soft Limit,其默认值为Java堆的最大值】
5)如果调用函数dvmHeapSourceAllocAndGrow分配内存成功,则直接将分配得到的地址直接返回给调用者了。
6)如果上一步内存分配还是失败,这时候就得出狠招了。再次调用函数gcForMalloc来执行GC。参数true表示要回收软引用对象引用的对象。
7)GC执行完毕,再次调用函数dvmHeapSourceAllocAndGrow进行内存分配。这是最后的努力了,成功与否到此为止。
dvmHeapSourceAlloc、dvmHeapSourceAllocAndGrow、gcForMalloc。
如果修改了Active堆的大小,仍然不能成功分配大小为n的内存,需要恢复为之前Zygote堆和Active堆的大小。
-
HeapSource
gHs是一个全局变量,它指向一个HeapSource结构。在这个HeapSource结构中,有一个heaps数组,其中第一个元素描述的是Active堆,第二个元素描述的是Zygote堆。
注意:hs->heaps[0]指向的是Active堆,而hs->heaps[1]指向的是Zygote堆。
-
Soft Limit值的作用
它主要是用来限制Active堆无节制地增长到最大值的,而是要根据预先设定的【堆目标利用率】来控制Active有节奏地增长到最大值。这样可以更有效地使用堆内存。想象一下,如果我们一开始Active堆的大小设置为最大值,那么就很有可能造成已分配的内存分布在一个很大的范围。这样随着Dalvik虚拟机不断地运行,Active堆的内存碎片就会越来越来重。相反,如果我们施加一个Soft Limit,那可以尽量地控制已分配的内存都位于较紧凑的范围内,这样就可以有效地减少碎片。
-
Dalivk虚拟机在启动时可以指定低内存模式,在低内存模式和非低内存模块中,对象内存的分配方式有所不同。
在低内存模式中,Dalvik虚拟机假设对象不会马上使用分配到的内存,因此,它就通过系统接口madvice和MADV_DONTNEED标志告诉内核,刚刚分配出去的内存在近期内不会使用,内核可以将该内存对应的物理页回收。当分配出去的内存被使用时,内核就会重新给它映射物理页,这样就可以按需分配物理内存,适合在内存小的设备上运行。
在非低内存模式中,处理的逻辑就简单很多了,直接使用函数mspace_calloc在Active堆上分配指定的内存大小即可,同时该函数还会将分配的内存初始化为0,正好是可以满足Dalvik虚拟机的要求。
-
内存分配成功后记录的信息
1)记录Active堆当前已经分配的字节数。
2)记录Active堆当前已经分配的对象数。
3)调用函数dvmHeapBitmapSetObjectBit将新分配的对象在Live Heap Bitmap上对应的位设置为1,也就是说将新创建的对象标记为是存活的。
4. Dalvik虚拟机的垃圾收集
第1步到第3步用于并行和非并行GC:
1. 调用函数dvmSuspendAllThreads挂起所有的线程,以免它们干扰GC。
2. 调用函数dvmHeapBeginMarkStep初始化Mark Stack,并且设定好GC范围。
3. 调用函数dvmHeapMarkRootSet标记根集对象。
第4到第6步用于并行GC:
4. 调用函数dvmClearCardTable清理Card Table。因为接下来我们将会唤醒第1步挂起的线程。并且使用这个Card Table来记录那些在GC过程中被修改的对象。
5. 调用函数dvmUnlock解锁堆。这个是针对调用函数dvmCollectGarbageInternal执行GC前的堆锁定操作。
6. 调用函数dvmResumeAllThreads唤醒第1步挂起的线程。
第7步用于并行和非并行GC:
7. 调用函数dvmHeapScanMarkedObjects从第3步获得的根集对象开始,归递标记所有被根集对象引用的对象。
第8步到第11步用于并行GC:
8. 调用函数dvmLockHeap重新锁定堆。这个是针对前面第5步的操作。
9. 调用函数dvmSuspendAllThreads重新挂起所有的线程。这个是针对前面第6步的操作。
10. 调用函数dvmHeapReMarkRootSet更新根集对象。因为有可能在第4步到第6步的执行过程中,有线程创建了新的根集对象。
11. 调用函数dvmHeapReScanMarkedObjects归递标记那些在第4步到第6步的执行过程中被修改的对象。这些对象记录在Card Table中。
第12步到第14步用于并行和非并行GC:
12. 调用函数dvmHeapProcessReferences处理那些被软引用(Soft Reference)、弱引用(Weak Reference)和影子引用(Phantom Reference)引用的对象,
以及重写了finalize方法的对象。这些对象都是需要特殊处理的。
13. 调用函数dvmHeapSweepSystemWeaks回收系统内部使用的那些被弱引用引用的对象。
14. 调用函数dvmHeapSourceSwapBitmaps交换Live Bitmap和Mark Bitmap。执行了前面的13步之后,所有还被引用的对象在Mark Bitmap中的bit都被设置为1。
而Live Bitmap记录的是当前GC前还被引用着的对象。通过交换这两个Bitmap,就可以使得当前GC完成之后,使得Live Bitmap记录的是下次GC前还被引用着的对象。
第15步和第16步用于并行GC:
15. 调用函数dvmUnlock解锁堆。这个是针对前面第8步的操作。
16. 调用函数dvmResumeAllThreads唤醒第9步挂起的线程。
第17步和第18步用于并行和非并行GC:
17. 调用函数dvmHeapSweepUnmarkedObjects回收那些没有被引用的对象。没有被引用的对象就是那些在执行第14步之前,在Live Bitmap中的bit设置为1,
但是在Mark Bitmap中的bit设置为0的对象。
18. 调用函数dvmHeapFinishMarkStep重置Mark Bitmap以及Mark Stack。这个是针对前面第2步的操作。
第19步用于并行GC:
19. 调用函数dvmLockHeap重新锁定堆。这个是针对前面第15步的操作。
第20步用于并行和非并行GC:
20. 调用函数dvmHeapSourceGrowForUtilization根据设置的堆目标利用率调整堆的大小。
第21步用于并行GC:
21. 调用函数dvmBroadcastCond唤醒那些等待GC执行完成再在堆上分配对象的线程。
第22步用于非并行GC:
22. 调用函数dvmResumeAllThreads唤醒第1步挂起的线程。
第23步用到并行和非并行GC:
23. 调用函数dvmEnqueueClearedReferences将那些目标对象已经被回收了的引用对象增加到相应的Java队列中去,以便应用程序可以知道哪些引用引用的对象已经被回收了。
-
GC的类型【GcSpec结构体】
isPartial: 为true时,表示仅仅回收Active堆的垃圾;为false时,表示同时回收Active堆和Zygote堆的垃圾。
isConcurrent: 为true时,表示执行并行GC;为false时,表示执行非并行GC。
doPreserve: 为true时,表示在执行GC的过程中,不回收软引用引用的对象;为false时,表示在执行GC的过程中,回收软引用引用的对象。
-
GC的时机
GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。
GC_CONCURRENT: 表示是在已分配内存达到一定量之后触发的GC。
GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。
GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。
GC线程平时没事的时候,就在条件变量gHs->gcThreadCond上进行等待HEAP_TRIM_IDLE_TIME_MS毫秒(5000毫秒)。如果在HEAP_TRIM_IDLE_TIME_MS毫秒内,都没有得到执行GC的通知,那么它就调用函数trimHeaps对Java堆进行裁剪,以便可以将堆上的一些没有使用到的内存交还给内核。
-
手动GC
如果Davik虚拟机在启动的时候,通过-XX:+DisableExplicitGC选项禁用了显式GC,那么函数dvmCollectGarbage什么也不做就返回了。这意味着Dalvik虚拟机可能会不支持应用程序显式的GC请求。
一旦Dalvik虚拟机支持显式GC,那么函数dvmCollectGarbage就会先锁定堆,并且等待有可能正在执行的GC_CONCURRENT类型的GC完成之后,再调用函数dvmCollectGarbageInternal进行类型为GC_EXPLICIT的GC。
注意:无论是并行GC,还是非并行GC,它们都是通过函数dvmCollectGarbageInternal来执行的。在调用函数dvmCollectGarbageInternal之前,堆是已经被锁定了的,因此这时候任何需要堆上分配对象的线程都会被挂起。但不会影响到那些不需要在堆上分配对象的线程。
-
栈标记
大小值只要是落在Java堆的地址范围之内就认为是Java对象引用的做法称为【保守GC算法】,与此相对的称为【准确GC】。在准确GC中,用一个称为Register Map的数据结构来辅助GC。Register Map记录了一个Java函数所有可能的GC执行点所对应的寄存器使用情况。如果在某一个GC执行点,某一个寄存器对应的是一个Java对象引用,那么在对应的Register Map中,就有一位被标记为1。
每当一个Dalvik虚拟机线程被挂起等待GC时,它们总是挂起在IF、GOTO、SWITCH、RETURN和THROW等跳转指令中,这些指令点对应的实际上就【GC执行点】。因此,可以在一个函数中针对出现上述指令的地方,均记录函数当前的寄存器使用情况,从而可以实现准确GC。
那么,这个工作是由谁去做的呢?我们知道APK在安装的时候,安装服务会把它们的DEX字节码进行优化,得到一个odex文件。实际上,APK在安装的时候,它们的DEX字节码除了会被优化之外,还会被验证和分析。验证是为了保证不包含非法指令,而分析就是为了得到指令的执行状况,其中就包括得到每一个GC执行点的寄存器使用情况,最终形成一个Register Map,并且保存在odex文件中。这样,当一个odex文件被加载使用的时候,我们就可以直接获得一个函数在某一个GC执行点的寄存器使用情况。
由于Register Map不是强制的,因此有可能某些函数不存在对应的Register Map,在这种情况下,就需要使用前面我们所分析的保守GC算法,遍历调用栈帧的所有数据,只要它们的值在Java堆的范围之内,均认为它们是Java对象引用,并且对它们进行标记。
例子:
举个例子,有四个对象A、B、C、D、E和F,其中,C和D是根集对象,它们的地址址依次递增,C引用了B,D引用了E。B又引用了A和F。
遍历Mark Bitmap的时候,依次发生以下事情:
1. C被遍历;
2. C引用了B,但是由于其地址值比C小,因此B被标记并且被压入Mark Stack中;
3. D被遍历;
4. D引用了E,但是由于其地址值比D大,因此E被标记;【E不被压入Mark Stack】
5. 由于E被标记了,因此E也会被遍历。
遍历完成Mark Bitmap之后,Mark Stack被压入了B,因此要从Mark Stack中弹出B,并且对B进行遍历。遍历B的过程如下所示:
1. B引用了A,因此A被标记同时也会被压入到Mark Stack中;
2. B引用了F,因此F也会被标标记以及压入到Mark Stack中;
现在Mark Stack又被压入了A和F,因此又要继续将它们弹出进行遍历:
1. 遍历A,它没有引用其它对象,因此没有对象被压入Mark Stack中;
2. 遍历F。它也没有引用其它对象,因此也没有对象被村入Mark Stack中。
此轮遍历结束后,Mark Stack为空,因此所有被根集对象直接或者间接引用均被标记。
Dalvik虚拟机在GC过程中保留SoftReference引用对象所引用的对象的策略是每隔一个就保留一个,保留总数的一半。只有那些之前没有被标记过的,并且被SoftReference和WeakReference引用的对象才会被处理。
-
dvmHeapSourceGrowForUtilization【设置堆理想值】
每次GC执行完成之后,都需要根据预先设置的目标堆利用率和已经分配出去的内存字节数计算得到理想的堆大小。注意,已经分配出去的内存字节数只考虑在Active堆上分配出去的字节数。
当Active堆允许分配的内存小于256K时,禁止执行并行GC,而当Active堆允许分配的内存大于等于256K,并且剩余的空闲内存小于128K,就会触发并行GC。