Skip to content

垃圾回收篇

什么是垃圾

垃圾是指在运行程序中没有任何指针指向的对象

垃圾回收重点区域

GC 只会存在与方法区与堆中,重点是堆,频繁 GC 的是堆区的新生代

垃圾判别阶段算法

引用计数算法

原理

对于一个对象 A ,只有有任何一个对象引用了 A ,则引用计数器加一,否则减一,当这个值为 0 的时候,这个对象就可以被回收

优点

实现简单,垃圾对象便于标识;判定效率高,回收没有延迟

缺点

引用计数器有一个严重的问题,无法处理循环引用的情况,这是致命缺陷。因此 JVM 没有选择这种算法。Python 支持引用计数算法,采用手动解除和弱引用的方式解决循环引用问题

循环引用.png

可达性分析算法

原理

Java / C# 选择可达性分析算法,以根对象集合 (GC Roots) 作为起始点,按照从上到下的方式搜索被根对象集合所链接的目标对象是否可达,如果目标对象不可达,则目标对象可以回收

可达性分析算法原理.png

优点

简单高效,解决循环引用问题

GC Roots 有哪些呢?

  • 虚拟机栈中引用的对象,比如各个线程被调用的方法中使用到的参数、局部变量等

  • 本地方法栈(本地方法)引用的一些对象

  • 方法区中常量引用的对象,比如字符串常量池里的引用

  • 所有被同步锁 synchronized 持有的引用

  • JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些常驻的异常对象 (NullPointerException),系统类加载器

  • 反映 JVM 内部情况的 JMXBean 、 本地代码缓存等

  • 除了以上固定的 GC Roots 以外,根据用户选择的垃圾收集器以及回收区域的不同,有其他对象“临时性”地加入这个集合中

注意

分析工作必须在一个能保障一致性地快照中进行,才能保证分析结果的准确性,因此必须进行 "Stop the world" ,枚举根节点时必须停顿

垃圾清除阶段算法

标记清除算法

将与根节点连接的对象标记出来,将未标记的对象回收。注意标记的是可达的对象,而不是垃圾!

标记清除算法.png

缺点

  • 效率比较低,递归全堆对象需要遍历两次
  • 在进行 GC 的时候,需要停止整个应用程序,导致用户体验差
  • 这种方法清理出来的内存空间不是连续的,产生内存碎片

复制算法

将活着的对象全部复制到另一个区域,然后将之前的所有对象进行回收

复制算法.png

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,避免内存碎片

缺点

  • 需要两倍的内存空间
  • 对于 G1 这种分拆未大量 region 的 GC ,复制而不是移动,意味着 GC 需要维护他们之间的对象引用关系,增加开销
  • 特别是,如果系统中的存活对象很多,复制算法的效率也不会很理想

标记-压缩算法

标记压缩算法.png

优点

  • 没有标记清除算法中的内存碎片化问题
  • 没有复制算法中的两倍内存问题

缺点

  • 效率低,比复制算法效率低,不仅要标记所有存活对象,还要整理所有存活对象的引用地址
  • 移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序

分代收集算法

每种算法有不同的优缺点,可以根据不同生命周期对象采用不同的收集算法,以便提高回收效率。

垃圾清除算法对比.png

目前几乎所有的 GC 都是采用分代收集算法执行垃圾回收的。

  • 新生代 : 区域相对老年代较小,对象生命周期短,存活率低,回收频繁,使用复制算法速度最快
  • 老年代 : 区域较大,对象生命周期长,存活率高,一般采用标记-清除或标记-清除与标记-压缩的混合实现

System.gc()

System.gc() 方法并不是一个强提示,JVM 不会立刻执行垃圾回收,而是将请求委托给垃圾回收器,由垃圾回收器决定何时执行垃圾回收。

内存泄露与内存溢出

内存溢出

内存不够的原因

  • JVM 的堆内存设置不够
  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集

OOM 前必有 GC ?

在正常情况下,在抛出 OutOfMemoryError 之前,垃圾收集器都会被触发,尽其所能去清理空间;但极端情况下,比如创建一个超大数组,超出了堆的内存空间最大值,就不会GC,直接抛出异常。

内存泄露

内存泄露就是指,本来应该回收的内存空间,但却一直无法被回收,有8种情况:

1、静态集合类

静态集合类的生命周期与 JVM 程序一致,则容器中的对象在程序结束之前将不能被释放,造成内存泄露。

简而言之,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而不能被回收。

java
public class MemoryLeak {
    static List list = new ArrayList();
    
    public void oomTests() {
        Object obj = new Object(); //局部变量
        list.add(obj);
    }
    
}

2、单例模式

单例模式和静态集合导致内存泄露的原因类型,因为单例的静态特性,它的生命周期也和JVM程序一致,所以如果单例对象持有外部对象的引用,这个外部对象将不能被回收

3、内部类持有外部类

内部类持有外部类的引用,外部类对象将不能被回收,从而导致内存泄露。

4、各种连接未及时关闭

数据库连接、网络连接或IO连接等,只有连接被关闭后, GC 才会回收相应地对象,如果没有显性关闭连接,会造成大量对象无法被回收,导致内存泄露

5、变量不合理的作用域

java
public class UsingRandom {
    private String msg; //不合理的作用域
    
    public void receiveMsg() {
        //private String msg; //合理的作用域
        readFromNet(); //从网络中读取数据
        saveDB(); //保存到数据库
        //msg = null; //由于 msg 不合理的作用域,这里需要显性置空,否则 msg 将一直被引用,无法被回收
    }
}

6、改变哈希值

java
public class ChangeHashCode {

    public static void main(String[] args) {
        HashSet<Person> set = new HashSet<>();
        Person aa = new Person(1001, "AA");
        Person bb = new Person(1002, "BB");

        set.add(aa);
        set.add(bb);
        aa.name = "CC";
        set.remove(aa); //重写了 HashCode,再改变移除不掉 aa
        set.forEach(System.out::println);  // aa bb
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Data
    static class Person {
        int id;
        String name;
    }

}

7、缓存泄露

一旦你把对象引用放到缓存中,遗忘了清理,那么这个对象就永远不能被回收。

可以使用 WeakHashMap 代表缓存,它的特点是,当除了自身有对 key 的引用外,此 key 没有其他引用,那么此 map 会自动丢弃此值

8、监听器和回调

内存泄露的另一个常见来源是监听器和其他回调,如果客户端在你实现的 API 中注册回调,却没有显式地取消,那么就会积聚。

需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为 WeakHashMap 的键

  • 强引用:不回收
java
Object obj = new Object(); //声明强引用
  • 软引用:内存不足时回收;软引用通常用来实现内存敏感的缓存,比如:高速缓存
java
//声明软引用
SoftReference<Object> softReference = new SoftReference<>(new Object());
  • 弱引用:发现即回收
java
//使用 WeakHashMap 来实现弱引用
WeakHashMap weakHashMap = new WeakHashMap();
  • 虚引用:对象回收跟踪

是所有引用类型中最弱的一个,一个对象是否有虚引用的存在,完全不会决定对象的生命周期。

  • 终结器引用

GC 评估指标

  • 吞吐量: 运行用户代码时间/(运行用户代码时间 + GC 时间)
  • 暂停时间: 执行垃圾收集时,程序的工作线程被暂停的时间
  • 内存占用: Java 堆区所占的内存大小

以上三项构成一个 “不可能三角”,这三个指标最多只能尽可能的取舍,最多同时保证其中两项指标尽可能优秀。

随着硬件发展,内存占用成了这三者中最能容忍的指标。尽可能减少 GC 停顿,提高吞吐量,从而提高程序性能。

垃圾回收器都有哪些?

  • Parallel GC 在 JDK6 之后称为 HotSpot 默认 GC
  • JDK9 中 G1 成为默认的垃圾收集器,以替代 CMS
  • JDK11 中,引入 ZGC,可伸缩的低延迟垃圾回收器

7种经典垃圾收集器.png

如何查看默认 GC ?

  • -XX:+PrintCommandLineFlags: 查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令 : jinfo -flag 相关垃圾回收器参数 进程ID

SerialGC

采用复制算法、串行回收和 “Stop-the-World” 的机制执行 GC。简单高效,适用于单核 CPU 场景下,桌面小内存应用选择。

Serial Old GCSerial GC采取的算法有所不同,它采用标记-压缩算法

ParNew GC

ParNewSerial GC 的改进版本,它采用并行复制算法,并行收集垃圾,从而提高吞吐量。是许多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

Parallel GC

Parallel GC 主打吞吐量,采用复制算法,并行回收和 “Stop-the-World” 的机制执行 GC。

Parallel Old GC 采用标记-压缩算法,并行回收,用来替代 CMS GC

CMS GC

CMS GC 主要关注低延迟,它采用标记-清除算法,采用 “Stop-the-World” 的机制执行 GC。

  • 初始标记(STW):暂停时间非常短,仅仅标记 GC Roots 直接关联的对象
  • 并发标记: 最耗时,从 GC Roots 开始遍历整个对象图的过程,不会停顿用户线程
  • 重新标记(STW):修复并发标记环节,因为用户线程执行导致的数据不一致性问题

优点

  • 并发收集
  • 低延迟

缺点

  • 会产生内存碎片,并发清除后,用户线程的可用空间不足,无法分配大对象的情况下,不得不提前触发 Full GC
  • CMS 收集器对 CPU 资源非常敏感,在并发阶段虽然不会导致用户停顿,但会降低吞吐量
  • CMS 收集器无法处理浮动垃圾,可能出现 Concurrent Mode Failure 而导致另一次 Full GC

G1 GC

G1 (Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐两的性能特征。

jdk9 之后的默认垃圾回收器,在 jdk8 中需要使用 -XX:+UseG1GC 来启用。

G1 将内存划分为一个个的 region。内存的回收是以 region 为基本单位的,region 之间是复制算法,但整体上实际可看作是标记-压缩算法。

G1 回收过程

  • Eden 区空间耗尽时,进行新生代回收,回收过程包括
    • 扫描根:根指 static 变量指向的对象,正在执行的方法调用链上的局部变量等
    • 更新 Rset :保证 Rset 可用准确的反映老年代对所在的内存分段中对象的引用
    • 处理 Rset :识别被老年代对象指向的 Eden 中的对象,这些对象被认为是存活的对象
    • 复制对象
    • 处理引用

G1回收过程1.png

  • 并发标记过程

    • 初始标记阶段:标记从根节点直接可达的对象,这个阶段是 STW 的,并且会触发一次年轻代 GC
    • 根区域扫描:G1 扫描 Survivor 区直接可达的老年代区域对象,并标记被引用的对象
    • 并发标记:在整个堆中进行并发标记,此过程可能被 young GC 中断。
    • 再次标记:由于应用程序持续进行,需要修正上一次的标记结果,是 STW 的
    • 独占清理 (STW)
    • 并发清理
  • 混合回收 : 当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发 Mixed GC,会回收整个 Young Region ,还会回收一部分 Old Region

G1混合回收.png

GC 日志分析

  • -XX:+PrintGC 打印 GC 日志,注意在 jdk21 中这个参数已经过时,需要使用 -Xlog:gc
  • -XX:+PrintGCDetails 打印详细 GC 日志,注意在 jdk21 中这个参数已经过时,需要使用 -Xlog:gc*
java
-Xlog:gc=info // 指定以 info 级别记录 GC 日志
:file=gc.log // 指定日志文件的路径
:time,uptime,pid,tid,level // 添加时间、进程ID、线程ID、日志级别等装饰器信息
:filecount=10,filesize=10M // 日志文件最大数量为 10个,单个日志文件最大大小为 10M

GC 日志分析工具

推荐使用 GC easy : https://gceasy.io/