垃圾收集器和部分垃圾收集器简介

1,362 阅读12分钟

垃圾收集的几种算法

  1. 分代收集理论 当前虚拟机的垃圾收集都是采用的分代收集算法, 就是根据对象存活周期的不同将内存分为几个逻辑区块. java中就区分为新生代和老年代, 这样就可以根据不同区块的对象特点选择合适的收集算法. 比如在新生代中, 每次收集都会有大量的对象被清除, 所以可以选择复制算法, 只需要付出少量对象的复制成本就可以完成每次的垃圾收集, 而老年代的对象存活几率很高, 而且没有额外的空间进行分配担保, 所以可以选择标记清除和标记整理算法. 标记清除和标记整理将会比复制算法慢很多, 大约十倍以上
  2. 标记-复制算法 复制算法就是将内存分为大小相同的两个区块, 使用的时候只能使用其中一个区块(也就是只能使用内存的一半), 垃圾收集的时候, 就会将那些不是垃圾的标记出来, 复制到另一半区块中, 然后直接清除原先区块的内存. 这样的操作效率会非常高, 并且也不会有内存碎片产生, 但是问题就是内存浪费
  3. 标记-清除算法 分为两个阶段: 标记/清除. 标记阶段: 标记应该存活的对象, 清除阶段: 统一回收所有没被标记的内存. 这样的操作效率会比较低, 如果有大量对象, 就需要很久的时间去标记, 并且清理内存后, 将会有大量的内存碎片
  4. 标记-整理算法 一样会有标记和整理两个阶段, 标记阶段也是将那些不是垃圾的对象标记, 整理阶段就是把那些被标记的对象(不是垃圾的对象)向内存一端移动, 然后直接清理掉端边界以外的内存 这样的操作好处就是不会产生内存碎片

垃圾收集器

image.png

上述三种收集算法是内存回收的方法论, 而垃圾收集器就是这些方法论的具体实现

并不存在最好的垃圾收集器, 只存在最适合的垃圾收集器. 我们无法找到一款最好的, 但是我们能根据具体的应用场景选择适合自己的垃圾收集器

Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)

Serial(串行化)收集器是最基本, 最古老的垃圾收集器, 它所谓的"串行"并不仅仅是它只有一个线程去进行垃圾回收, 并且在它执行GC的时候, 所有用户线程就会暂停(STW: Stop The World), 直到垃圾收集结束.

新生代采用复制算法, 老年代采用标记-整理算法

image.png

Serial的STW是非常影响用户体验的, 所以在之后的垃圾收集器中, 不断去想方设法把STW时间降低, 可是Serial的优点也是有的: 比如由于没有线程交互的开销, 即使是单个线程都能很高效的去执行收集

SerialOld收集器是Serial收集器的老年代版本, 它同样是一个单线程的垃圾收集器. 它主要有两大用途:

  1. 在jdk1.5以及以前的版本中与Parallel Scanvenge收集器搭配使用
  2. 作为CMS收集器的后备方案

ParallelScavenge收集器(-XX:+UseParallelGC(年轻代), -XX:+UseParallelOldGC(老年代))

image.png

PS+PO收集器是jdk1.8默认的垃圾收集器

PS收集器实际上就是Serial收集器的多线程版本, 除了多线程垃圾收集以外, 其余行为(控制参数, 收集算法, 回收策略等)和Serial收集器类似. 默认的手机线程数量与cpu核心数量相同, 当然也可以通过参数(-XX:ParallelGCThreads)指定收集线程数, 不过一般也不用修改这个.

PS收集器关注点是吞吐量(高效利用cpu), CMS等收集器关注点更多的是用户线程的停顿时间.

PS收集器提供了很多参数供用户找到最合适的STW时间或者最大吞吐量, 如果对于收集器运作不太了解的话, 可以选择把内存管理优化交给虚拟机去完成.

PS+PO收集器新生代采用复制算法, 老年代采用标记-整理算法.

PO收集器是PS收集器的老年代版本, 使用多线程和标记-整理算法, 在注重吞吐量以及cpu资源的场合, 都可以选择PS+PO.

ParNew收集器(-XX:+UseParNewGC)

image.png

PN收集器其实合PS收集器很类似, 区别主要在于它可以和CMS收集器配合使用

新生代采用复制算法, 老年代采用标记-整理算法

它是许多运行在Server模式下的虚拟机的首要选择, 除了Serial收集器以外, 只有它能与CMS收集器配合工作

CMS收集器(-XX:+UseConcMarkSweepGC(old))

image.png

CMS(Concurrent Mark Sweep), 翻译成中文就是并发标记清除. 它实现的算法是标记-清除. 因此会造成内存碎片.

CMS是一种以获取最短回收停顿时间为目标的GC收集器, 它非常符合在注重用户体验的应用上使用. 它是第一款实现了用户线程与GC线程(基本上)同时工作的垃圾收集器.

它具有5个阶段

  1. 初始标记 暂停所有工作线程(STW), 并记录下GC ROOTS直接能引用的对象, 速度很快
  2. 并发标记 并发标记阶段就是从GC ROOTS的直接关联对象开始遍历整个对象图的过程. 这个过程耗时比较长, 但是不需要STW, 因此用户体验比较好, 但是随之而来的问题就是: 在用户线程继续运行的时候, 将会改变各个对象本应该的状态(本该被GC线程判定删除, 却又被引用, 本该被GC线程判定为存活, 但是又被断开引用)
  3. 重新标记 重新标记阶段就是解决第二阶段的问题. 这个阶段停顿时间会比初始标记停顿的时间稍长, 远比并发标记的时间短. 主要用到三色标记里的增量更新算法做重新标记
  4. 并发清理 开启用户线程, 同时GC线程开始对未标记的区域做清除, 这个阶段如果有新增对象被标记为黑色则不会做任何处理(多标: 会引发浮动垃圾)
  5. 并发重置 重置本次GC标记的数据(多标)

CMS是一款比较优秀的垃圾收集器, 因为它并发收集, 低停顿, 但是它有几个非常明显的缺点

  1. 对cpu资源敏感(和用户线程抢资源)
  2. 无法处理浮动垃圾(在并发标记和并发清理阶段又产生的垃圾), 这种浮动垃圾只能等下一次GC去清除
  3. 标记-清除算法会带来大量的内存碎片, 当然可以通过参数(-XX:+UseCMSCompactAtFullCollection)让jvm在执行完标记清除后再做整理
  4. 执行过程中会有一定的不确定性: 比如会存在上一次垃圾回收还没执行完成, 然后垃圾回收又被触发, 特别是在并发标记和并发清理阶段会出现, 一遍回收, 系统一边运行, 也许还没回收完就会触发FullGC, 也就是"concurrent mode failure", 此时会直接进行STW, 使用SerialOld垃圾收集器来回收

CMS由于使用的是增量更新算法去解决多标问题, 因此也会引发一些低概率的bug

可达性分析算法和三色标记法

GC要执行回收, 首先得标记哪些是该存活的对象, 哪些又是该回收的对象, 然后再通过不同的状态标记去让GC线程回收内存. 那么如何找到哪些是存活/该回收的对象呢?

一个对象是否是垃圾, 判定界限就是这个对象是否还被其它对象引用. 于是就有了引用计数法

引用计数法

在对象头中预留一部分空间去存放该对象的引用次数, 一个对象每次被引用一次, 计数就会+1, 每次断开引用, 计数就会-1, 在GC的时候, 如果该计数为0, 就说明这个对象是一个垃圾. 但是这样会有bug

比如A, B两个类互相依赖, 但是没有其余的任何地方会使用这两个对象, 但是这两个对象由于互相引用导致它们两个的引用计数一直是1, 那么这两个对象将一直不会被清除, 这样就造成了内存泄露

可达性分析算法

引用计数法发生内存泄露的根本原因就是, 只考虑自身而不考虑其它对象, 实际上正确的方式应该判断这个对象是否被其它对象引用, 而一个程序的运行, 一定会有一些最根本的对象, 会像树状图一样一直引用到所有对象, 这就是可达性分析算法的原理.

可达性分析算法从GC ROOTS出发, 去遍历对象图看看哪些对象是可达的, 如果不可达, 就是垃圾.

所谓GC ROOTS, 就是最根本的一些对象

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中引用的对象

三色标记法

顾名思义, 三种颜色标记对象, 每种颜色代表不同的标记状态.

  1. 黑色 表示对象已经被垃圾收集器访问过, 并且这个对象的所有成员变量的引用也已经被扫描过. 如果有其它对象引用指向了黑色对象, 无须重新扫描. 黑色对象不可能直接(不经过灰色对象)指向某个白色对象
  2. 灰色 表示对象已经被垃圾收集器访问过, 但是至少有一个它的成员变量引用没被扫描过
  3. 白色 表示对象尚未被垃圾收集器访问过. 显然在可达性分析刚开始的时候, 所有对象都是白色的, 在分析结束的时候仍然是白色的对象, 就代表不可达.

多标问题-浮动垃圾

在并发标记的过程中, 本来A对象是可达的, 会被标记为黑色, 但是用户线程的执行可能导致A对象从GC ROOTS断开开可达, 那么这个对象本应该是白色, 但是由于先前已经被标记为黑色, GC收集器不会再去关注A对象, 就代表A对象被多标了, 那么这次垃圾收集将不会清除A对象. 这就是多标问题, A对象就是一个浮动垃圾, 只能靠下一次GC去回收它

少标-误删除

在并发标记过程中, 本来A对象是不可达的, 但是用户线程的执行使得A对象变得可达, 但是垃圾收集已经开始了, 那么这将是一个非常严重的bug.

写屏障

在jvm中, 给对象的成员变量赋值, 大概写这样

/**
 * @param field 某对象的成员变量,如 a.b.d
 * @param new_value 新值,如 null
 */
void oop_field_store(oop* field, oop new_value) {
    *field = new_value; // 赋值操作
}

所谓的写屏障, 就是在赋值的操作前后, 加入一些处理(类似于AOP)

void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field); // 写屏障‐写前操作
    *field = new_value;
    post_write_barrier(field, value); // 写屏障‐写后操作
}

写屏障+增量更新(Incremental Update)-CMS解决少标问题

当在一个黑色对象中插入新的引用的时候, 就将这个黑色对象改为灰色, 等并发标记结束之后, 重新标记阶段再以这些灰色对象为根, 不用再从GC ROOTS开始, 重新扫描一次

写屏障实现增量更新, 就是当对象A的成员变量引用发生改变的时候, 比如新增引用a.d = d, 可以利用写屏障, 将A的新的成员变量引用对象D记录下来

void post_write_barrier(oop* field, oop new_value) {
    remark_set.add(new_value); // 记录新引用的对象
}

增量更新的bug

增量更新有个很隐蔽的bug, 比如A对象有2个引用C和D, GC1线程正在标记A对象中的C, 这时候A对象是灰色, 而紧接着用户线程将C对象的引用指向了一个白色对象F, 于是GC2线程把A对象置为灰色, 紧接着GC1线程恢复运行了, GC1线程只知道C标记了, 还得标记D, 然后标记了D, 确认ACD都标记成功, 将A变为黑色, 如此, F对象就被漏标了.

写屏障+原始快照(Snapshot At The Beginning SATB)-G1解决少标问题

当灰色对象要删除指向白色对象的引用关系的时候, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次, 这样就能扫描到白色的对象. 如果这些白色对象在重新扫描的过程中, 有其它引用指向它, 就将白色对象直接标记为黑色(目的就是让这种对象在本轮GC中能存活下来, 等待下一轮GC的时候重新扫描, 这个对象也有可能是浮动垃圾).

写屏障实现SATB

void post_write_barrier(oop* field, oop new_value) {
    remark_set.add(new_value); // 记录新引用的对象
}

SATB牺牲了更多的浮动垃圾的代价解决增量更新的漏标bug