JVM成神之路(4):垃圾回收篇

前言

进入垃圾收集篇,在堆中、方法区产生的对象,如果不用了,就需要JVM对其进行回收,基本概念可能都很清楚,本文就从垃圾收集算法讲起,并介绍几种常用的垃圾收集器,来进入 GC 篇的学习。

一、垃圾收集算法

1.1 概述

首先是对垃圾的概念,在 Java 官网中,对垃圾的定义为:"在运行的程序中,当一个对象没有任何指针指向它时,它就会被视为垃圾"。

如果对象不被清除,一直留在内存中,就可能造成内存溢出,当然垃圾回收也不仅仅是将垃圾直接删除,也涉及到碎片空间的整理,不然就会出现零零散散的空位。

垃圾回收功能,是JVM自带的,对于开发者来说,是一个黑盒子的存在,而学习这块内容,就是了解JVM是如何管理内存的,这样在后续调优解决异常问题的时候能够帮助自己定位问题所在。

Java 的 GC 主要作用于运行时数据区中的堆和方法区。

1.2 垃圾收集算法

垃圾回收可以分成两个阶段:

  • 标记阶段:判断什么才是垃圾

  • 清除阶段:如何清除垃圾。

1.2.1 标记阶段

如何判断对象是否是垃圾?

常用两种办法。

  • 引用计数法

  • 可达性分析法

这些基础概念读者应该都没忘记,这里为了知识的系统性,也简单提一提。

引用计数法:在对象中添加一个引用计数器,每当有一个对方引用它的时候,计数器值就加1,当引用失效的时候,计数器值就减1,任何时刻,计数器为0的对象就是不可能在被使用的。引用计数法最大的缺点就是循环引用问题,当A引用B,B引用A的时候,这两个对象就无法再回收。

可达性分析算法:这个算法的基本思路,都是采用 "GC Roots" 作为根节点出发,从这些节点开始,根据引用关系向下搜索,搜索过程所走的路径称为"引用链",如果某个对象到 GC Roots 间没有任何引用链相连,就代表这个对象是垃圾,可以回收。

作为根节点出发,GC Roots 的对象必须是一组活跃的引用,那么哪些对象的引用可以放到这个集合中呢?

标准答案里有7种,但我们最好能记住里面的4种吧 。

  • (重要) 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • (重要) 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。

    • eg:private static Student student;
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用。

  • (重要) Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象引用,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError)等,还有系统类加载器。

  • (重要) 所有被同步锁(synchronized)持有的对象。

  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调,本地代码缓存等。

  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。

如果要使用可达性分析算法来判断内存是否可回收,那这分析工作就需要在一个能保证一致性的快照中进行,不然分析的结果就容易出错,因此,垃圾回收是需要暂停程序。

问题:如果对象不可达,一定会被回收吗?

不一定的,因为 finalize 方法的存在,如果对象实现了 finalize 方法,并且在这个方法中又对垃圾对象进行了引用,那这个对象就又复活了,也就是说,判断一个对象是否可以回收有两个判断:

  • 1、从GC Roots 出发,判断这个对象是否可达?没有就是垃圾(判刑)

  • 2、判断这个对象是否有必须执行 finalized() 方法。如果实现了并且于引用链发生关系,那可以不被回收,这是垃圾对象逃脱死亡命令的最后一次机会啦。

1.2.2 清除阶段

目前的垃圾收集器,大多都采用分代收集思想进行垃圾回收,了解下两个假说:

  • 1)弱分代假说:绝大多数对象都朝生夕灭;
  • 2)强分代假说:经历过多次垃圾收集过程的对象就越难消亡。

因此有了这两个假说,就划分出两个区域,新生代和老年代,分别采用不同的收集算法去做。

1) 标记-清除算法:是一种非常基础和常见的垃圾收集算法,先标记出垃圾,然后直接回收。缺点就是容易造成内存空间的碎片化。

2)标记-复制算法:思想就是将内存空间分为两块,每次将活跃的对象复制到另一块区域,复制的同时也顺带整理下空间内存,然后将剩余的垃圾清除。缺点就是浪费了一半的内存。

3)标记-整理算法:思想就是在回收垃圾的时候,将所有存货对象都向内存空间的一端移动,避免内存碎片化的出现,是标记-清除算法的一种改良版本。

二、垃圾收集器

垃圾收集器就是内存回收时对收集算法的具体实现。

在JVM规范中,并没有对垃圾收集器做太多的规定,因此,诞生了很多种类型的收集器,但是,没有任何一种垃圾收集器能够满足所有需求,衡量它性能的好坏有几个指标,这几个指标甚至互相矛盾。

  • 吞吐量:用户程序执行时间占总运行时间的比例(用户时间/用户时间+垃圾收集时间)。
  • 停顿时间:单次GC发生期间的停顿时间。

高吞吐量的程序越高,则说明我们应用程序的占比特别大,用户几乎无法感知GC的存在;而停顿时间较高,则会打断用户程序进行,影响到用户体验。

应用程序无法同时满足高吞吐量和低停顿时间,

  • 如果选择以吞吐量优先,那么比如需要降低内存回收的执行频率,那么单次停顿时间更久;
  • 选择低停顿时间:频繁执行内存回收,降低程序的吞吐量。

根据收集器的工作内存区域可以划分为新生代垃圾收集和老年代垃圾收集。

下图的连线说明它们可以搭配起来使用,其中G1是整堆垃圾收集器。

2.1 Serial 收集器(串行)

Serial 收集器是从 JDK1.3之前的唯一选择,采用复制算法。

Serial 和 Serial Old 垃圾收集器是单线程的收集器。

它在垃圾收集时,必须暂停其他所有工作线程STW,直到它收集结束。

2.2 ParNew 收集器(并行回收)

与 Serial 类似,就是多线程回收垃圾。除了 Serial ,目前只有它能与 CMS 搭配使用。

2.3 Parallel Scavenge 收集器(吞吐量)

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器。

自适应调节策略 也是Parallel Scavenge与ParNew一个重要区别,Parallel Scavenge获取应用程序的运行情况收集系统的性能监控信息,动态调整参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为垃圾收集的自适应调节策略。

常常与 Parallel Old 配合使用。Parallel Old 支持多线程并发版本。

2.4 CMS (低延时)

CMS 是第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作。

CMS 注重低延时,减少停顿时间,CMS 收集器是基于标记-清除算法实现的,它的运作过程是更复杂一些,包括4个阶段。

(1)初始标记:这个阶段,会暂停所有工作线程,主要任务是标记出 GC Roots 能直接关联到的对象,一旦标记完成就恢复其他应用线程,这里速度很快。

(2)并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要暂停用户线程,并发工作。

(3)重新标记:由于在并发标记阶段中,工作线程和GC线程并发执行,所以这里又需要暂停用户线程,对导致标记产生变动的一部分对象进行修正,时间非常短。

(4)并发清除:用户线程和工作线程同时并发执行。

CMS 的缺点:

  • 会产生内存碎片,空间不足提前触发 Full GC

  • 对 CPU 资源敏感,在并发阶段,占用了一部分线程资源用于GC

  • 无法处理浮动垃圾,一些垃圾出现在标记完成后,就只能下次回收。

JDK9 中 CMS 被标记为过期,JDK14中删除了CMS垃圾收集器。而JDK8 默认的收集器是:ParallelGC + ParallelOldGC

2.5 Garbage First

G1 收集器出现,不管新生代和老年代,整堆回收,这就是实力。

开创了面向局部收集的设计思路和基于 Region 区域的内存布局形式。

它将 Java 堆划分成 2048 个相同大小的独立 Region 块,新生代和老年代成为一种逻辑隔离在每个区。

每个 Region 大小都是一样的,可以是1M-32M 之间的数值,但是必须保证是2的n次幂。

如果对象太大,一个Region 放不下【超过Region大小的50%】,那么就会直接放到 H 中

所谓的 G1,其实就是优先回收垃圾最多的 Region 区域。

1、分代收集(仍然保留了分代的概念)

2、空间整合(整体上属于"标记-整理"算法,不会导致空间碎片)

3、可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段,垃圾回收时间不会超过这个值)

G1 的主要工作可以概括为以下几个方面:

1、内存划分:Java 堆被划分为多个大小相等的 Region,这些 Region 可以是新生代的一部分,也可以是老年代的一部分。

2、对象分配:新创建的对象首先会被放置在 Eden 区,当 Eden 区满的时候,会触发 YGC

3、存活对象标记:在 Young GC 过程中,G1 会标记出 Eden 和 Survivor 区域中的存活对象

4、对象复制与区域清空:根据预设的最大暂停时间和其他配置参数,G1 选择某些区域,将存活对象复制到一个新的 Survivor 区域老年代,并清空这些区域。

5、混合回收:当老年代的使用率达到一定的阈值时,G1 会触发 Mixed GC,同时回收年轻代和老年代。

G1 的调优策略:

为了优化 G1 垃圾回收期的性能,可以采取以下调优策略:

1、设置 Region 大小;

  • 使用-XX:HeapRegionSize参数设置Region的大小。默认值为1MB,可以根据应用程序的内存占用量进行调整。

2、调整新生代大小:

  • 使用-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent参数设置新生代的最小值和最大值。默认分别为5%和60%,可以根据应用程序的内存使用情况进行调整。

3、设置最大暂停时间

  • 使用-XX:MaxGCPauseMillis参数设置G1垃圾回收器的最大暂停时间。默认值为200毫秒,可以根据应用程序的响应时间要求进行调整。

4、调整并发标记线程数:

  • 使用-XX:ConcGCThreads参数设置并发标记过程中的线程数。默认值为并行垃圾收集器线程数(ParallelGCThreads)的四分之一,但不超过8。可以根据机器的硬件配置和应用程序的负载情况进行调整。

5、触发混合回收的阈值:

  • 使用-XX:InitiatingHeapOccupancyPercent参数设置触发混合回收的老年代使用率阈值。默认值为45%,可以根据应用程序的内存使用情况进行调整。

三、OOM 异常与JVM 调优

OOM 异常相当于是最后一道屏障,绝大多数产生 OOM 问题都是代码的问题,直接调整代码就行了。

JVM 调优,我们更关心新生代和老年代的参数设定,由于我们垃圾收集次数太频繁,我们才会考虑对其优化。

我们的 JVM 调优目的就是为了让 GC 变的更加和谐。

JVM 经过这么多年的发展和验证,整体是非常健壮的,大多数情况下,遇到的都是自己代码 bug 导致的 OOM、CPU Load 高等异常情况,不需要动 JVM 参数。

3.1 调优案例1-OOM异常

背景:

由 ExecutorCompletionService 引起的 OOM 异常。

前置知识:

ExecutorCompletionService : 当我们需要获取每一个线程执行的返回值的时候,使用 ExecutorCompletionService

设置 JVM 参数

diff 复制代码
-Xms5m -Xmx5m

第一版: 原先的第一版代码如下:因为我们用到了 ExecutorCompletionService ,期待它的返回值,如果返回成功的话,我们就更新数据库。

csharp 复制代码
public class OomTest1 {
    static Executor executor = Executors.newFixedThreadPool(3);
    static CompletionService<String> service = new ExecutorCompletionService<>(executor);

    public static void test() throws InterruptedException, ExecutionException {
        service.submit(()->"success:+"+Thread.currentThread().getName());
        if(service.take().get().startsWith("success")){
            System.out.println("success");
            updateDB();
        }
    }

    private static void updateDB() {

    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        for (int i = 0; i < 50000; i++) {
            System.out.println("====="+i);
            test();
        }
    }
}

第二版: 随着业务发展,我们来到第二版代码,这版业务逻辑就是不需要返回值了,同时,也不需要更新数据库。

因此,我们就直接把 if 全部删掉,然后程序上线一段时间后,发现了 OOM 异常。

csharp 复制代码
public class OomTest1 {
    static Executor executor = Executors.newFixedThreadPool(3);
    static CompletionService<String> service = new ExecutorCompletionService<>(executor);

    public static void test() throws InterruptedException, ExecutionException {
        service.submit(()->"success:+"+Thread.currentThread().getName());
    }

    private static void updateDB() {

    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        for (int i = 0; i < 50000; i++) {
            System.out.println("====="+i);
            test();
        }
    }
}
php 复制代码
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
        at com.xiaolei.juc.base.OomTest1.main(OomTest1.java:28)

原因分析:

我们在使用 ExecutorService submit 提交任务后需要关注每个任务返回结果的时候,才会用到这个线程池。

正常情况下,我们调用它的 take、poll、poll 三个方法的时候,会把阻塞队列的 task 执行结果从队列中移除掉。

而第二版代码中,我们删掉了后面的 take 逻辑,以为万事大吉,结果,这个阻塞队列越来越大,直接造成 OOM 异常。

  • Submit 方法可以会把队列放入到线程池的阻塞队列中。

所以,我们别没事就使用这个,需要返回值的时候才用这个方法。

排查步骤:

  1. 可以通过参数dump堆内存快照

-XX:+HeapDumpOnOutOfMemoryError 发生OOM时,理解dump堆内存

-XX:HeapDumpPath=E:\Temp 堆内存快照保存路径

  1. 也可以通过 JPS 获取虚拟机进程ID, 通过 jmap 命令dump堆内存快照 jmap -dump:format=b,file=E:\Temp\dump.hprof 进程PID。
  • 内存比较大的,2g以上,建议使用这个
  1. 将dump下来的堆内存快照进行分析。

内存泄漏与内存溢出区别。

  • 内存泄漏:对象已经没有实际的使用意义,但是仍然有被引用,而无法被垃圾收集,导致内存泄漏。

  • 内存溢出:内存溢出实际上是内存不够的表现,内存泄漏最终会导致内存溢出,对象有实际的使用意义无法被回收导致内存不够使用的话,称之为内存溢出。

内存泄漏导致的常见原因:

  • 1、静态集合类引起的内存泄漏;

  • 2、数据库连接,网络连接和 io 连接,除非其显式的调用其 close 方法将其连接关闭,否则是不会自动被 GC 回收的。

  • 3、内部类持有外部类。如果一个外部类的实力对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。

3.2 调优案例2-堆内存溢出

数据转储服务导致的内存溢出。

大数据相关的工作人员。

diff 复制代码
-Xms10m -Xmx10m
csharp 复制代码
public class OomTest2 {
    public static void main(String[] args) {
        byte[] bytes = getData(); // 接收数据
processData(bytes); // 进行数据的处理和转储
}

    private static void processData(byte[] bytes) {

    }

    private static byte[] getData() {
        return new byte[10 * 1024 * 1024];
    }
}

接收数据,通过中间件进行监听接收(mq) 。

读取数据库大量数据

读取大文件也会出现 OOM 异常

从网络上加载大的数据包。

采取解决方案:

  • 1、可以通过中间件进行数据的缓冲

  • 2、可以分批处理

  • 3、增加我们的内存量(堆内存)--考虑越大的堆内存面临着时间停顿越长,垃圾量太多

3.3 调优案例3-Mybatis批量插入

在打印的时候,当入参为几十万的时候,会陷入循环中,导致虚拟机内存占用飙升,频繁 GC。

问题发现:

首先是发现 Feigin 请求超时,然后以为是网络问题,但是其他接口也不行。然后我就去查看了文件服务和子系统服务的日志,并没有发现有用的,再通过top -h发现的监控信息中发现子服务占用的资源非常多。然后就我们就初步认定是子服务有问题,最后通过服务重启的方式先止损。

然后我们通过 jstack pid 将 dump 文件导出后,通过 jvisualvm 进行进一步分析,发现某个线程一直处于执行状态,导致内存一直没有得到释放,然后定位到这个语句,发现是一个 insertbatch 操作,当遇到数量巨大的插入项,导致生成了巨大的sql语句以及数量众多的占位符对象。同时因为SQL过于巨大,MyBatis对SQL的解析也需要极长的时间,同时也占用了大量的CPU。这样就导致了其他请求的响应时间也变长,堆内存中的对象逐渐累积,导致了fullGC的发生,但fullGC在进一步抢占CPU的同时,又不能有效回收垃圾释放空间,导致频繁FullGC,系统彻底卡死。

然后我们进行了两步优化:

  • 1、对插入进行一定的拆分,将一次 insert 的语句插入控制在 500 左右,保证单次插入时间较短
  • 2、后期用了32 G 的内存来运行该服务,这样 JVM 堆就有 8G 的运行内存。

3.4 JVM调优经验

1、在大访问压力下,MinorGC 频繁、MinorGC 是针对新生代进行回收的,每次在 MGC 存活下的对象。会移动到 Survivor1 区,先到这里为止,MGC 频繁一些是正常的,只要MGC 延迟不导致停顿时间过长或者引发 FGC ,那可以适当的增大 Eden 空间大小,降低频繁程度,同时要保证,空间增大堆垃圾回收时间产生的停顿时间增长也是可以接受的。

如果 MGC 频繁,容易引发 Full GC,

  • 注意它的大小,如果新时代的对象大小非常容易进入老年代,那是非常容易出现问题的。

2、由于大对象创建频繁,导致 Full GC 频繁。

对于大对象,JVM 专门有参数进行控制, -XX:PretenureSizeThreshod.

超过这个参数值的对象,会直接进入老年代,只能等到 Full GC 进行回收,所以在系统压测过程中,要重点监测大对象的产生,如果能够优化对象大小,则进行代码层面的优化,优化如:

  • 根据业务需求看是否可以将该大对象设置为单例模式下的对象

  • 或者该大对象是否可以进行拆分使用,或者使用完设为 null

3、MGC 与 FGC 停顿时间长导致影响用户体验。其实对于停顿时间长的问题无非就两种情况:

  • a、gc 真实回收过程时间长,大部分是因为内存过大导致的

    • 减少堆内存大小,考虑将16G内存拆分4个4G的内存区域,单机部署或JVM逻辑集群
  • b、gc 真实回收时间并不长,但是用户态执行时间和 核心态执行时间长,导致从客户角度来看,停顿时间长

4、内存泄漏导致的 MGC 和 FGC

5、代码级别

JVM 调优无非一个目的,在系统可接受的情况下达到一个合理的 MGC 和 FGC 频率和可接受的回收时间。

相关推荐
步行cgn15 分钟前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su31 分钟前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
码事漫谈1 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780512 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123452 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒2 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记2 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598782 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端
雨中飘荡的记忆2 小时前
Claude Code 入门必会:@、!、Slash Commands,一篇讲清楚
人工智能·后端
苜蓿muxu2 小时前
Java 字符串拼接:+、StringBuilder、StringBuffer的区别
java