内容参考《深入理解JVM虚拟机》,本文JVM均指HotSpot虚拟机。
Java程序运行期间无时无刻不在产生对象,JVM为对象动态的分配内存,如果内存不释放肯定会有耗尽的一天。
C语言需要开发者为创建的对象编写的配套的delete/free来释放内存,但是Java开发者却不需要那么做,这一切都要归功于:垃圾收集技术(Garbage Collection)。
垃圾收集技术的历史远比Java早,1960年诞生的Lisp是第一门使用垃圾收集技术的语言,Lisp的作者思考了垃圾收集需要完成的三件事:
在JVM的内存模型中,虚拟机栈、本地方法栈、程序计数器是线程私有的,和线程同生共死。栈帧应该分配多少内存基本是已知的,方法结束或线程结束内存自然就释放掉了,所以这几个内存区域不用过多的考虑如何回收的问题,垃圾收集重点管理的内存区域是:堆。
几乎所有的对象都在堆上分配内存,而且只有在程序运行期间才知道创建了多少对象,这部分内存的分配和回收是动态的,充满了各种不确定性。
堆中几乎存放着所有的对象,在GC开始工作之前,首先要做的事情就是判断哪些对象已“死”,即不可能再被任何途径使用的对象,例如:new的对象没有赋值给任何引用。
在对象中添加一个引用计数器,每引用一次计数器就加1,每取消一次引用计数器就减1,当计数器为0时表示对象不再被引用,此时就可以将对象回收了。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间,但是它原理简单,也很高效,在大多数情况下是一个不错的实现方案,但是它存在一个严重的弊端:无法解决循环引用。
如下代码所示,对象引用了2次,取消引用1次,引用计数器为1导致对象始终无法被回收。
class MyClass {
Object instance;
public static void main(String[] args) {
MyClass a = new MyClass();//引用+1
MyClass b = new MyClass();//引用+1
a.instance = b;//引用+1
b.instance = a;//引用+1
a = null;//引用-1
b = null;//引用-1
//a、b均不可能再被访问到,但是引用计数器为1,无法被回收
}
}
大多数虚拟机,如HotSpot就没有采用引用计数算法。
大部分主流的虚拟机都是通过可达性分许算法来判断对象是否存活的。
基本思路是:通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始向下搜寻,搜寻走过的路径被称为“引用链”(Reference Chain),如果对象到GC Roots没有任何引用链相连,则表示对象不可达,即对象不可能再被使用到,也就可以进行回收了。
在Java中,固定可作为GC Roots的对象包含以下几种:
如下代码,MyClass的静态引用b就可以当做一个GC Root,a引用b,b引用c,引用链为:a->b->c,b和c都是GC Root可达的,即不会被回收。
class MyClass{
static A a = new A();
static class A{
B b = new B();
static class B{
C c = new C();
static class C{}
}
}
}
无论通过何种算法判断对象是否存活,都和对象“引用”有关。对象存在引用,则表明能够被访问到,即不能被回收,反之则可以回收。
在JDK1.2之前,“引用”的概念过于狭隘,如果Reference类型的数据存储的是另外一块内存的起始地址,就称该Reference数据是某块地址、对象的引用,对象只有两种状态:被引用、未被引用。
这样的描述未免过于僵硬,对于这一类对象则无法描述:内存足够时暂不回收,内存吃紧时进行回收。例如:缓存数据。
在JDK1.2之后,Java对引用的概念做了一些扩充,将引用分为四种,由强到弱依次为:
Object o = new Object()
,只要强引用关系还在,对象就永远不会被回收。即使在可达性算法中被判定为不可达的对象,也不是“非死不可”的。
一个对象真正意义上的死亡,至少要经历两次标记过程:判定不可达时,进行第一次标记,随后进行筛选,筛选的条件是:是否有必要执行对象的finalize()方法。以下两种情况认为没有必要执行:
如果对象被判定需要执行finalize()方法,则会将其放到一个F-Queue
的队列中,由JVM自动创建、低优先级的Finalizer线程去依次执行它们的finalize()方法。
注意:JVM并不保证一定会等待finalize()方法执行结束,因为如果finalize()方法执行很慢或发生死循环将导致GC回收特别慢,甚至内存回收子系统崩溃。在回收大量对象时,finalize()方法甚至不会被执行,因为会给GC线程带来很大压力。
finalize()方法是对象拯救自己的最后一次机会,只要在finalize()方法中将对象重新建立引用关系,GC在进行第二次标记时就会将其踢除回收队列,反之对象则开始被回收。
如下代码,由于一个对象的finalize()只会被执行一次,所以第一次自救成功,第二次自救失败。
class MyClass {
static MyClass instance;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("MyClass finalize...");
//自救
instance = this;
}
public static void main(String[] args) throws InterruptedException {
instance = new MyClass();
instance = null;
System.gc();
//GC异步线程执行,Main等待1秒
Thread.sleep(1000);
System.out.println("第一次自救:"+instance);
instance = null;
System.gc();
//GC异步线程执行,Main等待1秒
Thread.sleep(1000);
System.out.println("第二次自救:"+instance);
/*
输出:
MyClass finalize...
第一次自救:com.xw.MyClass@5fd0d5ae
第二次自救:null
*/
}
}
由于finalize()方法的执行充满了不确定性,一是可能不会被执行,二是可能不会被执行结束,官方明确声明不推荐大家使用,开发者完全可以忘记finalize()方法。
Java堆中的对象,绝大多数“朝生夕死”,通常一次GC可以回收70%~99%的内存空间,而方法区的回收成果远低于堆。
方法区垃圾回收的条件非常苛刻,JVM规范也允许可以不对方法区进行垃圾回收,但是HotSpot仍然可以对方法区进行回收。
方法区的GC主要回收两部分内容:
回收类的条件比较苛刻,需要满足以下三个条件:
JVM对满足这些要求的类仅仅是“允许被回收”,不像对象没有了引用就一定被回收,HotSpot提供了参数-Xnoclassgc
来关闭JVM对类的回收。
GC算法涉及到大量的程序细节,且不同平台下的虚拟机实现也存在较大差异,这里只简单记录一下几种GC算法的思想和大致原理。
目前大多数JVM的垃圾收集器都遵循“分代收集”理论,分代收集理论建立在以下三个假说之上:
基于前两个假说,收集器将Java堆划分为两块不同的内存区域:新生代、老年代。
新生代存放朝生夕死的对象,每一次GC都可以回收大量内存空间,可以较高频次的进行垃圾回收。
老年代存放难以被回收的对象,GC成果远低于新生代,以较低频次进行垃圾回收。
将Java堆划分为新生代和老年代,针对不同的区域使用不同的垃圾回收机制,这样就兼顾了GC的时间开销和内存的空间利用率。垃圾回收器每次只针对一块特定的区域进行回收,按回收类型划分为:
对象可能存在跨代引用
如果只针对一块区域进行垃圾回收,可能存在一些问题。因为对象可能存在跨代引用,例如:新生代的对象被老年代引用。为了判断对象是否死亡,还需要额外遍历老年代来进行可达性分析,这无疑会降低GC的性能,因此第三条假说就是针对改问题提出的。
基于第三条假说,收集器不会再遍历整个Java堆,只需在新生代对象上建立一个全局的数据结构——记忆集(Remembered Set),记忆集把老年代划分为若干个小块,标记哪一块区域会存在跨代引用,当发生Minor GC时,只有被标记为存在跨代引用的老年代内存才会被加入到GC Roots进行扫描。
标记清除算法分为两个过程:标记、清除。
收集器首先标记需要被回收的对象,标记完成后统一清除。也可以标记存活对象,然后统一清除没有被标记的对象,这取决于内存中存活对象和死亡对象的占比。
缺点:
为了解决标记清除算法产生的内存碎片问题,标记复制算法进行了改进。
标记复制算法会将内存划分为两块区域,每次只使用其中一块,垃圾回收时首先进行标记,标记完成后将存活的对象复制到另一块区域,然后将当前区域全部清理。
缺点是:如果大量对象无法被回收,会产生大量的内存复制开销。可用内存缩小为一半,内存浪费也比较大。
由于绝大多数对象都会在第一次GC时被回收,需要被复制的往往是极少数对象,那么就完全没必要按照1:1去划分空间。
HotSpot虚拟机默认Eden区和Survivor区的大小比例是8:1,即Eden区80%,From Survivor区10%,To Survivor区10%,整个新生代可用内存为Eden区+一个Survivor区即90%,另一个Survivor区10%用于分区复制。
如果Minor GC后仍存活大量对象,超出了一个Survivor区的范围,那么就会进行分配担保(Handle Promotion),将对象直接分配进老年代。
标记复制算法除了在对象大量存活时需要进行较多的复制操作外,还需要额外的内存空间老年代来进行分配担保,所以在老年代中一般不采用这种回收算法。
能够在老年代中存活的对象,一般都是历经多次GC后仍无法被回收的对象,基于“强分代假说”,老年代中的对象一般很难被回收。针对老年代对象的生存特征,引入了标记整理算法。
标记整理算法的标记过程与标记清除算法一致,但是标记整理算法不会像标记清除算法一样直接清理标记的对象,而是将存活的对象都向内存区域的一端移动,然后直接清理掉边界外的内存空间。
标记整理算法相较于标记清除算法,最大的区别是:需要移动存活的对象。
GC时移动存活的对象既有优点,也有缺点。
由此可见,不管是否移动对象都有利弊。移动则内存回收时负责、内存分配时简单,不移动则内存回收时简单、内存分配时复杂。从整个程序的吞吐量来考虑,移动对象显然更划算一些,因为内存分配的频率比内存回收的频率要高的多的多。
还有一种解决方式是:平时不移动对象,采用标记清除算法,当内存碎片影响到大对象分配时,才启用标记整理算法。
GC算法内存回收的方法论,垃圾收集器才是内存回收真正的践行者。JVM规范没有对垃圾收集器作出任何规定,因此不同的JVM所包含的垃圾收集器存在很大差别。
最基础,最早的垃圾收集器,仅开启一个线程完成垃圾回收,而且回收时会暂停所有用户线程(STW)。
Serial的多线程版本,会开启多个线程并行完成垃圾回收(线程数和CPU有关),回收时同样会暂停所有用户线程。由于涉及到上下文切换开销,ParNew的性能未必比Serial好。
和ParNew类似,Parallel Scavenge的目标是达到一个可控制的吞吐量。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX: MaxGCPauseMillis
参数以及直接设置吞吐量大小的-XX:GCTimeRatio
参数。
如果需要减少GC停顿时间,Parallel Scavenge会将新生代内存调小一些,通过提高GC的频率来减少单次GC的停顿时间。
Serial的老年代版本,同样是单线程收集,暂停所有用户线程,使用标记整理算法。
Parallel Scavenge的老年代版本,多线程并发收集,标记整理算法,JDK6才开始提供。
CMS(Concurrent Mark Sweep)收集器的设计目标是:追求最短回收停顿时间。
CMS一般用于服务器端,整体回收过程分为以下四个步骤:
由于整个过程耗时最长的并发标记和并发清除中,可以与用户线程并发执行,不需要STW。所以总体上说,CMS收集器回收过程几乎是与用户线程并发执行的,是一款低停顿的垃圾收集器。
CMD收集器存在的一些缺点:
开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1之前的收集器回收的目标范围很明确:新生代、老年代,整个Java堆。G1跳出了这个樊笼,它可以面向堆内存的任何部分来组成“回收集”进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收成果最好,这就是G1开创的“Mixed GC”混合GC模式。
Shenandoah不是由Oracle团队开发的HotSpot垃圾收集器,只存在于OpenJDK中,OracleJDK并不包含。
最初由RedHat公司独立发展,2014年贡献给了OpenJDK,并推动它称为JDK12的正式特性之一。
ZGC与Shenandoah都是低延迟垃圾收集器,都希望在尽可能对吞吐量影响不大的情况下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内。
ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
Java语言的自动内存管理,目标是自动化的解决两个问题:自动分配内存、自动回收内存。
几乎所有的对象都是在堆中分配内存,当然,随着即时编译和逃逸分析技术的成熟,对象也可能直接在栈中分配内存。在分代的设计思想下,新创建的对象一般都会分配在新生代中。
实际上,对象分配的规则并不是固定的,JVM规范并未对此作出要求,这取决于虚拟机使用哪一种垃圾收集器以及相关的JVM参数。
绝大多数情况下,新生对象在Eden区分配内存。
HotSpot虚拟机提供了
-XX:+PrintGCDetails
参数,JVM进行垃圾回收时会输出GC日志,并在进程退出时输出堆中各个区域的内存情况。
如下代码,设置Java堆内存20M,新生代和老年代各10M,Eden区和Survivor区比例为8:1,运行查看JVM内存划分状况。
class Memory{
/*
VM Args: -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void main(String[] args) {
}
}
输出如下,新生代总可用内存为9216K,10M的90%,即Eden区+一个Survivor区,另一个Survivor区用作分区复制。Survivor区还未使用,因为没有触发Minor GC,老年代也未使用。
Heap
PSYoungGen total 9216K, used 2391K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
eden space 8192K, 29% used [0x00000007bf600000,0x00000007bf855f80,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
to space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace used 2915K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 318K, capacity 388K, committed 512K, reserved 1048576K
测试代码修改如下,创建一个2M大小的数组,再次查看内存分配情况,发现新生代内存使用比上一次多了两千多K。
class Memory{
static int _1MB = 1024 * 1024;
/*
VM Args: -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void main(String[] args) {
byte[] bytes = new byte[2 * _1MB];
}
}
//PSYoungGen total 9216K, used 4798K
-XX:PretenureSizeThreshold
参数用于设定对象所占内存大于该值直接进入老年代,只针对Serial和ParNew收集器有效。
JVM为大对象分配内存时,压力会比较大,首先大对象需要一块连续的较大内存,如果存在内存碎片则分配更加艰难,其次是如果大对象经历GC后存活的话,还需要高额的内存复制开销(标记复制算法)。
如下代码,4M大小的数组将会直接分配到老年代。
//-XX:+UseSerialGC 只针对Serial和ParNew收集器有效
byte[] bytes = new byte[4 * _1MB];
//tenured generation total 10240K, used 4096K
大多数垃圾收集器采用了分代思想,为了便于垃圾回收,JVM将Java堆划分为新生代和老年代。
在对象头的Mark Word中,用了4bit
来保存对象的GC年龄。对象首先在Eden区诞生,当Eden区内存不够用时,触发一次Minor GC,如果对象仍存活,将会进入其中一个Survivor区,且GC年龄会设为1,后面每经历一次GC,对象就会在From Survivor和To Survivor区之间复制一次(标记复制算法),每复制一次对象的GC年龄就加1,当年龄达到15时如果对象仍存活,就会晋升至老年代。
对象晋升老年代的GC年龄阈值通过参数
-XX:MaxTenuringThreshold
控制。
实际上,JVM并一定要等到对象的GC年龄达到阈值后才会将其晋升至老年代,内部会进行年龄的动态判定。
试想这样一种情况,如果程序突然产生大量新生对象,将会导致新生代区域快被撑满了,但老年代却非常空闲。
为了解决这种问题,JVM会这么处理:如果Survivor区中相同年龄的对象总和超过了一半,那么大于等于该年龄的对象就可以直接进入老年代,而无需达到GC年龄阈值。
在触发Minor GC之前,JVM会先检查老年代中的最大连续内存是否大于新生代所有对象总和,因为如果不满足这个条件的话,一旦新生代中所有对象都晋升到老年代将导致内存溢出。
如果满足该条件,那么JVM认为此次GC是安全的,可以直接执行。
否则,认为此次GC是有风险的,会先检查-XX:HandlePromotionFailure
参数是否允许担保失败,如果不允许,则将Minor GC改为Full GC。如果允许,则检查老年代最大连续内存是否大于历次新生代晋升到老年代的对象总和,如果大于,JVM将尝试进行Minor GC,反之则将Minor GC改为Full GC。
取历史平均值是存在风险的,一旦某次Minor GC后存活的对象激增,那么JVM在触发Minor GC后又不得不发起一次Full GC,将导致GC的停顿时间变得更长。