深入理解JVM第三章笔记
背景
垃圾收集需要考虑的三件事:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
如何判断对象的存活
在垃圾收集器对对象进行回收前,第一件事就是判断哪些对象是“存活”的,哪些是“死去”的。
引用计数法
原理:
给对象添加一个引用计数器,每当有一个方法引用它,计数器值+1;引用失效,计数器值-1;
任何时候计数器为0的对象就是不能再被使用的了。
虽然引用计数法实现简单,判定效率还不错,但主流的JVM并没有选择这个算法来管理内存,因为它很难解决对象直接相互循环引用的情况。
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
/*
对象 a 和 b都有字段instance,赋值令a.instance = b;以及b.instance = a
除此之外,实际上这两个对象已经不可能再被访问了,但是因为它们互相引用对方,导致它们的
引用计数都不为0,所以GC也回收不了它们
*/
public static void testGC() {
ReferenceCountingGC a = new ReferenceCountingGC();
ReferenceCountingGC b = new ReferenceCountingGC();
a.instance = b;
b.instance = a;
a = null;
b = null;
System.gc();
}
}
可达性分析算法
思路:
通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径叫做引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的
图示:
上图中,对象object5,object6,object7虽然相互有关联,但是它们到GC Roots是不可达的,所以它们将会判定为可回收的对象
在Java中,可以作为GC Roots的对象包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
引用
JDK1.2之后,Java对引用概念进行了扩展,将引用分为
- 强引用:
程序代码之中普遍存在的,类似“Object o = new Object()”这类的引用,只要强引用还存在,那么垃圾收集器永远不会回收被引用的对象
- 软引用:
用来描述一些还有用但未必必需的对象
- 弱引用
描述非必需的对象,但它的强度比弱引用更加弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。
- 虚引用
最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对齐生产时间构成影响,也无法通过虚引用来取得一个对象实例。
垃圾收集算法
标记-清除 算法
思路:
分为两个阶段:
标记
清除
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
图示:
有两个问题:
- 效率问题
标记和清除两个过程的效率都不高
- 空间问题
标记清除后会产生大量的不连续的内存碎片,空间碎片大多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
思路:
为解决效率问题,将可用内存容量划分为大小一样的两块,每次只使用其中的一块,当这一块内存用完,就将存活的对象复制到另一块上,然后把已经使用过的内存空间一次性清理掉,这样每次都可以只对内存半个区域进行垃圾收集。
内存分配也无需考虑内存碎片的情况,移动堆顶指针,按顺序分配内存即可。
图示:
但这种算法的代价是将内存缩小为原来一半,代价太高了一点。
标记-整理 算法
老年代一般不会选择复制算法,因为可能出现所有对象100%存活的极端情况。
根据老年代特点,出现了标记-整理算法。
思路:
标记过程与“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
图示:
分代收集算法
思路:
根据对象存活周期的不同将内存划分为几块,一般把Java堆划分为新生代和老年代。
- 新生代
每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选择复制算法,只需要付出少量存活对象的复制成本就可以完成收集
- 老年代
对象存活率高,没有额外空间进行分配担保,就使用“标记-清理”或者“标记-整理”算法来回收
HotSpot算法实现
枚举根节点
目前主流的虚拟机都是使用的是:可达性分析法。在可达性分析法中对象能被回收的条件是没有引用来引用它,要做到这点就需要得到所有的GC Roots节点,来从GC Root来遍历。可作为GC Root的主要是全局性引用(例如常量和静态变量),与执行上下文(栈帧中的本地变量表)中。那么如何在这么多的全局变量和栈中的局部变量表中找到栈上的根节点呢?
在栈中只有一部分数据是Reference(引用)类型,那些非Reference的类型的数据对于找到根节点没有什么用处,如果我们对栈全部扫描一遍这是相当浪费时间和资源的事情。
那怎么做可以减少回收时间呢?我们很自然的想到可以用空间来换取时间,我们可以在某个位置把栈上代表引用的位置记录下来,这样在gc发生的时候就不用全部扫描了,在HotSpot中使用的是一种叫做OopMap的数据结构来记录的。对于OopMap可以简单的理解是存放调试信息的对象。
安全点
在OopMap的协助下,我们可以快速的完成GC Roots枚举,但我们也不能随时随地都生成OopMap,那样一方面会需要更多的空间来存放这些对象,另一方面效率也会简单低下。所以只会在特定的位置来记录一下,主要是正在:
循环的末尾
方法临返回前/调用方法的call指令后
- 可能抛异常的位置
这些位置称为安全点。
做GC的时候需要让jvm停在某个时间点上,如果不是这样我们在分析对象间的引用关系的时候,引用关系还在不断的变化。这样我们的准确性就无法得到保证。 安全点就是所有的线程在要GC的时候停顿的位置。那么如何让所有的线程都到安全点上在停顿下来呢?这里有两种方案可以选择:
抢先式中断
抢先式中断中不需要线程主动配合,在GC发生的时候就让所有线程都中断,如果发现哪个线程中断的地方不在安全点上,那么就恢复线程,然后让它跑到安全点上。主动式中断
主动式中断是让GC在需要中断线程的时候不直接对线程操作,设置一个标志,让各个线程主动轮询这个标志,如果中断标志位真时就让自己中断
安全区域
使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
垃圾收集器
垃圾收集器是内存回收算法的具体实现
HotSpot虚拟机的垃圾收集器:
上图展示7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们之间可以搭配使用。
Serial收集器
特点:
采用复制算法
单线程收集
- 进行垃圾收集时,必须暂停所有工作线程,直到完成---即会" Stop the World"
图示:
应用:
依然是HotSpot在Client模式下默认的新生代收集器;
也有优于其他收集器的地方:
简单高效(与其他收集器的单线程相比);对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;
在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的
ParNew收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多条线程进⾏垃圾收集之 外,其余⾏为包括 Serial 收集器可⽤的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全⼀样
图示:
特点:
优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。
- 使用算法:复制算法
- 适用范围:新生代
- 应用:运行在Server模式下的虚拟机中首选的新生代收集器
Parallel Sacvenge收集器
Parallel Sacvenge收集器是一个新生代收集器。使用复制算法的并行多线程收集器
特点:
Parallel Scavenge收集器的关注点与其他收集器不同, ParallelScavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称为“吞吐量优先”收集器。
使用场景:
主要适应主要适合在后台运算而不需要太多交互的任务。
Serial Old收集器
Serial Old是 Serial收集器的老年代版本
特点:
针对老年代;
采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
单线程收集;
图示:
使用场景:
主要用于Client模式;
而在Server模式有两大用途:
(A)、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
(B)、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后面详解);
Parallel Old收集器
Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;
特点:
针对老年代;
采用"标记-整理"算法;
多线程收集;
图示:
使用场景:
JDK1.6及之后用来代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;
CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法实现,它的运作过程如下:
1)初始标记
2)并发标记
3)重新标记
4)并发清除
初始标记、从新标记这两个步骤仍然需要“stop the world”,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生表动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长点,但远比并发标记的时间短。
图示:
缺点:
1)CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
2)CMS收集器无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致Full GC产生。
3)浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随着程序运行自然就会有新的垃圾不断产生,这部分垃圾出现的标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC中再清理。这些垃圾就是“浮动垃圾”。
4)CMS是一款“标记--清除”算法实现的收集器,容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
G1收集器
G1是一款面向服务端应用的垃圾收集器。G1具备如下特点:
1、并行于并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
2、分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的收集效果。
3、空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
4、可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段
图示: