Java 垃圾收集技术

前言

在计算机科学中,垃圾回收(GC: garbage collection)是内存自动管理的一种方式,它并不是同 Java 语言一起诞生的,实际上,早在 1959 年为了简化 Lisp 语言的手动内存管理,该语言的作者就开始使用了内存自动管理技术。 垃圾收集手动内存管理刚好相反,后者需要编程人员自己去指定需要释放的对象然后将内存归还给操作系统,而前者不需要关心给对象分配的内存回收问题。Java 语言使用自动垃圾收集器来管理对象生命周期中的内存,要进行垃圾收集首先需要明确三个问题:1. 哪些内存需要回收2. 什么时候进行回收3. 怎么进行内存回收。接下来让我们一起看看 Java 语言对这些问题是如何处理的。

哪些内存需要回收

为了方便管理和跨平台,Java 虚拟机规范规定在执行 Java 程序的时候把它所管理的内存划分为若干个不同的数据区域。这些区域都有着各自不同的用途以及创建和销毁的时间,有的数据区域随着用户线程的启动和结束而建立和销毁,有的区域会随着虚拟机进程的启动和停止而存在和销毁。更多有关运行时数据区域的内容请看 Java 运行时数据区域
由于 Java 运行时数据区域中的 程序计数器虚拟机栈本地方法栈和线程的生命周期一致,随线程的启动和结束而建立和销毁。而且当我们的类结构确定了之后,在编译期间,一个栈帧需要分配内存的大小基本上也就确定下来了,这三个区域的内存分配和收回都是具备确定性的,不需要我们过多的去考虑内存回收问题。主要考虑Java 堆方法区的内存回收的问题。

什么时候进行回收

Java 语言中,一个对象的生命周期分为以下三个阶段:

  • 对象创建阶段 通常我们使用 new 关键字进行对象创建 e.g. Object obj = new Object();,当我们创建对象时,Java 虚拟机将分配一定大小的内存来存储该对象,分配的内存量可能会根据虚拟机厂商的不同而有所不同。
  • 对象使用阶段 在这个阶段,对象被应用程序的其它对象使用(其它活动对象拥有指向它的引用)。在使用期间,该对象会一直驻留在内存当中,并且可能包含对其它对象的引用。
  • 对象销毁阶段 垃圾收集系统监视对象,如果发现对象不被任何对象引用了,则进行该对象内存回收操作。

那么问题来了,该如何去判断一个对象有没有被引用呢?目前,主要有两种判断对象是否存活的算法,分别是 引用计数算法(Reference counting algorithm)可达性分析算法(Accessibility analysis algorithm)

引用计数算法

首先我们看看引用计数算法是如何判断的,该算法的主要思想就是给每个对象都添加一个引用计数器,当该对象被变量或者另一个对象引用时该计数器值就会加 1,同时当对象的一个引用无效时,对象计数器的值会相应的减 1。当对象引用计数器的值为 0 时,说明该对象已经不再被引用了,那么就可以销毁对象进行内存回收操作了。这个算法的实现比较简单,对象是否“存活”的判断效率也比较高,这个算法看起来确实不错,但是它有个致命的缺点就是:无法解决对象间相互引用的问题。相互引用简单来说就是,有两个对象 object1object2 都有一个引用类型字段 ref,并且做了如下赋值操作:

1
2
object1.ref = object2;
object2.ref = object1;

这两个对象除了上面这个赋值之外,不被其它任何对象引用,实际上这两个对象都不可能再被访问了,但是因为它们俩都互相引用了对方,导致引用计数器不为 0,导致使用引用计数器算法的 垃圾收集器 无法收集它们,它们就会一直存在于内存之中直到虚拟机进程结束。正是因为这个原因,市场上主流的 Java 虚拟机大部分都没有选用这个算法来管理内存,下面介绍的 可达性分析算法 就可以很好的避免了对象间相互引用的问题。

可达性分析算法

Java 虚拟机是通过可达性分析算法来判断对象是否存活的,该算法的主要思想是将一系列称为 GC Root 的对象作为起点,向下进行搜索,搜索经过的路径称为引用链(Reference chain),当一个对象到 GC Root 对象没有任何引用链的时候,则表示该对象是不可达的,可以对其进行内存回收。

accessibility-analysis-algorithm.png

Java 虚拟机中,规定以下几种情况可以作为 GC Root 对象:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 Native 方法引用的对象

怎么进行内存回收

当我们创建的对象不可达之后,Java 虚拟机会在后台自动去收集回收不可达对象的内存,自 Java 语言诞生以来,在垃圾收集算法上进行了许多更新,主要有标记-清除算法(Mark and sweep algorithm)复制算法(Copying algorithm)标记—整理算法(Mark and compact algorithm)分代收集算法(Generational collection algorithm),根据这些算法实现的垃圾收集器在后台默默运行以释放内存,下面让我们看看它们是如何工作的。

标记-清除算法(mark and sweep algorithm)

标记—清除算法是初始且非常基本的算法,主要分为以下两个阶段:

  1. 标记需要回收对象,找出程序中所有需要回收的对象并标记。
  2. 清除所有标记对象,在标记完成后统一回收被标记对象。

首先标记出需要回收的对象,标记完成后再统一回收被标记对象。这个算法是最基础的垃圾收集算法,后面将要介绍的几个算法都是在它的基础上优化改进的,算法主要有两个不足的地方:① 效率不高,标记和清除过程的效率都不高。② 空间利用率不高,标记清除之后会产生大量不连续的内存碎片,后面如果要分配大对象的时候由于连续内存不足可能会再次触发垃圾收集操作。

复制算法(copying algorithm)

复制算法就是为了解决标记—清除算法的效率问题的,主要思想就是将可用的内存分为大小相等的两个部分,每一次都只使用其中的一块,当这块内存使用完了之后,就将依然存活的对象复制到另一块内存上去,然后再把这块含有可回收对象的内存清理掉,这样每次都是清理一半的连续内存了,就不会存在内存碎片的情况。但是这个算法的缺点也很明显,它把可用内存的大小缩小到了一半。

标记-整理算法(mark and compact algorithm)

如果对象的存活率比较低的情况下,上面介绍的复制算法效率还是很高的,毕竟只要复制少部分存活对象到另一块内存中即可,但是当对象的存活率比较高时就会进行多次复制操作。比如老年代,老年代的对象是经过多次垃圾回收依然存活的对象,对象的存活率相对来说比较高,根据老年代的这个特点,于是针对这种情况就有了另一个算法称之为标记-整理算法,主要思想和其名字一样也是分为标记整理两个阶段,第一个标记阶段依然和标记—清除算法一样,后面的第二个整理阶段就不是直接对可回收对象进行清理了,而是让所有存活的对象都向内存的同一侧移动,然后就直接清除掉另一侧的内存。

分代收集算法(generational collection algorithm)

根据不同分代的特点,现在商业上的虚拟机针对不同的分代采取适合的垃圾收集,一般是把 Java 堆分为新生代和老年代。在新生代中,对象大部分存活时间都很短每次垃圾收集都会有很多的对象被清除,只有少部分对象可以存活下来,那么此时就可以使用复制算法,只需要复制出少部分存活的对象即可效率高。然而在老年代中大部分对象的存活时间比较长,则需采用标记-清除算法或者标记-整理算法来进行垃圾收集。
垃圾收集算法对于垃圾回收来说类似于我们程序中的接口,是一套垃圾回收的指导算法,算法的具体实现我们称之为垃圾收集器。但是 Java 虚拟机规范中并没有对垃圾收集器的实现有任何规定。所以不同的厂商和不同版本的虚拟机实现的垃圾收集器也不一样,不过一般都会提供一些配置参数来让用户根据自身情况来设置所需的垃圾收集器。

JVM 相关 GC 配置

Java 虚拟机部分垃圾收集(Garbage Collection,GC)相关配置如下

参数 描述
-Xms2048m 设置初始堆大小(新生代 + 老年代)
-XX:InitialHeapSize=3g 设置初始堆大小(新生代 + 老年代)
-Xmx3g 设置最大堆大小(新生代 + 老年代)
-XX:MaxHeapSize=3g 设置最大堆大小(新生代 + 老年代)
-XX:NewSize=128m 设置堆初始新生代大小
-XX:MaxNewSize=128m 设置堆最大新生代大小
-XX:PermSize=512m(JDK 1.7) 设置初始永久代(元空间)大小
-XX:MetaspaceSize=512m(JDK 1.8+) 设置初始永久代(元空间)大小
-XX:MaxPermSize=1g(JDK 1.7) 设置最大永久代(元空间)大小
-XX:MaxMetaspaceSize=1g(JDK 1.8+) 设置最大永久代(元空间)大小
-XX:+DisableExplicitGC 忽略应用程序对 System.gc() 方法的任何调用
-XX:+PrintGCDetails 打印输出 GC 收集相关信息

参考文章

-------------本文结束感谢您的阅读-------------
mghio wechat
微信公众号「mghio」
赏作者☕️