笔记 笔记
首页
  • 开发工具
  • Java Web
  • Java 进阶
  • 容器化技术
  • Java 专栏

    • Java 核心技术面试精讲
    • Java 业务开发常见错误 100 例
  • 数据库专栏

    • MySQL 实战 45 讲
    • Redis 核心技术与实战
  • 安全专栏

    • OAuth 2.0 实战课
  • 计算机系统
  • 程序设计语言
  • 数据结构
  • 知识产权
  • 数据库
  • 面向对象
  • UML
  • 设计模式
  • 操作系统
  • 结构化开发
  • 软件工程
  • 计算机网络
  • 上午题错题
在线工具 (opens new window)

EasT-Duan

Java 开发
首页
  • 开发工具
  • Java Web
  • Java 进阶
  • 容器化技术
  • Java 专栏

    • Java 核心技术面试精讲
    • Java 业务开发常见错误 100 例
  • 数据库专栏

    • MySQL 实战 45 讲
    • Redis 核心技术与实战
  • 安全专栏

    • OAuth 2.0 实战课
  • 计算机系统
  • 程序设计语言
  • 数据结构
  • 知识产权
  • 数据库
  • 面向对象
  • UML
  • 设计模式
  • 操作系统
  • 结构化开发
  • 软件工程
  • 计算机网络
  • 上午题错题
在线工具 (opens new window)

购买兑换码请添加

添加时候请写好备注,否则无法通过。

  • 设计模式

  • JVM 详解

    • JVM 与 Java 体系结构
    • 类加载子系统
    • 运行时数据区
    • 程序计数器
    • 虚拟机栈
    • 本地方法接口
    • 本地方法栈
    • 堆
    • 方法区
    • 对象的实例化内存布局与访问定位
    • 直接内存
    • 执行引擎
    • StringTable
    • 垃圾回收概述
    • 垃圾回收算法
      • 标记阶段:引用计数算法
        • 引用计数算法
        • 循环引用
        • 验证
      • 标记阶段:可达性分析算法
      • 对象的 finalization 机制
        • 生存还是死亡?
      • MAT 与 JProfiler 的 GC Roots 溯源
        • MAT 介绍
        • 生成 Dump 快照
        • 查看 Dump 快照
        • 查看 OOM 快照
      • 垃圾清除阶段
        • 清除阶段:标记-清除算法
        • 背景
        • 执行过程
        • 缺点
        • 清除阶段:复制算法
        • 背景
        • 执行过程
        • 优点
        • 缺点
        • 复制算法的应用场景
        • 清除阶段:标记-压缩算法
        • 背景
        • 执行过程
        • 与标记-清除算法的比较
        • 优点
        • 缺点
      • 小结
      • 分代收集算法
      • 增量收集算法、分区算法
    • 垃圾回收概念
    • 垃圾回收器
  • Linux

  • Redis

  • 分布式锁

  • Shiro

  • Gradle

  • Java 进阶
  • JVM 详解
East 东
2024-04-18
目录

垃圾回收算法

欢迎来到我的 ChatGPT 中转站,极具性价比,为付费不方便的朋友提供便利,有需求的可以添加左侧 QQ 二维码,另外,邀请新用户能获取余额哦!最后说一句,那啥:请自觉遵守《生成式人工智能服务管理暂行办法》。

# 标记阶段:引用计数算法

垃圾标记阶段:主要是为了判断对象是否存活

  1. 在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
  2. 那么在 JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
  3. 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。

# 引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。

  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。

  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。

# 循环引用

Java 的垃圾回收器并没有使用引用计数算法。虽然引用计数算法实现简单,判定效率高,但是它有一个严重的问题,即无法处理循环引用的情况。

当 p 的指针断开的时候,内部的引用形成一个循环,计数器都还算 1,无法被回收,这就是循环引用,从而造成内存泄漏。

# 验证

通过下面的代码运行结果,可以看到 JDK 没有使用引用计数算法。

/**
 * -XX:+PrintGCDetails
 * 证明:java 使用的不是引用计数算法
 */
public class RefCountGC {
    Object reference = null;
    //这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;
        // 先注释下面这行代码,然后再放开,对比前后结果
        System.gc();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//注释后结果,18104K,因为没有出现垃圾回收
Heap
 PSYoungGen      total 114176K, used 18104K [0x0000000741500000, 0x0000000749400000, 0x00000007c0000000)
  eden space 98304K, 18% used [0x0000000741500000,0x00000007426ae318,0x0000000747500000)
  from space 15872K, 0% used [0x0000000748480000,0x0000000748480000,0x0000000749400000)
  to   space 15872K, 0% used [0x0000000747500000,0x0000000747500000,0x0000000748480000)
 ParOldGen       total 261120K, used 0K [0x0000000643e00000, 0x0000000653d00000, 0x0000000741500000)
  object space 261120K, 0% used [0x0000000643e00000,0x0000000643e00000,0x0000000653d00000)
 Metaspace       used 3265K, capacity 4564K, committed 4864K, reserved 1056768K
  class space    used 354K, capacity 388K, committed 512K, reserved 1048576K
1
2
3
4
5
6
7
8
9
10
//手动执行 gc,2949K,已经出现了垃圾回收
[GC (System.gc()) [PSYoungGen: 16138K->808K(114176K)] 16138K->816K(375296K), 0.0008425 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 808K->0K(114176K)] [ParOldGen: 8K->619K(261120K)] 816K->619K(375296K), [Metaspace: 3265K->3265K(1056768K)], 0.0047771 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 114176K, used 2949K [0x0000000741500000, 0x0000000749400000, 0x00000007c0000000)
  eden space 98304K, 3% used [0x0000000741500000,0x00000007417e1608,0x0000000747500000)
  from space 15872K, 0% used [0x0000000747500000,0x0000000747500000,0x0000000748480000)
  to   space 15872K, 0% used [0x0000000748480000,0x0000000748480000,0x0000000749400000)
 ParOldGen       total 261120K, used 619K [0x0000000643e00000, 0x0000000653d00000, 0x0000000741500000)
  object space 261120K, 0% used [0x0000000643e00000,0x0000000643e9ad10,0x0000000653d00000)
 Metaspace       used 3286K, capacity 4564K, committed 4864K, reserved 1056768K
  class space    used 355K, capacity 388K, committed 512K, reserved 1048576K
1
2
3
4
5
6
7
8
9
10
11
12

# 标记阶段:可达性分析算法

也可以称为根搜索算法、追踪性垃圾收集。

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

相较于引用计数算法,这里的可达性分析就是 Java、C# 选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)。

可达性分析算法是 Java 中垃圾回收的主要算法之一。这种算法的主要思想是通过一系列的称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

笔记

可作为 GC Roots 的对象包括以下几种:

  1. 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  2. 本地方法栈内 JNI(通常说的本地方法)引用的对象。
  3. 方法区中类静态属性引用的对象。
    • 比如:Java 类的引用类型静态变量。
  4. 方法区中常量引用的对象。
    • 比如:字符串常量池(StringTable)里的引用。
  5. 所有被同步锁 synchronized 持有的对象。
  6. Java 虚拟机内部的引用。
    • 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),系统类加载器。
  7. 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

这种算法的主要优点是高效并且能解决循环引用的问题。但是,它的主要缺点是需要进行大量的引用链判断,如果项目中的引用关系较复杂,GC Roots 到对象之间的引用链就可能会比较长,这样就会导致效率降低。

在实际的垃圾回收过程中,标记和清除两个阶段的效率都很重要。如果标记阶段效率不高,会直接导致垃圾收集的总体效率低下。如果清除阶段效率不高,可能会导致内存中存在大量的垃圾对象,从而影响程序的运行效率。因此,Java 的垃圾回收器通常会结合使用几种算法,以达到在各种情况下都能有较好效率的目的。

总结一句话就是,除了堆空间的周边,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的都可以作为 GC Roots 进行可达性分析。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(PartialGC)。

如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GC Roots 集合中去考虑,才能保证可达性分析的准确性。

由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。

注意

  1. 如果使用可达性分析算法来判断内存是否可以被回收,那么这个分析过程必须在一个能够保证一致性的快照中进行。"一致性的快照" 就是指在进行分析的时候,堆内存的状况不能发生改变,即所有对堆内存的操作都必须暂停,只有这样才能确保分析结果的准确性。如果在执行可达性分析的同时,堆中对象的引用关系还在发生变化(比如新对象的创建,旧对象引用的更新等),那么就可能导致分析结果的不准确。
  2. 为了保证堆内存一致性,JVM 在执行垃圾收集时,必须停止应用程序的运行,也就是所有用户线程必须暂停,我们通常把这个阶段称为 "Stop The World"(STW)。即使是对暂停时间优化得很好的 CMS 垃圾收集器,它在执行 “枚举根节点” 这个步骤时(也就是确定 GC Roots 的过程),也必须要暂停应用,这是为了避免在确定 GC Roots 时因对象引用关系的变化导致的分析错误。简单说,STW 是为了保证内存回收的准确性而必须进行的一个步骤。

# 对象的 finalization 机制

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize () 方法。

finalize () 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

注意

永远不要主动调用某个对象的 finalize () 方法,应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize () 时可能会导致对象复活。
  • finalize () 方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize () 方法将没有执行机会。
  • 一个糟糕的 finalize () 会严重影响 GC 的性能。比如 finalize 是个死循环。

# 生存还是死亡?

由于 finalize () 方法的存在,虚拟机中的对象一般处于三种可能的状态。

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是 “非死不可” 的,这时候它们暂时处于 “缓刑” 阶段。一个无法触及的对象有可能在某一个条件下 “复活” 自己,如果这样,那么对它立即进行回收就是不合理的。为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize () 中复活。
  • 不可触及的:对象的 finalize () 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize () 只会被调用一次。

以上 3 种状态中,是由于 finalize () 方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行 finalize () 方法
    • 如果对象 objA 没有重写 finalize () 方法,或者 finalize () 方法已经被虚拟机调用过,则虚拟机视为 “没有必要执行”,objA 被判定为不可触及的。
    • 如果对象 objA 重写了 finalize () 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize () 方法执行。
    • finalize () 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize () 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出 “即将回收” 集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize () 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize () 方法只会被调用一次。
点击查看
/**
 * 测试 Object 类中 finalize()方法,即对象的 finalization 机制。
 */
public class CanReliveObj {
    public static CanReliveObj obj;//类变量,属于 GC Root

    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            // 对象第一次成功拯救自己
            obj = null;
            System.gc();//调用垃圾回收器
            System.out.println("第 1 次 gc");
            // 因为 Finalizer 线程优先级很低,暂停 2 秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
            System.out.println("第 2 次 gc");
            // 下面这段代码与上面的完全相同,但是这次自救却失败了
            obj = null;
            System.gc();
            // 因为 Finalizer 线程优先级很低,暂停 2 秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    //此方法只能被调用一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的 finalize()方法");
        obj = this;//当前待回收的对象在 finalize()方法中与引用链上的一个对象 obj 建立了联系
    }
}

第 1 次 gc
调用当前类重写的 finalize()方法
obj is still alive // 通过 finalize 复活了 obj
第 2 次 gc			
obj is dead		// 第二次 GC 后对象直接变成不可触及状态,也证明了 finalize 方法只能调用一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# MAT 与 JProfiler 的 GC Roots 溯源

# MAT 介绍

MAT 是 Memory Analyzer 的简称,它是一款功能强大的 Java 堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

MAT 是基于 Eclipse 开发的,是一款免费的性能分析工具。下载地址 (opens new window)。JDK8 的最后一个适用版本为 1.11.0。

# 生成 Dump 快照

方式一:通过命令行的方式

# 获取到当前进程的 ID
jps

# 比如进程的 ID 为 21076
jmap -dump:format=b,live,file=test1.bin 21076

## 存储堆快照的命令
jmap -dump:format=b,file=<filename> <pid> ## 加上 live 是只有活动对象(即还没有被垃圾收集器回收的对象)被转储到堆转储文件中。
1
2
3
4
5
6
7
8

方式二:通过 jvisualvm 工具

然后找到新生成的 heapdump 文件 —> 右键 —> 另存为。

# 查看 Dump 快照

/**
 * 需要生成两个快照,一个是程序启动的时候,一个是执行完 System.out.println("数据添加完毕,请操作:");之后
 */
public class GCRootsTest {
    public static void main(String[] args) {
        List<Object> numList = new ArrayList<>();
        Date birth = new Date();

        for (int i = 0; i < 100; i++) {
            numList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("数据添加完毕,请操作:");
        new Scanner(System.in).next();
        numList = null;
        birth = null;

        System.out.println("numList、birth 已置空,请操作:");
        new Scanner(System.in).next();

        System.out.println("结束");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

MAT 工具查看

JProfiler 工具查看

在实际开发中,我们很少会查看所有的 GC Roots。一般都是查看某一个或几个对象的 GC Root 是哪个,这个过程叫 GC Roots 溯源。

# 查看 OOM 快照

/**
 * -XX:+HeapDumpOnOutOfMemoryError 指的是如果发生了 OOM 就会在当前项目目录下生成一个 dump 文件
 * -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    byte[] buffer = new byte[1 * 1024 * 1024];//1MB

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();

        int count = 0;
        try {
            while (true) {
                list.add(new HeapOOM());
                count++;
            }
        } catch (Throwable e) {
            System.out.println("count = " + count);
            e.printStackTrace();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

通过 JProfiler 工具打开这个日志文件,确实在最大对象中存在一个 ArrayList 对象,在 main 方法中发生了 OOM 了。

# 垃圾清除阶段

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在 JVM 中比较常见的三种垃圾收集算法是。

  • 标记 - 清除算法(Mark-Sweep)
  • 复制算法(Copying)
  • 标记 - 压缩算法(Mark-Compact)

# 清除阶段:标记 - 清除算法

# 背景

标记 - 清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。

# 执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记阶段:在这个阶段,算法会遍历所有的对象,检查每个对象是否可以从根对象(通常是全局变量或者栈上的变量)直接或间接地访问到。如果可以访问到,那么这个对象就被标记为 “活动的” 或者 “可达的”。
  • 清除阶段:在这个阶段,算法会再次遍历所有的对象,对于那些没有被标记为 “活动的” 或者 “可达的” 对象,就会被清除,也就是说,它们所占用的内存会被回收。

# 缺点

  • 标记清除算法的效率不算高。
  • 在进行 GC 的时候,需要停止整个应用程序,用户体验较差。
  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表。

注意

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。

笔记

关于空闲列表是在为对象分配内存的时候提过,跳转。

  • 如果内存规整:采用指针碰撞的方式进行内存分配。
  • 如果内存不规整:虚拟机需要维护一个空闲列表:采用空闲列表分配内存。

# 清除阶段:复制算法

# 背景

为了解决标记 - 清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。

# 执行过程

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

# 优点

  • 没有标记和清除过程,实现简单,运行高效。
  • 复制过去以后保证空间的连续性,不会出现 “碎片” 问题。

# 缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。

# 复制算法的应用场景

  1. 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,效率较高。
  2. 老年代大量的对象存活,那么复制的对象将会有很多,效率会很低。
  3. 在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
    • 大部分对象生命周期短:许多对象在创建后很快就变得无用,这是因为它们只在程序的一个小范围或短时间内使用。这种现象被称为 “弱代假说” 或 “朝生夕死”。
    • 复制算法可以快速回收大量对象:复制算法通过将活动对象复制到新区域,然后一次性回收旧区域,可以快速回收大量无用对象。这使得复制算法非常适合于新生代,因为新生代中的对象通常有很高的死亡率。

# 清除阶段:标记 - 压缩算法

# 背景

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记 - 清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记 - 压缩(Mark-Compact)算法由此诞生。

1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记 - 压缩算法。在许多现代的垃圾收集器中,人们都使用了标记 - 压缩算法或其改进版本。

# 执行过程

标记阶段:在此阶段,算法遍历所有的存活对象并进行标记。

压缩阶段:在此阶段,算法将所有标记的存活对象向内存区的一端移动,并将未标记的对象(即垃圾对象)空出来的内存空间合并。

# 与标记 - 清除算法的比较

  1. 标记 - 压缩算法的最终效果等同于标记 - 清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记 - 清除 - 压缩(Mark-Sweep-Compact)算法。
  2. 二者的本质差异在于标记 - 清除算法是一种非移动式的回收算法,标记 - 压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
  3. 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

# 优点

  1. 消除了标记 - 清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  2. 消除了复制算法当中,内存减半的高额代价。

# 缺点

  1. 从效率上来说,标记 - 整理算法要低于复制算法。

  2. 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址(因为 HotSpot 虚拟机采用的不是句柄池的方式,而是直接指针)。

    当对象被移动时,如果这个对象被其他对象引用,那么这些引用的地址也需要相应地进行调整,以指向对象新的内存地址。这是因为在 HotSpot 虚拟机中,对象引用是通过直接指针来实现的,也就是说,对象引用直接存储了对象的内存地址。因此,当对象的内存地址改变时,所有引用该对象的指针也必须被更新为新的地址。

    这与句柄池的方式不同。在句柄池方式中,对象引用是通过一个间接的句柄(Handle)来实现的。句柄中包含了对象的内存地址信息。因此,即使对象的内存地址改变,只需要更新句柄中的地址信息,而不需要更新所有引用该对象的指针。

  3. 移动过程中,需要全程暂停用户应用程序。即:STW。

# 小结

标记清除 标记整理 复制
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的 2 倍空间(不堆积碎片)
移动对象 否 是 是

# 分代收集算法

没有最好的算法,只有最合适的算法,分代收集算法是基于这样一个观察结果:不同的对象的生命周期长度差异很大。具体来说,大部分对象的生命周期都非常短,即 “朝生夕死”,只有少部分对象会存活较长时间。

基于这个观察结果,我们可以将 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点选择最适合的垃圾收集算法。

分代收集算法的好处是:新生代因为每次都只处理少量的存活对象,所以复制算法非常高效;老年代的回收频率也比较低,所以可以承受更高成本的收集算法,如标记 - 清除或标记 - 压缩。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关:

  • 比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。
  • 但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

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

年轻代(Young Gen)

  • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
  • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。

老年代(Tenured Gen)

  • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
  • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记 - 清除或者是标记 - 清除与标记 - 整理的混合实现。
    • Mark 阶段的开销与存活对象的数量成正比。
    • Sweep 阶段的开销与所管理区域的大小成正相关。
    • Compact 阶段的开销与存活对象的数据成正比。
  1. 以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。
  2. 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

# 增量收集算法、分区算法

增量收集算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。

增量收集算法基本思想

  1. 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
  2. 总的来说,增量收集算法的基础仍是传统的标记 - 清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

分区算法

注意

分区算法只针对 G1。

一般来说,在相同条件下,堆空间越大,一次 GC 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

两者的区别

增量收集算法(Incremental Collecting)和分区算法(Regional Collecting)虽然在某些方面有相似之处,但它们在设计理念和实现细节上还是有所不同的。

增量收集算法的主要思想是将整个 Java 堆的垃圾收集工作分为多个小部分分别进行,每次只对堆的一部分进行垃圾收集,这样可以避免在进行垃圾收集时需要暂停整个应用程序,从而减少了垃圾收集对应用程序的影响。这种方式的主要问题是需要维护和处理跨区域的引用关系,增加了算法的复杂性。

分区算法则是将整个 Java 堆划分为多个独立的小堆,每个小堆都独立使用,可以独立进行垃圾收集。这种方式的主要优点是可以做到更细粒度的内存管理,可以根据每个小堆的特性选择最合适的垃圾收集算法,例如新生代和老年代就可以视为不同的区域。这种方式的主要问题是内存的划分可能会导致一些内存的浪费,并且也需要处理跨区域的引用关系。

#JVM
上次更新: 2025/04/12, 05:37:39
垃圾回收概述
垃圾回收概念

← 垃圾回收概述 垃圾回收概念→

最近更新
01
Reactor 核心
02-24
02
前置条件
10-30
03
计算机网络
09-13
更多文章>
Theme by Vdoing | Copyright © 2019-2025 powered by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式