深入理解JVM垃圾回收机制,带你一步步思考理解,告别死记硬背!

今天来专门聊一聊JVM中的垃圾回收机制。

垃圾回收主要针对的是JVM的堆内存,它分为新生代和老年代。

首先需要先对堆有一个大致的认识,堆分为新生代和老年代,在Jdk1.7以前还有一个永久代,它在方法区里,里面存储了我们的class信息、静态变量、常量池等。不过从JDK1.8开始,方法区的实现发生了该改变,取消了永久代的概念,并且在内存上与老年代不再是物理上连续的。方法区包含在元空间中,并直接存储在机器内存中,这么做的目的是使内存溢出的可能性进一步减小,它的空间大小更容易进一步扩展。同时,方法区也将一部分变量转移了出去,比如类的静态变量、字符串常量池都放到了堆内存当中。

如下图所示:

img

在堆内存中,从垃圾回收的范围上说,一般分为两种:针对新生代的 minorGC(也叫YoungGC) 和 针对老年代的 majorGC(也叫fullGC)。

下来来说明一下GC中一些常见的搜索算法和回收算法。

首先,我们肯定要先标记出回收的对象,也就是哪些是”垃圾”,大概有两种方法:

  1. 引用计数法

每个对象有一个引用计数器,每当有一个引用指向它的时候,它的计数器就+1。这样的话,是要观察这个对象的引用计数器,数值大于0就表示还有人在使用,这个对象不能回收。

缺点:如果两个对象相互引用,他们就永远不会被回收。

因此,java使用了一种叫可达性分析的算法。

  1. 可达性分析

就是从整个堆内存的根对象出发,看看有多少对象是可达的,无法到达的意味着无法访问,也就是垃圾了。

那么哪些是根对象呢?一般有以下几种:

  • 栈中引用的对象
  • 类静态属性引用的对象
  • 常量引用的对象
  • Native 方法引用的对象

标记出垃圾后就要开始回收了,以下是几种常见的回收算法:

  1. 标记清除算法(Mark-Sweep)

当垃圾回收器将内存扫码之后会标记出所有垃圾对象然后将他们回收,

缺点:会产生大量的内存碎片,使得内存的使用率越来越低(服务器维护:重启服务器)

  1. 复制算法

准备两块一模一样的内存,当第一块剩余空间不足时,可以将所有需要保留的对象拷贝至另一块内存,然后将前一块的内存清空,做到了碎片整理。

缺点:内存空间浪费了一倍

在新生代中的两块幸存区就是为了实现这一算法

3.标记整理法

其实就是在标记清楚的基础上多了一个碎片整理的工作,显然这样的回收机制不适合高频率的执行,一般当老年代空间不足时,会触发一次fullGC,这时就会执行碎片整理工作。

了解了常见的算法后,我们来看看JVM中具体的垃圾回收器:

首先根据JVM的迭代,最早期的就是Serial和Serial Old ,中期的 ParaallelScavenge 和 ParallellOld 以及过渡期的 ParNaw 和 CMS ,现在的G1和未来的ZGC。

工作在年轻代:

Serial Parallel Scavenge ParNew

工作在老年代:

Serial old Parallel Old CMS

一个能搞定整个堆:

G1 ZGC

下面我们分别对其做详细说明:

  1. Serial

    1. Serial 是工作在新生代的垃圾回收器,相对应的SerialOld是工作在老年代的,这种垃圾回收器是单线程的,并且不支持并发,开始垃圾回收时,所有的用户线程必须全部暂停,这个动作形象地被称为STW(Stop-the-world),然后垃圾回收器开始工作,标记并回收垃圾。

    2. img

  2. Parallel Scavenge

    1. Parallel Scavenge 也是工作在新生代的一个垃圾回收器,在垃圾回收时候也要触发STW,但是和Serila相比变成了多线程,这对于多CPU服务器来说,提高了不少效率。但因为STW不可避免,于是后来有了CMS

    2. img

  3. CMS

    1. CMS支持并发,与他搭档的ParNew和ParallelScavenge并没有什么区别,就是为了配合CMS做了一些调整。

    2. CMS的并发他分为几个阶段:

    3. 针对于用户线程,当CMS介入也会触发STW,不过在初始标阶段,它只会标记根对象的第一层(可达性分析中的第一层),因此STW时间很短。(这里第一次STM是为了避免在随后的并发标记阶段因用户线程的继续运行而错误地回收仍然存活的对象)

    4. 接下来进入真正的并发标记阶段,用户线程继续,CMS也同时进行垃圾标记,因为多线程并发进行,会形成错标,因此CMS有设计了一个节点

    5. 重新标记,他也会触发STW,已多线程的方式,重新修正错误的标记然后再已并发的方式,清理垃圾,此时由于用户线程也在运行,新产生的垃圾就不能及时被清理,我们称之为浮动垃圾,他只能等待下一轮被垃圾回收

    6. img

CMS解决了一些问题,但也带来了不少新的问题。

  1. G1
    1. G1在JDK1.8被引入,在1.9才变成了JDK的默认垃圾回收器。G1 对GC又做了很多优化,它允许用户手动的设置一个期望的STW时间,但是它不会严格的保证这个时间,只会尽量的缩短并靠近这个时间,它是怎么做到的呢?

    2. 首先G1会将整个堆内存划为为若干相等大小的区域,区域的数量默认2048,它依然保留了Eden区,幸存者区和老年代的概念,只不过,他们不再是物理上连续的空间。

    3. img

    4. 当一块区域被年轻代使用时,它就是年轻代,当被清空回收后,又被老年代使用时就是老年代。也就是说它们的空间大小都不是固定的了,这样不仅方便了扩展,而且当GC扫描内存时,它无需再扫描整个内存区域,这大大提高了它所支持的堆内存大小。

    5. 当G1进行垃圾回收时,它会根据你设定的STW时间来调整策略,它将需要扫描的区域进行价值排序,不同的区域垃圾的数量不同,回收的时间也不同。

    6. 假如你设定了50ms,它就会尽可能的保证50ms的时间优先回收一部分区域。当然如果时间设置的过低,也会带来另一个问题,回收掉了一部分仍然有大量的垃圾区域没有清理,GC过后,过一段时间又不够了,GC的触发次数变得频繁,如果虚拟机频繁的切换手中的用户工作,把更多的时间用来GC工作,那吞吐量就下降了。(频繁的GC会导致吞吐量下降)

    7. 这么看G1似乎还是存在碎片内存的问题,但G1在垃圾回收时在区域间使用了复制算法,直接进行了碎片整理。

    8. 此外,G1会将大对象单独存放,存放的区域叫做 humongous,在之前的GC算法中,大对象是会直接放入老年代中的,现在它依旧属于老年代,但存储时独立,避免了在对老年代进行垃圾回收整理时,频繁地移动大对象。

    9. G1的工作流程其实和CMS差不多,只不过不用扫描全部的内存,它的STW时间是非常短的,并且最终标记阶段,G1修正了CMS会出现错标的问题,这里是通过三色标记法实现的。

  • 三色标记法:
    • 其实三色标记也可以算是搜索算法的一种,就是用来标记内存中存存活和需要回收的对象。好处就是让JVM在极短时间内完成STW。CMS和G1垃圾回收器都用到了三色标记法。

    • 实现原理:

    • 将堆中的对象分为三种颜色:

    • 白色:表示还未被垃圾回收器扫描到的对象

    • 黑色:表示已经被垃圾回收器扫描过,且对象及其引用的其他对象都是存活的

    • 灰色:表示已经被垃圾回收器扫描过,但对象及其引用的其他对象尚未被扫描

    • 在GC开始的时候,先将所有对象都标记为白色,然后从根对象开始去遍历,接着把直接引用的对象标记为灰色,再判断灰色集合中的对象是否存在子引用:不存在 ->放入黑色集合中 ;存在 -> 放入灰色集合中

    • 按照这个步骤一直推导,知道灰色集合中的对象都变为黑色后,这一轮标记完成。最后还处于白色标记的对象就是不可达对象,可以直接回收。

    • 总结:就是通过黑白灰三个状态量来保存可达性分析中的中间状态

这里贴一个小tip:

如何查询自己的JVM使用了哪种垃圾回收机制

暂时无法在飞书文档外展示此内容

拓展:

  • 引用类型总结:
    • 无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

    • JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

    • JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱),强引用就是 Java 中普通的对象,而软引用、弱引用、虚引用在 JDK 中定义的类分别是 SoftReferenceWeakReferencePhantomReference

    • 强引用:

      • 最常见的引用,在默认情况下,任何普通的对象引用都是强引用。只要一个对象有强引用指向它,垃圾回收器就永远不会回收该对象。
    • 软引用:

      • 当系统内存不足时,垃圾回收器会回收软引用指向的对象,避免内存溢出。通常用于缓存机制,允许程序在不影响性能的情况下利用多余内存。
    • 弱引用:

      • 只要一个对象只有弱引用指向,该对象就会被立即回收,无论系统内存是否充足。
    • 虚引用:

      • 它和没用引用一样,随时会被垃圾回收。主要作用是跟踪对象的垃圾回收状态。
  • TLAB
    • 什么是TLAB?

    • 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内

    • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

    • OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计

    • 有什么作用?

    • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据

    • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

    • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

    • 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

    • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。