Java面试八股文-JVM篇

1. JVM内存结构

1.1 JVM内存结构包括哪些部分?

JVM内存结构是Java虚拟机在运行时使用的内存区域,不同的内存区域承担不同的职责。JVM内存结构主要包括以下几个部分:

内存区域 作用 线程共享 异常类型 特点
程序计数器 存储当前线程执行的字节码指令地址 线程私有,生命周期与线程相同
虚拟机栈 存储局部变量、操作数栈、方法返回地址等 StackOverflowError、OutOfMemoryError 线程私有,生命周期与线程相同
本地方法栈 存储本地方法的执行状态 StackOverflowError、OutOfMemoryError 为Native方法服务
存储对象实例和数组 OutOfMemoryError JVM中最大的内存区域
方法区 存储类信息、常量、静态变量、即时编译后的代码等 OutOfMemoryError 线程共享,生命周期与JVM相同

详细说明

1. 程序计数器(Program Counter Register)

  • 作用:存储当前线程执行的字节码指令地址,是线程私有的内存区域
  • 特点
    • 如果线程正在执行Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址
    • 如果线程正在执行Native方法,计数器值为空(Undefined)
    • 是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
  • 示例
    1
    2
    3
    4
    5
    6
    7
    public class ProgramCounterExample {
    public static void main(String[] args) {
    int a = 1; // 程序计数器记录这条指令的地址
    int b = 2; // 程序计数器记录这条指令的地址
    int c = a + b; // 程序计数器记录这条指令的地址
    }
    }

2. 虚拟机栈(VM Stack)

  • 作用:描述Java方法执行的内存模型,每个方法在执行时都会创建一个栈帧
  • 栈帧结构
    • 局部变量表:存储方法参数和局部变量
    • 操作数栈:存储方法执行过程中的中间结果
    • 动态链接:指向运行时常量池中该栈帧所属方法的引用
    • 方法返回地址:方法正常退出或异常退出的地址
  • 异常情况
    • StackOverflowError:线程请求的栈深度大于JVM所允许的深度
    • OutOfMemoryError:栈扩展时无法申请到足够的内存
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class StackExample {
    public static void main(String[] args) {
    method1();
    }

    public static void method1() {
    int a = 1; // 局部变量表
    int b = 2; // 局部变量表
    int result = a + b; // 操作数栈
    method2(); // 新的栈帧入栈
    }

    public static void method2() {
    // 栈帧结构
    // 局部变量表、操作数栈、动态链接、方法返回地址
    }
    }

3. 本地方法栈(Native Method Stack)

  • 作用:为虚拟机使用到的Native方法服务
  • 特点:与虚拟机栈类似,只是为Native方法服务
  • 异常情况:与虚拟机栈相同

4. 堆(Heap)

  • 作用:存储对象实例和数组,是JVM中最大的内存区域
  • 特点
    • 所有线程共享
    • 在虚拟机启动时创建
    • 是垃圾收集器管理的主要区域
    • 可以处于物理上不连续的内存空间中
  • 内存划分
    • 年轻代(Young Generation):新创建的对象首先分配到这里
    • 老年代(Old Generation):经过多次垃圾回收后仍然存活的对象
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class HeapExample {
    public static void main(String[] args) {
    // 对象实例存储在堆中
    Object obj = new Object();

    // 数组存储在堆中
    int[] array = new int[100];

    // 字符串对象存储在堆中
    String str = new String("Hello");

    // 垃圾回收主要针对堆内存
    obj = null; // 对象变为垃圾,等待回收
    }
    }

5. 方法区(Method Area)

  • 作用:存储类信息、常量、静态变量、即时编译后的代码等
  • 特点
    • 所有线程共享
    • 生命周期与JVM相同
    • 是一个逻辑概念,不同JVM实现不同
  • JDK版本差异
    • JDK 7及之前:使用永久代(Permanent Generation)实现
    • JDK 8及之后:使用元空间(Metaspace)实现
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class MethodAreaExample {
    // 静态变量存储在方法区
    private static int staticVar = 10;

    // 常量存储在方法区
    public static final String CONSTANT = "Constant";

    // 类信息存储在方法区
    public void method() {
    // 方法代码存储在方法区
    System.out.println("Method");
    }
    }

1.2 堆内存的结构?

堆内存是JVM中最大的内存区域,用于存储对象实例和数组。堆内存的结构可以分为以下几个部分:

1
2
3
4
5
6
7
8
9
10
堆内存结构:
┌─────────────────────────────────────────────────────────┐
│ 堆内存(Heap) │
├─────────────────────┬───────────────────────────────────┤
│ 年轻代(Young) │ 老年代(Old) │
│ 占堆内存的1/3 │ 占堆内存的2/3 │
├──────┬──────┬──────┤ │
│ Eden │ S0 │ S1 │ │
│ 8/10 │ 1/10 │ 1/10 │ │
└──────┴──────┴──────┴───────────────────────────────────┘

详细说明

1. 年轻代(Young Generation)

  • 作用:存储新创建的对象
  • 占比:约占堆内存的1/3
  • 结构
    • Eden区:新创建的对象首先分配到这里,约占年轻代的8/10
    • Survivor 0区(From区):经过一次垃圾回收后存活的对象,约占年轻代的1/10
    • Survivor 1区(To区):经过多次垃圾回收后存活的对象,约占年轻代的1/10
  • 垃圾回收:使用复制算法,效率高
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class YoungGenerationExample {
    public static void main(String[] args) {
    // 新对象分配到Eden区
    Object obj1 = new Object();
    Object obj2 = new Object();

    // Eden区满时,触发Minor GC
    // 存活的对象复制到Survivor区
    // 经过多次GC后,对象晋升到老年代
    }
    }

2. 老年代(Old Generation)

  • 作用:存储经过多次垃圾回收后仍然存活的对象
  • 占比:约占堆内存的2/3
  • 对象来源
    • 年轻代中经过多次垃圾回收后仍然存活的对象
    • 大对象直接进入老年代
    • 长期存活的对象
  • 垃圾回收:使用标记-清除或标记-整理算法,效率较低
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class OldGenerationExample {
    public static void main(String[] args) {
    // 大对象直接进入老年代
    byte[] largeArray = new byte[1024 * 1024 * 10]; // 10MB

    // 长期存活的对象会晋升到老年代
    Object longLivedObj = new Object();
    for (int i = 0; i < 100; i++) {
    // 模拟对象长期存活
    System.out.println(longLivedObj);
    }
    }
    }

3. 对象晋升规则

  • 年龄阈值:对象在Survivor区中每经过一次Minor GC,年龄增加1,当年龄达到阈值(默认为15)时,晋升到老年代
  • 动态年龄判定:如果Survivor区中相同年龄所有对象大小的总和大于Survivor区的一半,年龄大于或等于该年龄的对象可以直接进入老年代
  • 空间担保:在发生Minor GC之前,虚拟机检查老年代最大可用的连续空间是否大于新生代所有对象的总大小,如果大于,则Minor GC是安全的

4. 内存分配策略

  • 对象优先在Eden分配:大多数情况下,对象在新生代Eden区分配
  • 大对象直接进入老年代:大对象(需要大量连续内存空间的对象)直接在老年代分配
  • 长期存活对象进入老年代:对象在Survivor区中经过多次GC后晋升到老年代
  • 空间分配担保:确保老年代有足够的空间容纳新生代所有对象

1.3 方法区和元空间的区别?

方法区元空间是JVM中用于存储类信息、常量、静态变量等数据的内存区域,它们在不同JDK版本中有不同的实现:

对比项 方法区(永久代) 元空间
JDK版本 JDK 7及之前 JDK 8及之后
内存位置 JVM堆内存 本地内存
大小限制 有固定大小,容易溢出 理论上无限制,受系统内存限制
垃圾回收 较少回收 较少回收
配置参数 -XX:PermSize、-XX:MaxPermSize -XX:MetaspaceSize、-XX:MaxMetaspaceSize
存储内容 类信息、常量、静态变量 类信息、常量、静态变量
内存溢出 java.lang.OutOfMemoryError: PermGen space java.lang.OutOfMemoryError: Metaspace

详细说明

1. 方法区(永久代)

  • 特点
    • 是JVM规范中的概念,永久代是HotSpot虚拟机对方法区的实现
    • 使用JVM堆内存,有固定大小
    • 容易发生内存溢出
    • 垃圾回收效率低
  • 存储内容
    • 类信息:类的版本、字段、方法、接口等
    • 常量:字符串常量、final常量
    • 静态变量:类的静态字段
    • 即时编译后的代码:JIT编译器生成的代码
  • 配置参数
    1
    2
    3
    4
    5
    # 设置永久代初始大小
    -XX:PermSize=128m

    # 设置永久代最大大小
    -XX:MaxPermSize=256m

2. 元空间

  • 特点
    • 是JDK 8中方法区的实现
    • 使用本地内存,不受JVM堆内存限制
    • 理论上无大小限制,受系统内存限制
    • 不容易发生内存溢出
    • 垃圾回收效率相对较高
  • 存储内容
    • 类信息:类的版本、字段、方法、接口等
    • 常量:字符串常量、final常量(字符串常量池移到堆中)
    • 静态变量:类的静态字段
    • 即时编译后的代码:JIT编译器生成的代码
  • 配置参数
    1
    2
    3
    4
    5
    # 设置元空间初始大小
    -XX:MetaspaceSize=128m

    # 设置元空间最大大小
    -XX:MaxMetaspaceSize=256m

3. 为什么要用元空间替代永久代?

  • 解决内存溢出问题:永久代有固定大小,容易发生内存溢出;元空间使用本地内存,不容易溢出
  • 提高垃圾回收效率:永久代的垃圾回收效率低;元空间的垃圾回收效率相对较高
  • 简化JVM架构:永久代是JVM堆内存的一部分,增加了JVM的复杂性;元空间使用本地内存,简化了JVM架构
  • 与JRockit统一:JRockit虚拟机没有永久代,使用元空间可以与JRockit统一

4. 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MetaspaceExample {
// 静态变量存储在方法区/元空间
private static int staticVar = 10;

// 常量存储在方法区/元空间
public static final String CONSTANT = "Constant";

public static void main(String[] args) {
// 动态生成类,会占用元空间
for (int i = 0; i < 10000; i++) {
try {
// 使用反射动态加载类
Class.forName("com.example.Class" + i);
} catch (ClassNotFoundException e) {
// 类不存在
}
}
}
}

1.4 什么是直接内存?

直接内存(Direct Memory)是指通过Native方法分配的内存,不属于JVM堆内存的一部分。它主要用于NIO(New I/O)操作,可以提高I/O性能。

特点 说明
内存位置 本地内存,不属于JVM堆内存
分配方式 通过Native方法分配,如Unsafe.allocateMemory()
释放方式 需要手动释放,或通过垃圾回收器回收
访问速度 比堆内存快,减少了数据在Java堆和Native堆之间复制
分配成本 分配和释放成本较高
内存限制 受系统内存限制
异常类型 OutOfMemoryError: Direct buffer memory

详细说明

1. 直接内存的作用

  • 提高I/O性能:直接内存可以直接与操作系统交互,减少了数据在Java堆和Native堆之间的复制
  • 适用于大文件操作:对于大文件的读写操作,使用直接内存可以提高性能
  • NIO操作:Java NIO库使用直接内存进行I/O操作

2. 直接内存的优缺点

优点

  • 访问速度快:直接内存可以直接与操作系统交互,减少了数据复制
  • 提高I/O性能:适用于大文件操作和网络传输
  • 减少GC压力:直接内存不受JVM垃圾回收的管理

缺点

  • 分配成本高:直接内存的分配和释放成本较高
  • 难以管理:需要手动释放,或依赖垃圾回收器回收
  • 内存限制:受系统内存限制,可能导致内存溢出

3. 直接内存的使用场景

  • 大文件读写:使用NIO进行大文件的读写操作
  • 网络传输:使用NIO进行网络数据传输
  • 高性能计算:需要频繁进行内存操作的场景

4. 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.nio.ByteBuffer;

public class DirectMemoryExample {
public static void main(String[] args) {
// 分配直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB

// 写入数据
directBuffer.put("Hello, Direct Memory!".getBytes());

// 读取数据
directBuffer.flip();
byte[] data = new byte[directBuffer.remaining()];
directBuffer.get(data);
System.out.println(new String(data));

// 直接内存会被垃圾回收器回收,也可以手动释放
// 但不建议手动释放,因为可能导致不可预知的问题
}
}

5. 直接内存与堆内存的对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.nio.ByteBuffer;

public class MemoryComparison {
public static void main(String[] args) {
// 堆内存
ByteBuffer heapBuffer = ByteBuffer.allocate(1024); // 分配在JVM堆中
// 优点:分配速度快,由JVM管理
// 缺点:I/O操作时需要复制到本地内存

// 直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 分配在本地内存中
// 优点:I/O操作时不需要复制,性能高
// 缺点:分配速度慢,需要手动管理或依赖GC

// 使用场景:
// 1. 频繁的I/O操作:使用直接内存
// 2. 短期使用的小数据:使用堆内存
// 3. 大文件操作:使用直接内存
}
}

6. 直接内存的配置参数

1
2
# 设置直接内存最大大小
-XX:MaxDirectMemorySize=256m

7. 注意事项

  • 直接内存不受JVM堆内存大小的限制,但受系统内存限制
  • 直接内存的分配和释放成本较高,不适合频繁分配和释放
  • 直接内存适用于大文件操作和网络传输,不适用于小数据操作
  • 直接内存可能导致内存溢出,需要合理配置大小

2. 垃圾回收

2.1 什么是垃圾?如何判断一个对象是否是垃圾?

垃圾是指不再被程序引用的对象。判断一个对象是否是垃圾的方法:

  • 引用计数法:统计对象被引用的次数,当引用次数为0时,认为是垃圾
  • 可达性分析:从GC Roots出发,遍历对象引用链,不可达的对象被认为是垃圾

2.2 GC Roots包括哪些?

GC Roots包括:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 活跃线程引用的对象

2.3 垃圾回收算法有哪些?

垃圾回收算法是JVM用于回收不再使用的对象内存的核心技术,主要包括以下几种:

2.3.1 标记-清除算法(Mark-Sweep)

基本原理

  1. 标记阶段:从GC Roots出发,标记所有可达的对象
  2. 清除阶段:遍历整个堆,清除未被标记的对象

优点:实现简单
缺点

  • 产生内存碎片
  • 标记和清除过程效率较低

适用场景:适用于对象存活率较高的老年代

2.3.2 标记-整理算法(Mark-Compact)

基本原理

  1. 标记阶段:与标记-清除算法相同,标记所有可达的对象
  2. 整理阶段:将所有存活的对象整理到内存的一端
  3. 清除阶段:清除边界以外的内存

优点

  • 解决了内存碎片问题
  • 不需要额外的内存空间

缺点:整理过程需要移动对象,效率较低

适用场景:适用于对象存活率较高的老年代

2.3.3 复制算法(Copying)

基本原理

  1. 将内存分为大小相等的两块,每次只使用其中一块
  2. 当使用的那块内存满了,将存活的对象复制到另一块
  3. 清除使用过的那块内存

优点

  • 没有内存碎片
  • 分配内存时只需要指针移动,效率高

缺点

  • 内存利用率低,只能使用一半的内存

适用场景:适用于对象存活率较低的年轻代

2.3.4 分代收集算法(Generational Collection)

基本原理

  • 根据对象的生命周期,将内存分为年轻代和老年代
  • 年轻代使用复制算法
  • 老年代使用标记-清除或标记-整理算法

优点

  • 结合了不同算法的优点
  • 针对不同代的特点选择合适的算法

适用场景:现代JVM的默认垃圾回收策略

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
年轻代(Young Generation):
┌─────────────┐
│ Eden │ → 新创建的对象首先分配到这里
├─────────────┤
│ Survivor 0 │ → 经过一次垃圾回收后存活的对象
├─────────────┤
│ Survivor 1 │ → 经过多次垃圾回收后存活的对象
└─────────────┘

老年代(Old Generation):
┌─────────────┐
│ Old │ → 经过多次垃圾回收后仍然存活的对象
└─────────────┘

元空间(Metaspace):
┌─────────────┐
│ Metaspace │ → 存储类信息、常量、静态变量等
└─────────────┘

垃圾回收过程

  1. 新对象分配到Eden区
  2. Eden区满时,触发Minor GC,存活对象复制到Survivor 0区
  3. 再次触发Minor GC时,Eden区和Survivor 0区的存活对象复制到Survivor 1区
  4. 重复上述过程,对象在Survivor区之间复制
  5. 当对象年龄达到阈值(默认为15),晋升到老年代
  6. 老年代满时,触发Major GC或Full GC

2.4 垃圾回收器有哪些?

垃圾回收器是JVM中用于执行垃圾回收的具体实现,不同的垃圾回收器有不同的特点和适用场景。以下是JVM中常见的垃圾回收器:

垃圾回收器 类型 适用区域 特点 适用场景 JDK版本
Serial 单线程 年轻代 简单高效,单线程执行 客户端应用、小内存应用 JDK 1.3+
ParNew 多线程 年轻代 Serial的多线程版本 服务端应用、配合CMS使用 JDK 1.4+
Parallel Scavenge 多线程 年轻代 注重吞吐量,自适应调节策略 后台计算、批处理应用 JDK 1.4+
Serial Old 单线程 老年代 Serial的老年代版本 客户端应用、CMS后备方案 JDK 1.3+
Parallel Old 多线程 老年代 Parallel Scavenge的老年代版本 后台计算、批处理应用 JDK 1.6+
CMS 并发 老年代 注重响应时间,并发标记清除 互联网应用、Web应用 JDK 1.5+
G1 并发 整个堆 分区垃圾回收,可预测停顿时间 大内存应用、多核处理器 JDK 1.7+
ZGC 并发 整个堆 低延迟,停顿时间不超过10ms 大内存应用、低延迟应用 JDK 11+
Shenandoah 并发 整个堆 低延迟,停顿时间不超过10ms 大内存应用、低延迟应用 JDK 12+

详细说明

1. Serial垃圾回收器

  • 特点
    • 单线程执行垃圾回收
    • 简单高效,没有线程交互开销
    • 垃圾回收时需要暂停所有工作线程(Stop The World)
  • 工作原理
    • 新生代采用复制算法
    • 老年代采用标记-整理算法
  • 适用场景
    • 客户端应用
    • 单核处理器环境
    • 小内存应用(几百MB)
  • 配置参数
    1
    2
    # 启用Serial垃圾回收器
    -XX:+UseSerialGC

2. ParNew垃圾回收器

  • 特点
    • Serial的多线程版本
    • 使用多个线程进行垃圾回收
    • 垃圾回收时需要暂停所有工作线程
  • 工作原理
    • 新生代采用复制算法
    • 多线程并行执行垃圾回收
  • 适用场景
    • 服务端应用
    • 多核处理器环境
    • 配合CMS使用
  • 配置参数
    1
    2
    # 启用ParNew垃圾回收器
    -XX:+UseParNewGC

3. Parallel Scavenge垃圾回收器

  • 特点
    • 注重吞吐量(运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))
    • 自适应调节策略
    • 多线程并行执行垃圾回收
  • 工作原理
    • 新生代采用复制算法
    • 多线程并行执行垃圾回收
  • 适用场景
    • 后台计算应用
    • 批处理应用
    • 科学计算应用
  • 配置参数
    1
    2
    3
    4
    5
    6
    7
    8
    # 启用Parallel Scavenge垃圾回收器
    -XX:+UseParallelGC

    # 设置最大垃圾收集停顿时间
    -XX:MaxGCPauseMillis=200

    # 设置吞吐量大小
    -XX:GCTimeRatio=99

4. CMS(Concurrent Mark Sweep)垃圾回收器

  • 特点
    • 注重响应时间
    • 并发标记清除,减少停顿时间
    • 使用标记-清除算法
  • 工作原理
    1. 初始标记:标记GC Roots直接关联的对象(需要STW)
    2. 并发标记:并发标记所有可达对象
    3. 重新标记:修正并发标记期间变动的对象(需要STW)
    4. 并发清除:并发清除未标记的对象
  • 缺点
    • 对CPU资源敏感
    • 无法处理浮动垃圾
    • 产生内存碎片
  • 适用场景
    • 互联网应用
    • Web应用
    • 需要快速响应的应用
  • 配置参数
    1
    2
    3
    4
    5
    6
    7
    8
    # 启用CMS垃圾回收器
    -XX:+UseConcMarkSweepGC

    # 设置CMS触发阈值
    -XX:CMSInitiatingOccupancyFraction=75

    # 启用CMS压缩
    -XX:+UseCMSCompactAtFullCollection

5. G1(Garbage First)垃圾回收器

  • 特点
    • 分区垃圾回收,将堆划分为多个大小相等的Region
    • 可预测停顿时间
    • 不会产生内存碎片
  • 工作原理
    1. 初始标记:标记GC Roots直接关联的对象(需要STW)
    2. 并发标记:并发标记所有可达对象
    3. 最终标记:修正并发标记期间变动的对象(需要STW)
    4. 筛选回收:根据期望的停顿时间,回收价值最大的Region(需要STW)
  • 优点
    • 可预测停顿时间
    • 不会产生内存碎片
    • 适用于大内存应用
  • 适用场景
    • 大内存应用(6GB以上)
    • 多核处理器
    • 需要可预测停顿时间的应用
  • 配置参数
    1
    2
    3
    4
    5
    6
    7
    8
    # 启用G1垃圾回收器
    -XX:+UseG1GC

    # 设置最大垃圾收集停顿时间
    -XX:MaxGCPauseMillis=200

    # 设置Region大小
    -XX:G1HeapRegionSize=16m

6. ZGC(Z Garbage Collector)垃圾回收器

  • 特点
    • 低延迟,停顿时间不超过10ms
    • 支持大内存(TB级别)
    • 并发整理,不产生内存碎片
  • 工作原理
    • 使用着色指针和读屏障技术
    • 并发标记、并发转移、并发重定位
  • 优点
    • 停顿时间极短
    • 支持大内存
    • 不产生内存碎片
  • 适用场景
    • 大内存应用(TB级别)
    • 低延迟应用
    • 需要快速响应的应用
  • 配置参数
    1
    2
    3
    4
    5
    # 启用ZGC垃圾回收器
    -XX:+UseZGC

    # 设置最大堆内存
    -Xmx16g

7. Shenandoah垃圾回收器

  • 特点
    • 低延迟,停顿时间不超过10ms
    • 支持大内存
    • 并发整理,不产生内存碎片
  • 工作原理
    • 使用转发指针和读屏障技术
    • 并发标记、并发转移、并发重定位
  • 优点
    • 停顿时间极短
    • 支持大内存
    • 不产生内存碎片
  • 适用场景
    • 大内存应用
    • 低延迟应用
    • 需要快速响应的应用
  • 配置参数
    1
    2
    3
    4
    5
    # 启用Shenandoah垃圾回收器
    -XX:+UseShenandoahGC

    # 设置最大堆内存
    -Xmx16g

8. 垃圾回收器选择建议

应用场景 推荐垃圾回收器 原因
客户端应用 Serial 简单高效,适合小内存
后台计算应用 Parallel Scavenge + Parallel Old 注重吞吐量
Web应用 G1 可预测停顿时间,适合大内存
低延迟应用 ZGC / Shenandoah 停顿时间极短
小内存应用 Serial / Parallel Scavenge 简单高效
大内存应用 G1 / ZGC / Shenandoah 支持大内存,低延迟

2.5 什么是Minor GC、Major GC和Full GC?

垃圾回收类型是根据垃圾回收的范围和目标划分的,不同的垃圾回收类型有不同的特点和触发条件:

GC类型 回收区域 触发条件 停顿时间 特点
Minor GC 年轻代 Eden区满时 短(几毫秒到几十毫秒) 频率高,速度快
Major GC 老年代 老年代空间不足时 较长(几百毫秒到几秒) 频率低,速度慢
Full GC 整个堆 System.gc()、老年代空间不足、方法区空间不足等 长(几秒到几十秒) 频率最低,速度最慢

详细说明

1. Minor GC(年轻代垃圾回收)

  • 触发条件:Eden区满时触发
  • 回收过程
    1. Eden区和From Survivor区中存活的对象复制到To Survivor区
    2. 清空Eden区和From Survivor区
    3. From Survivor区和To Survivor区交换角色
  • 特点
    • 频率高,速度快
    • 使用复制算法
    • 停顿时间短
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class MinorGCExample {
    public static void main(String[] args) {
    // 创建大量对象,触发Minor GC
    for (int i = 0; i < 10000; i++) {
    byte[] bytes = new byte[1024]; // 1KB
    }
    // Eden区满时,触发Minor GC
    }
    }

2. Major GC(老年代垃圾回收)

  • 触发条件:老年代空间不足时触发
  • 回收过程
    1. 标记老年代中所有存活的对象
    2. 清除未标记的对象
  • 特点
    • 频率低,速度慢
    • 使用标记-清除或标记-整理算法
    • 停顿时间较长
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class MajorGCExample {
    public static void main(String[] args) {
    // 创建大对象,直接进入老年代
    byte[] largeArray = new byte[1024 * 1024 * 10]; // 10MB

    // 创建大量对象,触发Minor GC
    // 存活对象晋升到老年代
    // 老年代满时,触发Major GC
    for (int i = 0; i < 10000; i++) {
    byte[] bytes = new byte[1024]; // 1KB
    }
    }
    }

3. Full GC(全局垃圾回收)

  • 触发条件
    • 调用System.gc()方法
    • 老年代空间不足
    • 方法区空间不足
    • CMS GC出现promotion failed和concurrent mode failure
    • G1 GC的Allocation Failure
  • 回收过程
    1. 回收年轻代和老年代
    2. 回收方法区/元空间
  • 特点
    • 频率最低,速度最慢
    • 停顿时间最长
    • 对应用性能影响最大
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class FullGCExample {
    public static void main(String[] args) {
    // 调用System.gc()触发Full GC
    System.gc();

    // 创建大量对象,导致老年代空间不足,触发Full GC
    List<byte[]> list = new ArrayList<>();
    for (int i = 0; i < 10000; i++) {
    list.add(new byte[1024 * 1024]); // 1MB
    }
    }
    }

4. GC日志分析

1
2
3
4
# 启用GC日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log

GC日志示例

1
2
2025-04-12T13:00:00.123+0800: [GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0056789 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2025-04-12T13:00:01.234+0800: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(9216K)] [ParOldGen: 10240K->10240K(10240K)] 11264K->10240K(19456K), [Metaspace: 32768K->32768K(107520K)], 0.1234567 secs] [Times: user=0.50 sys=0.10, real=0.12 secs]

GC日志解读

  • **[GC]**:表示Minor GC
  • **[Full GC]**:表示Full GC
  • **[PSYoungGen]**:表示年轻代使用Parallel Scavenge垃圾回收器
  • **[ParOldGen]**:表示老年代使用Parallel Old垃圾回收器
  • **8192K->1024K(9216K)**:表示GC前使用8192K,GC后使用1024K,总大小为9216K
  • 0.0056789 secs:表示GC停顿时间

5. 避免Full GC的建议

  • 合理设置堆内存大小:避免堆内存过小导致频繁GC
  • 选择合适的垃圾回收器:根据应用场景选择合适的垃圾回收器
  • 避免创建大对象:大对象直接进入老年代,可能导致Full GC
  • 及时释放对象引用:避免对象长期存活,导致老年代空间不足
  • **避免调用System.gc()**:手动触发Full GC
  • 监控GC日志:定期分析GC日志,及时发现和解决问题

3. 类加载机制

3.1 类加载的过程?

类加载过程是JVM将类的字节码文件加载到内存中,并进行验证、准备、解析和初始化的过程。类加载过程包括以下几个阶段:

1
2
3
4
5
6
7
8
9
类加载过程:
┌─────────────────────────────────────────────────────────────────────┐
│ 类加载过程 │
├──────────┬───────────────────────────────────────────────┬─────────┤
│ 加载 │ 链接(Linking) │ 初始化 │
│ Loading ├───────────┬───────────┬───────────────────────┤Initialization│
│ │ 验证 │ 准备 │ 解析 │ │
│ │Verification│Preparation│ Resolution │ │
└──────────┴───────────┴───────────┴───────────────────────┴─────────┘

详细说明

1. 加载(Loading)

  • 作用:将类的字节码文件加载到内存中
  • 过程
    1. 获取类的二进制字节流:通过类名获取类的二进制字节流,可以从文件系统、网络、ZIP包等获取
    2. 转换为运行时数据结构:将字节流代表的静态存储结构转换为方法区的运行时数据结构
    3. 生成Class对象:在堆中生成一个代表这个类的Class对象,作为方法区这个类各种数据的访问入口
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class LoadingExample {
    public static void main(String[] args) throws ClassNotFoundException {
    // 类加载过程
    // 1. 加载:将类的字节码文件加载到内存中
    Class<?> clazz = Class.forName("java.lang.String");

    // 2. 获取Class对象
    System.out.println("Class对象:" + clazz);

    // 3. 获取类信息
    System.out.println("类名:" + clazz.getName());
    System.out.println("包名:" + clazz.getPackage().getName());
    }
    }

2. 链接(Linking)

链接过程包括三个阶段:验证、准备、解析。

2.1 验证(Verification)

  • 作用:确保被加载的类的正确性,防止恶意代码危害JVM
  • 验证内容
    • 文件格式验证:验证字节流是否符合Class文件格式规范
    • 元数据验证:验证类的元数据是否符合Java语言规范
    • 字节码验证:验证程序语义是否合法、符合逻辑
    • 符号引用验证:验证符号引用是否能找到对应的类、方法、字段等
  • 示例
    1
    2
    3
    4
    5
    // 验证阶段会检查以下内容:
    // 1. 文件格式:是否以0xCAFEBABE开头
    // 2. 元数据:是否有父类、是否继承了final类、是否实现了接口等
    // 3. 字节码:类型转换是否正确、操作数栈是否溢出等
    // 4. 符号引用:引用的类、方法、字段是否存在等

2.2 准备(Preparation)

  • 作用:为类的静态变量分配内存,并设置默认初始值
  • 注意
    • 此时只设置默认初始值,不设置代码中指定的初始值
    • final类型的静态变量在准备阶段就会被赋值
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class PreparationExample {
    // 准备阶段:staticVar = 0(默认值)
    // 初始化阶段:staticVar = 10(代码中指定的值)
    private static int staticVar = 10;

    // 准备阶段:CONSTANT = "Constant"(final类型在准备阶段就赋值)
    private static final String CONSTANT = "Constant";

    public static void main(String[] args) {
    System.out.println("staticVar = " + staticVar); // 10
    System.out.println("CONSTANT = " + CONSTANT); // Constant
    }
    }

2.3 解析(Resolution)

  • 作用:将常量池中的符号引用替换为直接引用
  • 符号引用:用一组符号来描述所引用的目标,如类的全限定名、方法的名称和描述符等
  • 直接引用:直接指向目标的指针、相对偏移量或间接定位到目标的句柄
  • 解析内容
    • 类或接口解析:将类的符号引用解析为直接引用
    • 字段解析:将字段的符号引用解析为直接引用
    • 方法解析:将方法的符号引用解析为直接引用
    • 接口方法解析:将接口方法的符号引用解析为直接引用
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class ResolutionExample {
    public static void main(String[] args) {
    // 解析前:符号引用(字符串"java.lang.String")
    // 解析后:直接引用(指向String类的指针)
    String str = "Hello";

    // 解析前:符号引用(方法名称和描述符)
    // 解析后:直接引用(方法的内存地址)
    str.length();
    }
    }

3. 初始化(Initialization)

  • 作用:执行类的初始化代码,包括静态代码块和静态变量的赋值操作
  • 过程
    1. 执行静态变量的赋值操作
    2. 执行静态代码块
  • 注意
    • 初始化阶段是类加载过程的最后一步
    • 初始化阶段是执行类构造器<clinit>()方法的过程
    • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class InitializationExample {
    // 静态变量
    private static int staticVar = 10;

    // 静态代码块
    static {
    System.out.println("静态代码块执行");
    staticVar = 20;
    }

    public static void main(String[] args) {
    System.out.println("staticVar = " + staticVar); // 20
    }
    }

4. 类加载过程的完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ClassLoadingProcessExample {
// 1. 加载阶段:将类的字节码文件加载到内存中

// 2. 链接阶段:
// 2.1 验证:验证类的正确性
// 2.2 准备:staticVar = 0(默认值),CONSTANT = "Constant"(final类型)
// 2.3 解析:将符号引用替换为直接引用

// 3. 初始化阶段:
// 执行静态变量赋值和静态代码块
private static int staticVar = 10;
private static final String CONSTANT = "Constant";

static {
System.out.println("静态代码块执行");
staticVar = 20;
}

public static void main(String[] args) {
System.out.println("staticVar = " + staticVar); // 20
System.out.println("CONSTANT = " + CONSTANT); // Constant
}
}

3.2 类加载器的分类?

类加载器是JVM中用于加载类的组件,不同的类加载器负责加载不同来源的类。JVM中的类加载器主要分为以下几类:

类加载器 加载范围 实现方式 父加载器 特点
启动类加载器 JDK核心类(JAVA_HOME/lib/rt.jar、resources.jar等) C++实现 无法被Java程序直接引用
扩展类加载器 JDK扩展类(JAVA_HOME/lib/ext/*.jar) Java实现 启动类加载器 JDK 9后改为平台类加载器
应用程序类加载器 应用程序类(CLASSPATH指定的类) Java实现 扩展类加载器 也称为系统类加载器
自定义类加载器 用户自定义的类 Java实现 应用程序类加载器 继承ClassLoader类实现

详细说明

1. 启动类加载器(Bootstrap ClassLoader)

  • 作用:加载JDK核心类库,如rt.jar、resources.jar、charsets.jar等
  • 特点
    • 由C++实现,是JVM的一部分
    • 无法被Java程序直接引用
    • 加载路径:JAVA_HOME/lib/
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class BootstrapClassLoaderExample {
    public static void main(String[] args) {
    // 获取String类的类加载器
    ClassLoader classLoader = String.class.getClassLoader();
    System.out.println("String类的类加载器:" + classLoader); // null(启动类加载器)

    // 获取启动类加载器的加载路径
    String bootClassPath = System.getProperty("sun.boot.class.path");
    System.out.println("启动类加载器加载路径:" + bootClassPath);
    }
    }

2. 扩展类加载器(Extension ClassLoader)

  • 作用:加载JDK扩展类库,如JAVA_HOME/lib/ext/*.jar
  • 特点
    • 由Java实现,是sun.misc.Launcher$ExtClassLoader类
    • 加载路径:JAVA_HOME/lib/ext/
    • JDK 9后改为平台类加载器(Platform ClassLoader)
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class ExtensionClassLoaderExample {
    public static void main(String[] args) {
    // 获取扩展类加载器
    ClassLoader extClassLoader = ClassLoader.getSystemClassLoader().getParent();
    System.out.println("扩展类加载器:" + extClassLoader);

    // 获取扩展类加载器的加载路径
    String extClassPath = System.getProperty("java.ext.dirs");
    System.out.println("扩展类加载器加载路径:" + extClassPath);
    }
    }

3. 应用程序类加载器(Application ClassLoader)

  • 作用:加载应用程序类,即CLASSPATH指定的类
  • 特点
    • 由Java实现,是sun.misc.Launcher$AppClassLoader类
    • 也称为系统类加载器
    • 加载路径:CLASSPATH
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class ApplicationClassLoaderExample {
    public static void main(String[] args) {
    // 获取应用程序类加载器
    ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println("应用程序类加载器:" + appClassLoader);

    // 获取应用程序类加载器的加载路径
    String classPath = System.getProperty("java.class.path");
    System.out.println("应用程序类加载器加载路径:" + classPath);
    }
    }

4. 自定义类加载器

  • 作用:加载用户自定义来源的类,如网络、加密文件等
  • 实现方式:继承ClassLoader类,重写findClass()方法
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    import java.io.ByteArrayOutputStream;
    import java.io.FileInputStream;
    import java.io.IOException;

    public class CustomClassLoader extends ClassLoader {
    private String classPath;

    public CustomClassLoader(String classPath) {
    this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
    try {
    // 读取类文件
    FileInputStream fis = new FileInputStream(classPath + name.replace('.', '/') + ".class");
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    byte[] buffer = new byte[1024];
    int len;
    while ((len = fis.read(buffer)) != -1) {
    bos.write(buffer, 0, len);
    }
    fis.close();

    // 将字节数组转换为Class对象
    byte[] classBytes = bos.toByteArray();
    return defineClass(name, classBytes, 0, classBytes.length);
    } catch (IOException e) {
    throw new ClassNotFoundException(name, e);
    }
    }

    public static void main(String[] args) throws Exception {
    // 创建自定义类加载器
    CustomClassLoader customClassLoader = new CustomClassLoader("d:/classes/");

    // 使用自定义类加载器加载类
    Class<?> clazz = customClassLoader.loadClass("com.example.CustomClass");
    System.out.println("类加载器:" + clazz.getClassLoader());
    }
    }

5. 类加载器的层次结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
类加载器层次结构:
┌─────────────────────────────────────┐
│ 启动类加载器(Bootstrap) │
│ 加载JDK核心类库 │
└─────────────────┬───────────────────┘
│ 父加载器
┌─────────────────┴───────────────────┐
│ 扩展类加载器(Extension) │
│ 加载JDK扩展类库 │
└─────────────────┬───────────────────┘
│ 父加载器
┌─────────────────┴───────────────────┐
│ 应用程序类加载器(Application) │
│ 加载应用程序类 │
└─────────────────┬───────────────────┘
│ 父加载器
┌─────────────────┴───────────────────┐
│ 自定义类加载器(Custom) │
│ 加载用户自定义类 │
└─────────────────────────────────────┘

3.3 什么是双亲委派模型?

双亲委派模型是Java类加载器的一种工作机制,它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器。当一个类加载器需要加载一个类时,首先委托给父类加载器加载,只有当父类加载器无法加载时,才由自己加载。

工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
类加载请求:
┌─────────────────────────────────────┐
│ 应用程序类加载器收到加载请求 │
└─────────────────┬───────────────────┘
│ 委托给父加载器
┌─────────────────┴───────────────────┐
│ 扩展类加载器收到加载请求 │
└─────────────────┬───────────────────┘
│ 委托给父加载器
┌─────────────────┴───────────────────┐
│ 启动类加载器收到加载请求 │
│ 尝试加载类 │
│ 如果无法加载,返回给子加载器 │
└─────────────────┬───────────────────┘
│ 无法加载,返回给子加载器
┌─────────────────┴───────────────────┐
│ 扩展类加载器尝试加载类 │
│ 如果无法加载,返回给子加载器 │
└─────────────────┬───────────────────┘
│ 无法加载,返回给子加载器
┌─────────────────┴───────────────────┐
│ 应用程序类加载器尝试加载类 │
│ 加载成功 │
└─────────────────────────────────────┘

详细说明

1. 双亲委派模型的工作原理

  • 步骤1:当应用程序类加载器收到类加载请求时,首先委托给父加载器(扩展类加载器)
  • 步骤2:扩展类加载器收到请求后,再委托给父加载器(启动类加载器)
  • 步骤3:启动类加载器尝试加载类,如果无法加载,返回给子加载器
  • 步骤4:扩展类加载器尝试加载类,如果无法加载,返回给子加载器
  • 步骤5:应用程序类加载器尝试加载类,加载成功

2. 双亲委派模型的优点

  • 避免类的重复加载:父加载器已经加载的类,子加载器不需要再加载
  • 保证核心类的安全性:核心类库(如java.lang.String)由启动类加载器加载,防止被篡改
  • 保证Java程序的稳定性:确保Java核心类库的唯一性和安全性

3. 双亲委派模型的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 委托给父加载器加载
c = parent.loadClass(name, false);
} else {
// 3. 委托给启动类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载,捕获异常
}

if (c == null) {
// 4. 父加载器无法加载,自己尝试加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

4. 双亲委派模型的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ParentDelegationModelExample {
public static void main(String[] args) {
// 获取String类的类加载器
ClassLoader stringClassLoader = String.class.getClassLoader();
System.out.println("String类的类加载器:" + stringClassLoader); // null(启动类加载器)

// 获取自定义类的类加载器
ClassLoader customClassLoader = ParentDelegationModelExample.class.getClassLoader();
System.out.println("自定义类的类加载器:" + customClassLoader); // 应用程序类加载器

// 验证双亲委派模型
// 即使自定义了java.lang.String类,也会由启动类加载器加载核心类库中的String类
// 保证核心类的安全性
}
}

3.4 如何打破双亲委派模型?

打破双亲委派模型是指在类加载过程中,不遵循双亲委派模型的规则,直接由当前类加载器加载类,或者自定义类加载器的加载顺序。

打破双亲委派模型的方法

1. 重写ClassLoader的loadClass()方法

  • 原理:不先委托给父加载器,直接由自己加载类
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    import java.io.ByteArrayOutputStream;
    import java.io.FileInputStream;
    import java.io.IOException;

    public class BreakParentDelegationClassLoader extends ClassLoader {
    private String classPath;

    public BreakParentDelegationClassLoader(String classPath) {
    this.classPath = classPath;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 检查类是否已经加载
    Class<?> c = findLoadedClass(name);
    if (c == null) {
    // 2. 不委托给父加载器,直接自己加载
    // 打破双亲委派模型
    if (name.startsWith("com.example")) {
    // 自定义类由自己加载
    c = findClass(name);
    } else {
    // 其他类委托给父加载器
    c = super.loadClass(name, resolve);
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
    try {
    // 读取类文件
    FileInputStream fis = new FileInputStream(classPath + name.replace('.', '/') + ".class");
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    byte[] buffer = new byte[1024];
    int len;
    while ((len = fis.read(buffer)) != -1) {
    bos.write(buffer, 0, len);
    }
    fis.close();

    // 将字节数组转换为Class对象
    byte[] classBytes = bos.toByteArray();
    return defineClass(name, classBytes, 0, classBytes.length);
    } catch (IOException e) {
    throw new ClassNotFoundException(name, e);
    }
    }

    public static void main(String[] args) throws Exception {
    // 创建打破双亲委派模型的类加载器
    BreakParentDelegationClassLoader classLoader = new BreakParentDelegationClassLoader("d:/classes/");

    // 使用自定义类加载器加载类
    Class<?> clazz = classLoader.loadClass("com.example.CustomClass");
    System.out.println("类加载器:" + clazz.getClassLoader());
    }
    }

2. 使用线程上下文类加载器

  • 原理:通过Thread.currentThread().setContextClassLoader()设置线程上下文类加载器,在需要时使用线程上下文类加载器加载类
  • 适用场景:JDBC、JNDI等SPI(Service Provider Interface)机制
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class ThreadContextClassLoaderExample {
    public static void main(String[] args) {
    // 获取当前线程的上下文类加载器
    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
    System.out.println("当前线程上下文类加载器:" + contextClassLoader);

    // 设置线程上下文类加载器
    Thread.currentThread().setContextClassLoader(new CustomClassLoader("d:/classes/"));

    // 在需要时使用线程上下文类加载器加载类
    // 例如:JDBC加载驱动
    try {
    Class<?> driverClass = Class.forName("com.mysql.cj.jdbc.Driver", true, Thread.currentThread().getContextClassLoader());
    System.out.println("驱动类:" + driverClass);
    } catch (ClassNotFoundException e) {
    e.printStackTrace();
    }
    }
    }

3. 打破双亲委派模型的应用场景

  • SPI机制:JDBC、JNDI等SPI机制需要打破双亲委派模型,由线程上下文类加载器加载实现类
  • 热部署:热部署需要打破双亲委派模型,重新加载类
  • 模块化:OSGi等模块化框架需要打破双亲委派模型,实现模块间的类隔离

4. 打破双亲委派模型的风险

  • 类冲突:可能导致同一个类被不同的类加载器加载多次,产生多个Class对象
  • 安全性问题:可能导致核心类被篡改,影响Java程序的稳定性
  • 类型转换异常:同一个类被不同的类加载器加载后,类型转换会抛出ClassCastException

3.5 什么是类的初始化?哪些情况会触发类的初始化?

类的初始化是类加载过程的最后一个阶段,主要执行类的静态代码块和静态变量的赋值操作。类的初始化是延迟进行的,只有在类被主动使用时才会触发。

触发类的初始化的情况(主动引用)

触发条件 说明 示例
使用new关键字创建对象 实例化类对象 new MyClass()
访问类的静态变量 读取或设置类的静态变量(除了final常量) MyClass.staticVar
调用类的静态方法 调用类的静态方法 MyClass.staticMethod()
反射操作 使用反射调用类 Class.forName("com.example.MyClass")
初始化子类 初始化子类时,会先初始化父类 class Child extends Parent
Java 7的动态语言支持 使用MethodHandle实例 MethodHandles.lookup().findStatic(...)

不会触发类的初始化的情况(被动引用)

情况 说明 示例
引用父类的静态变量 子类引用父类的静态变量,不会初始化子类 Child.parentStaticVar
引用类的final常量 引用类的final常量,不会初始化类 MyClass.CONSTANT
通过数组定义类引用 通过数组定义类引用,不会初始化类 MyClass[] array = new MyClass[10]

详细说明

1. 主动引用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Parent {
static {
System.out.println("Parent类初始化");
}

public static int parentStaticVar = 10;
}

class Child extends Parent {
static {
System.out.println("Child类初始化");
}

public static int childStaticVar = 20;
}

public class ActiveReferenceExample {
public static void main(String[] args) throws Exception {
// 1. 使用new关键字创建对象
System.out.println("=== 1. 使用new关键字创建对象 ===");
Child child = new Child();
// 输出:Parent类初始化
// Child类初始化

// 2. 访问类的静态变量
System.out.println("\n=== 2. 访问类的静态变量 ===");
int var = Child.childStaticVar;
// 输出:(类已经初始化,不会再次初始化)

// 3. 调用类的静态方法
System.out.println("\n=== 3. 调用类的静态方法 ===");
// 假设Child类有一个静态方法

// 4. 反射操作
System.out.println("\n=== 4. 反射操作 ===");
Class.forName("com.example.Child");
// 输出:(类已经初始化,不会再次初始化)

// 5. 初始化子类时,会先初始化父类
System.out.println("\n=== 5. 初始化子类时,会先初始化父类 ===");
// 上面已经演示过了
}
}

2. 被动引用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Parent {
static {
System.out.println("Parent类初始化");
}

public static int parentStaticVar = 10;
public static final String CONSTANT = "Constant";
}

class Child extends Parent {
static {
System.out.println("Child类初始化");
}
}

public class PassiveReferenceExample {
public static void main(String[] args) {
// 1. 引用父类的静态变量,不会初始化子类
System.out.println("=== 1. 引用父类的静态变量 ===");
int var = Child.parentStaticVar;
// 输出:Parent类初始化
// (Child类不会初始化)

// 2. 引用类的final常量,不会初始化类
System.out.println("\n=== 2. 引用类的final常量 ===");
String constant = Parent.CONSTANT;
// 输出:(Parent类不会初始化)

// 3. 通过数组定义类引用,不会初始化类
System.out.println("\n=== 3. 通过数组定义类引用 ===");
Parent[] array = new Parent[10];
// 输出:(Parent类不会初始化)
}
}

3. 接口的初始化

  • 接口在初始化时,不要求其父接口全部完成初始化
  • 只有在真正使用到父接口时(如引用父接口中定义的常量),才会初始化父接口
  • 接口的初始化过程与类类似,但没有静态代码块

4. 类初始化的注意事项

  • 线程安全:JVM会保证类的初始化过程是线程安全的,多个线程同时初始化一个类时,只有一个线程会执行初始化过程
  • 初始化顺序:静态变量赋值和静态代码块按照代码顺序执行
  • 初始化异常:如果类的初始化过程抛出异常,JVM会抛出ExceptionInInitializerError

5. 类初始化的完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class InitializationOrderExample {
// 静态变量
private static int staticVar1 = initStaticVar1();
private static int staticVar2 = initStaticVar2();

// 静态代码块
static {
System.out.println("静态代码块1执行");
}

private static int staticVar3 = initStaticVar3();

static {
System.out.println("静态代码块2执行");
}

private static int initStaticVar1() {
System.out.println("静态变量1初始化");
return 1;
}

private static int initStaticVar2() {
System.out.println("静态变量2初始化");
return 2;
}

private static int initStaticVar3() {
System.out.println("静态变量3初始化");
return 3;
}

public static void main(String[] args) {
System.out.println("主方法执行");
}
}

// 输出顺序:
// 静态变量1初始化
// 静态变量2初始化
// 静态代码块1执行
// 静态变量3初始化
// 静态代码块2执行
// 主方法执行
  • 启动类(包含main方法的类)

4. JVM调优

4.1 JVM调优的目标是什么?

JVM调优是指通过调整JVM参数和优化应用程序代码,提高Java应用程序的性能和稳定性。JVM调优的主要目标包括:

调优目标 说明 关键指标
提高应用程序性能 减少响应时间,提高吞吐量 响应时间、吞吐量、QPS
减少GC频率和时间 降低垃圾回收对应用的影响 GC频率、GC停顿时间、GC次数
避免内存溢出 合理配置内存,防止OOM 内存使用率、内存泄漏
合理利用系统资源 充分利用CPU、内存等资源 CPU使用率、内存使用率、I/O吞吐量

详细说明

1. 提高应用程序性能

  • 响应时间:减少单个请求的处理时间
  • 吞吐量:提高单位时间内处理的请求数量
  • QPS(Queries Per Second):提高每秒查询率
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 优化前:响应时间200ms,QPS 500
    public Response processRequest(Request request) {
    // 复杂的业务逻辑
    return response;
    }

    // 优化后:响应时间100ms,QPS 1000
    public Response processRequest(Request request) {
    // 优化后的业务逻辑
    return response;
    }

2. 减少GC频率和时间

  • GC频率:减少垃圾回收的次数
  • GC停顿时间:减少垃圾回收时的停顿时间
  • GC次数:减少Full GC的次数
  • 示例
    1
    2
    3
    4
    5
    6
    7
    优化前:
    Minor GC:每10秒一次,停顿时间50ms
    Full GC:每5分钟一次,停顿时间2s

    优化后:
    Minor GC:每30秒一次,停顿时间20ms
    Full GC:每30分钟一次,停顿时间500ms

3. 避免内存溢出

  • 内存使用率:合理配置堆内存大小,避免内存不足
  • 内存泄漏:及时发现和修复内存泄漏问题
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 内存泄漏示例
    public class MemoryLeakExample {
    private static List<Object> list = new ArrayList<>();

    public void addObject(Object obj) {
    list.add(obj); // 对象一直被引用,无法被回收
    }
    }

    // 修复内存泄漏
    public class FixedMemoryLeakExample {
    private List<Object> list = new ArrayList<>();

    public void addObject(Object obj) {
    list.add(obj);
    if (list.size() > 1000) {
    list.clear(); // 定期清理
    }
    }
    }

4. 合理利用系统资源

  • CPU使用率:充分利用CPU资源,避免CPU空闲或过载
  • 内存使用率:合理配置内存,避免内存浪费或不足
  • I/O吞吐量:优化I/O操作,提高吞吐量
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    优化前:
    CPU使用率:20%(CPU空闲)
    内存使用率:90%(内存紧张)
    I/O吞吐量:100MB/s

    优化后:
    CPU使用率:60%(CPU充分利用)
    内存使用率:70%(内存合理)
    I/O吞吐量:500MB/s

4.2 JVM的常用参数有哪些?

JVM参数是用于配置JVM行为的选项,通过设置不同的参数可以优化JVM的性能。JVM参数主要分为以下几类:

1. 堆内存参数

参数 说明 默认值 示例
-Xms 初始堆大小 物理内存的1/64 -Xms512m
-Xmx 最大堆大小 物理内存的1/4 -Xmx2g
-Xmn 年轻代大小 堆内存的1/3 -Xmn512m
-XX:SurvivorRatio Eden区与Survivor区的比例 8 -XX:SurvivorRatio=8
-XX:NewRatio 老年代与年轻代的比例 2 -XX:NewRatio=2
-XX:MaxTenuringThreshold 对象晋升老年代的年龄阈值 15 -XX:MaxTenuringThreshold=15
-XX:PretenureSizeThreshold 大对象直接进入老年代的阈值 0 -XX:PretenureSizeThreshold=1m

详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 设置初始堆大小为512MB
-Xms512m

# 设置最大堆大小为2GB
-Xmx2g

# 设置年轻代大小为512MB
-Xmn512m

# 设置Eden区与Survivor区的比例为8:1:1
-XX:SurvivorRatio=8

# 设置老年代与年轻代的比例为2:1
-XX:NewRatio=2

# 设置对象晋升老年代的年龄阈值为15
-XX:MaxTenuringThreshold=15

# 设置大对象直接进入老年代的阈值为1MB
-XX:PretenureSizeThreshold=1m

2. 垃圾回收器参数

参数 说明 适用场景
-XX:+UseSerialGC 使用Serial垃圾回收器 客户端应用、小内存应用
-XX:+UseParallelGC 使用Parallel Scavenge垃圾回收器 后台计算、批处理应用
-XX:+UseParallelOldGC 使用Parallel Old垃圾回收器 后台计算、批处理应用
-XX:+UseParNewGC 使用ParNew垃圾回收器 服务端应用、配合CMS使用
-XX:+UseConcMarkSweepGC 使用CMS垃圾回收器 互联网应用、Web应用
-XX:+UseG1GC 使用G1垃圾回收器 大内存应用、多核处理器
-XX:+UseZGC 使用ZGC垃圾回收器 大内存应用、低延迟应用
-XX:+UseShenandoahGC 使用Shenandoah垃圾回收器 大内存应用、低延迟应用

详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 使用Serial垃圾回收器
-XX:+UseSerialGC

# 使用Parallel Scavenge + Parallel Old垃圾回收器
-XX:+UseParallelGC
-XX:+UseParallelOldGC

# 使用ParNew + CMS垃圾回收器
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

# 使用G1垃圾回收器
-XX:+UseG1GC

# 使用ZGC垃圾回收器
-XX:+UseZGC

# 使用Shenandoah垃圾回收器
-XX:+UseShenandoahGC

3. GC日志参数

参数 说明 示例
-XX:+PrintGCDetails 打印GC详细信息 -XX:+PrintGCDetails
-XX:+PrintGCDateStamps 打印GC时间戳 -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps 打印GC相对时间 -XX:+PrintGCTimeStamps
-XX:+PrintGCApplicationStoppedTime 打印应用停顿时间 -XX:+PrintGCApplicationStoppedTime
-Xloggc:filename 指定GC日志文件 -Xloggc:gc.log

详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 打印GC详细信息
-XX:+PrintGCDetails

# 打印GC时间戳
-XX:+PrintGCDateStamps

# 打印GC相对时间
-XX:+PrintGCTimeStamps

# 打印应用停顿时间
-XX:+PrintGCApplicationStoppedTime

# 指定GC日志文件
-Xloggc:gc.log

# JDK 9+使用统一日志框架
-Xlog:gc*:file=gc.log:time,uptime,level,tags

4. 元空间参数

参数 说明 默认值 示例
-XX:MetaspaceSize 元空间初始大小 21MB -XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize 元空间最大大小 无限制 -XX:MaxMetaspaceSize=256m
-XX:MinMetaspaceFreeRatio 元空间最小空闲比例 40 -XX:MinMetaspaceFreeRatio=40
-XX:MaxMetaspaceFreeRatio 元空间最大空闲比例 70 -XX:MaxMetaspaceFreeRatio=70

详细说明

1
2
3
4
5
6
7
8
9
10
11
# 设置元空间初始大小为128MB
-XX:MetaspaceSize=128m

# 设置元空间最大大小为256MB
-XX:MaxMetaspaceSize=256m

# 设置元空间最小空闲比例为40%
-XX:MinMetaspaceFreeRatio=40

# 设置元空间最大空闲比例为70%
-XX:MaxMetaspaceFreeRatio=70

5. 线程栈参数

参数 说明 默认值 示例
-Xss 线程栈大小 1MB -Xss256k

详细说明

1
2
3
4
5
6
7
# 设置线程栈大小为256KB
-Xss256k

# 注意:
# 1. 线程栈越小,可以创建的线程越多
# 2. 线程栈太小可能导致StackOverflowError
# 3. 线程栈太大可能导致内存不足

6. 其他参数

参数 说明 示例
-XX:+HeapDumpOnOutOfMemoryError 发生OOM时生成堆转储文件 -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath 堆转储文件路径 -XX:HeapDumpPath=/path/to/dump
-XX:OnOutOfMemoryError 发生OOM时执行的命令 -XX:OnOutOfMemoryError=”kill -9 %p”
-XX:+UseCompressedOops 使用压缩指针 -XX:+UseCompressedOops
-XX:+UseCompressedClassPointers 使用压缩类指针 -XX:+UseCompressedClassPointers

详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 发生OOM时生成堆转储文件
-XX:+HeapDumpOnOutOfMemoryError

# 指定堆转储文件路径
-XX:HeapDumpPath=/path/to/dump.hprof

# 发生OOM时执行的命令
-XX:OnOutOfMemoryError="kill -9 %p"

# 使用压缩指针(64位JVM默认开启)
-XX:+UseCompressedOops

# 使用压缩类指针(64位JVM默认开启)
-XX:+UseCompressedClassPointers

7. JVM参数配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
# 生产环境JVM参数配置示例
java -Xms4g \
-Xmx4g \
-Xmn2g \
-XX:SurvivorRatio=8 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:gc.log \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/logs/dump.hprof \
-jar application.jar

4.3 如何分析JVM的性能问题?

JVM性能问题分析是指通过监控和分析JVM的运行状态,发现和解决性能瓶颈。以下是分析JVM性能问题的常用方法:

1. 使用命令行工具

工具 作用 使用场景
jps 查看Java进程 查看正在运行的Java进程
jstat 查看JVM统计信息 监控GC情况、类加载情况
jmap 生成堆转储文件 分析内存使用情况、内存泄漏
jstack 生成线程堆栈 分析线程状态、死锁问题
jinfo 查看和修改JVM参数 查看当前JVM参数配置
jcmd JVM诊断命令 综合诊断工具

详细说明

1.1 jps命令

1
2
3
4
5
6
7
8
# 查看Java进程
jps

# 查看Java进程详细信息
jps -l

# 查看Java进程主类全名
jps -lvm

1.2 jstat命令

1
2
3
4
5
6
7
8
9
10
11
# 查看GC统计信息
jstat -gc <pid>

# 查看GC汇总信息
jstat -gcutil <pid>

# 每隔1秒输出一次GC信息,共输出10次
jstat -gc <pid> 1000 10

# 查看类加载统计信息
jstat -class <pid>

1.3 jmap命令

1
2
3
4
5
6
7
8
# 查看堆内存使用情况
jmap -heap <pid>

# 生成堆转储文件
jmap -dump:format=b,file=heap.hprof <pid>

# 查看对象统计信息
jmap -histo <pid> | head -20

1.4 jstack命令

1
2
3
4
5
# 生成线程堆栈
jstack <pid>

# 生成线程堆栈并检测死锁
jstack -l <pid>

1.5 jinfo命令

1
2
3
4
5
6
7
8
# 查看JVM参数
jinfo -flags <pid>

# 查看指定参数
jinfo -flag MaxHeapSize <pid>

# 动态修改参数(部分参数支持)
jinfo -flag +PrintGCDetails <pid>

1.6 jcmd命令

1
2
3
4
5
6
7
8
9
10
11
# 查看JVM支持的命令
jcmd <pid> help

# 生成堆转储文件
jcmd <pid> GC.heap_dump /path/to/dump.hprof

# 查看线程堆栈
jcmd <pid> Thread.print

# 查看GC信息
jcmd <pid> GC.heap_info

2. 使用图形化工具

工具 作用 使用场景
jconsole JVM监控工具 实时监控JVM状态
VisualVM JVM分析工具 分析内存、线程、CPU
JMC(Java Mission Control) JVM监控和分析工具 生产环境监控和分析
Arthas 阿里开源的Java诊断工具 在线诊断、热更新

详细说明

2.1 jconsole

1
2
3
4
5
# 启动jconsole
jconsole

# 连接到远程JVM
jconsole <hostname>:<port>

2.2 VisualVM

1
2
3
4
5
6
7
8
# 启动VisualVM
jvisualvm

# 功能:
# 1. 监控内存、CPU、线程
# 2. 分析堆转储文件
# 3. 分析线程堆栈
# 4. 性能分析

2.3 JMC(Java Mission Control)

1
2
3
4
5
6
7
# 启动JMC
jmc

# 功能:
# 1. 实时监控JVM
# 2. 记录和分析JFR(Java Flight Recorder)
# 3. 生产环境监控

2.4 Arthas

1
2
3
4
5
6
7
8
9
10
11
# 启动Arthas
java -jar arthas-boot.jar

# 常用命令:
# 1. dashboard:查看JVM整体信息
# 2. thread:查看线程信息
# 3. memory:查看内存信息
# 4. heapdump:生成堆转储文件
# 5. jad:反编译类
# 6. watch:观察方法执行
# 7. trace:追踪方法调用

3. 分析GC日志

1
2
3
# GC日志示例
2025-04-12T13:00:00.123+0800: [GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0056789 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2025-04-12T13:00:01.234+0800: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(9216K)] [ParOldGen: 10240K->10240K(10240K)] 11264K->10240K(19456K), [Metaspace: 32768K->32768K(107520K)], 0.1234567 secs] [Times: user=0.50 sys=0.10, real=0.12 secs]

GC日志分析要点

  • GC类型:Minor GC、Major GC、Full GC
  • GC原因:Allocation Failure、System.gc()等
  • GC前后内存变化:8192K->1024K(9216K)
  • GC停顿时间:0.0056789 secs
  • GC频率:分析GC日志的时间戳

4. 分析堆转储文件

1
2
3
4
5
# 使用VisualVM分析堆转储文件
# 1. 打开VisualVM
# 2. File -> Load -> 选择.hprof文件
# 3. 分析对象数量、大小、引用关系
# 4. 查找内存泄漏

5. 分析线程堆栈

1
2
3
4
5
6
7
8
9
# 使用jstack分析线程堆栈
jstack <pid> > thread.txt

# 分析要点:
# 1. 查看线程状态:RUNNABLE、BLOCKED、WAITING、TIMED_WAITING
# 2. 查找死锁:Found one Java-level deadlock
# 3. 查找CPU占用高的线程:top -H -p <pid>
# 4. 将线程ID转换为16进制:printf "%x" <tid>
# 5. 在线程堆栈中查找对应的线程

6. JVM性能问题排查流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
JVM性能问题排查流程:
┌─────────────────────────────────────┐
│ 1. 确认问题:CPU高、内存高、响应慢 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 2. 收集信息:GC日志、堆转储、线程堆栈 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 3. 分析问题:GC频繁、内存泄漏、死锁 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 4. 解决问题:调整参数、修复代码 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 5. 验证效果:监控JVM状态 │
└─────────────────────────────────────┘

4.4 常见的JVM内存溢出问题有哪些?

JVM内存溢出是指JVM在运行过程中,由于内存不足或内存泄漏导致的错误。常见的JVM内存溢出问题包括:

溢出类型 错误信息 原因 解决方法
堆内存溢出 java.lang.OutOfMemoryError: Java heap space 对象过多、内存泄漏 增大堆内存、修复内存泄漏
永久代溢出 java.lang.OutOfMemoryError: PermGen space 类加载过多、常量过多 增大永久代、减少类加载
元空间溢出 java.lang.OutOfMemoryError: Metaspace 类加载过多、动态代理 增大元空间、减少类加载
栈溢出 java.lang.StackOverflowError 方法调用层次过深 优化递归、增大栈大小
直接内存溢出 java.lang.OutOfMemoryError: Direct buffer memory 直接内存使用过多 增大直接内存、减少使用

详细说明

1. 堆内存溢出(Java heap space)

  • 原因
    • 对象过多,无法回收
    • 内存泄漏,对象无法被回收
    • 堆内存设置过小
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class HeapOOMExample {
    public static void main(String[] args) {
    List<byte[]> list = new ArrayList<>();
    while (true) {
    list.add(new byte[1024 * 1024]); // 1MB
    }
    }
    }

    // 错误信息:
    // java.lang.OutOfMemoryError: Java heap space
  • 解决方法
    • 增大堆内存:-Xmx2g
    • 分析堆转储文件,查找内存泄漏
    • 优化代码,减少对象创建

2. 永久代溢出(PermGen space)

  • 原因
    • 类加载过多
    • 常量过多
    • 永久代设置过小
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class PermGenOOMExample {
    public static void main(String[] args) {
    while (true) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(Object.class);
    enhancer.setUseCache(false);
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
    return proxy.invokeSuper(obj, args);
    }
    });
    enhancer.create(); // 动态生成类
    }
    }
    }

    // 错误信息:
    // java.lang.OutOfMemoryError: PermGen space
  • 解决方法
    • 增大永久代:-XX:MaxPermSize=256m
    • 减少动态生成类的数量
    • 使用元空间替代永久代(JDK 8+)

3. 元空间溢出(Metaspace)

  • 原因
    • 类加载过多
    • 动态代理、CGLib等生成大量类
    • 元空间设置过小
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class MetaspaceOOMExample {
    public static void main(String[] args) {
    while (true) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(Object.class);
    enhancer.setUseCache(false);
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
    return proxy.invokeSuper(obj, args);
    }
    });
    enhancer.create(); // 动态生成类
    }
    }
    }

    // 错误信息:
    // java.lang.OutOfMemoryError: Metaspace
  • 解决方法
    • 增大元空间:-XX:MaxMetaspaceSize=256m
    • 减少动态生成类的数量
    • 使用类加载器卸载不需要的类

4. 栈溢出(StackOverflowError)

  • 原因
    • 方法调用层次过深
    • 递归调用没有终止条件
    • 栈大小设置过小
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class StackOverflowExample {
    public static void main(String[] args) {
    recursiveMethod(1);
    }

    public static void recursiveMethod(int count) {
    System.out.println("递归调用:" + count);
    recursiveMethod(count + 1); // 无限递归
    }
    }

    // 错误信息:
    // java.lang.StackOverflowError
  • 解决方法
    • 优化递归,使用循环替代
    • 增大栈大小:-Xss2m
    • 减少方法调用层次

5. 直接内存溢出(Direct buffer memory)

  • 原因
    • 直接内存使用过多
    • 未正确释放直接内存
    • 直接内存设置过小
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import java.nio.ByteBuffer;

    public class DirectMemoryOOMExample {
    public static void main(String[] args) {
    while (true) {
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB
    }
    }
    }

    // 错误信息:
    // java.lang.OutOfMemoryError: Direct buffer memory
  • 解决方法
    • 增大直接内存:-XX:MaxDirectMemorySize=256m
    • 及时释放直接内存
    • 减少直接内存的使用

6. 内存溢出排查流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
内存溢出排查流程:
┌─────────────────────────────────────┐
│ 1. 查看错误信息,确定溢出类型 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 2. 分析堆转储文件,查找大对象 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 3. 分析GC日志,查看GC情况 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 4. 分析代码,查找内存泄漏 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 5. 调整JVM参数,增大内存 │
└─────────────────┬───────────────────┘

┌─────────────────┴───────────────────┐
│ 6. 修复代码,避免内存泄漏 │
└─────────────────────────────────────┘

5. 其他JVM相关问题

5.1 什么是JIT编译器?它的作用是什么?

JIT(Just-In-Time)编译器是指在运行时将字节码编译为本地机器码的编译器。JIT编译器是JVM的重要组成部分,它可以显著提高Java程序的执行效率。

JIT编译器的作用

作用 说明 效果
提高代码执行速度 将字节码编译为本地机器码,直接执行 比解释执行快10-100倍
优化热点代码 对频繁执行的代码进行优化 减少方法调用、内联优化等
运行时优化 根据运行时信息进行优化 更精确的优化策略
自适应优化 根据代码执行情况动态调整优化策略 持续优化性能

详细说明

1. JIT编译器的工作原理

  • 解释执行:JVM首先使用解释器解释执行字节码
  • 热点检测:JVM检测到某个方法或代码块被频繁执行(热点代码)
  • 编译优化:JIT编译器将热点代码编译为本地机器码
  • 缓存复用:编译后的机器码被缓存,下次执行时直接使用

2. JIT编译器的类型

JIT编译器 说明 特点
C1编译器(Client编译器) 轻量级编译器 编译速度快,优化程度低,适用于客户端应用
C2编译器(Server编译器) 重量级编译器 编译速度慢,优化程度高,适用于服务端应用
分层编译(Tiered Compilation) C1和C2结合 先用C1快速编译,再用C2深度优化,JDK 8默认开启

3. JIT编译器的优化技术

3.1 方法内联(Method Inlining)

  • 作用:将被调用的方法的代码直接嵌入到调用方法中,减少方法调用的开销
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 优化前
    public int add(int a, int b) {
    return a + b;
    }

    public int calculate(int x, int y) {
    return add(x, y);
    }

    // 优化后(方法内联)
    public int calculate(int x, int y) {
    return x + y; // 直接内联add方法的代码
    }

3.2 逃逸分析(Escape Analysis)

  • 作用:分析对象的作用域,判断对象是否会逃逸出方法或线程
  • 优化
    • 栈上分配:将对象分配在栈上,减少堆内存的使用
    • 标量替换:将对象分解为基本类型,减少对象的创建
    • 同步消除:消除不必要的同步操作
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 优化前
    public void method() {
    Object obj = new Object(); // 对象在堆上分配
    // 使用obj
    }

    // 优化后(栈上分配)
    public void method() {
    // 对象在栈上分配,不需要垃圾回收
    // 如果对象不会逃逸,可以进行标量替换
    }

3.3 循环优化

  • 循环展开:减少循环次数,增加每次循环的代码量
  • 循环不变量外提:将循环中不变的计算移到循环外
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 优化前
    public int sum(int[] array) {
    int sum = 0;
    for (int i = 0; i < array.length; i++) {
    sum += array[i];
    }
    return sum;
    }

    // 优化后(循环展开)
    public int sum(int[] array) {
    int sum = 0;
    int i = 0;
    for (; i < array.length - 3; i += 4) {
    sum += array[i] + array[i + 1] + array[i + 2] + array[i + 3];
    }
    for (; i < array.length; i++) {
    sum += array[i];
    }
    return sum;
    }

4. JIT编译器的配置参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 启用JIT编译器(默认开启)
-XX:+DoEscapeAnalysis

# 启用方法内联(默认开启)
-XX:+Inline

# 设置内联方法大小阈值
-XX:MaxInlineSize=35

# 启用分层编译(JDK 8默认开启)
-XX:+TieredCompilation

# 设置JIT编译阈值
-XX:CompileThreshold=10000

5. JIT编译器的优缺点

优点

  • 执行速度快:编译后的机器码执行速度比解释执行快很多
  • 运行时优化:可以根据运行时信息进行更精确的优化
  • 自适应优化:可以根据代码执行情况动态调整优化策略

缺点

  • 启动时间慢:JIT编译需要时间,会增加应用启动时间
  • 内存占用高:编译后的代码需要占用内存
  • 编译开销:编译过程会消耗CPU资源

5.2 什么是方法内联?

方法内联(Method Inlining)是JIT编译器的一种优化技术,它将被调用的方法的代码直接嵌入到调用方法中,从而减少方法调用的开销。

方法内联的作用

作用 说明 效果
减少方法调用开销 消除方法调用的参数传递、栈帧创建等开销 提高执行速度
为其他优化创造条件 内联后可以进行更多的优化 进一步提高性能
减少代码体积 某些情况下可以减少代码体积 减少内存占用

详细说明

1. 方法调用的开销

  • 参数传递:将参数压入栈或寄存器
  • 栈帧创建:创建新的栈帧
  • 程序计数器保存:保存返回地址
  • 方法查找:查找方法入口
  • 返回值传递:将返回值传递给调用者

2. 方法内联的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 优化前
public class MethodInliningExample {
public int add(int a, int b) {
return a + b;
}

public int calculate(int x, int y) {
return add(x, y); // 方法调用
}

public static void main(String[] args) {
MethodInliningExample example = new MethodInliningExample();
int result = example.calculate(1, 2);
System.out.println(result);
}
}

// 优化后(方法内联)
public class MethodInliningExample {
public int calculate(int x, int y) {
return x + y; // 直接内联add方法的代码
}

public static void main(String[] args) {
MethodInliningExample example = new MethodInliningExample();
int result = example.calculate(1, 2);
System.out.println(result);
}
}

3. 方法内联的条件

  • 方法大小:方法不能太大,否则内联后代码体积会过大
  • 调用频率:频繁调用的方法更有可能被内联
  • 方法类型:final、static、private方法更容易被内联
  • 继承关系:非虚方法更容易被内联

4. 方法内联的配置参数

1
2
3
4
5
6
7
8
# 启用方法内联(默认开启)
-XX:+Inline

# 设置内联方法大小阈值(字节)
-XX:MaxInlineSize=35

# 设置频繁调用的方法大小阈值(字节)
-XX:FreqInlineSize=325

5. 方法内联的注意事项

  • 代码体积:内联会增加代码体积,可能导致代码缓存不足
  • 调试困难:内联后调试时看不到方法调用
  • 不适用于所有方法:大方法、递归方法等不适合内联

5.3 什么是逃逸分析?

逃逸分析(Escape Analysis)是JIT编译器的一种优化技术,它分析对象的作用域,判断对象是否会逃逸出方法或线程。如果对象不会逃逸,可以进行多种优化。

逃逸分析的作用

作用 说明 效果
栈上分配 将对象分配在栈上,而不是堆上 减少垃圾回收压力
标量替换 将对象分解为基本类型 减少对象创建
同步消除 消除不必要的同步操作 提高并发性能

详细说明

1. 逃逸的类型

逃逸类型 说明 示例
不逃逸 对象只在方法内部使用,不返回给调用者 public void method() { Object obj = new Object(); }
方法逃逸 对象作为返回值返回给调用者 public Object method() { return new Object(); }
线程逃逸 对象被其他线程访问 public void method() { list.add(new Object()); }

2. 栈上分配

  • 作用:将对象分配在栈上,而不是堆上
  • 优点
    • 减少垃圾回收压力
    • 提高内存分配速度
    • 方法结束时自动释放内存
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class StackAllocationExample {
    public void method() {
    // 对象不逃逸,可以在栈上分配
    Point point = new Point(1, 2);
    System.out.println(point.x + ", " + point.y);
    }
    }

    class Point {
    int x, y;
    Point(int x, int y) {
    this.x = x;
    this.y = y;
    }
    }

3. 标量替换

  • 作用:将对象分解为基本类型(标量),不再创建对象
  • 优点
    • 减少对象创建
    • 减少内存占用
    • 提高访问速度
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 优化前
    public void method() {
    Point point = new Point(1, 2); // 创建对象
    System.out.println(point.x + ", " + point.y);
    }

    // 优化后(标量替换)
    public void method() {
    int x = 1; // 标量替换
    int y = 2; // 标量替换
    System.out.println(x + ", " + y);
    }

4. 同步消除

  • 作用:如果对象不会逃逸出线程,可以消除同步操作
  • 优点
    • 减少同步开销
    • 提高并发性能
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 优化前
    public void method() {
    StringBuffer sb = new StringBuffer();
    sb.append("Hello"); // StringBuffer的方法是同步的
    sb.append(" World");
    System.out.println(sb.toString());
    }

    // 优化后(同步消除)
    public void method() {
    StringBuilder sb = new StringBuilder(); // 使用StringBuilder
    sb.append("Hello");
    sb.append(" World");
    System.out.println(sb.toString());
    }

5. 逃逸分析的配置参数

1
2
3
4
5
6
7
8
# 启用逃逸分析(默认开启)
-XX:+DoEscapeAnalysis

# 启用标量替换(默认开启)
-XX:+EliminateAllocations

# 启用同步消除(默认开启)
-XX:+EliminateLocks

6. 逃逸分析的注意事项

  • 分析开销:逃逸分析需要额外的计算,可能增加编译时间
  • 不适用于所有对象:只有不逃逸的对象才能进行优化
  • JVM版本差异:不同JVM版本的逃逸分析效果可能不同

5.4 什么是GC日志?如何分析GC日志?

GC日志是JVM在执行垃圾回收时输出的日志,记录了垃圾回收的详细信息。通过分析GC日志,可以了解JVM的内存使用情况和垃圾回收情况,从而优化JVM性能。

GC日志的作用

作用 说明 效果
监控GC情况 了解GC的频率、时间、原因 发现GC问题
分析内存使用 了解内存使用情况 发现内存泄漏
优化JVM参数 根据GC日志调整JVM参数 提高JVM性能
排查性能问题 分析性能瓶颈 解决性能问题

详细说明

1. 启用GC日志

1
2
3
4
5
6
7
8
9
# JDK 8及之前
-XX:+PrintGCDetails # 打印GC详细信息
-XX:+PrintGCDateStamps # 打印GC时间戳
-XX:+PrintGCTimeStamps # 打印GC相对时间
-XX:+PrintGCApplicationStoppedTime # 打印应用停顿时间
-Xloggc:gc.log # 指定GC日志文件

# JDK 9及之后
-Xlog:gc*:file=gc.log:time,uptime,level,tags

2. GC日志示例

1
2
3
2025-04-12T13:00:00.123+0800: [GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0056789 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

2025-04-12T13:00:01.234+0800: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(9216K)] [ParOldGen: 10240K->10240K(10240K)] 11264K->10240K(19456K), [Metaspace: 32768K->32768K(107520K)], 0.1234567 secs] [Times: user=0.50 sys=0.10, real=0.12 secs]

3. GC日志解读

3.1 Minor GC日志

1
2025-04-12T13:00:00.123+0800: [GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0056789 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
  • 2025-04-12T13:00:00.123+0800:GC发生的时间
  • **[GC]**:表示Minor GC
  • **(Allocation Failure)**:GC原因,Eden区满
  • **[PSYoungGen: 8192K->1024K(9216K)]**:年轻代GC前后使用情况
    • 8192K:GC前年轻代使用量
    • 1024K:GC后年轻代使用量
    • 9216K:年轻代总大小
  • **8192K->2048K(19456K)**:整个堆GC前后使用情况
  • 0.0056789 secs:GC停顿时间
  • **[Times: user=0.01 sys=0.00, real=0.01 secs]**:CPU时间

3.2 Full GC日志

1
2025-04-12T13:00:01.234+0800: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(9216K)] [ParOldGen: 10240K->10240K(10240K)] 11264K->10240K(19456K), [Metaspace: 32768K->32768K(107520K)], 0.1234567 secs] [Times: user=0.50 sys=0.10, real=0.12 secs]
  • **[Full GC]**:表示Full GC
  • **[PSYoungGen: 1024K->0K(9216K)]**:年轻代GC前后使用情况
  • **[ParOldGen: 10240K->10240K(10240K)]**:老年代GC前后使用情况
  • **[Metaspace: 32768K->32768K(107520K)]**:元空间使用情况
  • 0.1234567 secs:GC停顿时间

4. GC日志分析要点

4.1 GC频率

  • Minor GC频率:正常情况下,Minor GC频率应该适中
  • Full GC频率:Full GC频率应该很低,频繁的Full GC表示有问题

4.2 GC停顿时间

  • Minor GC停顿时间:通常几十毫秒
  • Full GC停顿时间:通常几百毫秒到几秒

4.3 GC原因

  • Allocation Failure:内存不足,正常
  • **System.gc()**:手动触发GC,应该避免
  • CMS Generation Full:老年代满,需要优化

4.4 内存使用情况

  • GC前后内存变化:了解内存回收效果
  • 内存使用趋势:是否存在内存泄漏

5. GC日志分析工具

工具 说明 使用场景
GCViewer 开源GC日志分析工具 可视化GC日志
GCEasy 在线GC日志分析工具 快速分析GC日志
GCPlot 开源GC日志分析工具 大规模GC日志分析
JVisualVM JDK自带工具 实时监控和分析

6. GC日志分析示例

示例1:频繁的Full GC

1
2
3
2025-04-12T13:00:00.123+0800: [Full GC (Allocation Failure) ...]
2025-04-12T13:00:01.234+0800: [Full GC (Allocation Failure) ...]
2025-04-12T13:00:02.345+0800: [Full GC (Allocation Failure) ...]

分析

  • Full GC频率过高,每秒一次
  • 可能原因:内存泄漏、堆内存不足
  • 解决方法:增大堆内存、分析内存泄漏

示例2:GC停顿时间过长

1
2025-04-12T13:00:00.123+0800: [Full GC (Allocation Failure) ... 5.1234567 secs]

分析

  • Full GC停顿时间过长,5秒
  • 可能原因:堆内存过大、垃圾回收器不适合
  • 解决方法:减小堆内存、更换垃圾回收器

7. GC日志分析的最佳实践

  • 定期分析GC日志:每周或每月分析一次
  • 设置合理的GC日志参数:打印详细的GC信息
  • 使用工具分析:使用GCViewer、GCEasy等工具
  • 关注关键指标:GC频率、停顿时间、内存使用率
  • 及时优化:发现问题及时优化JVM参数或代码