这篇承接上一篇 《Java的内存 - 内存模型》,分析内存回收相关的知识点。
垃圾回收包含两个步骤,①标记哪些内存是垃圾 ②回收内存。下面分别说这两个步骤有哪些算法:
1. 垃圾标记
1.1 引用计数算法
没有哪一种 JVM 是使用「引用计数」作为垃圾回收算法的,但这种算法又很经典,所以介绍一下。(Android 的 native 层用到了引用计数)
工作方式:
堆中每一个对象都有一个引用计数器。创建并初始化赋值后,引用计数置为1,每多一次引用,引用计数+1,每有一个引用失效(出作用域 或者 被设置为其他值)时,引用计数-1。引用计数 == 0 的对象可以被回收。
优点:
判定对象是否需要回收的效率高,不需要额外的线程做 GC 的工作,也不会暂停应用。
缺点:
无法检测循环依赖。由于需要实时计数,增加了程序执行时的开销。
应用实例:
Object-C 的 ARC 模式。
1.2 根搜索算法
根搜索算法是 Java 虚拟机主流的找垃圾算法。
首先要知道根集和finalize()相关的东西:
根集:
根集是肯定不需要回收的对象的引用。它包含:
Java栈 和 Native栈 的本地变量表中引用的对象;
方法区中的常量和静态变量引用的对象;
活跃的线程对象等。
关于 finalize() 方法:
finalize() 方法最多只会被 垃圾收集器 执行一次(不包括开发者主动调用)。已经被垃圾收集器执行过一次后,不会再执行第二次。如果一个类没有重写 finalize() 方法,垃圾回收器不会执行其对象的 finalize() 方法。
根搜索算法会有两次标记,第一次标记将需要执行 finalize() 的对象放入 F-Queue 中,等待执行 finalize() 方法;第二次再判断执行完 finalize() 的对象是否依然不可达,并最终确定哪些对象是垃圾。
finalize() 方法是在一个低优先级的线程中执行的。
工作步骤如下:
第一步:获取不可达对象
暂停整个应用(Stop The World);
生成根集(GC Roots);
从根集出发,找出根集中的对象引用的其他对象,并依次沿引用方向遍历,生成引用链;
根据引用链获取所有不可达的对象;
根搜索算法
第二步:垃圾的自我救赎
判断这些不可达对象是否需要执行 finalize() 方法;
如果不需要,直接标记为可回收对象,跳过后续步骤;如果需要,将对象加入到 F-Queue 中,等待执行 finalize() 方法;
如果在 finalize() 方法中,其他对象又持有了该对象,那么该对象又变为可达对象了。
第三步:再次获取不可达对象
F-Queue队列执行完后,再次判断执行完 finalize() 方法的这些对象是否可达;
如果仍然不可达,标记为可回收对象。
2.2 垃圾收集
关于垃圾回收,参考 Oracle 官网 《HotSpot 虚拟机内存管理白皮书》。
垃圾回收确保回收的对象必然是不可达对象,但是不确保所有的不可达对象都会被回收。
垃圾收集算法主要有3种: 标记清除算法(Mark-Sweep) 、标记压缩算法(Mark-Sweep-Compact)、复制算法(Copying)。在这3种的基础上派生出其它算法:
分代回收:考虑算法和内存分配的特点,对堆上不同的分代使用不同的回收算法;
并行回收(Parallel)和 串行回收(Serial): 考虑在内存回收时,是一个线程在回收,还是多个线程同时回收;
并发回收 和 Stop-The-World:考虑在内存回收时,是否一边执行应用一边回收,还是完全暂停整个应用;
以下是这些算法的具体信息:
2.2.1 标记清除算法
工作方式:
内部维护了一个空闲内存表,用来记录可分配内存的地址和大小;
工作时,先将标记为可释放的对象的内存释放,然后在空闲内存表中更新可分配内存信息。
标记清除算法
优点:
速度快。快的原因,相比后面的标记整理算法,是不需要移动内存。
缺点:
会产生大量的内存碎片;
维护一个空闲列表有一定的额外开销;
分配新内存时,需要遍历空闲列表找到合适的内存块。
2.2.2 标记整理算法
工作方式:
将所有活跃的对象,依次移动到内存的一端;
移动完毕后,清理边界之外的内存。
标记整理算法
优点:
不会产生内存碎片;
只需要记录内存末尾的指针,新内存分配时可以立即分配;
缺点:
由于需要移动内存,暂停应用的时间会延长。
2.2.3 复制算法
工作方式:
将堆内存分为两块相同的区域;
内存分配时,只在其中一块内存分配;
当内存不足以分配时,将所有活跃的对象依次复制到另一块区域;
一次性清理掉旧的内存区域。
复制算法
优点:
标记和复制可以同时进行;
效率高,清理内存时是对一整块内存进行操作;
不会产生内存碎片。
缺点:
可分配的堆内存减为一半了;
由于需要移动内存,暂停应用的时间会延长。
特点:
该算法的耗时,只跟活跃对象的数量有关,和这个算法管理的堆空间总大小无关。
2.2.3 分代回收
由于不同对象的生命周期是不一样的,因此可以对不同生命周期的对象采取不同的收集方式,以提高回收效率。
不分区有什么缺点:
如果不分区,GC是对整个堆区进行可达性分析、内存移动等,回收会很耗时。
如何划分内存区域:
由于大部分对象的生命周期很短,只有少部分对象会存活较长时间。所以基于它们的生命周期分代是个合适的选择。HotSpot 等虚拟机都把堆区分为 年轻代 和 老年代。
分代是如何减少可达性分析的:
对年轻代做可达性分析时,如果还要遍历老年代,那就没有减少可达性分析的时间。
但是。如果不遍历其它分代,如何知道一个年轻代的对象是否被老年代持有呢?这就产生了跨代引用的问题。
为了解决问题,引入了「跨代引用是 GC Root」的解决办法:如果老年代的 Old 对象,引用了年轻代的 Young 对象,在对年轻代进行可达性分析时,Old 对象算作 GC Root。这样就不用遍历老年代了。
分代回收算法需要有一个表,用来记录所有的跨代引用,很耗内存。HotSpot 使用 CardTable 记录老年代对年轻代的引用。把老年代按照 4KB 的大小分块,每一块对应在 CardTable 中都是1 bit。当值为1时,表示这4KB 的内存中有对年轻代的引用,需要加入到 GC Roots 中。
分代回收Card Table
这种解决办法也会有问题:如果老年代的B对象引用了年轻代的A对象,B没有被其它对象引用,实际上A、B都应该被回收,但却把B当作GC Root了。也就是部分不可达对象没有被回收。
如何选择合适的算法:
对于年轻代的对象,它们数量多、生命周期短,且大部分对象都是要回收的,所以需要速度更快的垃圾回收算法。Copying 算法的耗时,只跟堆内活跃对象的数量有关,跟堆的大小无关,所以特别适合用于年轻代的回收。
对于老年代的对象,他们占用内存大,不能使用复制算法,并且需要避免内存碎片,所以使用 标记整理算法。
在回收年轻代时,可达性分析只分析年轻代。在回收老年代时,是对整个堆区做可达性分析。