对于Java开发者来说,GC(垃圾回收器)就如同一个神秘的黑匣子,它在背后不知疲倦地运作,却也时常给我们带来诸多疑惑和挫折。今天,就让我们切开这个黑匣子,深入解析Java GC的工作原理,助你了解其中的奥秘,从此不再被GC所扰。


这篇文章的主要内容包括:

  1. 对象存活判断及引用类型辨析
  2. 三种主流GC算法的原理和特点
  3. 如何正确解读GC日志

一、对象存活与引用类型


在深入GC算法前,我们先了解一下Java中对象的生命周期。通过判断对象是否可被访问,JVM会决定其是否会被回收。这里涉及一个重要的概念——引用。

1、对象的生命周期


对象的生命周期开始于它的创建,结束于它的回收。在Java中,对象的回收由垃圾回收器(GC)负责,GC会根据引用的类型和数量来决定何时回收对象。
  • 强引用(Strong Reference) - 最普通的对象引用方式,只要存在强引用指向对象,它就不会被GC回收。

  • 软引用(Soft Reference) - 有时候用于实现内存敏感的高速缓存,当内存不足时会被GC回收。

  • 弱引用(Weak Reference) - 如WeakHashMap所使用,即使没被GC回收,也可能会被回收。

  • 虚引用(Phantom Reference) - 最弱的引用,唯一目的是能在对象被GC回收时收到系统通知。

理解了引用类型,我们就能判断对象在何种情况下会被回收。比如,当一个对象没有任何强引用指向它,那它就处于可被回收状态。


### 2、强引用(Strong Reference)
  • 定义: 强引用是最常见的引用类型,如果一个对象具有强引用,那么它永远不会被垃圾回收器回收,直到这个引用被显式地设置为null

  • 示例

Object obj = new Object();
// obj是一个强引用,只要obj存在,对象就不会被回收

3、软引用(Soft Reference)


  • 定义: 软引用用来描述一些有用但非必需的对象。当系统内存不足时,这些对象会被垃圾回收器回收。
  • 用途: 软引用通常用于实现内存敏感的缓存。
  • 示例
import java.lang.ref.SoftReference;

Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
obj = null; // 显式地清空强引用

// 当系统内存不足时,软引用指向的对象可能会被回收
if (softRef.get() == null) {
    System.out.println("对象已被回收");
}

4、弱引用(Weak Reference)


  • 定义: 弱引用不足以阻止对象的垃圾回收。也就是说,只要垃圾回收器发现了弱引用,不管当前内存是否足够,都会回收其指向的对象。
  • 用途: 弱引用通常用于监听对象的消失,例如,用于实现弱键(WeakHashMap)。
  • 示例
import java.lang.ref.WeakReference;

Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null; // 显式地清空强引用

// 对象几乎立即会被回收,因为弱引用不会阻止垃圾回收
if (weakRef.get() == null) {
    System.out.println("对象已被回收");
}

5、虚引用(Phantom Reference)


  • 定义: 虚引用是最弱的一种引用类型。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。
  • 用途: 虚引用主要用于在对象被回收后收到一个系统通知,用来跟踪对象被垃圾回收的状态。
  • 示例
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

Object obj = new Object();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, new ReferenceQueue<Object>());
obj = null; // 显式地清空强引用

// 虚引用可以注册到引用队列上
// 当对象被回收后,虚引用会被放入引用队列
if (phantomRef.isEnqueued()) {
    System.out.println("对象已被回收");
}

二、三种主流GC算法原理


JVM的GC算法负责探测并回收上述可回收的对象,以释放内存空间。目前主流的GC算法主要有以下三种:


1、标记-清除(Mark-Sweep)算法原理

这是最基础和常见的GC算法。

(1)、它分两个阶段工作

  • 标记阶段(Marking Phase)

    • 垃圾回收器从根对象(如类变量、局部变量等)开始,递归地访问所有可达的对象。
    • 所有被访问到的对象都会被标记为“存活”。
  • 清除阶段(Sweeping Phase)

    • 在标记阶段结束后,垃圾回收器会遍历堆内存。
    • 对于未被标记的对象,垃圾回收器会认为它们是“垃圾”,并进行回收。
    • 清除后,这些内存空间会被重新整理,为新对象的分配做准备。

(2)、标记-清除算法的问题

  • 内存碎片:清除阶段可能会导致内存碎片,因为回收的对象可能不是连续的。

  • 效率问题:标记和清除阶段可能需要暂停整个应用程序(Stop-The-World,STW),影响性能。


(3)、Java案例代码演示

下面演示了标记-清除算法的工作原理:

public class MarkSweepDemo {
    public static void main(String[] args) {
        // 创建一些对象
        Object obj1 = new Object();
        Object obj2 = new Object();
        Object obj3 = new Object();

        // 假设obj1和obj2是根对象,它们被标记为存活
        // obj3没有被根对象直接或间接引用,将被标记为垃圾

        // 模拟标记阶段
        mark(obj1); // 标记obj1
        mark(obj2); // 标记obj2
        // obj3不会被标记

        // 模拟清除阶段
        sweep();

        // 检查对象是否被回收
        System.out.println("obj1 is alive: " + (obj1 == null ? "No" : "Yes"));
        System.out.println("obj2 is alive: " + (obj2 == null ? "No" : "Yes"));
        System.out.println("obj3 is alive: " + (obj3 == null ? "No" : "Yes"));
    }

    // 模拟标记过程
    private static void mark(Object obj) {
        // 这里只是模拟,实际的标记过程由GC执行
        System.out.println(obj + " is marked as alive.");
    }

    // 模拟清除过程
    private static void sweep() {
        // 清除未被标记的对象,这里只是模拟
        System.out.println("Sweeping phase: clearing unmarked objects.");
    }
}

在这个示例中,我们创建了三个对象obj1obj2obj3。在模拟的标记阶段,obj1obj2被标记为存活,而obj3没有被标记。在模拟的清除阶段,未被标记的obj3将被“回收”(在实际的Java程序中,对象的回收是由JVM的垃圾回收器自动完成的)。

请注意,这个示例只是为了演示标记-清除算法的原理,并不是实际的Java垃圾回收过程。在实际的Java程序中,你不需要手动进行标记和清除,这些工作都是由JVM自动完成的。


2、复制(Copying)

复制(Copying)算法,也被称为“半区算法”或“新生代算法”,是一种简单且高效的垃圾回收算法,尤其适用于对象生命周期短的场景,如Java中的新生代(Young Generation)。

复制算法将堆内存分为两个相等的区域:一个用于分配新对象(称为From区),另一个用于垃圾回收时的复制操作(称为To区)。

// 初始内存布局
// ----容器1----      ----容器2----
// [obj1,obj2]          []

// 进行GC后
// ----容器1----      ----容器2----  
//    []              [obj1,obj2] 

(1)、算法的步骤如下

  • 对象分配

    • 新对象首先在From区分配。
  • 标记阶段

    • 从根对象开始,标记所有存活的对象。这一步与标记-清除算法相同。
  • 复制阶段

    • 将所有存活的对象从From区复制到To区,同时更新所有引用,使其指向To区的新位置。
    • 复制完成后,From区的所有对象都可以被清除。
  • 角色交换

    • 复制完成后,From区和To区的角色互换。新的To区变为新的From区,用于下一次垃圾回收。


(2)、复制算法的优点

  • 内存碎片问题:由于每次回收后都会进行复制,因此不会产生内存碎片。

  • 简单高效:复制算法实现简单,且在对象生命周期短的情况下效率很高。


    (3)、复制算法的缺点

  • 内存空间浪费:由于需要两个区域,因此需要额外的内存空间。

  • 复制开销:复制存活对象需要一定的时间开销。


(4)、Java案例代码演示

下面代码演示了复制算法的工作原理:

public class CopyingGCDemo {
    public static void main(String[] args) {
        // 模拟From区和To区
        Object[] fromArea = new Object[10];
        Object[] toArea = new Object[10];

        // 假设fromArea中前5个对象是新分配的
        for (int i = 0; i < 5; i++) {
            fromArea[i] = new Object();
        }

        // 模拟标记阶段
        mark(fromArea);

        // 模拟复制阶段
        int toIndex = 0;
        for (int i = 0; i < fromArea.length; i++) {
            if (fromArea[i] != null) {
                toArea[toIndex++] = fromArea[i];
            }
        }

        // 角色交换
        fromArea = toArea;
        toArea = new Object[10]; // 为下一次回收准备新的To区

        // 检查对象是否被复制
        for (int i = 0; i < fromArea.length; i++) {
            if (fromArea[i] != null) {
                System.out.println("Object at index " + i + " is alive and copied.");
            }
        }
    }

    // 模拟标记过程
    private static void mark(Object[] area) {
        // 这里只是模拟,实际的标记过程由GC执行
        for (Object obj : area) {
            if (obj != null) {
                System.out.println(obj + " is marked as alive.");
            }
        }
    }
}

在这个示例中,我们使用两个数组fromAreatoArea来模拟From区和To区。我们首先在fromArea中分配了一些新对象,然后模拟了标记阶段,接着将存活的对象复制到toArea,并更新索引。最后,我们交换了fromAreatoArea的角色,准备下一次垃圾回收。

请注意,这个示例只是为了演示复制算法的原理,并不是实际的Java垃圾回收过程。在实际的Java程序中,你不需要手动进行复制,这些工作都是由JVM的垃圾回收器自动完成的。


3、标记-整理(Mark-Compact)

标记-整理算法是垃圾回收中的另一种算法,它结合了标记-清除算法和复制算法的优点,旨在解决清除算法中的内存碎片问题。


(1)、标记-整理算法三个阶段

  • 标记阶段(Marking Phase)

    • 从根对象开始,递归地标记所有可达对象。被标记的对象被认为是存活的。
  • 整理阶段(Compacting Phase)

    • 将所有存活的对象向内存的一端移动,未被标记的对象则被忽略。
    • 移动对象时,会更新所有指向这些对象的引用,确保它们指向新的位置。
  • 清除阶段(Clearing Phase)(可选):

    • 在整理完成后,可能需要清除未被移动的对象占用的空间,以避免内存碎片。
// 初始内存布局
// ----内存区----
// [obj1,  ,obj2, ,  ,  ,obj3]

// 标记整理后
// ----内存区----  
// [obj1,obj2,obj3,        ]

(2)、标记-整理算法的优点

  • 内存碎片:通过整理阶段,可以减少或消除内存碎片。
  • 内存利用率:由于整理了存活对象,所以可以更有效地利用内存空间。

(3)、标记-整理算法的缺点

  • 移动开销:移动对象和更新引用需要额外的时间开销。
  • 暂停时间:标记和整理阶段可能需要暂停应用程序,影响性能。

(4)、案例代码演示

以下模拟了标记-整理算法的工作原理。

public class MarkCompactDemo {
    static class ObjectWithIndex {
        Object object;
        int index;

        ObjectWithIndex(Object object, int index) {
            this.object = object;
            this.index = index;
        }
    }

    public static void main(String[] args) {
        // 假设有5个对象,其中3个是存活的
        Object[] objects = new Object[5];
        objects[0] = new Object();
        objects[1] = new Object();
        objects[2] = new Object();
        objects[3] = null; // 假设这个对象是垃圾
        objects[4] = new Object();

        // 模拟根引用
        Object root = objects[0];

        // 标记阶段
        mark(objects, root);

        // 整理阶段
        int newIndex = 0;
        for (int i = 0; i < objects.length; i++) {
            if (objects[i] != null) {
                objects[newIndex] = objects[i];
                objects[i] = null; // 清除原位置
                newIndex++;
            }
        }

        // 清除阶段(可选)
        // 在实际的GC中,这一步通常由垃圾回收器自动完成

        // 输出整理后的对象
        for (int i = 0; i < newIndex; i++) {
            System.out.println("Object at index " + i + " is alive.");
        }
    }

    private static void mark(Object[] objects, Object root) {
        // 使用一个集合来记录已访问的对象
        HashSet<Object> marked = new HashSet<>();

        // 使用队列模拟递归过程
        Queue<Object> queue = new LinkedList<>();
        queue.add(root);

        while (!queue.isEmpty()) {
            Object current = queue.poll();
            if (marked.add(current)) { // 如果对象未被标记
                for (int i = 0; i < objects.length; i++) {
                    if (objects[i] == current) {
                        System.out.println("Object at index " + i + " is marked as alive.");
                        break;
                    }
                }
                // 假设current对象有引用属性,模拟递归标记
                // 这里简化处理,实际情况需要根据对象的实际引用进行标记
            }
        }
    }
}

在这个示例中,我们创建了一个对象数组objects,其中包含几个对象和一个null。我们模拟了一个根对象root,它引用了数组中的第一个对象。mark方法模拟了标记阶段,使用一个队列和一个集合来递归地标记所有可达对象。然后,我们在main方法中模拟了整理阶段,将所有存活的对象移动到数组的开始位置,并清除了原位置。

请注意,这个示例仅用于演示标记-整理算法的基本原理,实际的Java垃圾回收过程要复杂得多,并且由JVM自动管理。


三、解读GC日志


理解了垃圾回收的原理后,我们来看看如何解读GC日志 。

GC(Garbage Collection)日志是Java虚拟机(JVM)在执行垃圾回收时生成的日志信息,它记录了GC的触发时间、持续时间、回收的内存量、使用的GC算法等信息。通过分析GC日志,我们可以了解应用的内存使用情况,发现潜在的问题和性能瓶颈。


1、如何开启GC日志

在Java应用启动时,可以通过设置JVM参数来开启GC日志:

-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
  • PrintGC:打印GC发生的基本日志。
  • PrintGCDetails:打印GC的详细日志。
  • PrintGCDateStamps:在日志中包含日期时间戳。
  • PrintGCTimeStamps:在日志中包含自JVM启动以来的时间戳。

2、GC日志的关键信息

  • 时间戳:GC事件发生的时间。

  • GC类型:如Minor GC(新生代回收)、Full GC(全堆回收)等。

  • 持续时间:GC事件持续的时间。

  • 回收前后的内存使用情况:包括新生代、老年代等内存区域的内存使用情况。

  • GC原因:触发GC的原因,如分配失败、系统内存不足等。


3、解读GC日志示例

假设我们有以下GC日志片段:

2024-05-23T14:37:12.123+0000 [GC [PSYoungGen: 73328K->6336K(94208K)] 73328K->6336K(190464K), 0.003602 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
2024-05-23T14:37:12.126+0000 [Full GC [PSYoungGen: 6336K->0K(94208K)] [ParOldGen: 0K->5120K(95296K)] 6336K->5120K(189504K), [Metaspace: 3258K->3258K(1056768K)], 0.006773 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 

解读

(1)、时间戳

  • 第一条日志发生在 2024-05-23T14:37:12.123+0000

  • 第二条日志发生在 2024-05-23T14:37:12.126+0000

(2)、GC类型

  • 第一条日志是一次Minor GC。

  • 第二条日志是一次Full GC。

(3)、持续时间

  • 第一次Minor GC持续了 0.003602 秒。

  • 第二次Full GC持续了 0.006773 秒。

(4)、内存使用情况

  • 第一次Minor GC前,新生代使用了 73328K,回收后剩余 6336K
  • 第一次Minor GC后,整个堆使用了 6336K
  • 第二次Full GC前,新生代使用了 6336K,老年代未使用,回收后新生代 0K,老年代 5120K
  • 第二次Full GC后,整个堆使用了 5120K

(5)、GC原因:

  • 第一次Minor GC可能因为新生代空间不足。
  • 第二次Full GC可能因为Minor GC后仍然有内存需求,或者达到了Full GC的条件。

4、监控应用内存使用状况

通过定期检查GC日志,我们可以监控应用的内存使用情况:

  • 频繁的GC:如果GC频繁发生,可能表明内存使用率高,需要优化内存使用。

  • 长时间的GC:长时间的GC可能导致应用响应变慢,需要关注。

  • 内存泄漏:如果老年代的内存持续增长,可能存在内存泄漏。


5、发现潜在问题和性能瓶颈

  • 内存分配率:如果内存分配率持续高于GC回收率,可能导致内存不足。
  • Full GC频率:频繁的Full GC可能影响性能,需要优化。
  • 内存碎片:如果老年代的内存使用不连续,可能导致内存碎片问题。

通过分析GC日志,我们可以对应用的内存使用情况有一个清晰的了解,并据此进行性能调优。在实际的生产环境中,还可以使用专业的监控工具来自动化这一过程,及时发现并解决潜在的问题。


四、结语


以上内容涵盖了GC的常见知识,但Java GC为主题的探讨绝不止于此。比如说,JDK中还引入了全新的ZGC算法,用于低延迟处理;G1作为一种优秀的分代实现,如何工作;怎样有效地配置GC参数…等等,这些都是值得我们去学习和思考的重要话题。


Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐