Java垃圾回收

摩森特沃 2021年01月11日 413次浏览

声明:本文多处直接引用了文末的参考链接中的内容,如有侵权,请联系删除

什么是垃圾回收?

垃圾回收(Garbage Collection,GC):就是释放垃圾占用的空间,防止内存泄露。对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

什么地方需要垃圾回收

要搞懂垃圾回收的机制,我们首先要知道垃圾回收主要回收的是哪些数据,这些数据主要在哪一块区域,所以我们一起来看下 JVM 的内存区域

8LE6Rk

  • 虚拟机栈:描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢(下文会看到),主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息,方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据,入栈出栈的时机很明确,所以这块区域不需要进行 GC。
  • 本地方法栈:与虚拟机栈功能非常类似,主要区别在于虚拟机栈为虚拟机执行 Java 方法时服务,而本地方法栈为虚拟机执行本地方法时服务的。这块区域也不需要进行 GC
  • 程序计数器:线程独有的, 可以把它看作是当前线程执行的字节码的行号指示器,比如如下字节码内容,在每个字节码`前面都有一个数字(行号),我们可以认为它就是程序计数器存储的内容
    wkLsMy
    记录这些数字(指令地址)有啥用呢,我们知道 Java 虚拟机的多线程是通过线程轮流切换并分配处理器的时间来完成的,在任何一个时刻,一个处理器只会执行一个线程,如果这个线程被分配的时间片执行完了(线程被挂起),处理器会切换到另外一个线程执行,当下次轮到执行被挂起的线程(唤醒线程)时,怎么知道上次执行到哪了呢,通过记录在程序计数器中的行号指示器即可知道,所以程序计数器的主要作用是记录线程运行时的状态,方便线程被唤醒时能从上一次被挂起时的状态继续执行,需要注意的是,程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域,所以这块区域也不需要进行 GC
  • 本地内存:线程共享区域,Java 8 中,本地内存,也是我们通常说的堆外内存,包含元空间和直接内存,注意到上图中 Java 8 和 Java 8 之前的 JVM 内存区域的区别了吗,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能(发生 GC 会发生 Stop The Word,造成性能受到一定影响,后文会提到),也就不存在由于永久代限制大小而导致的 OOM 异常了(假设总内存1G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。综上所述,在 Java 8 以后这一区域也不需要进行 GC
  • 堆:前面几块数据区域都不进行 GC,那只剩下堆了,是的,这里是 GC 发生的区域!对象实例和数组都是在堆上分配的,GC 也主要对这两类数据进行回收,这块也是我们之后重点需要分析的区域

怎么发现它?

在发生 GC 的时候,Jvm 是怎么判断堆中的对象实例是不是垃圾呢?

这里有两种方式:

引用计数法

就是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加 1,每当有一个引用失效时,计数器的值就减 1。任何时刻只要对象的计数器值为 0,那么就可以被判定为垃圾对象。

这种方式,效率挺高,但是 Jvm 并没有使用引用计数算法。那是因为在某种场合下存在问题

比如下面的代码,会出现循环引用的问题:

public class Test {
    Test test;
    public Test(String name) {}

    public static  void main(String[] args) {
	// 第一步
        Test a = new Test("A");
        Test b = new Test("B");
	// 第二步
        a.test = b;
        b.test = a;
	// 第三步
        a = null;
        b = null;
    }
}

08jgw6
到了第三步,虽然 a,b 都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。

可达性分析法

这才是 jvm 默认使用的寻找垃圾算法。

它的原理是通过一些列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜素所走过的路叫做称为引用链“Reference Chain”,当一个对象到 GC Roots 没有任何引用链时,就说这个对象是不可达的。

YztyRcpHKB19OTD
从上图可以看到,即使 Object5 和 Object6 之间相互引用,但是没有 GC Roots 和它们关联,所以可以解决循环引用的问题。

当对象可回收,就一定会被回收吗?并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!

注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!

那么这些 GC Roots 到底是什么东西呢,哪些对象可以作为 GC Root 呢,有以下几类

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

如下代码所示,a 是栈帧中的本地变量,当 a = null 时,由于此时 a 充当了 GC Root 的作用,a 与原来指向的实例 new Test() 断开了连接,所以对象会被回收。

publicclass Test {
    public static  void main(String[] args) {
	Test a = new Test();
	a = null;
    }
}
方法区中类静态属性引用的对象

如下代码所示,当栈帧中的本地变量 a = null 时,由于 a 原来指向的对象与 GC Root (变量 a) 断开了连接,所以 a 原来指向的对象会被回收,而由于我们给 s 赋值了变量的引用,s 在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!

public class Test {
    public static Test s;
    public static  void main(String[] args) {
	Test a = new Test();
	a.s = new Test();
	a = null;
    }
}
方法区中常量引用的对象

如下代码所示,常量 s 指向的对象并不会因为 a 指向的对象被回收而回收

public class Test {
	public static final Test s = new Test();
        public static void main(String[] args) {
	    Test a = new Test();
	    a = null;
        }
}
本地方法栈中 JNI 引用的对象

这是简单给不清楚本地方法为何物的童鞋简单解释一下:所谓本地方法就是一个 java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使 JAVA 可以实现和本地机器的紧密联系,调用系统级的各接口方法。

当调用 Java 方法时,虚拟机会创建一个栈桢并压入 Java 栈,而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。

LBn0K5

JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {
...
   // 缓存String的class
   jclass jc = (*env)->FindClass(env, STRING_PATH);
}

如上代码所示,当 java 调用以上本地方法时,jc 会被本地方法栈压入栈中, jc 就是我们说的本地方法栈中 JNI 的对象引用,因此只会在此本地方法执行完成后才会被释放。

扩展知识点:
  1. 不得不说的四种引用
    强引用:就是在程序中普遍存在的,类似“Object a=new Object”这类的引用。只要强引用关系还存在,垃圾回收器就不会回收掉被引用的对象。
    软引用:用来描述一些还有用但是并非必须的对象。直到内存空间不够时(抛出 OutOfMemoryError 之前),才会被垃圾回收,通过 SoftReference 来实现。
    弱引用:比软引用还弱,也是用来描述非必须的对象的,当垃圾回收器开始工作时,无论内存是否足够用,弱引用的关联的对象都会被回收 WeakReference。
    虚引用:它是最弱的一种引用关系,它的唯一作用是用来作为一种通知。采用 PhantomRenference 实现。
  2. 为什么定义这些引用?
    个人理解,其实就是给对象加一种中间态,让一个对象不只有引用和非引用两种情况,还可以描述一些“食之无味弃之可惜”的对象。比如说:当内存空间足时,则能保存在内存中,如果内存空间在进行垃圾回收之后还不够时,才对这些对象进行回收。

生存还是死亡?

要真正宣告一个对象死亡,至少要经历两次标记过程和一次筛选。

一张图带你看明白:

vrCoay8pQI56bzm

垃圾收集算法

标记清除算法

分为两个阶段“标记”和“清除”,标记出所有要回收的对象,然后统一进行清除。

Qg9ts5aIPOfdc1W
缺点:

在对象变多的情况下,标记和清除效率都不高
会产生空间碎片

复制算法

就是将堆分成两块完全相同的区域,对象只在其中一块区域内分配,然后标记出那些是存活的对象,按顺序整体移到另外一个空间,然后回收掉之前那个区域的所有对象。

F15ifTHZXOdWLmU
缺点:

虽然能够解决空间碎片的问题,但是空间少了一半。也太多了吧!!

标记整理算法

DgOURqtGheIuyrb
这种算法是,先找到存活的对象,然后将它们向空间的一端移动,最后回收掉边界以外的垃圾对象。

分代收集

分代收集算法整合了以上算法,综合了这些算法的优点,最大程度避免了它们的缺点,所以是现代虚拟机采用的首选算法,与其说它是算法,倒不是说它是一种策略,因为它是把上述几种算法整合在了一起。
大部分的对象都很短命,都在很短的时间内都被回收了(IBM 专业研究表明,一般来说,98% 的对象都是朝生夕死的,经过一次 Minor GC 后就会被回收),所以分代收集算法根据对象存活周期的不同将堆分成新生代和老生代(Java8以前还有个永久代),默认比例为 1 : 2,新生代又分为 Eden 区, from Survivor 区(简称S0),to Survivor 区(简称 S1),三者的比例为 8: 1 : 1,这样就可以根据新老生代的特点选择最合适的垃圾回收算法,我们把新生代发生的 GC 称为 Young GC(也叫 Minor GC),老年代发生的 GC 称为 Old GC。

N44Ed4

垃圾收集器

在说垃圾回收器之前需要了解几个概念:

几个概念
  • 吞吐量
    CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。

比如说虚拟机总运行了 100 分钟,用户代码时间 99 分钟,垃圾回收时间 1 分钟,那么吞吐量就是 99%。

  • STW
    全称 Stop-The-World,即在 GC 期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。

  • 为什么需要 STW 呢?

在 java 程序中引用关系是不断会变化的,那么就会有很多种情况来导致垃圾标识出错。

想想一下如果一个对象 A 当前是个垃圾,GC 把它标记为垃圾,但是在清除前又有其他引用指向了 A,那么此刻又不是垃圾了。

那么,如果没有 STW 的话,就要去无限维护这种关系来去采集正确的信息,显然是不可取的。

  • 安全点
    从线程角度看,安全点可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的。

比如:方法调用、循环跳转、异常跳转等这些地方才会产生安全点。

如果有需要,可以在这个位置暂停,比如发生 GC 时,需要暂停所有活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。

  • 串行、并行
    串行:是指垃圾回收线程在进行垃圾回收工作,此时用户线程处于等待状态。

并行:是指用户线程和多条垃圾回收线程分别在不同 CPU 上同时工作。

回收器

下面是一张很经典的图,展示了 7 种不同分代的收集器,如果两个收集器之间存在连线,说明可以搭配使用。

xLedj5ikQlyZRTt

  • Serial
    Serial 收集器是一个单线程收集器,在进行垃圾回收器的时候,必须暂停其他工作线程,也就是发生 STW。在 GC 期间,应用是不可用的。

ezb8yTGRaj6xcID

特点:1、采用复制算法 2、单线程收集器 3、效率会比较慢,但是因为是单线程,所以消耗内存小

  • ParNew
    ParNew 是 Serial 的多线程版本,也是工作在新生代,能与 CMS 配合使用。

在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。

iEpCZg1zjsfAG3S

特点:1、采用复制算法 2、多线程收集器 3、效率高,能大大减少 STW 时间。

  • Parallel Scavenge
    Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器基本一样。

TklagbfOYwPNSU8

但是它有啥特别之处呢?关注点不同

ParNew 垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,更适合用到与用户交互的程序,因为停顿时间越短,用户体验肯定就好呀!!
Parallel Scavenge 目标是达到一个可控制的吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
Parallel Scavenge 收集器提供了两个参数来控制吞吐量,

-XX:MaxGCPauseMillis:控制最大垃圾收集时间
-XX:GCTimeRati:直接设置吞吐量大小
特点:1、采用复制算法 2、多线程收集器 3、吞吐量优先

  • Serial Old
    Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器。

作用:

在 Client 模式下与 Serial 回收器配合使用
Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用
它与 Serial 收集器配合使用示意图如下:

rYnmg9zNh12GbUO

特点:1、标记-整理算法 2、单线程 3、老年代工作

  • Parallel Old
    Parallel Old 是一个多线程的垃圾回收器,采用标记整理算法,负责老年代的垃圾回收工作,可以与 Parallel Scavenge 垃圾回收器一起搭配工作。真正的实现吞吐量优先

示意图如下:

qQB1WzNMmFkft7x
特点:1、标记-整理算法 2、多线程 3、老年代工作

  • CMS
    CMS 可以说是一款具有"跨时代"意义的垃圾回收器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是非常合适的,它是以获取最短回收停顿时间为目标的收集器!

CMS 虽然工作在老年代,和之前收集器不同的是,使用的标记清除算法

示意图如下:

2zsX9wWaQZSDexc
垃圾回收的 4 个步骤:

初始标记:标记出来和 GC Roots 直接关联的对象,整个速度是非常快的,会发生 STW,确保标记的准确性。
并发标记:并发标记这个阶段会直接根据第一步关联的对象找到所有的引用关系,耗时较长,但是这个阶段会与用户线程并发运行,不会有很大的影响。
重新标记:这个阶段是为了解决第二步并发标记所导致的标错情况。并发阶段会和用户线程并行,有可能会出现判断错误的情况,这个阶段就是对上一个阶段的修正。
并发清除:最后一个阶段,将之前确认为垃圾的对象进行回收,会和用户线程一起并发执行。
缺点:

影响用户线程的执行效率:CMS 默认启动的回收线程数是(处理器核心数 + 3)/ 4 ,由于是和用户线程一起并发清理,那么势必会影响到用户线程的执行速度
会产生浮动垃圾:CMS 的第 4 个阶段并发清除是和用户线程一起的,会产生新的垃圾,就叫浮动垃圾
会产生碎片化的空间

  • G1
    全称:Garbage-First

G1 回收的目标不再是整个新生代或者是老年代。G1 可以回收堆内存的任何空间来进行,不再是根据年代来区分,而是那块空间垃圾多就去回收,通过 Mixed GC 的方式去进行回收。

先看下堆空间的划分:

hdYTLRAxQptGF39
G1 垃圾回收器把堆划分成大小相同的 Region,每个 Region 都会扮演一个角色,分别为 H、S、E、O。

E 代表伊甸区
S 代表 Survivor 区
H 代表的是 Humongous 区
O 代表 Old 区
G1 的工作流程图:

YaoOe7C5hIQNKgW
初始标记:标记出来 GC Roots 能直接关联到的对象,修改 TAMS 的值以便于并发回收时新对象分配
并发标记:根据刚刚关联的对像扫描整个对象引用图,和用户线程并发执行,记录 SATB(原始快照) 在并发时有引用的值
最终标记:处理第二步遗留下来的少量 SATB(原始快照) 记录,会发生 STW
筛选回收:维护之前提到的优先级列表,根据优先级列表、用户设置的最大暂停时间来回收 Region
特点:

并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,可以通过并发的方式让 Java 程序继续执行,进一步缩短 STW 的时间。
分代收集:分代概念在 G1 中依然得以保留,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象来获得更好的收集效果。
空间整合:G1 从整体上看是基于标记-整理算法实现的,从局部(两个 Region 之间)上看是基于复制算法实现的,G1 运行期间不会产生内存空间碎片。
可预测停顿:G1 比 CMS 厉害在能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

内存分配与回收策略

上文说的一直都是回收内存的内容,那么怎么给对象分配内存呢?

堆空间的结构:

sRSTUWcaqYtFk8X

Eden 区

研究表明,有将近 98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配。

当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。

通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。

Survivor 区

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,Survivor 又分为 2 个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。

问题 1:为什么需要 Survivor?
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实或许第二次,第三次就需要被清除。

这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少老年代 GC 的发生。Survivor 的预筛选保证,只有经历 15 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

问题 2:为什么需要 From 和 To 两个呢?
这种机制最大的好处就是可以解决内存碎片化,整个过程中,永远有一个 Survivor 区是空的,另一个非空的 Survivor 区是无碎片的。

假设只有一个 Survivor 区。

Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。

那么问题来了,这时候我们怎么清除它们?

在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。

因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,To 区 到 From 区 ,以此反复。

Old 区

老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。

由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以在这里老年代采用的是标记整理算法。

下面三种情况也会直接进入老年代:

  • 大对象
    大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,需要注意。

  • 长期存活对象
    虚拟机给每个对象定义了一个对象年龄 Age 计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。

  • 动态对象年龄
    虚拟机并不重视要求对象年龄必须到 15 岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

如果条件成立的话,Minor GC 是可以确保安全的。

如果不成立,则虚拟机会查看 HandlePromotionFailure 设置是否担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。

如果大于,尝试进行一次 Minor GC。

如果小于或者 HandlePromotionFailure 不允许,则进行一次 Full GC。

引用参考