深入理解JVM:垃圾收集与内存分配

Author Avatar
AngelMsger 1月 11, 2018

概述

前一篇文章简单介绍了JVM的内存模型,这一篇文章讨论一下谈到JVM时人们最容易想到的话题:垃圾收集(GC),顺带介绍一下JVM内存分配策略,参考资料仍然是周志明老师的《深入理解Java虚拟机(第二版)》。

垃圾收集的位置

上一篇文章提到,JVM把内存区域分为多个子区。在这些子区中,程序计数器,虚拟机栈和本地方法栈生命周期与线程绑定,程序计数器始终指向当前执行的字节码位置,或为空,虚拟机栈和本地方法栈则随着方法的执行和结束执行如栈和出栈操作,每一个栈帧的内存大小在类结构确定下来时就为已知的,因此在这些子区中的内存分配和回收都具有确定性,因此后文讨论的“内存”主要指堆和方法区。

哪些可以回收

当内存不足需要回收时,首先要判断哪些内存可以回收。之前在学习Java时,我一直以为JVM是靠引用计数来判断对象是否已死,但实际上这并不正确。我们先来讨论一下判断对象是否可以回收的两个算法。

引用计数算法

引用计数算法的原理很简单,即当有一个地方引用到这个对象的时候,对象的引用计数器加1,当这个引用失效的时候,对象的引用计数器减1,这样当一个对象的引用计数减小到0的时候,我们就可以认为没有地方可以访问和使用这个对象,即对象已死,可以回收。

很多人同学都和曾经的我一样,以为这就是JVM判断对象是否可以回收的一句,但实际上JVM并没有采用这种方法,其原因之一就是引用计数算法难以解决若干不可访问对象间的相互引用问题,如以下代码:

ObjA.instance = ObjB;
ObjB.instance = ObjA;
ObjA = null;
OBjB = null;
System.gc();

对于以上代码,如果ObjA和ObjB在其他地方不存在引用,则这两个对象很明显已经不再可以使用了,当内存不足触发GC时,这两个对象都应该被回收,但若是采用引用计数算法,因为这两个实际已死的对象内部存在着相互引用,而导致计数器均不为0,GC收集器就不能回收他们。实际运行上述代码,可以发现JVM实际时回收掉这两个对象的,那么JVM的GC算法是如何判断他们已死并可以回收的呢?

可达性分析算法

可达性分析算法从一系列GC Roots对象出发向下搜索他们所引用的对象,搜索经过的路径称为引用链,最终形成一张图。其中以下对象可作为GC Roots对象:

  1. 虚拟机栈中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象

在搜索完毕之后,不再引用链上的对象为不可达对象,不可达对象即使因为内部相互存在引用而使计数器不为0,同样会被回收。实际上JVM采用的正是这种可达性分析算法。

引用的分类

前文中我们一直在围绕引用来谈如何判断对象是否可回收,但实际上在JDK 1.2之后,Java中的引用分为很多中,除了传统引用,也即我们最常使用的强引用外,新增了几种描述那些食之无味,弃之可惜的对象:

  1. 强引用:是指在应用中普遍存在的,形如 Object obj = new Object(); 的引用,只要对象存在强引用并且可达,对象就不会被回收。
  2. 软引用:用以描述有用但不必须的对象。被软引用引用的对象,将会在内存溢出异常之前(内存空间在不可达对象被清理之后仍然无法满足要求之时)被回收,如果在软引用引用对象被回收后内存空间仍然无法满足要求,才会抛出内存溢出异常。JDK 1.2后提供了SoftReference类来实现软引用。
  3. 弱引用:同样用以描述非必须对象,但被弱引用引用的对象会在垃圾收集算法执行时被回收,无论当前内存是否足够。即弱引用引用对象的生命周期仅为下一次垃圾收集之前。JDK 1.2后提供了WeakReference类来实现弱引用。
  4. 虚引用:也称为幽灵引用和幻影引用,是最弱的引用关系。对象是否存在虚引用对其生命周期并无影响,也无法通过虚引用获取对象实例。虚引用唯一的做通用就是当对象被垃圾收集器回收时收到一个系统通知。在JDK 1.2后提供了PhantomReference类来实现虚引用。

回收前的工作

即使在可达性分析算法中不可达的对象,也并非非死不可。要真正回收一个对象,需要经历两次标记的过程。当对象被判断为不可达,它将被进行第一次标记并根据其是否需要执行finalize()方法进行一次筛选。如果对象没有覆写finalize()方法或此对象finalize()已被执行过,它将被第二次标记并等待回收。

相反,如果一个对象在第一次标记后被认定为需要执行finalize()方法,则该对象会被加入到一个低优先级的队列中,JVM承诺会触发这个对象的finalize()方法,但不保证等待其执行结束。在finalize()方法中程序可以进行自救,即把自己与GC Roots引用链相关联,逃脱被回收的命运。但这种逃脱对于一个对象实际上仅可能出现一次,因为第二次会由于被JVM判定为其finalize()方法已被执行过而直接被二次标记等待回收。

实际上finalize()方法并无太大意义,由于不被JVM承诺等待运行结束,因此诸如关闭相关资源的操作应当在try-finally中完成而不是在finalize()中。原书中作者提到大家完全可以忘记Java中finalize()方法的存在。

方法区回收策略

很多人认为方法区是不存在垃圾收集的(在JDK 8前的HotSpot VM中被称作“永生代”),JVM规范也并不要求实现方法区的垃圾收集。其主要原因在于在方法区中进行垃圾收集的效率很低,在堆上进行的一次垃圾收集通常可以释放70%~90%的空间,而方法区远低于此。

对于方法区的垃圾收集,主要分为两部分内容:废弃常量和无用的类。废弃常量通常更容易判别,与堆上可达性分析一样,以字面量为例,若字符串"Java"存在于常量池中却没有其他地方引用此常量,则必要时"Java"将被回收。判断无用的类则复杂很多,已加载的类需要满足3个条件才会被判定为无用的类。

  1. 该类所有实例已被回收,即堆中不存在任何该类的实例。
  2. 加载该类的ClassLoader已被回收。
  3. 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

对于满足这3个条件的无用的类,虚拟机可以进行回收。此处仅为“可以”,而并不是必然回收。是否进行回收,HotSpot VM提供了-Xnoclassgc参数进行控制。在大量使用反射、动态代理、CGLib等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证方法区不会溢出。

垃圾收集算法

此处并不详细讨论算法的实现,而仅介绍几种算法的思想。

标记-清除算法

最基础的算法是标记-清除(Mark-Sweep)算法,正如名字所述,算法分为标记和清除两个阶段,其标记过程前文已有介绍,清除过程即将不可达对象回收以释放其占用的资源空间。它的主要不足之处在于执行效率问题和碎片问题。在执行标记-清除算法后会产生大量内存碎片,当遇到新的较大的内存申请时由于没有足够大的连续内存满足要求,则会不得不再次触发GC。

复制算法

针对标记-清除算法的效率问题,复制(Copying)算法出现了。复制算法的核心思想是将内存分为两个半区,每次只使用其中一个半区。当一个半区的内存用完时,垃圾收集器将这一半区中依然存活的对象复制到另一个半区,这一半区则完整回收,回收效率更高。而为新的对象分配内存仅需在新半区中顺序进行,分配效率也更高。

但复制算法的代价是将内存缩小为一半,未免太高。实际统计表明,Java中的对象大多是“朝生夕死”的,生命周期很短,所以并不需要按照1:1的比例来划分内存空间。HotSpot VM的做法是将堆内存中新生代空间划分成一块较大Eden空间和两个较小的Survivor空间,默认大小比例为8:1:1。每次JVM只会使用Eden空间和其中一个Survivor空间,当回收时,将Eden空间和Survivor空间中所有存活的对象复制到另一个Survivor空间。这样,JVM只会浪费10%的内存空间,还是可以接受的。

当复制行为发生时,如果存活对象的大小超过了Survivor空间所能容纳大小,则需要以来老年代来进行分配担保。

标记-整理算法

复制算法存在两个缺陷,其一是当对象的存活率较高时,需要反复执行复制操作,效率将会变低。其二是需要额外的空间进行分配担保,以应对所有对象都存活的情况。而进入老年代的对象,其生命周期通常都较长,生存率高,不适合直接使用复制算法。

根据老年代的特点,人们提出了标记-整理(Mark-Compact)算法,标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理边界以外的内存。

分代收集算法

综合以上所述,当前主流的JVM采用分代收集(Generational Collection)算法,这种算法并没有什么新思想,只是将JVM所管理的堆内存分为新生代和老年代,并根据各个年代对象存活的特征采用不同的算法。在新生代中,对象存活率低,采用复制算法仅需付出少量对象的复制成本即可完成收集,而在老年代中,对象存活率高,并且没有其他内存作为分配担保,故而采用标记-清理算法或标记-整理算法。

HotSpot VM的实现

HotSpot VM在实现上述算法时,还有更多细节需要考虑。

枚举根节点

在可达性分析算法中,首先需要找出GC Roots节点,并由此开始标记引用链。我们在源代码中标识了属性的类型,但在字节码中这些信息并不直接存在,JVM需要知道哪些是引用。JVM选用何种方式获知这一信息影响到GC的实现。

如果JVM选择不记录任何类型数据,即不能准确判断内存中某个位置是否是引用,则实现的GC是保守式GC,这种实现方式下只能将那些看起来像是引用的值当作引用。也因此保守式GC存在很多缺点。例如GC Roots节点可能存在于全局性引用和执行上下文之中,很多Java程序拥有非常多的对象,甚至方法区就有上百兆,如果逐个检查,必然需要消耗很多时间。其次,某些对象也许本身已经死了,但由于存在疑似引用指向他们,而使他们逃脱(尽管这是安全的,必须存活的对象都活着)。另外,由于对于是否为引用的判断不准确,因而JVM不能修改这个值,继而JVM无法移动任何对象。此处我们可以考虑利用句柄,即让引用指向句柄列表中的句柄,再通过句柄找到实际对象。当对象需要移动时仅需修改句柄的值即可。但这种实现方式在Classic VM中实践后效果并不理想。

类型信息也可以不记录在栈上,而记录在对象上。这样,在JVM扫描栈的过程中通过对象记录的类型信息得出在对象的什么位置存放着引用。这种方式被称为半保守式GC,也称为根上保守。

可达性分析算法对执行时间的敏感还体现在GC停顿上,因为分析工作必须在一个能够确保一致性的快照上进行,所谓的一致性是指在分析期间整个执行系统看起来就像冻结在了某一时间点,而不是在分析过程中引用关系还在不断变化,否则算法分析结果的准确性就难以得到保证。由于堆内存是所有线程共用的,这种停顿实际上意味着所有线程的停顿(“Stop the World”)。

实际上HotSpot VM并不需要扫描全部全局引用和执行上下文就可以知道哪些地方存放着引用

参考资料

  1. 《深入理解Java虚拟机(第二版)》,周志明,机械工业出版社
  2. 找出栈上的指针/引用

许可协议: CC BY-NC-SA 4.0
本文链接:https://blog.angelmsger.com/深入理解JVM:垃圾回收与内存分配/