分代收集理论

现在市面上主流的垃圾收集器都会遵循“分代收集理论”,这个理论是符合大多数程序运行实际情况的经验法则,它建立在三个分代假说之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过多次垃圾回收的对象就越难难以消亡
  • 跨代引用相对于同代引用来说占极少数

前面的两个分代假说也奠定了垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收的对象依据其年龄分配到不同的区域之中存储。如果是朝生夕灭的对象,它们会集中在一起,每次回收这个区域就只需要关注如何保留少量存活的对象,而不是去标记那些将要被回收的对象;如果是难以消亡的对象,它们也会集中放置在一块区域,垃圾收集器可以使用较低的频率来回收这个区域,这样同时兼顾了垃圾收集的时间开销和内存空间的有效利用。

最后一个假说可以推断出:一般来说,存在互相引用关系两个对象,是应该同时生成或同时消亡的。比如:某个新生代对象存在跨代引用,由于老对象难以消亡,该引用会使得新生代对象在收集时同样得以生存,进而随着年龄的增长也进行老年代,这时跨代引用也变成了同代引用。这条假说确定了我们不应为了少量的跨代引用而耗费大量资源去扫描整个老年代。
image.png

标记 - 清除法

算法实现

首先标记出所有需要回收的对象,在标记完成之后,统一回收所有被标记的对象。当然也可以反过来标记存活的对象,时间复杂度和空间复杂度都是一样的。

优缺点

优点

  • 算法实现简单,无需复杂的计数和代码实现

    缺点

  • 一个是效率问题,标记和清除两个过程的效率都不高,Java程序吞吐量大量降低

  • 另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

    image.png

    算法应用

    该算法仅仅只是垃圾收集最基础的算法,该算法并未应用到主流(不是全部)的垃圾收集器中,但是使用到的算法都是以这个算法为基础拓展出来的。

    标记 - 复制算法

    算法实现

    复制算法简单来说就是把内存一分为二,但只使用其中一份,在垃圾回收时,将正在使用的那份内存中标记为存活的对象复制到另一份空白的内存中,最后将正在使用的内存空间的对象清除,完成垃圾回收。
    image.png

    优缺点

    优点

  • 复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效

    缺点

  • 需要浪费一半的空间,空间利用率太低了

    算法应用

    现在市面上主流的垃圾收集器优先采用这种算法的优化方案来回收新生代。IBM公司做过一个实验,实验证明了新生代中的对象98%时熬不过第一轮垃圾收集的,因此并不需要按照1:1来划分新生代的内存空间。一般的做法是将新生代划分出一块较大的Eden空间(80%)和两块较小的Survivor空间(各10%,即from区和to区各占10%),每次给对象分配内存只使用Eden和其中一块Survivor空间上。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清除Eden和已经用过的那块Survivor空间。
    image.png
    当然这样的优化也会又一定的风险:比如另外一块Survivor区没有足够的内存空间去分配需要存活下来的对象。jvm对这个风险也是有一个分配担保机制的:这些多出来的对象直接进入老年代,这虽然会增加老年代的负担,但是这对虚拟机来说的安全的,安全是大于效率的。

    标记 - 整理算法

    算法实现

    标记过程和“标记 - 清除算法”一样,但是后续的步骤就不是对可回收对象进行清理,而是将所有存活的对象都按照内存地址次序依次排列移动到内存空间的一端,然后直接清理掉这个内存边界以外的全部内存。
    image.png

    优缺点

    优点

  • 标记 - 整理算法不仅可以弥补标记 - 清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

    缺点

  • 标记 - 整理算法唯一的缺点就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记 - 整理算法要低于复制算法。

    算法应用

    在堆的老年代中,对象的存活率一般都比较高,不会采用“标记 - 复制算法”,而是采用这里的“标记 - 整理算法”。

老年代不采用“标记 - 复制算法”的原因:

  • 老年代对象的存活率一般都比较高,如果这时候还采用像新生代一样的“标记 - 复制算法”就需要复制较多的对象,这个算法的效率就会大大降低
  • 需要浪费老年代至少50%,因为老年代对象存活率高导致至少50%,而不能像新生代一样只需要浪费10%的空间

在这个算法中涉及到了对象的移动问题:

  • 移动存活的对象并且更新所有引用这些对象的地方将是一种极为负重的操作,而且移动对象的操作必须要停止用户线程才能进行,如果长时间的停止对用户来说肯定是体验感很差的。
  • 不移动对象的话,也就是采用“标记 - 清除算法”,那就会产生大量的碎片空间,导致分配内存的时候必须要去维护一个空闲列表,这势必会影响程序的吞吐量。

从垃圾收集的停顿时间来看,不移动对象会更加划算;从整个程序的吞吐量来看,移动对象会更加划算。

注意: 吞吐量是指在单位时间内中央处理器 (CPU)从存储设备“读取->处理->存储”信息的量。影响吞吐量的因素:

  • 存储设备的存取速度,即从存储器读出数据或数据写入存储器所需时间
  • CPU性能
  • 每条指令所花的时钟周期数
  • 指令条数
  • 系统结构,如并行处理结构可增大吞吐量

在垃圾收集器的吞吐量可以表示为: 吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

比如Parallel Old收集器更加关注吞吐量,就会基于“标记 - 整理算法”实现老年代的垃圾回收;而更加关注延迟的CMS收集器就会基于“标记 - 清除算法”实现老年代的回收。

上述做法二选一是垃圾收集器不得不去平衡的,但是也会有一种“中庸之道”的做法:大多数时间采用“标记 - 清除”,暂时允许内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象的分配时,在使用一次“标记 - 整理”。