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

深入理解Java垃圾回收机制

2024-11-177.2k 阅读

Java垃圾回收机制概述

在Java编程中,垃圾回收(Garbage Collection,GC)机制是一个至关重要的特性。它自动管理内存,减轻了程序员手动管理内存的负担,降低了内存泄漏和悬空指针等问题出现的概率。Java的垃圾回收机制在后台默默运行,负责回收程序不再使用的内存空间,让这些空间可以被重新分配给新的对象。

Java程序运行时,内存被划分为不同的区域,其中与垃圾回收密切相关的主要有堆(Heap)和方法区(Method Area)。堆是对象实例和数组分配内存的地方,也是垃圾回收的主要场所。方法区存储类的结构信息、常量、静态变量等数据,虽然相对堆来说回收频率较低,但也存在垃圾回收的情况。

垃圾回收的判定

在进行垃圾回收之前,首先要确定哪些对象是垃圾,即哪些对象不再被程序使用,可以被回收。Java中采用了两种主要的算法来判定对象是否可回收:引用计数法和可达性分析法。

引用计数法

引用计数法是一种较为简单的垃圾判定算法。它为每个对象维护一个引用计数器,每当有一个地方引用该对象时,计数器值加1;当引用失效时,计数器值减1。当计数器值为0时,就认为该对象不再被使用,可以被回收。

以下是一个简单的示例代码,用伪代码来模拟引用计数法:

class RefCountObject {
    int refCount = 0;
    // 其他成员变量和方法
}

RefCountObject obj1 = new RefCountObject();
obj1.refCount++; // 引用计数加1
RefCountObject obj2 = obj1;
obj2.refCount++; // 引用计数再加1
obj1 = null; // obj1引用失效,obj2的引用计数减1
if (obj2.refCount == 0) {
    // 可以回收obj2指向的对象
}

然而,引用计数法存在一个严重的问题,即无法解决对象之间的循环引用。假设有两个对象A和B,A持有对B的引用,B也持有对A的引用,即使这两个对象在程序的其他地方不再被使用,但由于它们相互引用,引用计数永远不会为0,导致无法回收这两个对象,造成内存泄漏。

可达性分析法

为了解决引用计数法的循环引用问题,Java采用了可达性分析法。该方法以一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的,可以被回收。

在Java中,可作为GC Roots的对象包括:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象:例如,方法中的局部变量所引用的对象。
public class GCRootExample {
    public void method() {
        Object obj = new Object(); // obj是虚拟机栈中本地变量表引用的对象
        // 方法执行完毕,obj不再被引用,可被回收
    }
}
  1. 方法区中类静态属性引用的对象:比如类的静态成员变量所指向的对象。
public class StaticRefClass {
    static Object staticObj = new Object();
}
  1. 方法区中常量引用的对象:例如字符串常量池中的字符串对象。
public class ConstantRefClass {
    public static final String CONSTANT_STR = "Hello, GC";
}
  1. 本地方法栈中JNI(即Native方法)引用的对象:在使用JNI调用本地方法时,本地方法栈中引用的Java对象。

通过可达性分析法,Java虚拟机能够准确地识别出哪些对象不再被程序使用,从而为垃圾回收提供可靠的依据。

Java垃圾回收算法

确定了哪些对象是垃圾后,接下来就是如何回收这些垃圾对象,释放它们占用的内存空间。Java中主要使用的垃圾回收算法有标记 - 清除算法、复制算法、标记 - 整理算法和分代收集算法。

标记 - 清除算法

标记 - 清除算法是最基础的垃圾回收算法,分为两个阶段:标记阶段和清除阶段。

在标记阶段,垃圾回收器从GC Roots开始遍历,标记所有可达的对象。在清除阶段,垃圾回收器回收所有未被标记的对象,即垃圾对象,释放它们占用的内存空间。

以下是一个简单的代码示例来模拟标记 - 清除算法的过程(伪代码):

class ObjectNode {
    boolean isMarked = false;
    ObjectNode next;
    // 其他成员变量和方法
}

// 模拟堆内存中的对象链表
ObjectNode head = new ObjectNode();
ObjectNode node1 = new ObjectNode();
ObjectNode node2 = new ObjectNode();
ObjectNode node3 = new ObjectNode();
head.next = node1;
node1.next = node2;
node2.next = node3;

// 标记阶段
ObjectNode current = head;
while (current != null) {
    // 假设这里有逻辑判断哪些对象可达,标记可达对象
    current.isMarked = true; 
    current = current.next;
}

// 清除阶段
current = head;
ObjectNode prev = null;
while (current != null) {
    if (!current.isMarked) {
        if (prev == null) {
            head = current.next;
        } else {
            prev.next = current.next;
        }
        // 释放current对象占用的内存空间
    } else {
        prev = current;
    }
    current = current.next;
}

标记 - 清除算法虽然简单直接,但存在两个主要问题。一是效率问题,标记和清除两个过程的效率都不高。二是空间问题,清除之后会产生大量不连续的内存碎片,这些碎片可能导致后续大对象无法分配到足够的连续内存空间,即使堆中总的空闲内存空间足够。

复制算法

复制算法为了解决标记 - 清除算法的效率和碎片问题而产生。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次性清理掉。

假设我们有两个内存区域A和B,初始时使用A区域:

// 模拟复制算法
class MemoryRegion {
    Object[] objects;
    int size;
    int used;

    MemoryRegion(int size) {
        this.size = size;
        objects = new Object[size];
        used = 0;
    }

    void allocate(Object obj) {
        if (used < size) {
            objects[used++] = obj;
        }
    }
}

MemoryRegion regionA = new MemoryRegion(10);
MemoryRegion regionB = new MemoryRegion(10);

// 在regionA分配对象
regionA.allocate(new Object());
regionA.allocate(new Object());

// 复制存活对象到regionB
int newUsed = 0;
for (int i = 0; i < regionA.used; i++) {
    Object obj = regionA.objects[i];
    // 假设这里判断obj是存活对象
    regionB.objects[newUsed++] = obj; 
}

// 清理regionA
regionA.used = 0;

复制算法的优点是实现简单,运行高效,且不会产生内存碎片。但它的缺点也很明显,就是内存利用率较低,因为始终有一半的内存空间处于闲置状态。为了提高内存利用率,现代Java虚拟机采用了优化的复制算法,如在新生代中使用的Survivor空间机制。

标记 - 整理算法

标记 - 整理算法结合了标记 - 清除算法和复制算法的优点。它同样先进行标记阶段,标记出所有存活的对象。然后在整理阶段,将所有存活的对象向一端移动,最后直接清理掉边界以外的内存,这样就避免了内存碎片的产生。

以下是标记 - 整理算法的模拟代码(伪代码):

class ObjectNode {
    boolean isMarked = false;
    ObjectNode next;
    // 其他成员变量和方法
}

// 模拟堆内存中的对象链表
ObjectNode head = new ObjectNode();
ObjectNode node1 = new ObjectNode();
ObjectNode node2 = new ObjectNode();
ObjectNode node3 = new ObjectNode();
head.next = node1;
node1.next = node2;
node2.next = node3;

// 标记阶段
ObjectNode current = head;
while (current != null) {
    // 假设这里有逻辑判断哪些对象可达,标记可达对象
    current.isMarked = true; 
    current = current.next;
}

// 整理阶段
int count = 0;
current = head;
while (current != null) {
    if (current.isMarked) {
        if (count != 0) {
            ObjectNode temp = head;
            for (int i = 0; i < count - 1; i++) {
                temp = temp.next;
            }
            temp.next = current;
        }
        count++;
    }
    current = current.next;
}
// 清理边界以外的内存
ObjectNode end = head;
for (int i = 0; i < count - 1; i++) {
    end = end.next;
}
end.next = null;

标记 - 整理算法在解决内存碎片问题的同时,相较于复制算法,提高了内存利用率。但由于移动对象的操作比较耗时,其执行效率相对复制算法会低一些。

分代收集算法

分代收集算法是目前Java虚拟机普遍采用的垃圾回收算法,它基于这样一个事实:不同生命周期的对象具有不同的回收特点。在Java堆中,根据对象存活时间的长短,将堆内存划分为不同的代,一般分为新生代(Young Generation)、老年代(Old Generation)和永久代(Perm Generation,在Java 8及之后被元空间Meta Space取代)。

  1. 新生代:新生代主要存放新创建的对象,对象的生命周期较短。新生代又进一步划分为一个Eden区和两个Survivor区(通常称为From Survivor和To Survivor),比例一般为8:1:1。 当新对象创建时,首先分配在Eden区。当Eden区满了,就会触发Minor GC(新生代垃圾回收)。在Minor GC过程中,Eden区和From Survivor区中存活的对象会被复制到To Survivor区,同时对象的年龄加1。如果对象的年龄达到一定阈值(通常是15),就会被晋升到老年代。如果To Survivor区满了,年龄小于阈值的对象会被复制到老年代。
// 模拟新生代垃圾回收
class YoungGen {
    Object[] eden;
    Object[] fromSurvivor;
    Object[] toSurvivor;
    int edenSize;
    int survivorSize;
    int edenUsed;
    int fromUsed;
    int toUsed;

    YoungGen(int edenSize, int survivorSize) {
        this.edenSize = edenSize;
        this.survivorSize = survivorSize;
        eden = new Object[edenSize];
        fromSurvivor = new Object[survivorSize];
        toSurvivor = new Object[survivorSize];
        edenUsed = 0;
        fromUsed = 0;
        toUsed = 0;
    }

    void allocate(Object obj) {
        if (edenUsed < edenSize) {
            eden[edenUsed++] = obj;
        } else {
            minorGC();
            allocate(obj);
        }
    }

    void minorGC() {
        // 将Eden区和From Survivor区存活对象复制到To Survivor区
        int newToUsed = 0;
        for (int i = 0; i < edenUsed; i++) {
            Object obj = eden[i];
            // 假设这里判断obj存活
            toSurvivor[newToUsed++] = obj; 
        }
        for (int i = 0; i < fromUsed; i++) {
            Object obj = fromSurvivor[i];
            // 假设这里判断obj存活
            toSurvivor[newToUsed++] = obj; 
        }
        edenUsed = 0;
        fromUsed = 0;
        toUsed = newToUsed;
        // 交换From Survivor和To Survivor
        Object[] temp = fromSurvivor;
        fromSurvivor = toSurvivor;
        toSurvivor = temp;
    }
}
  1. 老年代:老年代存放经过多次Minor GC仍然存活的对象,这些对象生命周期较长。当老年代内存空间不足时,会触发Major GC(老年代垃圾回收,通常也会伴随Minor GC)。老年代一般采用标记 - 整理算法进行垃圾回收。
class OldGen {
    Object[] objects;
    int size;
    int used;

    OldGen(int size) {
        this.size = size;
        objects = new Object[size];
        used = 0;
    }

    void allocate(Object obj) {
        if (used < size) {
            objects[used++] = obj;
        } else {
            majorGC();
            allocate(obj);
        }
    }

    void majorGC() {
        // 标记 - 整理算法实现
        boolean[] marked = new boolean[used];
        // 标记阶段
        for (int i = 0; i < used; i++) {
            Object obj = objects[i];
            // 假设这里判断obj存活,标记
            marked[i] = true; 
        }
        int newUsed = 0;
        for (int i = 0; i < used; i++) {
            if (marked[i]) {
                objects[newUsed++] = objects[i];
            }
        }
        used = newUsed;
    }
}
  1. 永久代/元空间:在Java 8之前,永久代主要存储类的元数据信息,如类的结构、常量、静态变量等。由于永久代的大小在启动时就固定了,容易出现内存溢出问题。在Java 8及之后,使用元空间取代永久代,元空间使用本地内存,不受Java堆大小的限制。垃圾回收对永久代/元空间的回收频率较低,主要回收不再使用的类元数据信息。

分代收集算法根据不同代的特点采用不同的垃圾回收算法,提高了垃圾回收的效率和性能。

Java垃圾回收器

垃圾回收算法是理论基础,而垃圾回收器则是具体实现这些算法的组件。Java提供了多种垃圾回收器,每种垃圾回收器针对不同的应用场景进行了优化,用户可以根据应用的特点选择合适的垃圾回收器。

Serial垃圾回收器

Serial垃圾回收器是最基本、最古老的垃圾回收器,它是单线程的垃圾回收器。在进行垃圾回收时,它会暂停所有的用户线程,直到垃圾回收完成,这种现象称为“Stop - The - World”。

Serial垃圾回收器在新生代采用复制算法,在老年代采用标记 - 整理算法。它适用于单核处理器环境,对于内存较小的应用程序,由于其简单高效,仍然是一个不错的选择。

可以通过以下参数启用Serial垃圾回收器:

-XX:+UseSerialGC

ParNew垃圾回收器

ParNew垃圾回收器是Serial垃圾回收器的多线程版本,它在新生代采用多线程进行垃圾回收,老年代仍然使用Serial垃圾回收器的单线程方式。ParNew垃圾回收器在多核处理器环境下能够利用多个处理器核心并行执行垃圾回收任务,从而缩短垃圾回收的时间。

它同样会导致“Stop - The - World”现象,但由于多线程的优势,在整体性能上优于Serial垃圾回收器。可以通过以下参数启用ParNew垃圾回收器:

-XX:+UseParNewGC

Parallel Scavenge垃圾回收器

Parallel Scavenge垃圾回收器也是一个多线程的新生代垃圾回收器,它的目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间))。Parallel Scavenge垃圾回收器在新生代采用复制算法,并且可以通过参数调整来控制吞吐量。

可以通过以下参数启用Parallel Scavenge垃圾回收器:

-XX:+UseParallelGC

为了精确控制吞吐量,还可以使用 -XX:MaxGCPauseMillis 参数设置最大垃圾回收停顿时间,或使用 -XX:GCTimeRatio 参数设置垃圾回收时间占总时间的比例。

Parallel Old垃圾回收器

Parallel Old垃圾回收器是Parallel Scavenge垃圾回收器在老年代的多线程版本,它与Parallel Scavenge垃圾回收器配合使用,在老年代采用多线程的标记 - 整理算法。Parallel Old垃圾回收器主要用于注重吞吐量的应用场景,特别是在多核处理器环境下,能够提供较高的垃圾回收效率。

可以通过以下参数启用Parallel Old垃圾回收器:

-XX:+UseParallelOldGC

CMS(Concurrent Mark Sweep)垃圾回收器

CMS垃圾回收器是一种以获取最短回收停顿时间为目标的垃圾回收器,适用于对响应时间要求较高的应用,如Web应用。CMS垃圾回收器在老年代采用标记 - 清除算法,在垃圾回收过程中尽量减少“Stop - The - World”的时间。

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

  1. 初始标记(Initial Mark):暂停所有用户线程,标记出所有与GC Roots直接相连的对象。这个阶段速度很快,但仍然会导致“Stop - The - World”。
  2. 并发标记(Concurrent Mark):与用户线程并发执行,从初始标记的对象开始,标记出所有可达对象。这个阶段不会暂停用户线程。
  3. 重新标记(Remark):暂停所有用户线程,修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段也会导致“Stop - The - World”,但时间比初始标记稍长。
  4. 并发清除(Concurrent Sweep):与用户线程并发执行,清除所有未被标记的对象,即垃圾对象。

可以通过以下参数启用CMS垃圾回收器:

-XX:+UseConcMarkSweepGC

CMS垃圾回收器虽然尽量减少了停顿时间,但也存在一些问题,比如会产生内存碎片,并且由于在并发阶段需要与用户线程共享资源,可能会降低应用程序的吞吐量。

G1(Garbage - First)垃圾回收器

G1垃圾回收器是Java 7引入的一款面向服务器端应用的垃圾回收器,它旨在取代CMS垃圾回收器。G1垃圾回收器将堆内存划分为多个大小相等的Region,这些Region可以根据需要扮演Eden区、Survivor区或老年代的角色。

G1垃圾回收器的主要特点包括:

  1. 可预测的停顿时间:G1垃圾回收器可以通过参数 -XX:MaxGCPauseMillis 设置最大停顿时间,并且尽量满足这个目标。
  2. 混合回收(Mixed GC):G1垃圾回收器不仅可以进行新生代垃圾回收,还可以在老年代空间占用达到一定阈值时,对新生代和老年代同时进行回收,称为混合回收。
  3. Region划分:通过将堆内存划分为Region,G1垃圾回收器可以更灵活地管理内存,并且可以优先回收垃圾最多的Region,即“垃圾优先”(Garbage - First)的理念。

G1垃圾回收器的工作过程主要包括:

  1. 初始标记(Initial Mark):暂停所有用户线程,标记出所有与GC Roots直接相连的对象。
  2. 并发标记(Concurrent Mark):与用户线程并发执行,标记出所有可达对象。
  3. 最终标记(Final Mark):暂停所有用户线程,处理并发标记阶段产生的SATB(Snapshot - At - The - Beginning)记录,修正标记结果。
  4. 筛选回收(Evacuation):根据每个Region中垃圾对象的比例,选择垃圾最多的Region进行回收,采用复制算法将存活对象复制到其他Region中,同时清理掉原Region。

可以通过以下参数启用G1垃圾回收器:

-XX:+UseG1GC

G1垃圾回收器在兼顾吞吐量和响应时间方面表现出色,特别适合大内存、多核处理器的服务器应用场景。

垃圾回收相关的JVM参数调优

合理调整JVM参数可以优化垃圾回收的性能,提高应用程序的运行效率。以下是一些常用的与垃圾回收相关的JVM参数:

堆内存相关参数

  1. -Xms:设置堆内存的初始大小。例如, -Xms2g 表示将堆内存初始大小设置为2GB。
  2. -Xmx:设置堆内存的最大大小。例如, -Xmx4g 表示将堆内存最大大小设置为4GB。合理设置 -Xms-Xmx 可以避免频繁的堆内存扩展和收缩,提高性能。

新生代相关参数

  1. -Xmn:设置新生代的大小。例如, -Xmn1g 表示将新生代大小设置为1GB。适当调整新生代大小可以影响Minor GC的频率和时间。
  2. -XX:SurvivorRatio:设置Eden区与Survivor区的比例。默认值是8,表示Eden区与一个Survivor区的比例为8:1。例如, -XX:SurvivorRatio=6 表示Eden区与一个Survivor区的比例为6:1。

垃圾回收器选择参数

  1. -XX:+UseSerialGC:启用Serial垃圾回收器。
  2. -XX:+UseParNewGC:启用ParNew垃圾回收器。
  3. -XX:+UseParallelGC:启用Parallel Scavenge垃圾回收器。
  4. -XX:+UseParallelOldGC:启用Parallel Old垃圾回收器。
  5. -XX:+UseConcMarkSweepGC:启用CMS垃圾回收器。
  6. -XX:+UseG1GC:启用G1垃圾回收器。

垃圾回收器调优参数

  1. -XX:MaxGCPauseMillis:设置最大垃圾回收停顿时间(毫秒)。例如, -XX:MaxGCPauseMillis=200 表示将最大垃圾回收停顿时间设置为200毫秒。
  2. -XX:GCTimeRatio:设置垃圾回收时间占总时间的比例。例如, -XX:GCTimeRatio=19 表示垃圾回收时间占总时间的比例为1 / (19 + 1) = 5%。
  3. -XX:CMSInitiatingOccupancyFraction:设置CMS垃圾回收器在老年代空间占用达到多少比例时开始进行垃圾回收。默认值是68%,例如, -XX:CMSInitiatingOccupancyFraction=75 表示当老年代空间占用达到75%时开始CMS垃圾回收。

在进行JVM参数调优时,需要根据应用程序的特点和运行环境进行反复测试和调整,以找到最优的参数配置。

总结

Java垃圾回收机制是Java语言的核心特性之一,它自动管理内存,提高了编程的便利性和安全性。通过深入理解垃圾回收的判定、算法、垃圾回收器以及相关的JVM参数调优,开发人员可以更好地优化Java应用程序的性能,避免内存泄漏和性能瓶颈等问题。在实际应用中,应根据应用的特点和需求选择合适的垃圾回收器,并合理调整JVM参数,以实现最佳的性能表现。随着Java技术的不断发展,垃圾回收机制也在持续优化和改进,开发人员需要关注最新的技术动态,不断提升对垃圾回收机制的理解和应用能力。