MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java并发标记清理回收详解

2022-03-277.5k 阅读

Java 垃圾回收机制概述

在深入探讨并发标记清理回收(Concurrent Mark - Sweep,CMS)之前,我们先来了解一下 Java 垃圾回收机制的整体背景。Java 作为一种自动管理内存的编程语言,垃圾回收(Garbage Collection,GC)是其重要特性之一。GC 负责在程序运行过程中自动回收不再被使用的内存空间,避免了手动管理内存可能出现的内存泄漏和悬空指针等问题。

Java 的垃圾回收器有多种类型,每种垃圾回收器都有其适用场景和特点。不同的垃圾回收器在回收算法、回收阶段以及对应用程序的影响等方面存在差异。常见的垃圾回收器包括 Serial GC、Parallel GC、CMS GC、G1 GC 等。而 CMS 是一种以获取最短停顿时间为目标的垃圾回收器,特别适用于注重响应时间的应用程序,如 Web 应用服务器。

标记 - 清理算法基础

标记 - 清理(Mark - Sweep)算法是 CMS 垃圾回收器的核心算法基础。它分为两个主要阶段:标记阶段和清理阶段。

标记阶段

在标记阶段,垃圾回收器会从一组被称为“根对象”(Root Objects)的对象开始遍历。根对象通常包括栈上的局部变量、静态变量、JNI 引用等。从这些根对象出发,垃圾回收器会标记出所有可以被访问到的对象,这些对象被认为是“存活”的。而那些没有被标记的对象则被认为是“垃圾”,即不再被程序使用,可以被回收的对象。

例如,考虑以下简单的 Java 代码:

public class MarkSweepExample {
    public static void main(String[] args) {
        Object obj1 = new Object();
        Object obj2 = new Object();
        obj1 = null;
        // 此时 obj1 所指向的对象成为垃圾
        // 垃圾回收器在标记阶段会标记 obj2 为存活对象,而 obj1 所指对象未被标记
    }
}

清理阶段

在标记阶段完成后,清理阶段开始。垃圾回收器会遍历整个堆内存空间,回收所有未被标记的对象所占用的内存空间。它会将这些空闲的内存空间重新标记为可用,以便后续分配新的对象。

标记 - 清理算法虽然简单直接,但存在一些缺点。其中一个主要问题是在清理过程中会产生内存碎片。由于回收的内存空间可能是不连续的,随着时间的推移,堆内存中会出现许多小块的空闲内存,这可能导致在分配大对象时,即使总的空闲内存足够,但由于没有连续的大块内存,而无法分配成功。

CMS 垃圾回收器的特点与目标

CMS 垃圾回收器具有以下几个显著特点和目标:

  1. 并发执行:CMS 垃圾回收器的主要目标是尽量减少垃圾回收过程中应用程序的停顿时间。为了实现这一目标,它在标记和清理阶段尽量与应用程序并发执行。这意味着在垃圾回收过程中,应用程序可以继续运行,只有在某些特定的短暂阶段(如初始标记和重新标记)才会暂停应用程序。
  2. 低停顿:通过并发执行,CMS 旨在提供低停顿时间,使得应用程序能够在垃圾回收期间保持较好的响应性。这对于交互式应用程序和对延迟敏感的系统非常重要,例如 Web 服务器,用户希望请求能够快速得到响应,而不会因为长时间的垃圾回收停顿而等待。
  3. 适合老年代:CMS 主要适用于老年代(Tenured Generation)的垃圾回收。在 Java 堆内存中,对象经过多次垃圾回收后仍然存活,会被移动到老年代。老年代中的对象通常生命周期较长,使用 CMS 可以在不影响应用程序性能的前提下进行垃圾回收。

CMS 垃圾回收器的工作过程

CMS 垃圾回收器的工作过程可以分为以下几个阶段:

  1. 初始标记(Initial Mark)
    • 操作:这个阶段会暂停应用程序(Stop - the - World,STW)。垃圾回收器标记出所有从根对象直接可达的对象。由于只需要标记根对象直接可达的对象,这个阶段的停顿时间相对较短。
    • 代码示例(模拟根对象可达性标记)
import java.util.ArrayList;
import java.util.List;

public class InitialMarkExample {
    public static void main(String[] args) {
        List<Object> rootObjects = new ArrayList<>();
        Object obj1 = new Object();
        Object obj2 = new Object();
        rootObjects.add(obj1);
        // 模拟初始标记,标记根对象直接可达的 obj1
        // 这里简单演示概念,实际 GC 过程更复杂
    }
}
  1. 并发标记(Concurrent Mark)
    • 操作:在这个阶段,垃圾回收器与应用程序并发运行。从初始标记阶段标记的根对象出发,遍历整个对象图,标记出所有存活的对象。在并发标记过程中,应用程序可能会创建新的对象或修改对象之间的引用关系,这可能导致一些对象在并发标记过程中被遗漏标记,这种情况被称为“浮动垃圾”(Floating Garbage)。
    • 代码示例(模拟并发标记过程中的对象创建与引用变化)
public class ConcurrentMarkExample {
    public static void main(String[] args) {
        Object obj1 = new Object();
        Thread markingThread = new Thread(() -> {
            // 模拟并发标记过程
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 标记存活对象
        });
        markingThread.start();
        // 应用程序线程继续运行,创建新对象
        Object newObj = new Object();
        // 应用程序线程修改引用关系
        obj1 = newObj;
        try {
            markingThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. 重新标记(Remark)
    • 操作:由于并发标记过程中可能出现浮动垃圾,重新标记阶段需要再次暂停应用程序(STW)。这个阶段会修正并发标记过程中因为应用程序运行而导致的标记不准确问题,确保所有存活对象都被正确标记。重新标记阶段的停顿时间通常比初始标记阶段稍长,因为它需要处理并发标记期间的引用变化。
    • 代码示例(模拟重新标记修正标记不准确问题)
import java.util.ArrayList;
import java.util.List;

public class RemarkExample {
    public static void main(String[] args) {
        List<Object> markedObjects = new ArrayList<>();
        Object obj1 = new Object();
        Object obj2 = new Object();
        markedObjects.add(obj1);
        // 模拟并发标记后,应用程序改变引用
        obj1 = obj2;
        // 重新标记,修正标记
        if (!markedObjects.contains(obj2)) {
            markedObjects.add(obj2);
        }
    }
}
  1. 并发清理(Concurrent Sweep)
    • 操作:在重新标记完成后,垃圾回收器与应用程序并发运行,清理所有未被标记的对象,回收其占用的内存空间。在清理过程中,应用程序可以继续运行。
    • 代码示例(模拟并发清理)
import java.util.ArrayList;
import java.util.List;

public class ConcurrentSweepExample {
    public static void main(String[] args) {
        List<Object> liveObjects = new ArrayList<>();
        Object obj1 = new Object();
        Object obj2 = new Object();
        liveObjects.add(obj1);
        // 模拟标记完成后,obj2 为垃圾对象
        // 并发清理线程
        Thread sweepThread = new Thread(() -> {
            for (Object obj : new ArrayList<>(liveObjects)) {
                if (!liveObjects.contains(obj)) {
                    // 模拟清理垃圾对象
                }
            }
        });
        sweepThread.start();
        // 应用程序线程继续运行
        try {
            sweepThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. 并发重置(Concurrent Reset)
    • 操作:清理完成后,垃圾回收器会进行一些内部状态的重置操作,为下一次垃圾回收做准备。这个阶段与应用程序并发执行,不会对应用程序造成停顿。

CMS 垃圾回收器的调优参数

在使用 CMS 垃圾回收器时,可以通过一些调优参数来优化其性能,以适应不同的应用场景。以下是一些常用的调优参数:

  1. -XX:+UseConcMarkSweepGC:启用 CMS 垃圾回收器。例如,在启动 Java 应用程序时,可以使用以下命令:
java -XX:+UseConcMarkSweepGC -jar yourApp.jar
  1. -XX:CMSInitiatingOccupancyFraction:指定 CMS 垃圾回收器在老年代空间占用达到多少比例时开始启动垃圾回收。默认值是 68%(不同 JVM 版本可能略有差异)。如果应用程序的对象晋升到老年代的速度较快,可以适当降低这个值,以提前启动 CMS 垃圾回收,避免老年代空间耗尽。例如:
java -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=50 -jar yourApp.jar
  1. -XX:ParallelCMSThreads:指定 CMS 垃圾回收器在并发标记和并发清理阶段使用的线程数。默认情况下,这个值会根据 CPU 核心数动态调整。如果应用程序在垃圾回收期间 CPU 资源紧张,可以适当增加这个值,以加快垃圾回收速度,但同时也会增加 CPU 负载。例如:
java -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=4 -jar yourApp.jar
  1. -XX:+CMSClassUnloadingEnabled:启用类卸载功能。在某些情况下,当类不再被使用时,CMS 垃圾回收器可以卸载这些类,以回收相关的内存空间。默认情况下,这个功能是关闭的。如果应用程序动态加载大量类,并且这些类在使用后不再需要,可以启用这个参数来提高内存利用率。例如:
java -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -jar yourApp.jar

CMS 垃圾回收器的局限性

尽管 CMS 垃圾回收器在减少停顿时间方面表现出色,但它也存在一些局限性:

  1. 内存碎片问题:由于 CMS 采用标记 - 清理算法,在清理过程中会产生内存碎片。随着时间的推移,内存碎片可能会导致大对象无法分配,即使堆内存中有足够的空闲空间。为了缓解这个问题,可以使用 -XX:+UseCMSCompactAtFullCollection 参数,在每次 Full GC 后进行内存压缩,但这会增加停顿时间。
  2. 浮动垃圾:并发标记过程中由于应用程序的运行可能产生浮动垃圾。这些浮动垃圾只能在下次垃圾回收时被回收,这可能导致老年代空间提前耗尽,触发不必要的 Full GC。
  3. CPU 资源消耗:CMS 在并发阶段与应用程序共享 CPU 资源,可能会对应用程序的性能产生一定影响,特别是在 CPU 资源紧张的情况下。如果应用程序对 CPU 性能要求较高,可能需要权衡是否使用 CMS 垃圾回收器。

与其他垃圾回收器的对比

  1. 与 Serial GC 的对比
    • Serial GC:是一种单线程的垃圾回收器,在垃圾回收过程中会暂停应用程序。它适用于单核 CPU 环境或对停顿时间不敏感的小型应用程序。与 CMS 相比,Serial GC 的优点是简单高效,没有多线程协调的开销;缺点是停顿时间长,不适合对响应时间要求高的应用程序。
    • CMS:并发执行,停顿时间短,适合对响应时间敏感的应用程序。但由于并发执行需要多线程协调,会增加一定的系统开销。
  2. 与 Parallel GC 的对比
    • Parallel GC:也称为吞吐量优先垃圾回收器,它使用多线程进行垃圾回收,目标是最大化应用程序的吞吐量。在垃圾回收时,它会暂停应用程序,与 Serial GC 类似,但由于多线程的使用,回收速度更快。与 CMS 相比,Parallel GC 的优点是吞吐量高,适合对吞吐量要求高的批处理应用程序;缺点是停顿时间相对较长,不适合对响应时间敏感的应用。
    • CMS:注重低停顿时间,适合交互式应用程序。但由于并发执行的特性,其吞吐量可能不如 Parallel GC。
  3. 与 G1 GC 的对比
    • G1 GC:是一种面向服务端应用的垃圾回收器,它将堆内存划分为多个大小相等的 Region,采用标记 - 整理算法,避免了内存碎片问题。G1 GC 可以在垃圾回收过程中动态调整停顿时间,同时兼顾吞吐量和低停顿。与 CMS 相比,G1 GC 的优点是更好地处理大堆内存,减少内存碎片,并且停顿时间更可控;缺点是在小堆内存场景下,可能不如 CMS 高效。
    • CMS:在老年代垃圾回收方面有较好的性能,对于注重响应时间且堆内存不是特别大的应用程序,CMS 仍然是一个不错的选择。但随着堆内存的增大和对内存管理要求的提高,G1 GC 逐渐成为更优的选择。

实际应用场景与案例分析

  1. Web 应用服务器:在 Web 应用服务器中,响应时间至关重要。用户希望能够快速加载网页,而长时间的垃圾回收停顿会导致页面加载缓慢,影响用户体验。例如,一个基于 Java 的电商网站,每天处理大量的用户请求。使用 CMS 垃圾回收器可以在垃圾回收期间保持较低的停顿时间,确保用户请求能够及时得到处理。通过合理调整 -XX:CMSInitiatingOccupancyFraction 等参数,可以优化垃圾回收的启动时机,避免老年代空间耗尽导致的长时间停顿。
  2. 实时数据分析系统:实时数据分析系统需要快速处理大量的数据,并及时返回分析结果。在这种场景下,CMS 垃圾回收器的低停顿特性可以保证数据处理的连续性。例如,一个金融实时行情分析系统,需要实时接收和处理股票交易数据。使用 CMS 垃圾回收器可以减少垃圾回收对数据处理的影响,确保系统能够及时准确地提供行情分析结果。

总结 CMS 垃圾回收器的关键要点

  1. 核心算法:基于标记 - 清理算法,分为标记和清理阶段,在标记阶段标记存活对象,清理阶段回收未标记对象的内存空间。
  2. 工作过程:包括初始标记(STW)、并发标记、重新标记(STW)、并发清理和并发重置等阶段。通过并发执行尽量减少应用程序停顿时间,但也带来了浮动垃圾等问题。
  3. 调优参数:如 -XX:+UseConcMarkSweepGC 启用 CMS,-XX:CMSInitiatingOccupancyFraction 控制垃圾回收启动时机,-XX:ParallelCMSThreads 调整并发线程数等,合理调整这些参数可以优化 CMS 性能。
  4. 局限性:存在内存碎片、浮动垃圾和 CPU 资源消耗等问题,在使用时需要根据应用场景权衡利弊。
  5. 对比与选择:与其他垃圾回收器相比,CMS 适合注重响应时间的应用场景,但在不同的应用需求下,需要综合考虑堆内存大小、吞吐量要求等因素选择合适的垃圾回收器。

通过深入理解 CMS 垃圾回收器的原理、工作过程、调优参数以及其局限性和适用场景,开发人员可以更好地优化 Java 应用程序的内存管理,提高应用程序的性能和稳定性。在实际应用中,需要根据具体的业务需求和系统环境,合理选择和配置垃圾回收器,以达到最佳的性能效果。同时,随着 Java 技术的不断发展,新的垃圾回收器和优化技术也在不断涌现,开发人员需要持续关注和学习,以保持对最新技术的掌握和应用能力。