【JVM】流程汇总

发布于:2025-08-09 ⋅ 阅读:(17) ⋅ 点赞:(0)

【JVM】流程汇总

【一】编译过程和内存分布

【1】案例程序:简单的 Java 类

先定义一个简单的 Java 类 UserDemo.java,用于后续分析:

import java.util.ArrayList;
import java.util.List;

// 用户类
class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void printInfo() {
        System.out.println("Name: " + name + ", Age: " + age);
    }
}

// 主程序类
public class UserDemo {
    // 静态变量(类级别)
    private static List<User> userList = new ArrayList<>();

    public static void main(String[] args) {
        // 局部变量(方法栈中)
        String prefix = "User_";
        for (int i = 0; i < 3; i++) {
            // 创建对象(堆中)
            User user = new User(prefix + i, 20 + i);
            userList.add(user); // 静态列表引用对象(避免被GC回收)
            user.printInfo();   // 调用方法(栈帧入栈)
        }
    }
}

【2】Java 编译过程:从.java到.class

Java 是跨平台语言,需要先编译为字节码(.class),再由 JVM 解释执行。
在这里插入图片描述

(1)编译命令

使用 javac 命令编译 UserDemo.java:

javac UserDemo.java

(2)编译结果

编译成功后,会生成两个字节码文件:
User.class:User类的字节码(包含类结构、方法指令等)
UserDemo.class:UserDemo类的字节码(包含main方法等)

(3)字节码的作用

字节码是一种中间代码,不依赖具体操作系统,仅面向 JVM。它包含:
(1)类的元信息(类名、父类、接口等)
(2)方法的指令(如创建对象、调用方法、循环等操作的二进制指令)
(3)常量池(字符串常量、类引用、方法引用等)

在这里插入图片描述

【3】Java 运行过程:从.class到 JVM 执行

使用 java 命令运行程序:

java UserDemo

运行过程可分为类加载、内存分配、代码执行三个核心阶段。

(1)类加载:将.class加载到 JVM 方法区

JVM 通过类加载器(ClassLoader) 加载.class文件到内存的方法区(JDK8 + 中为 “元空间”),加载过程分为 5 步:
(1)加载:通过类全限定名(如UserDemo)找到.class文件,读取字节流到内存。
(2)验证:校验字节码合法性(如是否符合 JVM 规范、是否有安全漏洞)。
(3)准备:为类的静态变量分配内存并设置默认值(如userList默认值为null)。
(4)解析:将常量池中的符号引用(如User类的引用)替换为直接内存地址(真实引用)。
(5)初始化:执行静态代码块和静态变量赋值(如userList = new ArrayList<>())。

(2)类加载器层级

启动类加载器(Bootstrap ClassLoader):加载 JDK 核心类(如java.lang.String)。
扩展类加载器(Extension ClassLoader):加载 JDK 扩展类。
应用程序类加载器(Application ClassLoader):加载用户自定义类(如UserDemo)。
在这里插入图片描述

(3)内存分配:JVM 各区域的存储内容

JVM 运行时内存分为 5 个区域,UserDemo运行时的内存分配如下:

(1)程序计数器(线程私有)
记录当前线程执行的字节码指令地址(如main方法中循环的当前步骤)。
(2)虚拟机栈(线程私有)
存储方法调用的栈帧:
1、局部变量表(如prefix、i、user等局部变量)
2、操作数栈(方法执行时的临时数据)
3、方法返回地址。
(3)本地方法栈(线程私有)
类似虚拟机栈,但用于执行本地方法(如Object.hashCode()等 native 方法)。
(4)堆(Heap)(线程共享)
存储对象实例:
1、new ArrayList<>()创建的集合对象
2、new User(…)创建的 3 个User对象。
(5)方法区(元空间)(线程共享)
存储类信息:
1、User和UserDemo的类结构(属性、方法定义)
2、常量池(如字符串"User_")
3、静态变量userList。

关键逻辑:
(1)main方法执行时,虚拟机栈会创建一个栈帧,包含局部变量prefix(字符串引用)、i(int 值)、user(User对象引用)。
(2)user引用指向堆中的User对象(真实数据存储在堆中)。
(3)静态变量userList存储在方法区,其引用指向堆中的ArrayList对象。

在这里插入图片描述

(4)JDK版本的JVM内存区域变化

(5)对象创建的过程

【4】代码执行:JVM 解释 / 编译字节码

JVM 通过解释器逐行解释字节码指令(如new创建对象、invokevirtual调用printInfo方法),或通过JIT 编译器(即时编译)将热点代码(频繁执行的代码)编译为本地机器码(提高执行效率)。

UserDemo的执行流程:
(1)main方法栈帧入栈,初始化局部变量prefix = “User_”。
(2)循环 3 次:
1、执行new User(…):在堆中创建User对象,调用构造方法初始化name和age。
2、将User对象引用赋值给局部变量user。
3、调用user.printInfo():虚拟机栈创建printInfo方法的栈帧,执行打印逻辑后栈帧出栈。
4、将user添加到userList(User对象被静态列表引用,不会被 GC 回收)。
(3)循环结束,main方法栈帧出栈,程序执行完成。

【5】Idea中的编译案例

(1)对Math.java文件进行反编译,得到Math.class文件

编写一个简单的Math.java文件

public class Math {

    public static final int initData = 666;
    public static User user = new User();

    public int compute(){
        int a=1;
        int b=2;
        int c=(a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        Math math=new Math();
        math.compute();
        System.out.println("test");
    }
}

(1)先执行main方法,然后找到target目录下已经编译好的Math.class文件,在控制器中打开
在这里插入图片描述
(2)此时虽然已经得到class文件,但是文件里都是字节码,不方便阅读,于是使用命令进行反编译,得到一个我们容易阅读的代码内容
在这里插入图片描述(3)在控制器中得到我们想要的字节码文件内容

public class com.itheima.Math {
  public static final int initData;

  public static com.itheima.User user;

  public com.itheima.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
       0: iconst_1 //把int类的常量1压入操作数栈---1;
       1: istore_1 //把int类的值存入局部变量1---int a=1;
       2: iconst_2 //把int类的常量2压入操作数栈---2;
       3: istore_2 //把int类的值存入局部变量2---int b=2;
       4: iload_1 //
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/itheima/Math
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      16: ldc           #6                  // String test
      18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      21: return

  static {};
    Code:
       0: new           #8                  // class com/itheima/User
       3: dup
       4: invokespecial #9                  // Method com/itheima/User."<init>":()V
       7: putstatic     #10                 // Field user:Lcom/itheima/User;
      10: return
}

(2)从Math.class开始分析流程

【1】Math.class进入JVM

Math.class文件最开始放在磁盘中,然后经过【类装载子系统】装入【方法区】,字节码执行引擎读取方法区的字节码自适应解析,边解析边运行,然后PC寄存器指向了main函数所在的位置,虚拟机开始在栈中为main函数预留一个栈帧,然后开始运行main函数,main函数里的代码被字节码执行引擎映射成本地操作系统里相应的实现,然后调用本地方法接口,本地方法运行的时候,操纵系统会为本地方法分配本地方法栈,用来储存一些临时变量,然后运行本地方法,调用操作系统APIi等等。

在这里插入图片描述

在这里插入图片描述
上图表明:jvm 虚拟机位于操作系统的堆中,并且,程序员写好的类加载到虚拟机执行的过程是:当一个 classLoder 启动的时候,classLoader 的生存地点在 jvm 中的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成了一个A字节码的对象,然后A字节码这个内存文件有两个引用一个指向A的class对象,一个指向加载自己的classLoader。

【2】认识方法区

类加载器把加载到的所有信息放入方法区,也就是说方法区里存放了类的信息,有类的static静态变量、final类型变量、field自动信息、方法信息,处理逻辑的指令集,我们仔细想想一个类里面也就这些东西。

而堆中存放是对象和数组,
1-这里的对应关系就是 “方法区–类” “堆–对象”,以“人”为例就是,堆里面放的是你这个“实实在在的人,有血有肉的”,而方法区中存放的是描述你的文字信息,如“你的名字,身高,体重,还有你的行为,如吃饭,走路等”。
2-再者我们从另一个角度理解,就是从前我们得知方法区中的类是唯一的,同步的。但是我们在代码中往往同一个类会new几次,也就是有多个实例,既然有多个实例,那么在堆中就会分配多个实例空间内存。

方法区的内容是边加载边执行,例如我们使用tomcat启动一个spring工程,通常启动过程中会加载数据库信息,配置文件中的拦截器信息,service的注解信息,一些验证信息等,其中的类信息就会率先加载到方法区。但如果我们想让程序启动的快一点就会设置懒加载,把一些验证去掉,如一些类信息的加载等真正使用的时候再去加载,这样说明了方法区的内容可以先加载进去,也可以在使用到的时候加载。

方法区是被字节码执行引擎直接执行的,所以静态信息都是在类加载的时候就创建了的,非静态的信息是在堆内存实例化对象的时候才创建,所以静态方法中不能调用非静态的方法和变量
在这里插入图片描述

【3】认识栈内存

(1)在之前学习Static关键字的时候了解到,Math math=new Math();,在这个代码执行时分为三块:

  1. Math math:负责在栈内存开辟内存空间,用来存放类对象的名称,其实就是对象的地址,此时对象还没有完成初始化,还不能使用
  2. new Math():负责在堆内存开辟内存空间,用来存放类对象,此时完成对象的初始化,对象可以使用了
  3. Math math=new Math():负责将栈内存里的地址指向堆内存里的对象,此时类对象的名字和类对象的实例合为一体,我们就可以通过类对象名称来调用对象中的信息了

(2)我们把栈放大,可以看到在栈里面,每一个方法都有一个自己的栈帧,main方法有一个栈帧,compute方法有一个栈帧,在代码中main方法还调用了compute方法,那么它们是怎么存放信息、处理信息并相互传递信息的呢?

因为先运行main方法,所以main方法先入栈,再调用compute方法,再入栈,因为栈的特点是先入后出,所以compute先出栈,接下分析compute的结构和信息处理过程。

方法栈帧的内存又可以分为4个部分:局部变量表、操作数栈、动态链接、方法出口。其中局部变量表用来存放变量的名称a、b、c等,操作数栈存放要进行赋值的操作数1、2。操作流程如下:
在这里插入图片描述
在这里插入图片描述
把操作数栈里的值赋值给局部变量表中的变量,还有其他一系列操作,操作结果就得到了 c=30,并且使用return语句把结果返回了,但是这个值是怎么传递给main方法的呢?那就是通过方法出口,把结果传递给栈中的下一个方法(方法栈帧是按照调用顺序入栈的,所以结果都是顺着往下传递),此时compute方法栈帧完成出栈。
在这里插入图片描述
在这里插入图片描述

再来分析main方法栈帧,它同样也包含局部变量表
在这里插入图片描述
在这里插入图片描述
从上面的分析可以看出,通过栈和命令使得方法完成了数据的赋值和处理,还有方法的调用和处理等操作,最后把方法的处理的结果通过方法出口传递给调用者。

【4】认识程序计数器

在Math.class文件中可以看到,每一行指令都有一个行数,如果线程在执行指令的时候突然被挂起了,等线程被唤醒后要接着上次被挂起的位置执行怎么办?

那就要给每一个方法线程配备一个程序计数器,用来标记线程执行的位置,每执行一行指令,字节码执行引擎都会命令程序计数器更新到最新的行数。

所以程序计数器的作用为:

  1. 读取指令:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 标记位置:在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

在这里插入图片描述

【5】认识堆

在main方法中对Math进行实例化,栈内存中的math地址指向堆内存中的math对象,但是math对象对应的那些属性和方法不会也都放在堆内存中,这就要讲到下一个区域方法区(元空间)了

在这里插入图片描述

【6】方法区,栈、堆之间的过程

类加载器加载的类信息放到方法区---->执行程序后,方法区的方法压入栈的栈顶---->栈执行压入栈顶的方法---->遇到new对象的情况就在堆中开辟这个类的实例空间。(这里栈是有此对象在堆中的地址的)

【二】JVM 内存清理:垃圾回收(GC)机制

当对象不再被引用时,JVM 会通过垃圾回收器自动清理其占用的内存(主要针对堆和方法区)。

【1】如何判断对象 “可回收”?

JVM 通过可达性分析判断对象是否存活:
(1)以 “GC Roots”(如虚拟机栈中的局部变量、方法区的静态变量)为起点,遍历对象引用链。
(2)若对象无法通过任何引用链连接到 GC Roots,则标记为 “可回收”。
例如:若UserDemo中userList未引用User对象(即userList.remove(user)),则User对象会被标记为可回收。

【2】垃圾回收的核心流程

以堆内存回收为例(堆分为年轻代和老年代):
(1)标记:通过可达性分析标记所有可回收对象。
(2)清除:删除标记的对象,释放内存(不同 GC 算法实现不同)。
年轻代(存放新创建的对象):采用复制算法(将存活对象复制到另一块区域,清空原区域)。
老年代(存放存活久的对象):采用标记 - 整理算法(移动存活对象到一端,清空另一端)。
(3)内存分配:新对象优先在年轻代的 Eden 区分配内存。

【3】触发时机

Minor GC:年轻代内存不足时触发(频繁,回收速度快)。
Major GC/Full GC:老年代内存不足时触发(较少,回收速度慢,会暂停所有用户线程)。

【4】垃圾回收的详细过程

JVM 垃圾回收(Garbage Collection,GC)的核心目的是自动释放不再被使用的对象所占用的内存,避免内存泄漏和溢出。其过程可分为对象存活判定、垃圾标记、垃圾回收、内存整理四个核心阶段,结合 JVM 内存分代模型(年轻代、老年代等)会有更具体的执行逻辑。以下是详细过程:

(1)触发条件:何时需要垃圾回收?

垃圾回收并非随时执行,而是当满足特定条件时触发,常见触发条件包括:
(1)年轻代空间不足:对象优先在年轻代的 Eden 区分配,当 Eden 区满时,触发Minor GC(仅回收年轻代)。
(2)老年代空间不足:当对象从年轻代晋升到老年代后,若老年代剩余空间不足以容纳新对象,触发Major GC(主要回收老年代,通常伴随 Minor GC)。
(3)方法区(元空间)不足:当加载的类、常量等信息超过方法区容量时,触发方法区回收。
(4)手动调用:通过 System.gc() 建议 JVM 执行回收(但 JVM 可忽略该建议)。

(2)阶段 1:对象存活判定

垃圾回收的前提是确定 “哪些对象已经死亡(不再被使用)”。JVM 主要通过可达性分析算法判断对象存活状态:
(1)核心原理
以 “GC Roots” 为起点,遍历对象引用链(对象之间的引用关系):
1、若一个对象能被 GC Roots 直接或间接引用,则为 “存活对象”;
2、若一个对象无法被 GC Roots 引用(引用链断裂),则为 “可回收对象”。
(2)GC Roots 包含哪些对象?
1、虚拟机栈(栈帧中的局部变量表)中引用的对象(如方法参数、局部变量);
2、方法区中类静态属性引用的对象(如 static 变量);
3、方法区中常量引用的对象(如 final 常量);
4、本地方法栈中 Native 方法引用的对象;
5、JVM 内部的引用(如类加载器、基本数据类型对应的 Class 对象)。

(3)阶段 2:垃圾标记(确定可回收对象)

通过可达性分析后,需对 “可回收对象” 进行标记,具体分为两次标记(给对象最后一次 “自救” 机会):
(1)第一次标记
所有不可达对象被初步标记为 “待回收”,但并非立即被回收。此时会检查对象是否重写了 finalize() 方法:
1、若未重写 finalize() 或该方法已执行过,则直接判定为 “可回收”;
2、若重写了且未执行过,则将对象放入 F-Queue 队列,由 JVM 自动创建的 Finalizer 线程(低优先级)执行其 finalize() 方法。
(2)第二次标记
Finalizer 线程执行完 F-Queue 中对象的 finalize() 后,JVM 会再次检查这些对象是否可达:
1、若在 finalize() 中对象重新与 GC Roots 建立引用(如赋值给其他存活对象),则被移除 “待回收” 列表;
2、若仍不可达,则最终标记为 “可回收对象”。

(4)阶段 3:垃圾回收(清除可回收对象)

标记完成后,JVM 会根据内存区域(年轻代 / 老年代)的特点,选择合适的垃圾回收算法清除可回收对象:
(1)年轻代:标记 - 复制算法(Copying)
年轻代对象存活时间短(朝生夕死),回收频繁,适合用标记 - 复制算法:
年轻代分为 1 个 Eden 区 + 2 个 Survivor 区(From/To),默认比例为 8:1:1;

回收过程:
1、新对象优先分配到 Eden 区和 Survivor From 区;
2、当 Eden 区满时,触发 Minor GC,标记 Eden 和 From 区中的存活对象;
3、将存活对象复制到 Survivor To 区(年龄计数器 +1);
4、清空 Eden 区和 From 区,交换 From 和 To 区的角色(下次回收时 To 变为 From);
5、若存活对象体积超过 To 区容量,直接晋升到老年代(“分配担保” 机制);
6、当对象年龄计数器达到阈值(默认 15,可通过 -XX:MaxTenuringThreshold 调整),也会晋升到老年代。

(2)老年代:标记 - 清除(Mark-Sweep)或标记 - 整理(Mark-Compact)
老年代对象存活时间长(存活率高),回收频率低,适合用以下算法:
1、标记 - 清除算法:
标记所有可回收对象;
直接清除这些对象,释放内存。
缺点:会产生大量内存碎片,可能导致后续大对象无法分配连续内存。

2、标记 - 整理算法:
标记所有存活对象;
将存活对象向内存空间的一端移动,集中排列;
清除边界外的所有内存(即回收对象)。
优点:无内存碎片,适合老年代(避免频繁复制大对象)。

(5)阶段 4:内存整理(可选)

回收后,部分算法(如标记 - 整理、标记 - 复制)会自动完成内存整理,目的是:
(1)消除内存碎片,保证后续大对象能分配到连续内存;
(2)提高内存分配效率(连续内存可快速定位空闲区域)。

(6)垃圾收集器的影响

不同的垃圾收集器(如 SerialGC、ParallelGC、CMS、G1、ZGC 等)会影响回收过程的效率和停顿时间:
(1)SerialGC:单线程执行回收,STW(Stop-The-World)时间长,适合简单应用;
(2)ParallelGC:多线程回收,注重吞吐量(回收时间占比低);
(3)CMS:并发标记清除,STW 时间短,适合响应时间敏感的应用;
(4)G1/ZGC:区域化分代式收集,兼顾吞吐量和响应时间,支持大堆内存。

【三】jvm常用的配置参数

JVM 参数分为标准参数(如-version)、非标准参数(如-Xms)和高级参数(如-XX:+UseG1GC),以下是开发中最常用的核心参数

【1】内存区域配置(核心)

(1)-Xms:初始堆内存大小
-Xms512m 建议与-Xmx一致,避免频繁扩容堆
(2)-Xmx:最大堆内存大小
-Xmx1024m 堆内存上限,根据服务器内存调整(如 8G 服务器可设 4G)
(3)-Xmn:年轻代内存大小(Eden+Survivor)
-Xmn256m 年轻代占堆的 1/3~1/2 较合理(小了易频繁 Minor GC)
(4)-XX:NewRatio:老年代与年轻代的比例(默认 2:1)
-XX:NewRatio=3 老年代:年轻代 = 3:1(即年轻代占堆 1/4)
(5)-XX:SurvivorRatio:Eden 区与 Survivor 区的比例(默认 8:1:1)
-XX:SurvivorRatio=4 Eden:From Survivor:To Survivor=4:1:1
(6)-XX:MetaspaceSize:元空间初始大小(JDK8+,替代永久代)
-XX:MetaspaceSize=128m 元空间不足时会扩容,直到MaxMetaspaceSize
(7)-XX:MaxMetaspaceSize:元空间最大大小
-XX:MaxMetaspaceSize=256m 避免元空间无限增长(默认无上限)
(8)-Xss:每个线程的栈内存大小
-Xss256k

【2】垃圾收集器配置

(1)-XX:+UseSerialGC 使用串行收集器(年轻代 Serial + 老年代 Serial Old) 单线程环境、小内存应用(如桌面程序)
(2)-XX:+UseParallelGC 年轻代使用 Parallel Scavenge(并行收集) 多 CPU、批处理应用(追求吞吐量)
(3)-XX:+UseParallelOldGC 老年代使用 Parallel Old(并行收集) 与UseParallelGC配合,适合吞吐量优先
(4)-XX:+UseConcMarkSweepGC 老年代使用 CMS 收集器(并发标记清除) 低延迟应用(如 Web 服务,避免长时间停顿)
(5)-XX:+UseG1GC 使用 G1 收集器(区域化分代式) 大堆(>4G)、低延迟 + 高吞吐量兼顾场景
(6)-XX:MaxGCPauseMillis G1 目标最大停顿时间(默认 200ms) -XX:MaxGCPauseMillis=100 调小可减少停顿,但可能增加 GC 频率

【3】日志与调试配置

(1)-XX:+PrintGCDetails 打印详细 GC 日志 配合-Xloggc:gc.log输出到文件
(2)-XX:+PrintGCDateStamps GC 日志带时间戳 便于分析 GC 发生时间点
(4)-XX:+HeapDumpOnOutOfMemoryError OOM 时自动生成堆转储文件(.hprof) -XX:HeapDumpPath=./oom.hprof 用于后续分析内存溢出原因
(4)-verbose:class 打印类加载日志 排查类冲突、类重复加载问题

【4】JVM 优化案例

(1)场景 1:Web 应用频繁 Minor GC,响应时间长

问题现象:
某 Spring Boot 应用(处理用户请求)频繁卡顿,监控发现每秒发生 3-5 次 Minor GC,每次耗时 50ms 以上,影响接口响应时间(P99>500ms)。

分析:
查看 GC 日志(通过-XX:+PrintGCDetails -Xloggc:gc.log),发现年轻代(Eden 区)仅 512MB,而应用每秒创建大量临时对象(如请求 DTO、JSON 序列化对象),Eden 区很快填满,导致频繁 Minor GC。
老年代使用率稳定(约 30%),无 Full GC,排除老年代问题。

优化措施:
调整年轻代大小:增大 Eden 区,减少 Minor GC 频率。
原参数:-Xms2g -Xmx2g -Xmn512m(年轻代 512M,老年代 1.5G)
新参数:-Xms4g -Xmx4g -Xmn2g(年轻代 2G,占堆的 50%,Eden 区约 1.6G)。

效果:
Minor GC 频率降至每 30 秒 1 次,每次耗时 10-20ms。
接口 P99 降至 200ms 以内,卡顿消失。

(2)场景 2:大内存应用(8G 堆)Full GC 频繁,停顿时间长

问题现象:
某数据分析应用(堆内存 8G)每小时发生 2-3 次 Full GC,每次停顿 1-2 秒,导致任务执行中断。

分析:
原使用ParallelGC(吞吐量优先),老年代采用Parallel Old收集器,Full GC 时会 Stop The World(STW),对 8G 堆进行标记 - 整理,耗时较长。
应用中存在大对象(如 100MB + 的数据集),直接进入老年代,导致老年代碎片化严重,触发 Full GC。

优化措施:
切换为 G1 收集器(适合大堆、低延迟),并设置目标停顿时间。
新参数:-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=70
(InitiatingHeapOccupancyPercent=70:堆占用 70% 时触发 G1 的混合收集,提前清理老年代)。

效果:
Full GC 消除,改为 G1 的混合收集(部分老年代区域被回收),每次 STW 停顿控制在 200ms 以内。
任务执行流畅,无中断。

【5】参数调优原则

先监控(GC 频率、内存使用率、停顿时间),再优化,避免盲目调参。
堆内存:-Xms与-Xmx一致,年轻代占比 1/3~1/2(视对象生命周期)。
收集器:小堆(<4G)用ParallelGC,大堆(>4G)用G1,低延迟用CMS(注意 CMS 的内存碎片问题)。

【四】内存溢出(OOM):当内存不足时

内存溢出(OutOfMemoryError)是指 JVM 无法为新对象分配内存,且垃圾回收也无法释放足够空间的情况。不同内存区域的溢出原因和案例如下:

【1】堆内存溢出(最常见)

原因:创建大量对象且长期被引用(无法被 GC 回收),导致堆内存耗尽。

import java.util.ArrayList;
import java.util.List;

public class HeapOOM {
    static class OOMObject {}

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject()); // 不断创建对象并引用,无法回收
        }
    }
}

设置堆内存大小(限制为 20MB):

java -Xms20m -Xmx20m HeapOOM

运行后会抛出:
java.lang.OutOfMemoryError: Java heap space(堆内存不足)。

【2】虚拟机栈溢出

原因:方法调用栈深度过大(如无限递归),或创建过多线程(每个线程占用独立栈空间)。

(1)案例 1:递归过深

public class StackOverflow {
    private int depth = 0;

    public void recursion() {
        depth++;
        recursion(); // 无限递归,栈帧不断入栈
    }

    public static void main(String[] args) {
        StackOverflow oom = new StackOverflow();
        try {
            oom.recursion();
        } catch (StackOverflowError e) {
            System.out.println("栈深度:" + oom.depth);
            throw e;
        }
    }
}

结果:抛出 StackOverflowError(栈深度超过最大限制)。

(2)案例 2:线程过多

public class StackOOM {
    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                try {
                    Thread.sleep(Integer.MAX_VALUE); // 线程不结束,占用栈空间
                } catch (InterruptedException e) {}
            }).start();
        }
    }
}

结果:抛出 OutOfMemoryError: Unable to create new native thread(栈内存不足,无法创建新线程)。

【3】方法区(元空间)溢出

原因:动态生成大量类(如反射、CGLib 代理),导致方法区存储的类信息过多。
案例代码(需引入 CGLib 依赖):

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;

public class MethodAreaOOM {
    static class OOMClass {}

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMClass.class);
            enhancer.setUseCache(false); // 禁用缓存,每次生成新类
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
            enhancer.create(); // 动态生成OOMClass的子类(新类)
        }
    }
}

运行与结果:
设置元空间大小(限制为 10MB):

java -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m MethodAreaOOM

运行后抛出:
java.lang.OutOfMemoryError: Metaspace(元空间内存不足)。

【4】内存溢出(OOM)排查与解决案例

(1)场景:堆内存溢出(Java heap space)

问题:某电商平台促销期间,商品详情页接口频繁报OutOfMemoryError: Java heap space,导致服务宕机。

(2)排查步骤

(1)开启 OOM 自动 dump:
重启服务时添加参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof,确保 OOM 时生成堆转储文件。
(2)复现问题并获取 dump:
促销流量触发 OOM 后,得到heapdump.hprof文件(约 2G)。
(3)分析堆转储文件:
使用工具MAT(Eclipse Memory Analyzer Tool) 打开 dump 文件:
查看 “Histogram”(对象直方图),发现HashMap实例占用 60% 堆内存,其中存储了大量Product对象。
查看 “Dominator Tree”(支配树),发现ProductCache类的静态变量cacheMap引用了该HashMap,且cacheMap的 size 超过 100 万(正常应缓存 10 万以内)。
(4)定位代码问题:
检查ProductCache代码,发现缓存清理逻辑错误:仅在添加时判断大小,未在过期时移除旧数据,导致cacheMap无限增长,对象无法被 GC 回收。

(3)解决措施

(1)修复缓存逻辑:
将HashMap替换为LinkedHashMap,重写removeEldestEntry方法,当缓存数量超过 10 万时自动移除最旧数据。

public class ProductCache {
    private static final int MAX_SIZE = 100000;
    private static Map<Long, Product> cacheMap = new LinkedHashMap<Long, Product>(MAX_SIZE, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<Long, Product> eldest) {
            return size() > MAX_SIZE; // 超过上限时移除最旧元素
        }
    };
}

(2)临时调大堆内存:
促销期间临时调整参数:-Xms6g -Xmx6g,避免短期内再次 OOM。
(3)监控验证:
修复后,cacheMap大小稳定在 10 万左右,堆内存使用率降至 40%,OOM 不再发生。

(4)总结流程

① 开启堆 dump(-XX:+HeapDumpOnOutOfMemoryError)→ ② 用 MAT/JProfiler 分析 dump,定位泄漏对象 → ③ 检查代码中不合理的引用(如静态集合未清理、长生命周期对象持有短生命周期对象)→ ④ 修复并验证。


网站公告

今日签到

点亮在社区的每一天
去签到