垃圾收集
一、垃圾收集
垃圾收集需要考虑的三件事:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
1. 对象已死
堆里面存放着Java世界中几乎所有的对象实例,在垃圾回收前,第一件事就是要确定哪些对象还“或者”,哪些已经“死去”。
1.1 引用计数算法
在对象中添加一个引用计数器,没当有一个地方引用它时,计数器值加一,引用失效时,值减一;任何时刻计数器为零表示对象不可能再被使用。
从客观上看,引用计数法虽然占用了一点额外的内存空间进行计数,但是原理简单,效率高,大多数情况下它都是一个不错的算法。Java未使用,主要原因是,这个算法有很多例外的情况需要考虑,需要配合大量的额外处理才能确保正确工作,如引用计数法很难解决对象相互循环引用的问题。
如下:对象objA和objB都有字段instance,赋值objA.instance=objB,objB.instance=objA,除此之外,这两个对象没有其它引用,实际上这两个对象不能再被方法,但是这两个对象相互引用,计数器不为零,导致引用计数法无法回收。
/**
* -XX:+PrintGCDetails
*/
public class ReferenceCountingGC {
public Object instance = null;
// 10M占用内存,方便GC日志分析结果
private byte[] bytes = new byte[10 * 1024 * 1024];
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
// 赋值为空,表示两个对象不再被使用
objA = null;
objB = null;
// 手动触发GC,看objA和objB能否被回收
System.gc();
}
}
GC日志
[GC (System.gc()) [PSYoungGen: 24228K->783K(36352K)] 24228K->791K(119808K), 0.0038239 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 783K->0K(36352K)] [ParOldGen: 8K->666K(83456K)] 791K->666K(119808K), [Metaspace: 3050K->3050K(1056768K)], 0.0051717 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 36352K, used 937K [0x00000000d7780000, 0x00000000da000000, 0x0000000100000000)
eden space 31232K, 3% used [0x00000000d7780000,0x00000000d786a558,0x00000000d9600000)
from space 5120K, 0% used [0x00000000d9600000,0x00000000d9600000,0x00000000d9b00000)
to space 5120K, 0% used [0x00000000d9b00000,0x00000000d9b00000,0x00000000da000000)
ParOldGen total 83456K, used 666K [0x0000000086600000, 0x000000008b780000, 0x00000000d7780000)
object space 83456K, 0% used [0x0000000086600000,0x00000000866a6a68,0x000000008b780000)
Metaspace used 3057K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 323K, capacity 392K, committed 512K, reserved 1048576K
日志里面"24228K->791K(119808K)",意味着虚拟机GC的时候回收了这两个相互引用的对象,侧面说明Java虚拟机不是通过引用计数法判断对象是否存活的。
1.2 可达性分析算法
基本思路:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,通过引用关系向下搜索,搜索过程走过的路径称为“引用链”,如果对象到GC Roots间没有引用链相连(GC Roots到这个对象不可达),证明该对象不可能再被使用。
如下图:object 5、object 6、object 7之间虽有关联,但是到GC Roots不可达,会被判定为可回收对象。
在Java中,可以当成GC Roots的对象包括一下几种:
- 虚拟机栈(栈帧中本地变量表)中引用的对象,如各个线程被调用的方法堆栈中使用的参数、局部变量、临时变量等。
- 方法区中类静态属性引用的对象,如Java类的引用类型静态变量。
- 方法区中常量引用对象,如字符串常量池里的引用
- 本地方法栈中引用的对象
- 所有被同步锁(synchronized关键字)持有的对象
- …
1.3 引用
从JDK1.2开始,将引用分为强引用、软引用、弱引用和虚引用,这四种引用强度依次减弱。
- 强引用:程序代码中普遍存在的引用赋值,类似“Object obj = new Object()”这种引用关系。无论任何情况,强引用关系还存在,垃圾收集器就不会回收掉被引用的对象。
- 软引用:用来描述一些还有用,但是非必须的对象。被软引用关联的对象,系统将要内存溢出前,将这些对象列入二次回收的范围,如果这次回收还没有足够的内存,才会内存溢出。
- 弱引用:用来描述非必须对象,强度比软引用更弱一下,被软引用关联的对象只能生存到下一次垃圾收集发生为止。
- 虚引用:也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。唯一的作用是为了能在被垃圾回收时收到一个系统通知。
2. 垃圾收集算法
2.1 分代收集理论
当前商业虚拟器的垃圾收集器,大多数都遵循“分代收集”的理论进行设计,建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是招生死灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。
2.2 标记-清除算法
算法分为“标记”和“清除”两个阶段,首先标记需要回收的对象,标记完成后,统一回收被标记的对象,或者反过来,标记存活的对像,在统一回收未被标记的对象。
主要缺点有两个:第一是执行效率不稳定,如堆中包含大量对象,其中大部分都是需要被回收的,这时需要进行大量的标记和清除操作,导致执行效率随着对象增长而降低。第二是内存碎片化,标记清除后会产生连续不断的内存碎片,碎片太多可能导致以后程序运行过程中需要分配大对象无法找到足够的连续内存空间而提前触发下一次的垃圾收集操作。
2.3 标记-复制算法
为了解决标记-清除算法面对大量可回收对象执行效率低的问题,它将可用的内存划分为小小相同的两块,每次只使用其中一块。这一块内存用完了,就或者的对象复制到另一块上,然后把这一块内存空间一次性清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存复制开销,但是对于多数对象都是需要回收的情况,算法复制的就是少量存活的对象,每次针对半区进行内存回收,分配内存是不需要考虑空间碎片化问题,按顺序分配即可。简单高效,缺陷显而易见,代价是将可使用的内存缩小为了原来的一半,造成空间浪费。
针对具备“朝生夕死”特点的对象,提出了一种更优的半区复制分代策略。将生代分为一块较大的Eden空间和两块较小的Survivor空间 ,内次内存分配使用Eden和启动一块Survivor。发生垃圾收集时,将Eden和Survivor中存活的对象复制到另一块Survivor空间,然后直接清理Eden和已经使用过的Survivor空间。
内存分配担保:用来应对另一块Survivor空间不足以存放Eden和Survivor存活的对象这种极端情况,这些对象通过分配担保机制直接进入老年代。
2.4 标记-整理算法
让所有或者的对象都向内存空间的一端移动,然后直接清理到边界外的内存。
3. 经典垃圾收集器
如图展示了七种用于不同分代的收集器,如果两个收集器之间存在连线,表示可以搭配使用。
3.1 Serial收集器
这个收集器是一个单线程工作的收集器,“单线程”并不是说明它只会使用一个处理器或者一条收集线程去完成垃圾收集操作,更重要的是强调它在垃圾收集时,必须暂停所有的工作线程,直到收集结束。
3.2 ParNew收集器
ParNew的实质是Serial收集器的多线程并行版本,区别是使用多线程进行垃圾收集。
3.3 ParNew Scavenge收集器
ParNew Scavenge是一款基于标志-复制算法实现的新生代垃圾收集器,也是并行收集的多线程收集器。
ParNew Scavenge收集器关注点与其他收集器不同,CMS等收集器关注点是尽可能地缩短垃圾收集时线程的停顿时间,而ParNew Scavenge收集器则是达到一个可控的吞吐量。
3.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程的收集器,使用标记-整理算法。
3.5 Parallel Old收集器
Parallel Old是Parallel收集器的老年代版本,支持线程并发收集,基于标记-整理算法实现。
3.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短停顿时间为目标的收集器。
整个过程分为四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记和重新标记任需要暂停工作线程(Stop The World)。初始标记仅仅标记一下GC Roots能直接关联的对象,速度很快。并发标记就是从GC Roots直接关联的对象开始遍历整个对象图的过程,不需要暂停工作线程,耗时长,与垃圾收集线程并发运行。重新标记是修正并发标记期间,因为程序继续运行而导致标记产生变动的那一部分对象的标记记录,工作线程停顿比初始标记稍长,远比并发标记时间短。并发清除是清除掉未标记(死亡)对象,与用户线程并发执行。
三个明显缺陷
- 处理器资源敏感。并发阶段,虽不会暂定工作线程,单也会占用一部分线程(垃圾收集线程)导致程序变慢,降低总吞吐量。
- 无法清除“浮动垃圾”。并发标记和并发清除阶段,用户线程还在继续执行,会产生新的垃圾对象,这一部分垃圾对象是出现在并发标记过程结束后,CMS无法在本次处理掉它们,只好留待下一次垃圾收集时再清理。
- 大量空间碎片。CMS使用标记-清除算法实现,意味着垃圾收集结束后会产生大量的空间碎片。
3.7 Garbage First收集器
Garbage First(简称G1)是一款主要面向服务端应用的垃圾收集器。直到JDK8 Update40,G1提供并发的类卸载的支持,这个版本以后G1收集器才被Oracle官方称为“全功能的垃圾收集器”。
作为CMS收集器的替换者和继承人,设计者希望能做出一款能够建立起“停顿时间模型”的收集器,停顿时间模型的意思是能够支持在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
G1遵循分代收集理论设计的,但其堆内存的布局与其它收集器有着非常明显的差异:G1不再坚持固有以及固定数量的分区划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演色新生代的Eden空间和Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不用的策略去处理。
Region中有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要超过了Region容量一半的对象判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize=n
设置,n取值范围1MB~32MB,为2的N次幂。对于那些超过了Region容量的超级大对象,将会放在N个连续的Humongous Region中,G1大多数行为把Humongous Region作为老年代的一部分来看待。
G1收集器之所以能建立可预测停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全面的区域的垃圾收集。
G1收集器的运作大致可划分为一下四个步骤
- 初始标记:仅仅标记一下GC Roots能直接关联的对象,并修改TAMS(Top at Mark Sweep)指针的值,让下一阶段用户线程并发运行时,能够正确的在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里面的对象图,找出需要回收的对象,耗时较长,但可与用户线程并发执行。当对象图扫描完成后,还需要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收:负责更新Region统计数据,对各个Region的回收价值和成本进行排序,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region空间。这里的操作涉及到对存活对象的移动,是必须暂停用户线程的,由多条线程并行完成。