JVM系列-深入理解JVM垃圾回收

前言

在 Java 虚拟机中,一个 Java 对象被加载进 JVM 后,它的生命周期被划分为 7 个阶段:

如上图,对象的生命周期的 7 个阶段分别为:创建阶段、应用阶段、不可见阶段、不可达阶段、收集阶段、终结阶段、以及对象内存空间重新分配阶段。

  • 创建阶段

创建阶段的步骤主要可以分为:

(1)为对象分配空间; (2)构造对象; (3)从超类到子类对 static 成员进行初始化; (4)递归调用超类的构造方法; (5)调用子类的构造方法;

  • 应用阶段

当应用被初始化赋初值后,就切换进入应用阶段。这一阶段的对象至少具有一个强引用、或者显式地使用软引用、弱引用、或者虚引用;

  • 不可见阶段

在应用程序中找不到对象的任何强引用,例如程序的执行已经超出了对象的作用域。但此时的对象仍然有可能被特殊的 GC Roots 所持有,例如对象被本地方法栈中的 JNI 引用或者被运行中的线程引用等;

  • 不可达阶段

对象不被任何强引用所引用,并且垃圾收集器发现不可达;

  • 收集阶段

垃圾收集器已经发现该对象不可达,并且垃圾收集器准备对该对象的内存空间重新分配。如果这时候垃圾收集器发现该对象重写了 finalize() 方法,垃圾收集器会豁免该对象的收集,并且调用 finalize() 方法。如果该对象没有重写 finalize() 方法,则等待垃圾收集器回收该对象的内存空间。

  • 终结阶段

此时对象可能执行了 finalize() 方法(GC 不一定会等待该对象的 finalize() 方法执行完),或者该对象没有重写 finalize() 方法,这时候等待垃圾收集器收集该对象的内存空间。

  • 对象空间重新分配阶段

当对象被 GC 回收了内存空间,该对象的生命周期就完全结束了。

以上,是一个对象被加载进 JVM 中的生命周期。而在 Java 虚拟机中,对象的回收对程序员是不可见的,也就是说一旦对象不被其他对象所引用,就有可能被 GC 标记为不可达,进而等待 GC 的回收。在 Java 虚拟机回收不被引用的对象的时候,会经历对象的标记、以及对象被垃圾收集器的回收过程。

垃圾标记算法

在 Java 虚拟机中,垃圾对象(当一个对象不被其他对象所持有的时候被称为垃圾对象)的标记算法,可以分为 引用计数法可达性分析 (也有部分文章把可达性分析称为根搜索算法) 。

引用计数法

在《深入理解 Java 虚拟机》一书中,给出的引用计数法的定义:给对象中添加一个引用计数器,每当有一个地方引用它时,引用计数器的值就会加 1;当引用失效的时候,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。但在目前主流的商用虚拟机中都没有采用引用计数法,原因是它很难解决对象之间互相引用的问题,如下代码:

引用计数算法证明_1

引用计数算法证明_2

如上代码,当执行

TestReferenceCountingGC gc_1 = new TestReferenceCountingGC();
TestReferenceCountingGC gc_2 = new TestReferenceCountingGC();

gc_1.instance = gc_2;
gc_2.instance = gc_1;
复制代码

的时候,由于 new TestReferenceCountingGC()new TestReferenceCountingGC() 两个对象被引用了两次,如果根据引用计数算法,那么 new TestReferenceCountingGC()new TestReferenceCountingGC() 的引用计数器的值都为 2。当执行 gc_1 = null;gc_2 = null; 的时候,就会有 1 次引用失效,那么 new TestReferenceCountingGC()new TestReferenceCountingGC() 还有 1 次引用,那么如果 Java 虚拟机采用的是引用计数算法标记垃圾对象,这两个对象的内存空间不会被垃圾收集器所回收,应该会出现如下 GC 日志:

[GC (System.gc()) [PSYoungGen: 9339K->4872K(76288K)] 9339K->4880K(251392K), 0.0057164 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
复制代码

但是在实际中,却出现了如下的 GC 日志:

[GC (System.gc()) [PSYoungGen: 9339K->776K(76288K)] 9339K->784K(251392K), 0.0015327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
复制代码

上述的 GC 日志证明目前的 Java 虚拟机的垃圾标记算法,并不是采用引用计数算法。

可达性分析

可达性分析的主要思路就是通过一系列的称为 GC Roots 的对象作为起点,然后从这个节点往下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何的引用链相联(在图论中,就是从 GC Roots 到这个对象不可达)时,则证明这个对象时不用用的。

在 Java 中,可以作为 GC Root 的有以下几种(部分):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中的静态属性引用的对象;
  • 方法区中的 final 关键字修饰的常量引用的对象;
  • 本地方法栈中 JNI 引用的对象;

在 JDK 1.2 以前,引用的定义:在虚拟机栈的局部变量表中 reference 类型的数据中存储的数值代表的是另一种内存的起始地址,就称为这块内存代表着一个引用。但这种定义的说法只能用来定义被引用、和没有被引用这两种状态。为了可以描述这样的一类对象:当内存足够的时候,则保留在内存之中,如果内存空间在进行垃圾收集后,内存占用还是非常紧张,则可以回收这些对象。

于是,提出了强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phanton Reference):

  • 强引用类似于 Object obj = new Object() 这类的引用,只要强引用存在,垃圾收集器永远不会回收这类对象;

  • 软引用是一种相对于强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会回收软引用指向的对象。JVM 会确保在抛出 OOM 之前,清理软引用指向的对象;

  • 弱引用并不能豁免垃圾收集,仅仅时提供访问在弱引用状态下对象的途径。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前的内存是否足够,都会回收掉只被弱引用关联的对象;

  • 虚引用也被称为幽灵引用或者幻影引用,你不能通过它访问对象。虚引用仅仅时提供了一种确保对象在 finalize() 以后,做某些事情的机制,如能在对象被垃圾收集器回收时收到一个系统的通知。

垃圾收集算法

标记-清除算法

标记-清除算法分为两个阶段:

  • 标记阶段:标记可以被回收的对象;
  • 清除阶段:回收被标记的对象内存;

标记-清除算法时最基础的算法,因为后面提到的垃圾回收算法都是基于此算法的基础上面改造的,标记-清除算法的执行过程如下:

标记-清除算法主要有两个缺点:一是标记和清除的效率都不高;二是如上图所示,在标记清除可回收的对象空间后,会产生大量不连续的内存碎片,碎片太多可能会导致后续没有足够的内存分配给较大的对象,从而导致触发新一轮的垃圾收集动作。

复制算法

为了解决标记-清除算法带来的内存碎片的问题,于是提出了复制算法。复制算法把内存空间划分为大小相等的两块,每次只使用其中的一块,然后再把另一块内存空间清理掉:

复制算法存在着复制效率低的不足,并且如果不想浪费 50% 空间内存,则需要提供额外的空间担保,以应对被使用的内存中所有的对象都 100% 存活的极端情况。

标记-整理算法

复制算法一般不使用在老年代,因为在老年代中,大部分的对象的存活率比较高,选择复制算法就会导致过多的复制操作,导致效率变低。同时也不采用标记-清除算法,因为会产生过多的内存碎片,导致容易触发新的一轮垃圾回收动作。于是出现了一种标记-整理算法(标记-压缩算法)。标记-整理算法与标记-清除算法不同的是,在标记完内存中的对象以后,把存活下来的对象压缩到内存的一端,使得他们紧凑地排序在一起,然后对存活对象边界外的对象进行回收。

分代收集

分代收集算法会结合不同的多种垃圾算法来处理不同的空间,因此在学习分代收集算法之前首先需要了解 Java 堆的空间划分。Java 堆被划分为新生代(Young Generation)和老年代(Tenured Generation),而新生代又被细分为 Eden 空间、From Survivor 空间和 To Survivor 空间。因为在 Java 堆里面,大部分对象都是"朝生夕灭",只有少数的对象的生命周期比较长,甚至有的对象的生命周期和虚拟机的生命周期一样长,对不同对象地生命周期采用不同的垃圾收集算法,这就是分代收集的概念。

根据 Java 堆的空间的划分,垃圾收集最要可以分为两种方法:

  • Minor GC: 新生代垃圾收集;
  • Full GC: 又称为 Major GC,Full GC 通常至少会伴随一次 Minor GC,它的收集频率较低,耗时较长。

当执行一次 Minor GC 的时候,虚拟机会把 Eden 空间中存活的对象复制到 To Survivor 空间,同时把 From Survivor 空间存活的对象也复制到 To Survivor 空间,然后再把 Eden 空间和 From Survivor 空间里面的所有对象清除,这时候把 To Survivor 空间的指针指向 From Survivor 空间,也就是说 To Survivor 空间的名字变成了 From Survivor 空间,以等待下一次 Minor GC 的来临。当然,并不是所有的新对象都是分配在 Eden 空间的,当新对象需要占用的内存空间要比 Eden 空间可用的空间要大的时候,新对象会直接分配在老年代。

当对象在新生代经过一定数量的 Minor GC 后仍然存活,那么虚拟机会把该对象晋升到老年代中。虚拟机给每个对象都定义了一个对象年龄(Age)计数器。当新对象在 Eden 空间经过一次 Minor GC 仍然存活,并且可以被 Survivor 空间接纳,就把对象的年龄计数器设为 1,然后该对象每经过一次 Minor Gc,就把该对象的年龄计数器加 1,当对象的年龄计数器达到晋升老年代的阀值的时候,该对象就会晋升到老年代中,一般虚拟机设为 15。

当然,虚拟机也不一定需要对象的年龄计数器的值达到了晋升老年代的阀值来晋升对象的。如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以进入老年代,而无需等到对象的年龄计数器的值满足晋升老年代的阀值。

小结

原本还打算把内存的分配和回收策略、GC 日志分析也写在这里的,但瞄了瞄本章的篇幅,感觉有点篇幅过长了,哈哈哈...那....我就不写啦。

猜你喜欢

转载自juejin.im/post/5e81e0075188257382097586