JVM(3) 垃圾回收
2024-11-20 16:37:50 # Language # Java

1. 方法区的回收

方法区中能回收的内容主要就是不再使用的类,判定一个类可以被卸载。需要同时满足下面三个条件:

  1. 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象,
  2. 加载该类的类加载器已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用

实际开发中此类场景很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。

通过 -XX:+TraceClassLoading -XX:+TraceClassUnloading 查看类的加载和卸载过程

System.gc(): 手动触发垃圾回收,会向虚拟机发送垃圾回收的请求,是否要执行需要虚拟机来判断

2. 堆回收

2.1 引用计数法和可达性分析法

引用计数法:会为每个对象维护一个引用计数器,当对象被引用时+1,取消引用时-1

  • 存在循环引用问题,例如 a 引用 b,b 也引用 a,导致无法回收

可达性分析法:如果某个对象从GC Root对象是可达的,那么就不可以被回收

  • 对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系
  • GC Root对象包括
    • 线程Thread对象,引用线程栈桢中的方法参数、局部变量等
    • 系统类加载器加载的 java.lang.Class 对象,引用类中的静态变量
    • 监视器对象,用来保存同步锁synchronized关键字持有的对象
    • 本地方法调用时使用的全局对象

查看垃圾回收信息:添加 -verbose:gc 参数

查看GC Root

  • arthas heapdump {目录/xxx.hprof}: 将堆内存快照保存到本地磁盘中
  • 使用 MAT 工具打开堆内存快照文件
  • 选择GC Roots功能查看所有的GC Root

2.2 不同的对象引用

强引用:默认

软引用

  • 当程序内存不足时,会将软引用中的数据进行回收,常用于缓存中
  • new SoftReference<对象类型>(对象,[引用队列])
  • 当软引用中的对象被回收时,SoftReference对象本身也需要被回收,其提供了一套队列机制
    • 软引用创建时,通过构造器传入引用队列(ReferenceQueue<对象类型>)
    • 软引用包含的对象被回收时,该软引用对象会被放入引用队列
    • 通过代码遍历引用队列,将SoftReference的强引用删除
  • 也可以通过继承一个SoftReference类来实现

弱引用

  • 不管内存够不够都会直接被回收,主要用在ThreadLocal
  • new WeakReference<对象类型>(对象,[引用队列])

虚引用

  • 虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。

终结器引用

  • 终结器引用指的是在对象需要被回收时,对象将会被放置在 Finalizer 类中的引用队列中,并在稍后由一条 FinalizerThread 线程从队列中获取对象,然后执行对象的 finalize 方法。在这个过程中可以在 finalize 方法中再将自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收。

3. 垃圾回收算法

Java垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World,简称STW,如果 STW 时间过长则会影响用户的使用。

评价标准:吞吐量、最大暂停时间、堆使用效率

3.1 标记-清除算法

  1. 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
  2. 清除阶段,从内存中删除没有被标记也就是非存活对象。

优点:实现简单

缺点:内存碎片化、分配速度慢

3.2 复制算法

  1. 将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
  2. GC阶段开始,将GC Root搬运到To空间
  3. 将GC Root关联的对象,搬运到To空间
  4. 清理From空间,并把名称互换

优点:吞吐量高(但不如标记-清除算法)、不会发生碎片化

缺点:内存使用效率低

3.3 标记-整理算法

  1. 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GCRoot开始通过引用链遍历出所有存活对象。
  2. 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。

优点:内存使用效率高,不会发生碎片化

缺点:整理阶段的效率不高

3.4 分代GC

分代GC将整块内存区域划分为年轻代和老年代

  • 年轻代(新生代):存放存活时间较短的对象

    • Eden区(伊甸园)

    • Survivor幸存区(分为From和To区)

      • S0
      • S1
  • 老年代:存放存活时间较长的对象

执行流程

分代回收时,创建出来的对象,首先会被放入Eden伊甸园区

随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为 Minor GC 或者 Young GC

  • Minor GC会把 Eden 和 From(S0) 区中需要回收的对象回收,把没有回收的对象放入 To(S1) 区

  • 接下来 S0 会变成 To 区,S1 变成 From 区,当 Eden 区满时再往里放入对象,依然会发生 MinorGC。此时会回收 Eden 区和 S1(From) 中的对象,并把 Eden 和 From 区中剩余的对象放入S0。

  • :每次 Minor GC 都会为对象记录年龄,初始值为 0,每次 GC 结束 +1。如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。

当老年代中空间不足,无法放入新的对象时,先尝试 Minor GC,如果还是不足,就会触发 Full GC,会对整个堆进行垃圾回收。如果 Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。

JDK8中,通过-XX:+UseSerialGC参数使用分代回收的垃圾回收器

arthas memory 查看不同区域的内存情况

image-20241030151410228

为什么分代GC算法要将堆分为年轻代和老年代

  • 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收

  • 老年代中会存放长期存活的对象,比如Spring的大部分bean对象

  • 在虚拟机的默认设置中,新生代大小要远小于老年代的大小
  • 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
  • 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法
  • 分代的设计中允许只回收新生代(Minor GC),如果能满足对象分配的要求就不需要对整个堆进行回收(Full GC),STW 时间就会减少。

4. 垃圾回收器

垃圾回收器是垃圾回收算法的具体实现。由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。

image-20241031155401504

4.1 年轻代-Serial垃圾回收器

image-20241031160723642

Serial是一种单线程串行回收年轻代的垃圾回收器

回收年代和算法:年轻代、复制算法

优点:单CPU下吞吐量(用户线程执行时间/总时间)很出色

缺点:多CPU下吞吐量低,堆如果偏大会让用户线程处于长时间的等待

适用场景:Java编写的客户端程序或者硬件配置有限的场景

4.2 老年代-SerialOld垃圾回收器

image-20241031160723642

SerialOld是Serial的老年代版本,采用单线程串行回收

-XX:+UseSerialGC 新生代、老年代都使用串行回收器

回收年代和算法:老年代、标记-整理算法

优点:单CPU下吞吐量很出色

缺点:多CPU下吞吐量低,堆如果偏大会让用户线程处于长时间的等待

适用场景:与 Serial 搭配使用或者在 CMS 特殊情况下使用

4.3 年轻代-ParNew垃圾回收器

image-20241031174552186

ParNew 垃圾回收器本质上是对 Serial 在多CPU下的优化,使用多线程进行垃圾回收

-XX:+UseParNewGc 新生代使用ParNew,老年代使用串行回收器回收器

回收年代和算法:年轻代、复制算法

优点:多CPU下停顿时间较短

缺点:吞吐量和停顿时间不如G1

适用场景:JDK8及之前版本中,与CMS老年代垃圾回收器搭配使用

4.4 老年代-CMS(Concurrent Mark Sweep)垃圾回收器

image-20241103205239028

CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。

-XX:+UseConcMarkSweepGC

回收年代和算法:老年代、标记-清除算法

优点:系统由于垃圾回收出现的停顿时间较短,用户体验好

缺点:

  • 内存碎片问题:CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。
    这样会导致用户线程暂停,可以使用 -XX:CMSFullGCsBeforecompaction=N 参数(默认0)调整 N 次Full GC之后再整理。
  • 浮动垃圾问题(并发清理过程中新产生的垃圾无法回收)
  • 退化问题(如果由于浮动垃圾导致老年代内存不足无法分配对象,CMS就会退化到Serial Old单线程回收老年代)

适用场景:大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等

CMS执行步骤

  1. 初始标记,用极短的时间标记出GC Roots能直接关联到的对象。
  2. 并发标记,标记所有的对象,用户线程不需要暂停。
  3. 重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记
  4. 并发清理,清理死亡的对象,用户线程不需要暂停

4.5 年轻代-Parallel Scavenge垃圾回收器

image-20241103211158816

Parallel Scavenge 是 JDK8 默认的年轻代垃圾回收器多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。

Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。

  • 最大暂停时间:-XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫秒
  • 吞吐量:-XX:GCTimeRatio=n 设置吞吐量为n(用户线程执行时间占 n/n+1)
  • 自动调整内存大小:-XX:+UseAdaptiveSizePolicy 设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

回收年代和算法:年轻代、复制算法

优点:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆参数

缺点:不能保证单次的停顿时间

适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。例如大数据的处理、大文件导出

4.6 老年代-Parallel Old垃圾回收器

image-20241103211158816

Parallel Old 是为 Parallel Scavenge 设计的老年代版本,利用多线程并发收集

-XX:+UseParallelGC-XX:+UseParallelOldGC 可以使用 PS + PO 组合

回收年代和算法:老年代、标记-整理算法

优点:并发收集,在多核CPU下效率较高

缺点:暂停时间会比较长

适用场景:与 PC 配套使用

4.6 G1垃圾回收器

JDK9 之后默认的垃圾回收器是 G1(Garbage First) 垃圾回收器。

-XX:+UseG1GC 打开G1,JDK9后默认不需要打开

-XX:MaxGCPauseMillis=毫秒值 最大暂停时间

回收年代和算法:年轻代+老年代、复制算法

优点:对比较大的堆如超过 6G 的堆回收时,延迟可控,不会产生内存碎片,并发标记的SATB算法效率高

缺点:JDK8之前还不成熟

适用场景:JDK8最新版本、JDK9之后建议默认使用

内存结构

G1 出现之前的垃圾回收器,内存结构一般是连续的(新生代、老年代)

而 G1 的整个堆会被划分成多个大小相等的区域,称之为区 Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过 堆空间大小/2048 计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m 指定,Region size 必须是 2 的指数幕,取值范围从 1M 到 32M

image-20241103214351776

垃圾回收

G1垃圾回收有两种方式

年轻代回收(Young GC)

回收 Eden 区和 Survivor 区中不用的对象。会导致 STW,G1中可以通过参数 -XX:MaxGCPauseMilis=n (默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间

执行流程:

  1. 新创建的对象会存放在Eden区。当 G1 判断年轻代区不足(堆内存占用达到60%),无法分配对象时需要回收时会执行Young GC
  2. 标记出Eden和Survivor区域中的存活对象,
  3. 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域
  4. 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
  5. 当某个存活对象的年龄到达阈值(默认15),将被放入老年代
  6. 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是 4G,每个Region是 2M,只要一个大对象超过了 1M 就被放入Humongous区,如果对象过大会跨多个Region。
  7. 多次回收之后,会出现很多 Old 区,此时总堆占有率达到阈值时(-XX:InitiatingHeap0ccupancyPercent,默认45%),会触发混合回收 MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。

G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。

比如 -XX:MaxGCPauseMiis=n (默认200),每个Region回收耗时40ms,那么这次回收最多只能回收 4 个Region。

混合回收(Mixed GC)

image-20241120163656921

  1. 初始标记(initial mark)
  2. 并发标记(concurrent mark)
  3. 最终标记(remark,Finalize Marking)
  4. 筛选回收(evacuation)

G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也G1(Garbage First)名称的由来。最后的清理阶段使用复制算法,不会产生内存碎片

注意: 如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。

5. 新一代的GC

image-20241114112350550

5.1 Shenandoah GC

Shenandoah 是由Red Hat开发的一款低延迟的垃圾收集器,Shenandoah 并发执行大部分 GC 工作,包括并发的整理,堆大小对STW的时间基本没有影响。

Shenandoah 只包含在 OpenJDK 中,需要单独构建,或者直接下载构建好的

-XX:+UseShenandoahGC 开启Shenandoah GC

-Xlog:gc 打印GC日志

5.2 ZGC

ZGC 是一种可扩展的低延迟垃圾回收器。ZGC在垃圾回收过程中,STW的时间不会超过一毫秒,适合需要低延迟的应用。支持几百兆到 16TB 的堆大小,堆大小对STW的时间基本没有影响。

ZGC降低了停顿时间,能降低接口的最大耗时,提升用户体验。但是吞吐量不佳,所以如果Java服务比较关注QPS(每秒的查询次数)那么G1是比较不错的选择。

使用方法

OracleJDK和OpenJDK中都支持ZGC,阿里的DragonWell龙井JDK也支持ZGC但属于其自行对OpenJDK11的ZGC进行优化的版本。建议使用JDK17之后的版本,延迟较低同时无需手动配置并行线程数。

  • 分代 ZGC添加如下参数启用-XX:+UseZGC -XX:+ZGenerational

  • 非分代 ZGC通过命令行选项启用 -XX:+UseZGC

ZGC在设计上做到了自适应,根据运行情况自动调整参数,让用户手动配置的参数最少化。

  • 自动设置年轻代大小,无需设置-Xmn参数。
  • 自动晋升阈值(复制中存活多少次才搬运到老年代),无需设置-XX:TenuringThreshold
  • JDK17之后支持自动的并行线程数,无需设置-XX:ConcGCThreads

参数设置

需要设置的参数:

-Xmx值: 这是ZGC最重要的一个参数,必须设置。ZGC在运行过程中会使用一部分内存用来处理垃圾回收,所以尽量保
证堆中有足够的空间。设置多少值取决于对象分配的速度,根据测试情况来决定。

可以设置的参数:

-XX:SoftMaxHeapSize=值: ZGC会尽量保证堆内存小于该值,这样在内存靠近这个值时会尽早地进行垃圾回收,但是依然有可能会超过该值。

调优

ZGC 中可以使用Linux的Huge Page大页技术优化性能,提升吞吐量、降低延迟。

注意:安装过程需要 root 权限,所以zGC默认没有开启此功能。

操作步骤:

  1. 计算所需页数,Linux x86架构中大页大小为2MB,根据所需堆内存的大小估算大页数量。比如堆空间需要16G,预留2G(JVM需要额外的一些非堆空间),那么页数就是18G/2MB=9216。
  2. 配置系统的大页池以具有所需的页数(需要root权限):
    $echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
  3. 添加参数 -XX:+UseLargePages 启动程序进行测试