深入理解JVM虚拟机(二):垃圾回收机制

谈起GC,应该是让Java程序员最激动的一项技术,我相信每个Java程序员都有探究GC本质的冲动!JVM垃圾回收机制对于了解对象的创建和对象的回收极为重要,是每个Java程序员必须掌握的技能。

本博客围绕三个问题来展开

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

前面介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随线程而灭;栈中的栈针随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧分配多少内存基本上是在类结构确定下来就是已知的。因此这几个区域的内存分配和回收都具备确定性,在这几个区域中就不需要过多考虑回收的问题,因为方法结束或者线程结束,内存也就跟着回收了。而Java堆和方法区不一样,一个接口中的多个实现类需要的内存是不一样的,一个方法中的多个分支需要的内存也是不一样的,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收是动态的。

1.哪些内存需要回收

首先回答第一个问题:不再使用的对象需要进行回收,不使用的类也有可能回收。

那么我们如何判断一个对象不再使用呢?主要有以下两种方法。


1.1 引用计数算法

定义:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减一;任何时刻计数器为0的对象就是不会被使用的对象。

我们可以看出,引用技术方法实现简单。并且有一些GC中确实使用的是引用计数算法,但是在Java虚拟机中并没有使用这个方法进行内存管理,原因就是一个问题很难被解决—对象之间循环引用。‘

来看一个例子:

class Node { 
    Node next ;
} 

Node a = new Node (); 
Node b = new Node (); 

a.next = b ; 
b.next = a ; 

a = null;
b = null;

如上述代码,当我们执行最后两行代码的时候,堆中的对象因为还存在着循环引用,因此引用计数并不是0,导致GC并不会回收这两个对象的内存。

存在的问题:循环引用导致GC不会回收循环引用的对象。

2.2 可达性分析算法

基本思想:

过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到“GC Roots”没有任何引用链相连的时候,就证明此对象是不可用的。

在这里插入图片描述

在Java语言中,可作为GC Root对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中的引用对象。
  • 方法区中的静态属性或常量(final)引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

2. 方法区的回收

Java虚拟机规范中描述可以不要求虚拟机在方法区实现垃圾收集,因此很多人认为方法区中是没有垃圾收集的。

不要求虚拟机对方法区进行垃圾收集的原因主要是性价比比较低,在堆中,尤其是新生代中,进行一次垃圾收集一般会回收70%~95%的空间,但方法区的垃圾收集率远低于此。

即使这样,对方法区进行垃圾收集也并非没有必要,在大量使用反射、动态代理等这类频繁定义ClassLoader的场景都需要虚拟机卸载类的功能,以保证方法区不会溢出。

方法区的垃圾收集主要回收废弃常量与无用的类。

废弃常量的判定与回收比较简单:以“abc”这个常量为例,如果当前系统中没有任何对象引用这个常量,也没有任何其他地方(博主猜测是.class文件中有些地方对此常量的引用)引用这个字面量。此时如果发生内存回收,这个常量就会被清理出常量池。(常量池中其他类、接口、方法、字段的符号引用与此类似)

一个无用的类则需要满足以下三个条件:

  1. 该类不存在任何实例。
  2. 加载该类的ClassLoader已经被回收(条件比较苛刻)。
  3. 该类对应的Class对象没有在任何地方被引用,也就是无法使用反射机制。

虚拟机可以对满足上述三个条件的无用类进行回收。

3. Java中的引用

引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

1.强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

2.软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。

public class SoftRef {
    public static void main(String args[]) {
        String fk = "do you like me";
        SoftReference<String> soft = new SoftReference<String>(fk);
        System.out.println(soft.get());
    }
}

3.弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。

public class WeakRef {
    public static void main(String args[]) {
        String temp = "fk that";
        WeakReference<String> wReference = new WeakReference<String>(temp);
        System.err.println(wReference.get());
    }
}

4.虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

public class PhRef {
    public static void main(String args[]) {
        String temp = "hello china";
        ReferenceQueue<String> queue = new ReferenceQueue<>();
        PhantomReference<String> phReference = new PhantomReference<String>(temp, queue);
        System.out.println(phReference.get());
    }
}

4.HotSpot 算法实现

4.1 枚举根节点

在可达性分析法中对象能被回收的条件是没有引用来引用它,要做到这点就需要得到所有的GC Roots节点,来从GC Root来遍历。可作为GC Root的主要是全局性引用(例如常量和静态变量),与执行上下文(栈帧中的本地变量表)中。那么如何在这么多的全局变量和栈中的局部变量表中找到栈上的根节点呢?

在栈中只有一部分数据是Reference(引用)类型,那些非Reference的类型的数据对于找到根节点没有什么用处,如果我们对栈全部扫描一遍这是相当浪费时间和资源的事情。

那怎么做可以减少回收时间呢?我们很自然的想到可以用空间来换取时间,我们可以在某个位置把栈上代表引用的位置记录下来,这样在gc发生的时候就不用全部扫描了,在HotSpot中使用的是一种叫做OopMap的数据结构来记录的。对于OopMap可以简单的理解是存放调试信息的对象。

在OopMap的协助下,我们可以快速的完成GC Roots枚举,但我们也不能随时随地都生成OopMap,那样一方面会需要更多的空间来存放这些对象,另一方面效率也会简单低下。所以只会在特定的位置来记录一下,主要是正在:

  1. 循环的末尾
  2. 方法临返回前/调用方法的call指令后
  3. 可能抛异常的位置

这些位置称为安全点。

4.2 安全点

我们在做GC的时候需要让jvm停在某个时间点上,如果不是这样我们在分析对象间的引用关系的时候,引用关系还在不断的变化。这样我们的准确性就无法得到保证。 安全点就是所有的线程在要GC的时候停顿的位置。那么如何让所有的线程都到安全点上在停顿下来呢?这里有两种方案可以选择:

  • 抢先式中断
  • 主动式中断

在抢先式中断:中不需要线程主动配合,在GC发生的时候就让所有线程都中断,如果发现哪个线程中断的地方不在安全点上,那么就恢复线程,然后让它跑到安全点上。

主动式中断是:让GC在需要中断线程的时候不直接对线程操作,设置一个标志,让各个线程主动轮询这个标志,如果中断标志位真时就让自己中断。


5. 什么时候回收

finalize方法

通过上面几种算法,虚拟机可以知道此时内存中有哪些需要被回收的对象,但是虚拟机什么时候会对这些对象进行回收呢?我们需要来谈一谈finalize方法。

在JVM中,当一个对象通过可达性分析算法被判定为垃圾的时候,JVM并不能直接对其进行回收,一是垃圾回收机制并不是实时进行,二是真正的回收一个对象之前还会判断是否要运行它的finalize方法。

当一个对象被判定为是垃圾之后,它将会被第一次标记并进行一次筛选,筛选的条件就是此对象是否有必要执行finalize方法。

如何判断一个对象是否有必要执行finalize方法呢?

两种情况下虚拟机会视为“没有必要执行”:

  • 对象没有覆盖finalize方法
  • finalize方法已经被虚拟机调用过(finalize方法只会被调用一次)

如果这个对象被判定为有必要执行finalize方法,那么这个对象会被放置在一个叫做F-Queue的队列之中,并在稍后由一个被虚拟机创建的,低优先级的Finalizer线程去执行该对象的finalize()方法,并且对象在finalize()方法执行中如果出现执行缓慢或者发生死循环,将会导致F-Queue队列中其他对象永久处于等待。甚至导致整个内存回收系统崩溃。之后GC将会对F-Queue之中的对象进行第二次标记。**如果在第二次标记前这些对象在自己的finalize()方法中可以拯救自己(重新与引用链上的任何一个对象建立关联即可)**也是可以成功存活下来并被移除“即将回收”的集合的。 如果此时还没有逃脱,那就真的要被回收了。

注意:finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。博主建议大家完全可以忘掉Java语言中有这个方法的存在。

6. 如何回收

JVM虚拟机如何对标记的对象进行垃圾回收,这里主要有四种垃圾回收算法:标记-清除算法、复制-整理算法、标记-整理算法、分代收集算法

6.1 标记-清除算法

算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程就是使用可达性算法进行标记的。

存在的问题:

  1. 效率问题,标记和清除两个过程的效率都不高。
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,导致以后分配较大对象时内存不足以至于不得不提前触发另一次垃圾收集动作。

在这里插入图片描述

6.2 复制-整理算法

为了解决标记-清除算法的效率问题,JVM虚拟机提供复制-整理算法

基本思想:将现有的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。因此在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象在垃圾回收过程中统一被复制到新的内存空间中,因此,可确保回收后的内存空间是没有碎片的。该算法的缺点是将系统内存折半。

现在的商业虚拟机都采用这种收集算法回收新生代,同时我们显然不能忍受内存折半的损耗,好在IBM公司研究表明,新生代中98%的对象都是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间。

通常是将内存分为一块较大的Eden空间两块较小的Survivor空间,每次使用Eden和其中一块Survivor。HotSpot虚拟机默认Eden和Survivor的比例为8:1。

当每次进行回收时,将Eden和Survivor中还存活的对象一次性的复制到另一块Survivor空间上,然后清理掉Eden和刚才使用的Survivor空间。

存在的问题:

  1. 内存缩小为原来的一半,内存的利用率太低。
  2. 在内存存活率较高时就要进行较多的复制操作,复制的效率会变低

6.3 标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

基本思想:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下图所示。

在这里插入图片描述

6.4 分代收集算法

稍后详细介绍

7 JVM堆内存分配模型

Java堆根据对象存活周期的不同将内存划分为新生代与老年代。新生代又被划分为三个区域:Eden、From Survivor、To Survivor。Java堆的内存模型大致为:

在这里插入图片描述

当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代(长期存活的对象进入老年代)。但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代(PretenureSizeThreshold参数的设定)。

Java堆根据对象存活周期的不同将内存划分为新生代与老年代。那么新生代的对象如何变成老生代的对象的呢?下面有三种情况新生代的对象变成老生带代对象。

7.1 大对象直接进入老生代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象是那种很长很长的字符串以及数组(笔者列出的Example中的byte[]数组就是典型的大对象)。

7.2 长期存活的对象进入老生代

虚拟机给每个对象定义一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC仍然存活,且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代(长期存活的对象进入老年代)。但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代(PretenureSizeThreshold参数的设定)。

7.3 对象动态年龄判断

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果新生代中的Eden与from Survivor空间相同年龄对象的大小之和大于to Survivor空间中的一半,则大于或等于这个年龄的对象则无须等到MaxTenuringThreshold中要求的年龄,即可晋升老年代。

8. 分代收集算法

Minor GC(新生代GC):在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需付出少量存活对象的复制成本就可以完成收集。Minor GC非常频繁,并且回收速度也很快。

Full GC/Major GC(老年代GC):老年代中对象存活率高、没有额外空间对它进行分配担保,就要使用“标记-清理”或“标记-整理”算法进行回收。回收速度比Minor GC慢上很多,发生也不频繁。

8.1 分配担保机制

我们之前说过在新生代GC的时候,会将Eden和Survivor中还存活的对象一次性的复制到另一块Survivor空间上,然后清理掉Eden和刚才使用的Survivor空间。

但是会存在另一块Survivor空间不够用的情况,这时就需要分配担保了。

其实在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总大小,如果大于,则此次Minor GC是安全的。如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许开启分配担保机制。如果HandlePromotionFailure=true,说明开启了分配担保机制,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。

但取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败。如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。

猜你喜欢

转载自blog.csdn.net/qq_21125183/article/details/84951802