" />
文章

一篇文章说清楚Java栈堆&GC&如何设置GC

基础知识点

如果你已经对基础知识了然于胸可以跳过本节。

Java 8 移除永久代后,内存模型中的主要部分包括:

  • 堆(Heap):存储所有对象实例和静态变量。

  • 栈(Stack):用于存储局部变量、方法调用栈帧。

  • 元空间(Metaspace):用于存储类的元数据。

  • 代码缓存:用于存储编译后的本地代码(如 JIT 编译后的代码)。

1. 栈(Stack)

栈是一种后进先出(LIFO)的数据结构,主要用于存储方法调用和局部变量。栈是每个线程私有的,且随着方法的调用和结束动态管理内存。

栈的特点

  • 线程私有:每个线程都有自己的栈,不与其他线程共享。

  • 生命周期短:栈内存会随着方法的结束自动释放,不需要显式管理。

  • 存储内容:栈中存储局部变量、方法调用帧和返回地址。

栈的工作机制

每当方法被调用时,JVM 会在栈中创建一个新的栈帧,栈帧用于存储局部变量和返回地址。例如:

public void example() {
    int a = 10;
    int b = 20;
    int c = add(a, b);
}
​
public int add(int x, int y) {
    return x + y;
}

在这个例子中,example() 方法和 add() 方法的调用会创建各自的栈帧。局部变量 abxy 都存储在各自方法的栈帧中,返回值 c 存储在 example() 的栈帧中。

栈的限制

栈内存是固定大小的,无法动态扩展。因此,如果递归调用过深或方法调用过多,可能会抛出 StackOverflowError

栈中存储的内容

主要包括以下几类:

  1. 局部变量(Local Variables)

    • 栈最主要的用途之一是存储局部变量。局部变量是在方法内部定义的变量,它们仅在方法执行时存活。

    • 这些局部变量可以是基本数据类型(intfloatboolean 等)和对象引用类型(如 String 或用户定义的类)。

    注意:对于对象类型,栈中存储的只是对象的引用,对象本身存储在堆中。

    示例:

    public void method() {
        int x = 10;  // 基本数据类型,存储在栈中
        String s = "Hello";  // 引用类型,s 是对堆中 String 对象的引用
    }

    在上面的例子中,x 是一个局部变量,存储在栈中,而 s 也是存储在栈中的局部变量,但它指向的 String 对象存储在堆中。

  2. 方法调用帧(Method Call Frame)

    • 每当一个方法被调用时,都会在栈中创建一个栈帧(Stack Frame)。栈帧包含了方法的局部变量、操作数栈、返回地址等信息。

    • 当方法返回时,栈帧会被弹出,局部变量等信息随之销毁。

    栈帧包含的内容:

    • 局部变量表:存储方法的局部变量(包括方法参数)。

    • 操作数栈:用于执行字节码指令时的操作数。

    • 返回地址:用于记录方法调用完成后返回的地址。

    示例:

    public void methodA() {
        int a = 5;
        methodB(a);  // 调用 methodB 时会创建一个新的栈帧
    }
    ​
    public void methodB(int param) {
        int b = param;
    }
    • 调用 methodA() 时,栈中会创建一个 methodA() 的栈帧,存储 a 的值。

    • methodA() 调用 methodB() 时,栈中会创建一个 methodB() 的栈帧,存储 paramb 的值。

    • methodB() 执行完毕后,methodB() 的栈帧会从栈中弹出,methodA() 恢复执行。

  3. 方法参数(Method Parameters)

    • 当调用方法时,方法的参数也会存储在栈帧的局部变量表中,作为局部变量的一部分。

    • 如果是基本类型参数,值会被直接存储在栈中;如果是引用类型参数,引用会存储在栈中,但实际对象存储在堆中。

    示例:

    public void add(int x, int y) {
        int sum = x + y;  // x 和 y 是方法参数,存储在栈中
    }
  4. 操作数栈(Operand Stack)

    • JVM 在执行字节码指令时会使用栈来存储操作数。操作数栈用来存储方法执行过程中需要进行运算的数据,运算结果也会存放在操作数栈上。

    • 操作数栈是字节码执行的核心部分,在每个方法执行过程中,操作数栈负责处理所有计算和数据操作。

    示例:

    int x = 2;
    int y = 3;
    int result = x + y;  // 操作数栈用来暂存 x 和 y,进行加法操作

    在这个加法操作的背后,JVM 会将 xy 推入操作数栈,执行 iadd 指令,栈顶保存的结果会被写回到 result

  5. 返回地址(Return Address)

    • 当一个方法调用另一个方法时,JVM 会在栈帧中存储返回地址,即方法调用完成后应该返回执行的位置。当被调用的方法执行完毕后,程序会跳转回到返回地址继续执行。

    • 返回地址通常包含调用该方法的下一条指令的地址。

  6. 异常处理信息

    • 栈中也会存储与异常处理相关的信息,比如当发生异常时,栈中的调用链会用于查找合适的异常处理器。异常抛出时,栈帧会被依次弹出,直到找到合适的异常处理器为止。

栈的特点

  • 线程私有:每个线程都有自己的栈,栈中的数据不会被其他线程访问,因此栈操作是线程安全的。

  • 自动释放:当方法调用结束后,栈帧会自动释放,不需要程序员显式释放栈中的内存。

  • 生命周期短:栈中的数据的生命周期是短暂的,它们只存在于方法的调用过程中,一旦方法返回,相关的局部变量和栈帧都会被销毁。

  • 内存较小:栈的大小是有限的,如果递归过深或方法调用过多,会导致 StackOverflowError

总结一下

  • 局部变量:包括基本数据类型(intcharboolean 等)和对象引用。

  • 方法调用帧:存储方法执行的局部变量、操作数栈、返回地址等信息。

  • 方法参数:作为局部变量的一部分,存储在栈中。

  • 操作数栈:存储字节码指令执行过程中使用的操作数。

  • 返回地址:存储方法调用后的返回地址。

  • 异常处理信息:在异常抛出时,栈用于查找异常处理器。

栈是 JVM 用来管理方法调用和执行的重要数据结构,它在每个线程独立存在,主要用于存储局部变量和方法调用信息。

2. 堆(Heap)

堆是 JVM 中用于存储对象实例的内存区域,是 Java 中最大的内存区域。所有对象及数组都存储在堆中,并且是所有线程共享的。

堆的特点

  • 线程共享:堆内存由所有线程共享。

  • 生命周期长:堆中的对象生命周期由垃圾回收器管理,不会随着方法的结束自动释放。

  • 存储内容:堆中存储对象实例及数组。

堆的内存管理

堆中的内存由垃圾回收器(GC)管理。GC 会自动回收不再被引用的对象,释放内存空间。堆可以动态扩展,随着对象的创建和销毁,JVM 会自动调整堆大小。

例子

public class Person {
    String name;
    int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
​
public void createPerson() {
    Person p = new Person("Alice", 30);
}

在这个例子中,Person 对象存储在堆中,而引用 p 存储在栈中。对象的属性 nameage 也存储在堆中。

Java 堆内存的划分主要包括以下几个部分:

  • 新生代(Young Generation):所有新创建的对象首先会被分配到新生代。新生代中的对象大部分生命周期很短(即“朝生夕死”)。新生代又进一步分为:

    • Eden 区:新创建的对象首先被分配到 Eden 区。

    • Survivor 区:Eden 区对象在第一次垃圾回收后,幸存下来的对象会被转移到 Survivor 区。Survivor 区通常被分为两块:Survivor FromSurvivor To。每次 GC 后,存活的对象会在这两块区域之间复制和切换。

  • 老年代(Old Generation):经过多次新生代的垃圾回收后仍然存活的对象,会被移到老年代。老年代的对象生命周期通常较长。

  • 永久代(Permanent Generation,方法区/元空间):用于存储类的元数据、静态变量、常量等信息。在 Java 8 之后,永久代被移除,元数据存储在元空间(Metaspace)

⭐️ 重点: Java 在1.8之后移除了永久代,原来保存在永久代的内容,变更如下:

Java 8 及更高版本中,永久代(PermGen)被移除,取而代之的是 元空间(Metaspace)。因此,在 Java 8 之后,静态变量常量元数据 的存储位置和管理机制有所变化。

静态变量(Static Variable)

定义:
  • 静态变量是使用 static 关键字修饰的类级别的变量,不依赖于任何类的实例存在。它们属于类本身,而不是某个对象,因此所有实例共享同一个静态变量。

  • 静态变量在类加载时就初始化,一直存在到类被卸载。

存储位置(Java 8+):
  • 静态变量 在 Java 8 之后被存储在 堆(Heap) 中。虽然它们是类级变量,但在永久代被移除后,它们不再存储在永久代,而是转移到堆中。

  • 当类加载时,静态变量会分配内存并初始化,这些变量的生命周期与类的生命周期相同,即类一直被类加载器持有,静态变量也会一直存在。

示例:
class MyClass {
    static int count = 0;  // 静态变量
}

在上面的例子中,count 是一个静态变量,属于类 MyClass,无论创建多少个 MyClass 实例,它们都会共享这个 count 变量。

常量(Constant)

定义:
  • 常量是指在定义之后其值不能被修改的变量。Java 中常量通常使用 final 关键字修饰。

  • 常量可以是静态的,也可以是非静态的,最常见的场景是通过 static final 修饰的静态常量。

存储位置:
  • 静态常量(使用 static final 修饰的常量)也存储在 堆(Heap) 中,和静态变量一样,它们的生命周期与类的生命周期相同。

  • 编译期常量:某些 static final 修饰的基本数据类型或 String 类型常量在编译时就被内联(inlined)到字节码中,不需要存储在运行时的内存中。因此这些常量有时甚至不会占用堆内存,它们会在字节码中被直接引用。

示例:

class MyClass {
    static final int MAX_COUNT = 100;  // 静态常量
    final int MIN_COUNT = 0;  // 实例常量
}

在上面的例子中,MAX_COUNT 是一个静态常量,MIN_COUNT 是一个实例常量。MAX_COUNT 属于类 MyClass,且它的值不会改变。对于 MIN_COUNT,每个对象都会有一个单独的值,但它也是不可改变的。

元数据(Metadata)

定义:

  • 元数据是指与类结构相关的信息,通常包括类的名称、方法签名、字段描述符、方法字节码等。这些数据由 Java 虚拟机在类加载时产生,用于支持 Java 程序的运行。

  • 元数据包含的信息包括:

    • 类和接口的定义

    • 方法的字节码

    • 类的方法、字段和构造器的描述

    • 类的常量池(constant pool),用于存储类文件中的常量、方法引用、字段引用等

    • 类的类加载器信息

    • 注解等元数据

存储位置:
  • 在 Java 8 之后,元数据存储在 元空间(Metaspace) 中。元空间位于本机内存(native memory)中,而不是堆内存中。与永久代不同,元空间的大小默认是动态扩展的,因此不容易出现永久代中的 OutOfMemoryError(PermGen space)问题。

示例:

元数据是由 JVM 在运行时自动管理的,开发者无需显式操作。例如,在 Java 中,当你定义一个类时,类的定义和结构信息(元数据)会存储在元空间中:

class MyClass {
    int count;
    void doSomething() {
        System.out.println("Doing something");
    }
}

在这个例子中,MyClass 的结构信息(如 count 字段、doSomething() 方法的定义等)都会存储在元空间中。

存储位置总结

  • 静态变量:存储在堆中,属于类级别,生命周期与类一致。

  • 常量static final 常量可以存储在堆中(如果是静态的),也可能在编译时内联到字节码中,避免占用运行时内存。

  • 元数据:存储在元空间中,包括类和方法的结构信息、常量池等。

静态变量、常量、元数据的区别

特性

静态变量

常量

元数据

定义

类级别的变量,使用 static 修饰,所有实例共享

不可变的变量,通常使用 final 修饰

类的结构信息,如方法、字段、字节码等

存储位置

堆内存

静态常量在堆中或字节码中;实例常量存储在对象中

元空间

生命周期

与类的生命周期相同

与类或对象的生命周期相同

与类的生命周期相同

用途

用于存储类共享的数据

用于定义不可变的值,避免重复赋值

JVM 使用的类加载和执行信息

示例

static int counter

static final int MAX_VALUE = 100;

类的结构信息、方法、字段、常量池、注解等

3. 堆与栈的交互

堆和栈协同工作:

  • 对象引用存储在栈中:方法中的局部变量或者参数(引用类型)存储在栈中,但它们引用的对象本身存储在堆中。栈中的变量指向堆中的实际对象。

  • 对象的生命周期与栈无关:对象的生命周期受堆内存和垃圾回收机制的控制,即使创建对象的栈帧被销毁,只要有其他引用指向这个对象,它依然会存在于堆中。

  • 栈帧销毁:当方法执行完毕时,对应的栈帧会被销毁,栈中的局部变量也随之消失,但如果这些变量引用了堆中的对象,只要这些对象还有其他引用,它们仍然会存在,直到没有任何引用指向它们,才会被垃圾回收。

4. 垃圾回收的工作原理

堆中的对象通过垃圾回收(GC)机制进行管理。GC 会自动回收那些不再被引用的对象,从而释放内存,常用的垃圾回收算法包括:

  • 标记-清除算法(Mark-and-Sweep):标记出所有可达的对象(即有引用指向的对象),然后清除没有标记的对象。

  • 复制算法(Copying):将存活的对象从一个区域复制到另一个区域,清除源区域内的无用对象。

  • 分代收集(Generational Collection):将堆划分为新生代和老年代,不同的代采用不同的垃圾回收策略。新创建的对象先存放在新生代,存活时间较长的对象会移动到老年代。

5. GC 模式(GC 算法)

Java 中有多种垃圾回收算法,不同的 GC 算法适用于不同的应用场景。常见的垃圾回收器和它们的工作模式包括:

Serial GC

  • 工作方式:Serial GC 是一种单线程的垃圾回收器,它在执行 GC 时会暂停所有应用程序的工作(“Stop The World”)。

  • 新生代回收算法:采用复制算法(Copying),即将新生代存活的对象从 Eden 区和 Survivor 区的一块复制到 Survivor 区的另一块,然后清理 Eden 区。

  • 老年代回收算法:采用标记-整理算法(Mark-Compact),先标记存活的对象,然后将这些对象整理到堆的一端,清理无用对象。

  • 适用场景:适用于单处理器、内存较小的环境,如客户端程序。

Parallel GC

  • 工作方式:Parallel GC 是一种多线程的垃圾回收器,在执行垃圾回收时会使用多个线程并行地进行对象回收,同时也会暂停应用程序(“Stop The World”)。

  • 新生代回收算法:同样使用复制算法,多个线程并行进行对象复制。

  • 老年代回收算法:采用标记-整理算法

  • 适用场景:适用于多核服务器环境,能够提供较高的吞吐量。

CMS(Concurrent Mark-Sweep)GC

  • 工作方式:CMS 是一种并发垃圾回收器,专为老年代设计,它与应用程序线程并发工作,减少“Stop The World”暂停时间。

  • 新生代回收算法:通常与 Parallel GC 相同,使用复制算法

  • 老年代回收算法:采用标记-清除算法(Mark-Sweep),分为四个阶段:

    1. 初始标记:标记 GC Roots 可达的对象,此时会暂停应用程序。

    2. 并发标记:并发扫描整个堆,标记可达对象,应用程序继续运行。

    3. 重新标记:修正并发标记阶段中对象引用关系发生变化的部分,暂停应用程序。

    4. 并发清除:并发清理无用对象,应用程序继续运行。

  • 适用场景:适用于需要低延迟的应用(如交互式应用),它可以减少老年代的暂停时间。但它的缺点是垃圾回收时会产生碎片化。

G1(Garbage First)GC

  • 工作方式:G1 是一种面向多核和大内存应用的垃圾回收器,支持并发回收。它将堆划分为多个区域(Region),而不再简单地划分为新生代和老年代。

  • 新生代回收算法:使用复制算法回收新生代对象。

  • 老年代回收算法:使用标记-整理算法,G1 在回收时会先标记需要回收的区域(Region),然后并发清理,最后对堆进行整理。

  • 特点:G1 的最大特点是可以控制暂停时间,并且在内存回收时不会产生碎片。G1 更适合大内存、多处理器的环境,并且在需要可预测的暂停时间的场景中表现出色。

ZGC(Z Garbage Collector)

  • 工作方式:ZGC 是一种低延迟的垃圾回收器,它支持非常大的堆内存(高达数 TB),并且可以将 GC 暂停时间控制在 10ms 以下。

  • 新生代回收算法老年代回收算法:ZGC 采用了并发标记并发复制算法,几乎所有的操作都可以与应用程序线程并发进行,进一步减少停顿时间。

  • 适用场景:适用于对延迟要求非常高的应用,且希望减少垃圾回收对性能的影响。

Shenandoah GC

  • 工作方式:Shenandoah 与 ZGC 类似,也是一个专注于低延迟的垃圾回收器,目标是最小化 GC 暂停时间。它的特点是并发标记并发整理,几乎所有垃圾回收阶段都与应用程序并发运行。

  • 适用场景:适用于超大堆内存场景,并且对暂停时间有极高要求的应用。

具体问题

新生代与老年代的区别

新生代(Young Generation)

  • 对象生命周期短:大多数新创建的对象生命周期较短,通常会很快被回收。

  • 新生代回收(Minor GC):新生代的垃圾回收频繁,但由于对象存活时间短,所以回收通常很快。新生代通常使用复制算法

    1. 对象最初被分配在 Eden 区。

    2. 当 Eden 区满时,发生一次 Minor GC,存活的对象被复制到 Survivor 区的一块(Survivor From)。

    3. 在下一次 GC 时,存活的对象会在 Survivor 区的两块之间交换,经过多次 GC 后仍然存活的对象会被移到老年代。

老年代(Old Generation)

  • 对象生命周期长:老年代中的对象通常存活时间较长,存活率高。经过多次新生代回收后,仍然存活的对象会被移到老年代。

  • 老年代回收(Major GC/Full GC):老年代回收不如新生代频繁,但回收耗时较长。老年代的回收常用标记-整理标记-清除算法:

    1. 标记阶段:标记出所有存活的对象。

    2. 清除阶段:清除无用对象(标记-清除)或将存活对象整理到堆的一端(标记-整理),以减少内存碎片化问题。

新生代 vs 老年代的 GC 处理

  • 新生代 GC(Minor GC):发生频率高,回收速度快,通常不会引发长时间的停顿。

  • 老年代 GC(Major GC/Full GC):发生频率较低,但每次回收的时间较长,且会对应用性能产生较大的影响,通常伴随着新生代的 GC 触发。

为什么不总是使用 ZGC 和 Shenandoah GC

GC 的性能开销与适用场景

不同的垃圾回收器有不同的设计目标和适用场景。例如:

  • ZGCShenandoah GC 专注于低延迟,它们会并发执行大部分的垃圾回收任务以最小化停顿时间。虽然它们适合延迟敏感、超大内存的场景,但这种并发操作通常带来一定的吞吐量损失,适合对停顿时间要求极高的场景。

  • Parallel GCG1 GC 更侧重于吞吐量和整体系统的性能效率,适合高吞吐量、大批量任务处理的服务(如后台批处理系统、数据处理系统等)。它们在一些场景下能够更好地利用多核 CPU,确保应用获得较高的 CPU 利用率。

核心问题是,ZGC 和 Shenandoah 的设计在某些情况下会有比 Parallel GC 或 G1 GC 更高的 CPU 开销,并发 GC 操作会占用更多的资源,因此这些垃圾回收器在没有低延迟要求的应用中并不是最优解。

支持与稳定性

  • Java 版本兼容性:ZGC 和 Shenandoah GC 都是在 Java 11 之后才引入的,对于早期版本的 Java,无法使用这些新的 GC。即使在较新的版本中,它们也需要适当的配置和调优来发挥最佳性能。

  • 社区支持与成熟度:ZGC 和 Shenandoah 虽然是新一代的低延迟垃圾回收器,但相较于已经成熟稳定的 Parallel GC 和 CMS,仍然在一些特定场景中需要持续调优和更新。

如何选择不同服务使用不同的 GC?

选择垃圾回收器时,应根据应用的特点、硬件资源和业务需求来决定。以下是常见场景下 GC 选择的指南:

基于性能目标的选择

  • 低延迟应用(如金融交易系统、在线游戏、实时监控系统等):选择 ZGCShenandoah GC。这些垃圾回收器可以将 GC 暂停时间降低到 10 毫秒以下,适合低延迟和超大堆内存(如 100GB 以上)的场景。

  • 高吞吐量应用(如批处理、大数据处理、后台服务等):选择 Parallel GC。它能够最大化吞吐量,减少 GC 开销对 CPU 的影响。

  • 兼顾低延迟与高吞吐量(如 Web 服务器、大型在线应用):选择 G1 GC。G1 在控制延迟的同时能够保持较好的吞吐量,适合一般的服务器应用。

基于内存大小的选择

  • 小到中等堆内存(<4GB):可以使用 Serial GCParallel GC,因为这些垃圾回收器在小堆内存中效率较高,能够快速回收内存并且暂停时间较短。

  • 大堆内存(>4GB,尤其是 >10GB):推荐使用 G1 GCZGCShenandoah GC,它们能够在较大的内存堆中有效避免长时间的暂停。

其他考虑因素

  • 多核 CPUParallel GCG1 GC 能够更好地利用多核 CPU 进行并行回收,因此在 CPU 核数较多的环境中表现更好。

  • 超大内存(>100GB):ZGC 和 Shenandoah 能够处理超大内存堆,它们的设计就是为了解决大堆内存带来的长时间停顿问题。

如何判断问题是否由 GC 引起

在实际运行中,如果系统出现性能问题,比如响应时间突然变长、系统卡顿、CPU 使用率异常上升,可能是垃圾回收导致的。以下是判断 GC 是否导致问题的步骤:

监控 GC 日志

Java 提供了丰富的工具和选项来监控和分析垃圾回收活动:

  • 启用 GC 日志:可以通过 JVM 参数开启 GC 日志记录。

    -Xlog:gc*  # 在 Java 9 及更高版本
    -XX:+PrintGCDetails  # 在早期版本
  • 分析日志:查看 GC 日志中是否存在较长的 GC 暂停时间(特别是 Full GC)。例如,Full GC 通常是整个堆的回收,会导致较长的暂停。

    • 如果你看到频繁的 Full GC,通常说明老年代内存不足,可能需要调整堆大小或使用不同的 GC。

    • 检查 Minor GC 的频率是否异常高,可能说明新生代过小导致频繁的垃圾回收。

监控工具

使用 JVM 的监控工具可以帮助实时监控 GC 的行为:

  • JVisualVM:Java 提供的可视化监控工具,能展示 GC 活动和内存使用情况。

  • JConsole:监控 JVM 内存的工具,可以实时查看 GC 的运行频率和内存堆使用情况。

  • 第三方工具:如 PrometheusGrafana,可以通过 JMX 监控 JVM 指标,结合图表监控 GC 停顿和内存使用情况。

JVM 堆转储(Heap Dump)分析

  • 使用 heap dump 工具(如 jmap)获取应用程序的堆转储,分析堆内存中对象的分布情况。如果发现大量不必要的对象或内存泄漏,这可能会导致频繁的垃圾回收。

  • MAT(Memory Analyzer Tool) 可以分析 heap dump 并找出内存泄漏问题,帮助判断是否有对象未被回收。

如何解决 GC 问题?

一旦确认 GC 是性能问题的根源,接下来可以考虑以下的解决措施:

调整堆大小

  • 增加堆大小:如果发现 Full GC 频繁发生,可以增加堆的最大大小,确保老年代有足够的空间。

    -Xmx2g  # 设置堆的最大大小为 2GB
  • 调整新生代大小:可以通过 -Xmn 参数调整新生代的大小。如果新生代过小,可能会导致频繁的 Minor GC。增大新生代可以减少 Minor GC 的频率。

选择合适的 GC 算法

  • 如果低延迟是关键,尝试使用 ZGCShenandoah GC

  • 如果你发现 Full GC 过于频繁,而堆并不大,可能可以使用 G1 GC,它能够有效地避免 Full GC 造成的长时间暂停。

GC 调优

  • G1 GC 调优:G1 提供了许多可以调优的参数。例如,可以通过设置 -XX:MaxGCPauseMillis 来调整最大 GC 暂停时间,从而优化回收过程中的性能。

  • Parallel GC 调优:可以调整并行垃圾回收的线程数量(-XX:ParallelGCThreads)来提高 GC 性能。

减少对象的生成

  • 尽量减少短命对象的创建频率,特别是在大对象和频繁的对象分配上。如果能减少对象的生成,将减少 GC 的压力。

  • 使用对象池(Object Pool)模式,可以重用对象,避免频繁创建和销毁对象。

优化代码中的内存使用

  • 如果在 heap dump 中发现有些对象没有被及时回收,可以检查代码中的内存管理,确保没有未被回收的引用(如缓存中未清除的对象)。

  • 检查是否有循环引用或不必要的长时间持有的对象引用导致对象无法被垃圾回收。

总结:

  1. ZGC 和 Shenandoah GC 虽然在低延迟场景中表现出色,但它们并不适用于所有场景。在高吞吐量的场景中,Parallel GCG1 GC 可能是更好的选择。 2

. 选择 GC 的依据 应该是应用的具体性能需求、硬件环境以及堆的大小。低延迟和大堆内存适合 ZGC/Shenandoah,而高吞吐量的应用适合 Parallel GC。

  1. 监控 GC 和分析 GC 日志是发现 GC 问题的关键步骤,可以通过日志、监控工具和 heap dump 来确定问题。

  2. 调整 GC 策略和参数、增加堆大小、优化内存使用是解决 GC 问题的常用方法。

实际问题排查

使用 Arthas 分析高 CPU 问题

curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

选择目标Java进程进行挂载后,您将看到Arthas的启动信息,表明它已准备好接受命令。

排除GC影响

根据Arthas的使用指导,首先运行dashboard命令来检查系统状态,重点关注Memory部分的信息。这一步是为了排除内存使用满或GC频率过高导致的CPU升高。观察:

  • 内存使用情况:是否接近满载?

  • GC计数:频率是否异常高?

定位高CPU消耗线程

dashboard命令的输出中,继续关注进程相关信息,找出CPU消耗较高的线程。注意排在列表前面的线程,思考它们可能与程序的哪部分操作相关。

深入分析高CPU线程

使用thread -n 8命令来深入查看最忙的8个线程的堆栈信息。这将帮助您定位到具体哪个方法或代码块消耗了大量CPU资源。仔细查看这些线程的堆栈跟踪,通常最频繁出现的方法就是性能瓶颈所在。

解释说明

  • 排除GC影响:因为频繁的垃圾回收也会占用CPU资源,因此首先需要确认CPU高负载是否由于GC引起。

  • 定位高CPU线程:通过查看线程信息,可以直接找到导致CPU过载的源头,这是问题定位的关键步骤。

  • 深入分析:通过堆栈信息,我们可以具体到代码层面,了解哪些方法执行时间过长,从而进行针对性的优化。

以上步骤依托于Arthas提供的功能,能够有效地帮助您诊断并定位导致CPU使用率过高的原因。如果在执行过程中遇到任何疑问或需要进一步的帮助,请随时告知。

arthas 怎么排查cpu占用过高?

排除GC影响

  • 运行dashboard命令,关注屏幕中间Memory相关的信息。检查内存使用情况(usage)是否已满以及GC(垃圾回收)频率是否过高。如果GC频繁,可能说明存在内存泄露或不当的内存使用模式导致CPU被频繁用于GC操作。

定位高CPU消耗的线程

  • 继续使用dashboard命令,这次重点关注屏幕上部的进程信息,识别出哪些线程占据了较高的CPU资源。思考这些线程与程序的哪部分逻辑相关联。

深入分析高CPU消耗线程

  • 执行thread -n 8命令来查看最繁忙的8个线程的堆栈信息。这些堆栈信息会展示线程当前主要停留在哪个方法上执行,从而帮助定位到CPU消耗较多的具体方法或代码块。

解释上述步骤

  • 通过排除GC影响,我们首先确定问题是否由内存管理不当引起,因为频繁的GC活动也会占用大量CPU资源。

  • 定位到高CPU消耗的线程后,我们能更有针对性地调查问题,而不是盲目地在整个应用中搜索。

  • 深入分析线程堆栈是关键一步,它直接指向了问题发生的代码位置,为后续的代码优化或问题修复提供了准确的方向。

综上所述,利用Arthas的这一系列命令,你可以逐步缩小问题范围,最终定位到造成CPU占用过高的具体原因,并据此采取相应的优化措施。

License:  CC BY 4.0