《深入理解JVM虚拟机》中这样说道:“Java和C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙”。显然Java中的一个最大特性就是垃圾收集,垃圾收集这一技术诞生的作者思考过垃圾收集需要完成的三件事情:
哪些内存需要回收?
什么时候回收?
如何回收?
我们从这三个问题去真正认识一下垃圾回收器,以及为什么这堵墙墙外的人想进去,墙里面的人想出去?
如上图所示(源自网络),本地方法栈、虚拟栈、程序计数器三个区域随线程而生,随线程而灭,这几个区域内不需要过多考虑如何回收的问题。而Java堆和方法区(1.8后变成了元空间和直接内存)这两个区域有着明显的不确定性:一个接口的多个实现类需要内存可能不一样,一个方法的不同分支所需内存也可能不一样,垃圾收集器所关注的正是这部分内存该如何管理。
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象哪些还“存活着”,哪些已经“死去”,然后再对死去的对象进行回收。
在对象中添加一个引用计数器,每当在一个地方引用时,计数器就加1,失效时就减1;任何时候计数器为0的对象就是不可能再被使用的对象。
缺点:尽管它的原理简单,但是缺点也很明显,比如单纯的引用计数器算法无法确定相互循环引用的问题。因此,在Java领域里主流的Java虚拟机没有选用引用计数器算法进行内存管理。举个例子:
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员方法的意义就是占用内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否会被回收?
System.gc();
}
}
结果:
从结果可以看出虚拟机并没有因为它们相互引用就放弃回收。
当前主流的应用程序语言的内存管理都是采用可达性分析算法来判断对象是否存活。这个算法的基本思想就是通过一系列的“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,如果某个对象到GC Roots之间没有任何引用链相连,则证明此对象不能再被使用。
如上图,object5、object6、object7虽然相互之间有关联,但是到GC Roots是不可达的,因此判断他们为可回收的对象。
在Java体系中,固定可做GC Roots的对象包括以下几种:
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前会后的内存区域不同,还可以有其他对象“临时性”的加入,共同构成完整GC Roots集合。
无论是通过什么方法来判断对象是否存活,都和“引用”离不开关系。在jdk1.2之前,java中的引用还是很传统的定义:如果reference类型的数据中存储的数值是另一块内存的起始地址,就称该reference数据代表某块内存、某个对象的引用。
在jdk1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用。这4中引用强度依次减弱。
Object obj = new Object()
这种,无论什么情况,只要强引用关系还在,垃圾收集器就永远不会回收这些被引用的对象 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”,这种时候还处于“缓刑”阶段,要真正宣告一个对象的死亡,至少要经过两次标记过程:
不可达为第一次标记
判断是否有必要执行finalize()方法:假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机都将视为没有必要执行。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象会被放到一个F-Queue的队列中,稍后为有一个finalizer线程对其进行finalize()方法。
以上就是真正判刑了,不过对象还可以在finalize()方法中进行一次自救:即将自己和引用链上的任何一个对象关联即可,例如把自己赋值给某个类变量。有如下例子:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, I am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable{
SAVE_HOOK = new FinalizeEscapeGC();
// 对象的第一次成功自救
SAVE_HOOK = null;
System.gc();
// 暂停0.5s,等待Finalizer方法执行,
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("No, I am dead :)");
}
// 对象的第二次成功自救:失败!因为finalize方法只会被系统自动调用一次
SAVE_HOOK = null;
System.gc();
// 暂停0.5s,等待Finalizer方法执行,
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("No, I am dead :(");
}
}
}
结果:
可以注意到,第二次自救失败了,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次。(PS:周老师让我们忘掉这个方法)
同样,垃圾回收算法也可以划分为两大类:引用计数式垃圾回收和追踪式垃圾回收。第二个是主流Java虚拟机中使用的,因此主要介绍下第二种。
当前商业虚拟机的垃圾收集器,大多数遵循“分代收集”的理论进行设计,它建立在两个分代假说之上:
这两个分代假说共同奠定了多款常用垃圾收集器的一致设计原则:收集器应该将Java堆划分为不同的区域,然后将回收对象按照其年龄分配到不同的区域之中存储。比如在新生代中,每次收集都会有大量对象的死亡,所以可选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾回收。
不同分代的名词定义:
部分收集 (Partial GC):
整堆收集 (Full GC):收集整个 Java 堆和方法区。
最早出现的垃圾收集算法就是“标记-清除算法”,首先标记出需要回收的对象,然后在标记完成后统一回收所有被标记的对象。也可以反过来标记存活的对象,统一回收所有未被标记的对象。
缺点:
图示:
为了解决标记-清除算法的效率低问题,1969年Fenichel提出一个“半区复制”垃圾回收算法,它将可用内存按照容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。不过这种算法的缺点也显而易见,空间浪费太多了一点。执行过程如下:
现在商用的Java虚拟机大多都优先采用这种收集算法去回收新生代。
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward提出了另外一种有针对性的“标记-整理算法”,其标记过程仍和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。它与标记-清除算法的本质差异就是标记-清除算法是一种非移动式的回收算法,而标记-整理算法是移动式的。示意图如下:
堆空间的基本结构如下:
如上图,Eden区,From Survivor区,To Survivor区都属于新生代,Old Memory区属于老年代。
大部分情况下,对象首先会在Eden区域分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。如果对象还存活就会进入s0,并且对象的年龄加1,当年龄增加到一定程度(默认15岁),就会被晋升到老年代中。然后将Eden区和s1区全部删除,等下一次快满了的时候再将s0区和Eden区所有的对象进行打标,将存活的放入s1区,然后删除s0区和Eden区,如此反复工作。
垃圾收集器主要有如下几种:
(本文图大部分来自于JavaGuide,很好的一个面经博客)
Serial收集器是最基础、历史最悠久的收集器,曾经是Hotspot虚拟机新生代收集器的唯一选择。这是一个单线程工作的收集器,它再进行垃圾回收时必须暂停其他所有工作线程,直到它收集结束。即“Stop the World”。
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和Serial收集器一样。
也是采用标记-复制算法的多线程收集器。它与ParNew收集器不同的是它关注点是吞吐量(高效的利用CPU)。CMS等垃圾收集器关注点更多的是用户线程的停顿时间(提高用户体验)。
Serial Old收集器是Serial收集器的老年代版本。
Parallel Scavenge收集器的老年代版本。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。非常符合在注重用户体验的应用上使用。
G1(Garbage-first)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
扩充:CMS收集器和G1收集器
CMS(Concurren Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,基于标记——清理实现。仅作用于老年代收集。步骤如下:
G1收集器
G1收集器的内存结构完全区别于CMS,弱化了CMS原有的分代模型,将堆内存划分成一个个Region,这么做的目的是在进行收集时不必在全堆范围内进行。它主要特点在于达到可控的停顿时间,用户可以指定收集操作在多长时间内完成,即G1提供了接近实时的收集特性。它的步骤如下: