上一篇博客写了JVM的内存溢出问题,比较了内存溢出和内存泄漏的区别,然后对虚拟机栈的OOM和SOF、方法区和运行时常量池的OOM、堆的OOM做了相关实验验证,在实验过程中发现了java8对方法区perm gen的改进,即metaspace代替perm gen(java7将字符串常量池移入了堆中)。最后,对于使用stringbuilder造成的heap space的OOM的实验现象提出了两个疑问,目前还没有解决,期待和读者一起讨论。
前面几篇博客大致将java运行时数据区域的基本内容写完了,本篇博客进入JVM的垃圾收集部分。这部分预计会分两篇博客来写,本篇博客会对对象存活判定算法、垃圾收集算法以及JVM对以上算法的实现进行学习。
看了一篇百度置顶的简书上的对象存活判定算法的讲解,讲得太好了。。。搞得我都不太好意思写了,本节最后会给上链接,这里我就硬着头皮以那篇文章的思路为基础来写吧!(这里对那篇文章作者表示无比尊敬之情,盗张图先。。)
首先介绍一下大致流程:
首先进行可达性分析,对于不可达的对象进行两次标记/筛选过程,任何一次标记/筛选阶段没有成功“自救”,则该对象将会被回收。
判定方式
目前在编程语言中常见的是引用计数法和可达性分析法。
1.引用计数法
引用计数法虽然没有在主流的JVM中使用,但是在Python、游戏脚本语言等领域也有广泛使用,是一个经典的内存管理算法。
优点:实现简单,判定效率比较高
缺点:无法解决对象循环引用的问题
例如下面的代码:
public class ReferenceCountingGC {
private ReferenceCountingGC instance = null;
private static final int _1MB = 1024*1024;
private byte[] bigsize = new byte[2*_1MB];//占用内存,方便查看GC,因为每个对象都有它
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
//相互引用
objA.instance = objB;
objB.instance = objA;
//置为null
objA = null;
objB = null;
//想要触发GC
System.gc();
}
}
下面是GC日志:
[GC (System.gc()) [PSYoungGen: 9876K->1292K(36864K)] 9876K->1300K(121856K), 0.0794454 secs] [Times: user=0.16 sys=0.00, real=0.08 secs]
[Full GC (System.gc()) [PSYoungGen: 1292K->0K(36864K)] [ParOldGen: 8K->1188K(84992K)] 1300K->1188K(121856K), [Metaspace: 4725K->4725K(1056768K)], 0.0098699 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 36864K, used 317K [0x00000000d6c00000, 0x00000000d9500000, 0x0000000100000000)
eden space 31744K, 1% used [0x00000000d6c00000,0x00000000d6c4f738,0x00000000d8b00000)
from space 5120K, 0% used [0x00000000d8b00000,0x00000000d8b00000,0x00000000d9000000)
to space 5120K, 0% used [0x00000000d9000000,0x00000000d9000000,0x00000000d9500000)
ParOldGen total 84992K, used 1188K [0x0000000084400000, 0x0000000089700000, 0x00000000d6c00000)
object space 84992K, 1% used [0x0000000084400000,0x0000000084529058,0x0000000089700000)
Metaspace used 4732K, capacity 4930K, committed 5248K, reserved 1056768K
class space used 510K, capacity 561K, committed 640K, reserved 1048576K
可以看到在一次GC后,9876→1292,堆中的对象是被回收了的。这从侧面说明Hotspot虚拟机不是使用的引用计数法。
2.可达性分析算法
可达性分析的两个重要概念:GC roots和引用链。
GC roots
gc roots是引用链的根节点,能够作为gc roots的对象有以下几类:
1.虚拟机栈本地变量表中引用的对象
2.本地方法栈中JNI(java native interface)引用的对象
3.方法区的常量、类静态属性引用的对象
引用链
从gc roots出发,向下搜索,搜索走过的路径叫做引用链。
如下图所示:
可达性分析
如果一个对象和gc roots之间没有通过任何引用链相连,那么就说该对象是不可达的。不可达的对象即将进入标记/筛选阶段。
标记/筛选阶段
1.第一次标记/筛选过程
简单来说,第一次标记过程就是查看对象需不需要执行finalize方法。如果需要,就进入下一标记阶段,如果没有必要执行,就结束标记阶段,基本判定该对象需要被回收了。
是否有必要执行finalize方法的判断依据
1.如果该对象对应的类中没有覆盖finalize方法,则说明没有必要执行
2.如果在之前,该对象已经执行过一次finalize方法了,则说明没有必要执行(因为finalize方法只能执行一次)
2.第二次标记/筛选过程
在第一次标记/筛选过程后,被认为有必要执行finalize方法的对象放入一个F-QUEUE中,JVM会自动创建一个低优先级的线程执行F-QUEUE中的finalize方法。这里的“执行”并不一定表示要完全执行完。因为如果一个finalize方法中有死循环,难道还要一直等到它执行吗?队列会被阻塞的!
判定对象是否存活的依据:
在finalize方法中,该对象又重新连接到了gc roots的引用链上
实验
package gctest;
public class FinalizeEscapeGC {
private static FinalizeEscapeGC instance = null;//instance是gc root
@Override
protected void finalize() throws Throwable {
super.finalize();
FinalizeEscapeGC.instance = this;
System.out.println("I save mylife!");
}
public void isAlive() {
System.out.println("I'm alive!");
}
public static void main(String[] args) {
instance = new FinalizeEscapeGC();
instance = null;//对象和gc roots之间失去连接
System.gc();
//第一次拯救过程
try {
Thread.sleep(500L);
} catch (Exception e) {
e.printStackTrace();
}
try {
instance.isAlive();//判断对象是否存活,若存活,则不会是空指针
} catch (NullPointerException e) {
System.out.println("nullpointerException happens");
}
instance = null;//对象和gc roots之间失去连接
System.gc();
//第二次拯救过程,就是把第一次的代码重复一遍
try {
Thread.sleep(500L);
} catch (Exception e) {
e.printStackTrace();
}
try {
instance.isAlive();//判断对象是否存活,若存活,则instance不会是空指针
} catch (NullPointerException e) {
System.out.println("NullPointerException happens");
}
}
}
可以看到对象开始进行了一次自救,后来自救失败,和instance失去了连接,instance为null。
参考简书文章超链接
简书关于对象存活判定算法的超链接
方法区的回收
方法区同样是由GC存在的。主要是对废弃常量和无用的类进行回收。
废弃常量:不再被使用的常量(指的是常量池中的字面量和符号引用),比如常量池中存入了一个“ABC”,但是任何地方都没有使用到它,就判定为废弃的常量。
无用的类:
1.该类的实例对象都被回收
2.加载该类的类加载器被回收
3.该类的java.lang.Class对象没有在任何地方引用到,无法通过反射访问该类中的方法
本节总结
对象一旦被不可达分析判定为不可达后,要经历两次标记过程的重重考验才能成功自救,并且只能通过finalize方法自救一次。
共分为三种收集算法和一种收集思想。即标记-清除算法,标记-整理算法,复制算法,分代收集思想。
1.标记-清除算法
这个算法很好理解,就是判定死亡的对象就回收那块内存,啥也不管了。
优点:简单
缺点:会形成大量空间碎片,导致没有完整的内存分配给新创建的对象,提前出发full GC.
2.标记-整理算法
标记整理算法在标记清除算法基础上进行了改进,在回收完死亡对象的内存后,将存活的对象向内存空间的一端进行移动,这样就解决了空间碎片的问题。
优点:不会产生空间碎片,防止提前进行Full GC
缺点:执行速度要比标记清除慢
3.复制算法
复制算法,简单来说,就是你现在有两块空间,一块A存对象,另一块B空。在回收死亡对象后,将A中的存活对象复制到B中,然后清空A的内存,这样就又形成了一块存对象的空间和一块空的空间。
优点:不会有空间碎片问题,在分配空间时很高效
缺点:牺牲掉了一部分内存空间作为空的内存空间。
4.分代收集思想
分代收集思想就是将堆内存分为老年代和新生代,对不同的区域执行不同的垃圾回收算法,使得内存的分配与回收更加高效!比如,老年代适合使用标记清除算法和标记整理算法。新生代则进一步分为Eden区、from survivor区和to survivor区,执行复制算法。
1.枚举根节点
在进行可达性分析时,我们需要有根节点信息和引用链信息,这就需要有两方面的要求:STW(stop the world)和引用信息
STW:可达性分析对时间是敏感的,不能再分析的同时,引用关系还在发生变化,所以在GC进行时必须停顿java所有线程,称为stop the world。
引用信息:建立引用关系必须知道当前内存中哪些是对象的引用,传统方法是直接扫描整个内存,获取引用信息。Hotspot采用oopMap的方式。
1.记录栈、寄存器等区域中哪些位置是GC管理的指针
2.一段代码内可以有多处使用oopMap,但不是每条指令都会使用oopMap,也就是安全点,safepoint将一段代码分为好几段
3.oopMap的作用域也只在它所在的那一段里
2.安全点
GC只有在安全点出才能执行,安全点一般是在“程序能长时间执行的地方”,特征是指令序列复用。(看书的时候这里一直不明白为什么要这么选,后面会讲到)
1.循环跳转
2.方法调用
3.异常跳转
为什么这样选择呢?
1.如果选择的安全点过少,每次GC之间间隔时间太长,安全点选择过多,GC执行又过于频繁。
2.想象如果A、B两个安全点之间有一个上面说的循环跳转、异常跳转等代码段,刚好这段代码执行时间非常长长长。。。。,甚至是死循环,那想要在B安全点进行GC不是要等到天荒地老?(即GC之间间隔时间太长)
怎样让所有线程都在安全点呢?
GC是整个JVM的,JVM中会有很多个线程在执行,那么如何保证GC时所有线程都在安全点呢?
分为抢先式中断和主动式中断
抢先式中断
特点:不照顾线程感受
过程:GC时先中断所有的线程,然后让那些没有到安全点的线程自己再跑到安全点
使用:现在已经没有使用抢先式中断的了
主动式中断
特点:照顾线程感受,让它自己去吧
过程:设置一个GC标志,线程执行到安全点或创建对象分配内存时,主动去轮询这个标志,为真时就主动中断自己(这个时候是安全点,中断就中断呗)
使用:大家都说好
3.安全区域
上面考虑到了多线程,但是没有考虑到线程在执行过程中可能会sleep或者block呀。如果等待它休眠结束或者CPU时间片分配过来,又是天荒地老了啊!所以引出安全区域的概念
定义:安全区域是指这一段代码中的引用关系不会发生变化
线程在安全区域行为本质上是一个握手过程
过程:
1.线程A进入safe region,设置一个标志Ready flag.
2.GC如果在线程A处于safe region的时间内进行,由于ready flag的存在,不再检查
线程A
3.线程A将要离开safe region时,轮询GC设置的标志,若为真表示GC还没有执行完,则线程A中断自己,保证自己不离开safe region。若此时GC已经执行完毕,则A顺利离开region。
本篇博客对对象存活判定算法和垃圾收集算法进行了学习,后面将会对垃圾收集器、对象的内存分配与回收策略以及JVM对以上算法的实现进行学习。