Java垃圾回收大解析

101 阅读14分钟

本文将针对HotSpot虚拟机进行解析。在看这篇文章之前,大家记住一句话,在Java中,万物皆对象。

1、堆内存结构

堆内存结构主要分为5个部分,分别是Eden区S0区S1区老年代,和元空间(jdk1.7中为永久代,1.8后改为元空间),同时只有元空间使用的是直接内存,也就是本地内存,其他都使用的是JVM内存。直接上图。

堆内存结构图.png

至于为什么分这么多区,接下来我会在下面的内容进行阐述。

2、对象分配规则

在HotSpot虚拟机中,默认的eden区s0s1区的内存比例是8:1:1,因为大多数情况下的对象的生命周期都很短,有些甚至会在栈上分配,多数情况下都随着线程的结束就使用完毕了,不信的话你们翻一翻代码,看看你们自己创建的对象有多少能撑过一个线程任务的。所以eden区会比较大,而s0s1就是在和老年代之间设置一层缓存,毕竟你不能刚撑过eden的一次GC就直接进老年代吧,太便宜你了。

分配对象首先会优先放在Eden区,如果Eden区放不下了,就会触发一次Minor GC,还需要进行空间担保机制,来决定是否进行FullGC,也就是针对年轻代的GC,在Eden中还存活的那就进入S0区或者S1区,如果S区也放不下,那就需要直接进入老年代。那什么是空间担保机制?

空间担保机制:就是在Minor GC之前做一次计算,看一下在老年代中最大连续可用的内存空间是否大于新生代所有对象大小的总和,如果大于,那就进行Minor GC,否则就进行Full GC。说白了,就是要保证极端情况下,你这些对象都存活了,我老年代得能容得下你们啊。

接着说哈,对于大对象,也就是需要很大连续内存空间的对象直接进入老年代,但是这个是根据情况决定的,不同的垃圾收集器是不同的,比如G1垃圾收集器,可以通过参数堆大小和阈值来决定哪些对象进入老年代,具体参数大家自己搜索,背也记不住,知道G1能这么设置就可以。对于Parallel Scavenge垃圾收集器,是由虚拟机根据堆内存情况和历史数据的状态动态决定阈值的。顺便说一嘴,Java8默认的垃圾回收器是Parallel Scavenge处理新生代+Parallel Old处理老年代的组合。

再接着说。正常情况下,对象是如何一步一步的进入到老年代的呢?我们先文字叙述,然后上个图。说了半天新生代和老年代,而这么区分的目的就想说一件事,分代年龄,这个就是决定对象升级的依据。当进行一次Minor GC之后,如果eden的对象还存活,那么就进入S区,可能是S0可能是S1,为啥S区要分两个呢?这其实涉及到对象回收算法了,我后面会说,这里简单介绍一下啊,就是比如先进入S0,然后回收新生代,死的对象清理掉,留下的复制到s1,同时保证顺序排列整齐,同时年龄+1,然后再来一次新生代回收,s1里面的该清除的清除,剩下的移动到s0,在+1,就这个过程,能保证进入S区的对象内存排列整齐。也就是标记复制。然后年龄不断的+,到一个阈值,默认是15,当然了这个也跟垃圾收集器相关,如果是CMS,这个年龄阈值就是6了。当然了这个阈值是默认的,不是最终决定晋升老年代的年龄阈值,还要经过一个算法,算法就是,S区的对象,年龄从小到大累计他们的大小,当大小超过了S区内存的50%(默认是这个,也可以设置),那么这个年龄和默认的阈值取一个最小值作为晋升阈值。然后晋升到老年代。说了这么多文字,也是时候上个图了。

对象分配过程流程图1.png 而收集的方式从形式上可以分为YonGC新生代GCOldGC老年代GCMixed GC新生代和部分老年代的GC(G1具备这个能力),和Full GCGC

3、判定对象是否可回收算法

垃圾回收算法的核心就是判断对象是否死亡,而常见的有两种算法,分别是引用计数法和可达性分析算法。

3.1引用计数法

这个就是给对象加一个引用计数器,有一个地方引用就+1,引用失效就-1,为0的时候就是不被引用,但是这个算法有个非常大的弊端就是循环引用的场景,a引用b,b引用a,除此之外就再也没有对象引用了,那么这样的话就无法被GC

3.2可达性分析算法

这个算法就是通过一系列的GC Roots作为起点,然后根据引用关系向下搜索,找出引用链路,当有对象不在链路中的话,那么就是需要被回收的对象,如下图 可达性算法示意图.png 那么问题来了,什么样的对象可以作为GC Roots呢?我们总不能去堆中随机找一个对象吧,这些对象一定有着某些可以查找的依据的。

  • 虚拟机栈中引用的对象,这个很好理解,虚拟机栈就是线程正在执行的嘛,正在执行的被引用的肯定不能让你回收掉对吧
  • 本地方法栈中引用的对象,同理啊,我正在用着呢,不能回收
  • 方法区中类静态属性引用的对象,这个类变量肯定不能让你回收的,回收了之后再用的话对象就不对了,那就不能叫类变量了不是
  • 方法区中常量引用的对象,同理啊,我都是常量了,就说明我是不可变的,肯定不能让你回收了
  • 所有被同步锁持有的对象,这个也很好理解,我持有锁呢,说明我正在用着呢
  • JNI引用的对象,这个和本地方法那个类似 所以,综上所述,能作为GC Roots的对象一定是在GC时刻不能被回收的对象,同时还能根据具体场景找到的。 说了半天引用,那么在Java中引用都有哪几种?也就是我们常说的强软弱虚。

3.3 Java中的四种引用类型

3.3.1 强引用(StrongReference)

我们实际开发中大多数用的都是强引用,说白点就是必须用的,内存空间不够了,我就抛出OOM

3.3.2 软引用(SoftReference)

这个吧就是相当于可有可无的,内存空间足,那么我就留着你,不足的话,虽然你有引用关系,但是,对不起,你也得拜拜。

3.3.3 弱引用(WeakReference)

这个相对于软引用来说更废,只要是我垃圾收集器扫到你了,不管内存够不够,我都回收,ThreadLocal就是用了弱引用,等讲ThreadLocal的时候再仔细说。

3.3.4 虚引用(PhantomReference)

虚引用就是形同虚设,如果你一个对象持有一个虚引用,那么就认为你没有引用,你还没办法使用这个对象实例,它的唯一作用就是跟踪对象看是否已经被回收,就像是一个通知机制一样,如果作用的对象被回收了,那么虚引用中的clear方法就会被调用,然后开发者就可以做一些必要的机制,像是一种埋点。

咱们刚才也说了引用类型和堆空间什么时候GC,大家看那个图就能看明白了,但是还说了两点就是对于字符串常量池和元空间的垃圾回收。

3.4 Java8中对字符串常量池的回收

我们知道在Java8之后,字符串常量池放在了堆中,那么GC的时候也需要去考虑字符串的GC的,但是有些不同的是字符串是一个独立的内存区域,它可不分什么新生代老年代,所以当发生GC的时候,JVM就会去检查字符串常量池,这个常量池的结构就是字面量和字符串对象引用的映射关系,而真正的字符串对象还是在新生代或者老年代中。 所以,字符串常量池你可以看作另一种的对象句柄,所以字符串对象可能被回收掉,但是常量池中可能还存在这个字面量,所以GC就会清理掉这些没有了对象引用的字符串常量了。

3.5 对于元空间的垃圾回收

元空间我们都知道存储的是类的元信息和一些运行时常量。而这些信息也是经过类加载完成的,所以它也是存在回收的,但是不同的是它的回收比较严苛了。你得这个类的所有实例对象都被回收了,加载这个类的ClassLoader也被回收了,该类的Class对象也没有任何地方引用,任何地方都不能通过反射访问该类的方法,这才满足类的回收。

说了半天回收回收,也说了半天怎么标记可回收的对象是吧,那JVM到底怎么回收的对象呢?垃圾场也得处理垃圾不是。接下来说,垃圾回收算法。

4、垃圾回收算法

4.1 标记-清除算法

这个算法就是两个步骤,一个标记,一个清除,我们先看个图 标记清除算法示意图.png 经过这个算法之后,我们发现标记之后清除,是两个过程,效率,同时内存不规整,产生很多不连续的内存碎片。

4.2 复制算法

为了解决上面标记-清除算法产生的问题,然后出了复制算法,接着上图

复制算法示意图.png 这个算法的核心就是将内存分为大小相等的2块,当GC的时候,会将存活对象复制到另一边,然后将这边的内存清空。这个算法确实快了,空间也连续了,但是也有别的问题了,首先就是内存一分,内存不能同时用了,利用率少一半,然后就是如果存活数量比较大,复制代价也很大。

4.3 标记-整理算法

这个是根据老年代特点出的一个算法,标记的过程是一样的,但是不直接清除,而是整理,就是将存活对象往一端移动,然后清理掉边界以外的内存空间,直接上图

标记整理示意图.png 这个整理的步骤效率也不高,但是适合老年代。

所以你会发现,各有各的优势和缺点,所以你就会想到武林中的一句话,集各家之所长。

4.4 分代收集算法

这个没什么思想,就是虚拟机将堆分成新生代老年代,然后根据年代特性选择不同的垃圾收集算法,就是上面那三个。比如新生代,eden区S区,标记完,直接复制到S0,之后S0S1,一样复制,因为对于新生代来说,大多数的对象的存活时间都比较短,所以复制的成本不高。那么针对老年代,就可以选择标记-清除或者标记-整理算法了。所以这就是分代收集算法--集各家之所长。而不同的算法选择就和垃圾收集器相关了,那么我下面就说垃圾收集器。

5、垃圾收集器

垃圾收集器有很多,都有自己的特点,那么就需要根据自己的场景来选择适合自己的垃圾收集器。在jdk8中默认的垃圾收集器是(新生代)Parallel Scavenge+(老年代)Parallel Old。Jdk9~20使用的是G1。那么我开始介绍垃圾收集器

5.1 Serial收集器

这个是历史最悠久的收集器,顾名思义,单线程收集器,同时它进行垃圾回收工作的时候会暂停其他所有线程工作也就是STW,对于我们越来越高的业务背景下,这个收集器注定被淘汰了。它的新生代采用标记-复制算法。老年代采用标记-整理算法

5.2 ParNew收集器

这个就是Serial的多线程版本,算法选择也一样,一样会造成STW

5.3 Parallel Scavenge收集器

这个是Jdk8默认的新生代的垃圾收集器,算法和上面一样,但是它有个特点就是更关注吞吐量,吞吐量就是用户代码执行时间和CPU总消耗时间的比值,这个值的反向就是非用户代码运行的时间占比,也可以粗略的认为垃圾收集器工作时间占比,这个收集器提供了很多参数用来找到最大的停顿时间和吞吐量,再配合自适应调节策略就是一个不错的选择。也就是它兼容了多线程的能力,还不死板的去造成STW

5.4 Serial Old收集器

Serial的老年代版本,单线程,可以配合Parallel Scavenge收集器使用。

5.5 Parallel Old收集器

Parallel Scavenge收集器的老年代版本,使用多线程+标记整理算法。

这上面其实都是基础的垃圾收集器,jdk8就是通过上面组合使用。下面说的就是一些牛b的收集器了,也比较复杂。

5.6 CMS收集器

这个收集器就是以最短回收停顿时间为目标的收集器,也第一次实现了垃圾收集线程和用户线程同时工作。 采用的算法是标记-清除算法,而且还是一个老年代垃圾收集器,一般配合ParNew使用。但是执行过程比较复杂,如下图 CMS过程图.png 这也叫做三色标记法G1也用。 我们看到它优秀在于并发收集,然后停顿时间短,但是缺点也很明显。首先CPU资源得充足,然后在并发阶段中产生的新垃圾它这没有,比如你初始阶段标记了存活的,但是吧并发标记的时候,它不存活了,这部分标记不到。其次就是标记-清除算法的缺点,造成大量空间碎片。

5.7 G1收集器

这个收集器是面向配置多颗处理器和大容量内存的机器的,保证STW短的同时,还具备高吞吐量。很关键的就是需要你的机器性能顶,当然了,现在的机器都很顶。

它不需要其他收集器配合也能保留分代的概念,而且整体上看着是标记-整理算法,局部上还有标记-复制算法。最牛逼的是它可以建立一个可预测的停顿时间模型,让使用者明确消耗在垃圾收集上的时间阈值,而且它还维护了一个优先列表,每次根据允许的收集时间,选择回收价值最大的区域,大大的提高的收集效率,时代在进步啊,可以认为是CMS的超级豪华版,jdk9之后默认就是用G1了。

5.8 ZGC收集器

它和G1类似,采用标记-复制算法,但是对算法改进了很多,STW更少,但是Java11的时候在试验,java15已经可以正式使用,不过默认的还是G1,[# 新一代垃圾回收器ZGC的探索与实践](新一代垃圾回收器ZGC的探索与实践 - 美团技术团队 (meituan.com))这个文章就是讲解ZGC的,很不错。