垃圾收集的几种算法
- 分代收集理论 当前虚拟机的垃圾收集都是采用的分代收集算法, 就是根据对象存活周期的不同将内存分为几个逻辑区块. java中就区分为新生代和老年代, 这样就可以根据不同区块的对象特点选择合适的收集算法. 比如在新生代中, 每次收集都会有大量的对象被清除, 所以可以选择复制算法, 只需要付出少量对象的复制成本就可以完成每次的垃圾收集, 而老年代的对象存活几率很高, 而且没有额外的空间进行分配担保, 所以可以选择标记清除和标记整理算法. 标记清除和标记整理将会比复制算法慢很多, 大约十倍以上
- 标记-复制算法 复制算法就是将内存分为大小相同的两个区块, 使用的时候只能使用其中一个区块(也就是只能使用内存的一半), 垃圾收集的时候, 就会将那些不是垃圾的标记出来, 复制到另一半区块中, 然后直接清除原先区块的内存. 这样的操作效率会非常高, 并且也不会有内存碎片产生, 但是问题就是内存浪费
- 标记-清除算法 分为两个阶段: 标记/清除. 标记阶段: 标记应该存活的对象, 清除阶段: 统一回收所有没被标记的内存. 这样的操作效率会比较低, 如果有大量对象, 就需要很久的时间去标记, 并且清理内存后, 将会有大量的内存碎片
- 标记-整理算法 一样会有标记和整理两个阶段, 标记阶段也是将那些不是垃圾的对象标记, 整理阶段就是把那些被标记的对象(不是垃圾的对象)向内存一端移动, 然后直接清理掉端边界以外的内存 这样的操作好处就是不会产生内存碎片
垃圾收集器
上述三种收集算法是内存回收的方法论, 而垃圾收集器就是这些方法论的具体实现
并不存在最好的垃圾收集器, 只存在最适合的垃圾收集器. 我们无法找到一款最好的, 但是我们能根据具体的应用场景选择适合自己的垃圾收集器
Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
Serial(串行化)收集器是最基本, 最古老的垃圾收集器, 它所谓的"串行"并不仅仅是它只有一个线程去进行垃圾回收, 并且在它执行GC的时候, 所有用户线程就会暂停(STW: Stop The World), 直到垃圾收集结束.
新生代采用复制算法, 老年代采用标记-整理算法
Serial的STW是非常影响用户体验的, 所以在之后的垃圾收集器中, 不断去想方设法把STW时间降低, 可是Serial的优点也是有的: 比如由于没有线程交互的开销, 即使是单个线程都能很高效的去执行收集
SerialOld收集器是Serial收集器的老年代版本, 它同样是一个单线程的垃圾收集器. 它主要有两大用途:
- 在jdk1.5以及以前的版本中与Parallel Scanvenge收集器搭配使用
- 作为CMS收集器的后备方案
ParallelScavenge收集器(-XX:+UseParallelGC(年轻代), -XX:+UseParallelOldGC(老年代))
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)
PN收集器其实合PS收集器很类似, 区别主要在于它可以和CMS收集器配合使用
新生代采用复制算法, 老年代采用标记-整理算法
它是许多运行在Server模式下的虚拟机的首要选择, 除了Serial收集器以外, 只有它能与CMS收集器配合工作
CMS收集器(-XX:+UseConcMarkSweepGC(old))
CMS(Concurrent Mark Sweep), 翻译成中文就是并发标记清除. 它实现的算法是标记-清除. 因此会造成内存碎片.
CMS是一种以获取最短回收停顿时间为目标的GC收集器, 它非常符合在注重用户体验的应用上使用. 它是第一款实现了用户线程与GC线程(基本上)同时工作的垃圾收集器.
它具有5个阶段
- 初始标记 暂停所有工作线程(STW), 并记录下GC ROOTS直接能引用的对象, 速度很快
- 并发标记 并发标记阶段就是从GC ROOTS的直接关联对象开始遍历整个对象图的过程. 这个过程耗时比较长, 但是不需要STW, 因此用户体验比较好, 但是随之而来的问题就是: 在用户线程继续运行的时候, 将会改变各个对象本应该的状态(本该被GC线程判定删除, 却又被引用, 本该被GC线程判定为存活, 但是又被断开引用)
- 重新标记 重新标记阶段就是解决第二阶段的问题. 这个阶段停顿时间会比初始标记停顿的时间稍长, 远比并发标记的时间短. 主要用到三色标记里的增量更新算法做重新标记
- 并发清理 开启用户线程, 同时GC线程开始对未标记的区域做清除, 这个阶段如果有新增对象被标记为黑色则不会做任何处理(多标: 会引发浮动垃圾)
- 并发重置 重置本次GC标记的数据(多标)
CMS是一款比较优秀的垃圾收集器, 因为它并发收集, 低停顿, 但是它有几个非常明显的缺点
- 对cpu资源敏感(和用户线程抢资源)
- 无法处理浮动垃圾(在并发标记和并发清理阶段又产生的垃圾), 这种浮动垃圾只能等下一次GC去清除
- 标记-清除算法会带来大量的内存碎片, 当然可以通过参数(-XX:+UseCMSCompactAtFullCollection)让jvm在执行完标记清除后再做整理
- 执行过程中会有一定的不确定性: 比如会存在上一次垃圾回收还没执行完成, 然后垃圾回收又被触发, 特别是在并发标记和并发清理阶段会出现, 一遍回收, 系统一边运行, 也许还没回收完就会触发FullGC, 也就是"concurrent mode failure", 此时会直接进行STW, 使用SerialOld垃圾收集器来回收
CMS由于使用的是增量更新算法去解决多标问题, 因此也会引发一些低概率的bug
可达性分析算法和三色标记法
GC要执行回收, 首先得标记哪些是该存活的对象, 哪些又是该回收的对象, 然后再通过不同的状态标记去让GC线程回收内存. 那么如何找到哪些是存活/该回收的对象呢?
一个对象是否是垃圾, 判定界限就是这个对象是否还被其它对象引用. 于是就有了引用计数法
引用计数法
在对象头中预留一部分空间去存放该对象的引用次数, 一个对象每次被引用一次, 计数就会+1, 每次断开引用, 计数就会-1, 在GC的时候, 如果该计数为0, 就说明这个对象是一个垃圾. 但是这样会有bug
比如A, B两个类互相依赖, 但是没有其余的任何地方会使用这两个对象, 但是这两个对象由于互相引用导致它们两个的引用计数一直是1, 那么这两个对象将一直不会被清除, 这样就造成了内存泄露
可达性分析算法
引用计数法发生内存泄露的根本原因就是, 只考虑自身而不考虑其它对象, 实际上正确的方式应该判断这个对象是否被其它对象引用, 而一个程序的运行, 一定会有一些最根本的对象, 会像树状图一样一直引用到所有对象, 这就是可达性分析算法的原理.
可达性分析算法从GC ROOTS出发, 去遍历对象图看看哪些对象是可达的, 如果不可达, 就是垃圾.
所谓GC ROOTS, 就是最根本的一些对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
三色标记法
顾名思义, 三种颜色标记对象, 每种颜色代表不同的标记状态.
- 黑色 表示对象已经被垃圾收集器访问过, 并且这个对象的所有成员变量的引用也已经被扫描过. 如果有其它对象引用指向了黑色对象, 无须重新扫描. 黑色对象不可能直接(不经过灰色对象)指向某个白色对象
- 灰色 表示对象已经被垃圾收集器访问过, 但是至少有一个它的成员变量引用没被扫描过
- 白色 表示对象尚未被垃圾收集器访问过. 显然在可达性分析刚开始的时候, 所有对象都是白色的, 在分析结束的时候仍然是白色的对象, 就代表不可达.
多标问题-浮动垃圾
在并发标记的过程中, 本来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