跳转至

JVM深入理解

🎯 学习目标

完成本章学习后,你将能够: - 深入理解JVM体系结构与各组件职责 - 掌握类加载机制与双亲委派模型 - 理解JVM内存模型与各区域特点 - 掌握主流垃圾回收算法与收集器的原理及适用场景 - 具备GC调优与内存泄漏排查的实战能力 - 理解JIT编译优化策略

JVM运行时数据区示意图


1. JVM体系结构总览

Text Only
┌─────────────────────────────────────────────────────┐
│                    Java源代码(.java)                 │
│                         ↓ javac编译                   │
│                    字节码文件(.class)                 │
└──────────────────────────┬──────────────────────────┘
┌──────────────────────────────────────────────────────┐
│                      JVM                              │
│  ┌──────────────────────────────────────────────┐    │
│  │           类加载子系统 (Class Loader)           │    │
│  │   Bootstrap → Extension → Application         │    │
│  └──────────────────┬───────────────────────────┘    │
│                     ↓                                 │
│  ┌──────────────────────────────────────────────┐    │
│  │            运行时数据区 (Runtime Data Areas)    │    │
│  │  ┌─────────┐ ┌─────────┐ ┌───────────────┐  │    │
│  │  │程序计数器│ │虚拟机栈  │ │  本地方法栈    │  │    │
│  │  │(线程私有)│ │(线程私有)│ │  (线程私有)    │  │    │
│  │  └─────────┘ └─────────┘ └───────────────┘  │    │
│  │  ┌────────────────────┐ ┌────────────────┐   │    │
│  │  │    堆 Heap          │ │ 方法区/元空间   │   │    │
│  │  │   (线程共享)        │ │  (线程共享)     │   │    │
│  │  └────────────────────┘ └────────────────┘   │    │
│  └──────────────────────────────────────────────┘    │
│                     ↓                                 │
│  ┌──────────────────────────────────────────────┐    │
│  │           执行引擎 (Execution Engine)          │    │
│  │   解释器 + JIT编译器 + 垃圾收集器(GC)          │    │
│  └──────────────────────────────────────────────┘    │
│                     ↓                                 │
│  ┌──────────────────────────────────────────────┐    │
│  │           本地方法接口 (JNI)                    │    │
│  └──────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

2. 类加载机制

2.1 类的生命周期

Text Only
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution)
     → 初始化(Initialization) → 使用(Using) → 卸载(Unloading)
           ↑______ 连接(Linking) ______↑
  • 加载:读取.class文件字节流,创建java.lang.Class对象
  • 验证:检查字节码是否合法(文件格式、元数据、字节码、符号引用验证)
  • 准备:为类变量(static)分配内存并赋零值(注意:static final常量在此阶段赋实际值)
  • 解析:将常量池中符号引用替换为直接引用
  • 初始化:执行<clinit>()方法(类变量赋值 + static代码块)

2.2 双亲委派模型

Java
// 类加载器层级
// Bootstrap ClassLoader   ← 加载 jre/lib 核心类库(C++实现,Java中表现为null)
//       ↑
// Platform ClassLoader    ← 加载平台模块类库(Java 9+,替代原Extension ClassLoader)
//       ↑
// Application ClassLoader ← 加载 classpath 下的用户类(默认类加载器)
//       ↑
// Custom ClassLoader      ← 用户自定义

// 双亲委派流程:
// 1. 收到类加载请求 → 先委派给父加载器
// 2. 父加载器无法加载 → 才由自身尝试加载
// 优点:保证核心类安全(防止用户篡改java.lang.String等)

// ClassLoader.loadClass() 源码核心逻辑
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查是否已加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 2. 委派给父加载器
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载
            }
            if (c == null) {
                // 3. 自身尝试加载
                c = findClass(name);
            }
        }
        return c;
    }
}

2.3 自定义类加载器

Java
import java.io.*;
import java.nio.file.*;

/**
 * 自定义类加载器 — 加载指定目录下的.class文件
 * 典型应用:热部署、加密类加载、模块隔离(如Tomcat)
 */
public class CustomClassLoader extends ClassLoader {

    private final String classPath;

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

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            String fileName = classPath + File.separator
                + name.replace('.', File.separatorChar) + ".class";
            byte[] data = Files.readAllBytes(Path.of(fileName));
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("无法加载类: " + name, e);
        }
    }

    public static void main(String[] args) throws Exception {
        CustomClassLoader loader = new CustomClassLoader("/opt/classes");
        Class<?> clazz = loader.loadClass("com.example.MyPlugin");
        Object instance = clazz.getDeclaredConstructor().newInstance();
        System.out.println("类加载器: " + clazz.getClassLoader());
        // 输出: 类加载器: com.example.CustomClassLoader@...
    }
}

打破双亲委派的场景: - SPI机制(JDBC、JNDI):Bootstrap加载接口,Application加载实现 → 使用Thread Context ClassLoader - Tomcat:每个Web应用独立ClassLoader,优先加载Web应用自身类 - OSGi:网状类加载结构,按Bundle组织


3. 运行时数据区详解

3.1 线程私有区域

Java
// ==================== 程序计数器 (PC Register) ====================
// - 当前线程执行的字节码指令行号指示器
// - 唯一不会发生OOM的区域
// - 执行Java方法时记录字节码地址,执行Native方法时为Undefined

// ==================== 虚拟机栈 (JVM Stack) ====================
// - 每个方法调用创建一个栈帧(Stack Frame)
// - 栈帧包含:局部变量表、操作数栈、动态链接、方法返回地址
// - -Xss 设置栈大小(默认512K~1M)
// - 异常:StackOverflowError(栈深度超限)、OutOfMemoryError(无法申请栈空间)

public class StackDemo {
    // 触发 StackOverflowError
    static int depth = 0;

    public static void recursiveCall() {
        depth++;
        recursiveCall();  // 无限递归
    }

    public static void main(String[] args) {
        try {
            recursiveCall();
        } catch (StackOverflowError e) {
            System.out.println("栈溢出!递归深度: " + depth);
            // 默认栈大小约可递归 5000-20000 层
        }
    }
}

// ==================== 本地方法栈 (Native Method Stack) ====================
// - 为Native方法(JNI调用)服务
// - HotSpot中与虚拟机栈合并实现

3.2 线程共享区域

Java
// ==================== 堆 (Heap) ====================
// - 存放所有对象实例和数组
// - GC主要管理区域("GC堆")
// - -Xms 初始堆大小,-Xmx 最大堆大小(建议设为相同值,避免扩缩容开销)
// - 逻辑分代:新生代(Young) + 老年代(Old)

// 堆内存结构(以G1之前的经典分代为例)
//
// 新生代 (Young Generation)  —  -Xmn 或 -XX:NewRatio
// ┌────────────────────┬──────┬──────┐
// │   Eden (80%)       │ S0   │ S1   │  — 默认 Eden:S0:S1 = 8:1:1
// │   新对象分配于此     │(10%) │(10%) │  — -XX:SurvivorRatio=8
// └────────────────────┴──────┴──────┘
//
// 老年代 (Old Generation)
// ┌──────────────────────────────────┐
// │  长期存活对象、大对象               │
// │  -XX:MaxTenuringThreshold=15     │  — 晋升年龄(默认15)
// └──────────────────────────────────┘

// ==================== 方法区 / 元空间 (Metaspace) ====================
// - 存储类信息、常量、静态变量、JIT编译后的代码
// - JDK7及之前:PermGen(永久代),受-XX:MaxPermSize限制
// - JDK8+:Metaspace(元空间),使用本地内存,-XX:MaxMetaspaceSize
// - 运行时常量池是方法区的一部分

// TLAB (Thread Local Allocation Buffer)
// - 每个线程在Eden区预分配一小块私有缓冲区
// - 避免多线程分配对象时的锁竞争
// - -XX:+UseTLAB(默认开启)

4. 垃圾回收

4.1 对象存活判断

Java
// 方式一:引用计数法(Python使用,Java不用)
// 缺点:无法解决循环引用

// 方式二:可达性分析(Java采用)
// 从GC Roots出发,沿引用链搜索,不可达的对象判定为垃圾

// GC Roots包括:
// 1. 虚拟机栈中引用的对象(局部变量)
// 2. 方法区中静态属性引用的对象
// 3. 方法区中常量引用的对象
// 4. 本地方法栈中Native方法引用的对象
// 5. 同步锁持有的对象
// 6. JVM内部引用(基本类型包装类、系统类加载器等)

// 引用类型强度:强引用 > 软引用 > 弱引用 > 虚引用
Object strong = new Object();            // 强引用:GC绝不回收
SoftReference<Object> soft = new SoftReference<>(new Object());  // 软引用:内存不足时回收
WeakReference<Object> weak = new WeakReference<>(new Object());  // 弱引用:下次GC即回收
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue); // 虚引用:配合ReferenceQueue

4.2 垃圾回收算法

算法 原理 优点 缺点 适用场景
标记-清除 标记存活对象→清除未标记对象 简单 内存碎片 CMS老年代
标记-复制 将存活对象复制到另一半空间 无碎片、高效 空间浪费一半 新生代(Eden/S0/S1)
标记-整理 标记存活→向一端移动压缩 无碎片 移动对象开销 老年代
分代收集 新生代用复制、老年代用标记-整理/清除 综合最优 实现复杂 HotSpot默认

4.3 GC类型

  • Minor GC / Young GC:新生代GC,频繁但速度快
  • Major GC / Old GC:老年代GC,通常伴随Full GC
  • Full GC:整堆GC(新生代 + 老年代 + 元空间),STW时间最长
  • Mixed GC:G1独有,回收整个新生代 + 部分老年代

4.4 对象分配与晋升

Text Only
新对象 → Eden区(TLAB快速分配)
     ↓ Minor GC触发(Eden满)
存活对象 → S0/S1(年龄+1)
     ↓ 每次Minor GC在S0/S1之间来回复制
年龄 ≥ 15 → 晋升Old区(-XX:MaxTenuringThreshold)
大对象 → 直接进入Old区(-XX:PretenureSizeThreshold)
动态年龄判定 → 相同年龄对象总大小 > S区一半 → 该年龄及以上晋升

5. 垃圾收集器详解

5.1 收集器对比

收集器 区域 算法 线程 STW 特点
Serial 新生代 复制 单线程 简单高效,Client模式默认
ParNew 新生代 复制 多线程 Serial多线程版本,配合CMS
Parallel Scavenge 新生代 复制 多线程 吞吐量优先,JDK8默认搭档
Serial Old 老年代 标记-整理 单线程 Serial老年代版本
Parallel Old 老年代 标记-整理 多线程 JDK8默认(+Parallel Scavenge)
CMS 老年代 标记-清除 并发 短暂 低延迟,JDK9废弃,JDK14移除
G1 整堆 分区+复制+整理 并发 可控 JDK9+默认,平衡吞吐与延迟
ZGC 整堆 染色指针+读屏障 并发 <1ms 超低延迟,JDK15+生产可用
Shenandoah 整堆 转发指针+读屏障 并发 <10ms OpenJDK,与ZGC竞争

5.2 G1收集器深入

Java
// G1 (Garbage-First) — JDK9+默认收集器
// 核心思想:将堆划分为大小相等的Region(1~32MB),按回收价值排序优先回收

// Region类型:
// Eden Region — 新对象分配
// Survivor Region — 新生代存活对象
// Old Region — 老年代对象
// Humongous Region — 大对象(> Region 50%)

// G1回收过程:
// 1. 初始标记(Initial Mark)— STW,标记GC Roots直接关联的对象
// 2. 并发标记(Concurrent Mark)— 与用户线程并发,遍历引用链
// 3. 最终标记(Final Mark)— STW,处理SATB(Snapshot-At-The-Beginning)遗留记录
// 4. 筛选回收(Cleanup/Evacuation)— STW,按Region回收价值排序,复制存活对象

// JVM参数
// -XX:+UseG1GC                         开启G1(JDK9+默认)
// -XX:MaxGCPauseMillis=200             目标最大停顿时间(默认200ms)
// -XX:G1HeapRegionSize=4m              Region大小(1~32MB,须为2的幂)
// -XX:G1NewSizePercent=5               新生代最小比例
// -XX:G1MaxNewSizePercent=60           新生代最大比例
// -XX:InitiatingHeapOccupancyPercent=45 触发Mixed GC的老年代占比阈值

5.3 ZGC(面试加分项)

Java
// ZGC — 超低延迟收集器(STW < 1ms,不随堆大小增长)
// 核心技术:
// 1. 染色指针(Colored Pointers)— 在指针中存储GC元数据(标记、重映射)
// 2. 读屏障(Load Barrier)— 读取对象引用时检查指针状态
// 3. 并发整理 — 几乎所有阶段与用户线程并发

// 适用场景:
// - 超大堆(TB级别) + 要求极低延迟(金融、游戏)
// - JDK15+ 生产可用

// 启用ZGC
// -XX:+UseZGC
// -XX:+ZGenerational                   分代ZGC(JDK21+,默认开启)
// -Xmx16g                             设置最大堆

6. GC调优实战

6.1 常用JVM参数

Bash
# 堆内存
-Xms4g -Xmx4g            # 初始/最大堆(建议设相同值)
-Xmn2g                    # 新生代大小
-XX:MetaspaceSize=256m    # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间最大大小

# GC选择
-XX:+UseG1GC              # 使用G1
-XX:MaxGCPauseMillis=100  # G1目标停顿时间

# GC日志(JDK9+统一日志格式)
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=100m

# 线程栈
-Xss512k                  # 每个线程栈大小

# OOM时自动dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof

6.2 GC日志分析

Java
// 示例GC日志(G1)
// [0.123s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
//     Eden: 24M(24M)->0M(24M) Survivors: 0M->4M Heap: 24M(256M)->5M(256M)
// [0.123s][info][gc] GC(0) Pause Young (Normal) 24M->5M(256M) 3.456ms

// 关键指标:
// 1. GC频率(Young GC几秒一次算正常,Full GC应极少出现)
// 2. GC耗时(单次停顿几十ms内,累计占比<5%)
// 3. 内存回收效率(每次GC回收比例)
// 4. 堆使用趋势(是否持续增长 → 可能内存泄漏)

6.3 实用诊断工具

Bash
# ===== JDK自带工具 =====

# jps — 查看Java进程
jps -lv

# jstat — GC统计(每1秒打印一次,共10次)
jstat -gcutil <pid> 1000 10
# S0   S1   E    O    M    CCS  YGC YGCT  FGC FGCT GCT
# 0.00 50.2 45.3 32.1 95.0 93.2 120 1.23  2   0.45 1.68

# jmap — 堆信息 / 生成堆转储
jmap -heap <pid>                              # 查看堆使用概况
jmap -histo:live <pid> | head -20             # 对象统计(触发Full GC)
jmap -dump:format=b,file=heap.hprof <pid>     # 生成堆转储

# jstack — 线程快照(排查死锁、线程阻塞)
jstack <pid> > thread_dump.txt
jstack -l <pid>                               # 包含锁信息

# ===== Arthas(阿里开源,强烈推荐) =====
# 下载: curl -O https://arthas.aliyun.com/arthas-boot.jar
# 启动: java -jar arthas-boot.jar

# dashboard          — 实时面板(CPU、内存、GC、线程等)
# thread -n 3        — CPU占用最高的3个线程
# thread -b          — 查找阻塞线程
# heapdump /tmp/dump.hprof  — 生成堆转储
# sc -d com.example.MyClass — 查看类信息
# watch com.example.Service method '{params, returnObj}' — 观察方法调用
# trace com.example.Service method  — 方法调用链耗时
# jad com.example.MyClass           — 反编译

7. 内存泄漏排查

7.1 常见OOM类型

Java
// 1. java.lang.OutOfMemoryError: Java heap space
//    原因:堆内存不足(对象过多或内存泄漏)
//    解决:增大-Xmx,排查泄漏

// 2. java.lang.OutOfMemoryError: Metaspace
//    原因:加载的类过多(如动态代理、CGLIB大量生成类)
//    解决:增大-XX:MaxMetaspaceSize

// 3. java.lang.OutOfMemoryError: GC overhead limit exceeded
//    原因:98%以上时间在GC,回收不到2%内存
//    解决:排查内存泄漏

// 4. java.lang.StackOverflowError
//    原因:栈深度超限(通常是无限递归)
//    解决:检查递归终止条件,增大-Xss

// 5. java.lang.OutOfMemoryError: Direct buffer memory
//    原因:NIO DirectByteBuffer溢出
//    解决:增大-XX:MaxDirectMemorySize

7.2 内存泄漏排查流程

Java
// 常见内存泄漏场景
public class MemoryLeakExamples {

    // 场景1:静态集合持续增长
    private static final List<byte[]> cache = new ArrayList<>();

    public void addToCache(byte[] data) {
        cache.add(data);  // 永远不清理 → 泄漏
    }

    // 场景2:未关闭的资源
    public void readFile(String path) throws Exception {
        InputStream is = new FileInputStream(path);
        // 忘记 is.close() → 资源泄漏
        // 正确做法:try-with-resources
    }

    // 场景3:监听器/回调未注销
    public void register() {
        EventBus.register(this);  // 注册后从不unregister
    }

    // 场景4:内部类持有外部类引用
    public class InnerTask implements Runnable {
        // 非静态内部类隐式持有外部类引用
        // 如果InnerTask被线程池持有 → 外部类无法被GC
        @Override
        public void run() { }
    }
}

// 排查流程:
// 1. 观察 → jstat -gcutil <pid> 查看Old区持续增长
// 2. 抓堆 → jmap -dump:format=b,file=heap.hprof <pid>
// 3. 分析 → 使用Eclipse MAT或VisualVM打开hprof
//    - Leak Suspects Report: 自动检测可疑泄漏点
//    - Dominator Tree: 查看对象占用排名
//    - GC Roots → 追踪引用链,定位是谁持有了不该持有的引用
// 4. 修复 → 清理引用、关闭资源、使用弱引用等

8. JIT编译优化

Java
// HotSpot采用"解释执行 + JIT编译"混合模式
// 热点代码(方法调用计数器 / 回边计数器超过阈值)触发JIT编译为本地机器码

// ==================== 逃逸分析 ====================
// 分析对象是否逃逸到方法/线程外部
// 不逃逸的对象可进行以下优化:

// 1. 栈上分配:对象不逃逸 → 在栈帧中分配,方法结束自动回收,不进入堆
public void process() {
    Point p = new Point(1, 2);  // p不逃逸 → 可能在栈上分配
    int sum = p.x + p.y;
    System.out.println(sum);
}

// 2. 标量替换:对象拆解为基本类型
// Point p = new Point(1, 2); → 替换为 int x = 1; int y = 2;

// 3. 锁消除:对象不逃逸 → 去除无意义的同步
public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer();  // sb不逃逸
    sb.append(a);
    sb.append(b);
    return sb.toString();
    // JIT可能消除StringBuffer内部的synchronized
}

// ==================== 方法内联 ====================
// 将方法调用替换为方法体本身,减少调用开销
// -XX:MaxInlineSize=35       — 方法字节码 ≤ 35字节自动内联
// -XX:FreqInlineSize=325     — 热点方法字节码 ≤ 325字节自动内联

// ==================== 循环优化 ====================
// 循环展开、循环判断外提等

// JIT相关参数
// -XX:-TieredCompilation         关闭分层编译
// -XX:CompileThreshold=10000     方法调用计数阈值
// -XX:+PrintCompilation          打印JIT编译信息

✏️ 练习

  1. 基础:编写程序触发StackOverflowErrorOutOfMemoryError: Java heap space,并记录错误信息
  2. 中级:使用jstat -gcutil监控一个持续创建对象的程序,观察各代内存变化和GC频率
  3. 高级:编写一个存在内存泄漏的程序(静态集合 + 不断添加大对象),使用jmap生成堆转储,用MAT分析泄漏原因并修复

📋 经典JVM面试题

1. JVM内存结构说一下?哪些是线程私有的?

:程序计数器、虚拟机栈、本地方法栈(线程私有);堆、方法区/元空间(线程共享)。堆存放对象实例,方法区存放类信息。JDK8后永久代被元空间替代,使用本地内存。

2. 什么是双亲委派模型?为什么需要?如何打破?

:类加载请求先委派给父加载器,父无法加载再由子加载器处理。保证核心类安全性和唯一性。打破方式:重写loadClass()而非findClass(),如Tomcat(Web应用隔离)、SPI(TCCL)、OSGi(网状加载)。

3. 如何判断对象可以被回收?

:可达性分析——从GC Roots出发,不可达的对象判定为垃圾。GC Roots包括栈帧中的引用、静态变量、同步锁持有的对象等。回收前还有finalize()自救机会(只执行一次,不推荐使用)。

4. 垃圾回收算法及其优缺点?

:①标记-清除(简单,有碎片)②标记-复制(无碎片,浪费空间,适合新生代)③标记-整理(无碎片,移动开销大,适合老年代)④分代收集(综合以上,HotSpot实际采用)。

5. G1和CMS的区别?

:CMS基于标记-清除,目标是最短停顿,会产生碎片;G1基于Region分区+复制整理,可预测停顿时间(-XX:MaxGCPauseMillis),无碎片。G1是JDK9+默认,CMS在JDK14被移除。

6. Full GC触发条件?

:①老年代空间不足②元空间不足③System.gc()调用④CMS并发失败(Concurrent Mode Failure)⑤晋升担保失败(老年代连续空间不足以容纳新生代晋升对象)。

7. 如何排查线上OOM?

:①提前配置-XX:+HeapDumpOnOutOfMemoryError②分析GC日志(jstat/GC log)③获取堆转储(jmap或自动dump)④MAT分析(Dominator Tree找大对象 → GC Roots追踪引用链)⑤定位代码修复⑥Arthas在线诊断(dashboard/thread/heapdump)。

8. JVM调优的一般流程?

:①明确目标(低延迟/高吞吐)②选择合适的GC(G1/ZGC)③设定初始参数④压测 + GC日志分析⑤调整参数(堆大小、Region、停顿目标)⑥反复迭代直至达标。

9. 强引用、软引用、弱引用、虚引用的区别?

:强引用不回收;软引用内存不足时回收(适合缓存);弱引用下次GC即回收(WeakHashMap);虚引用无法获取对象,仅用于回收通知(配合ReferenceQueue)。

10. 什么是逃逸分析?

:JIT编译器分析对象是否逃逸出方法或线程。不逃逸可优化为:栈上分配(避免GC)、标量替换(拆解为基本类型)、锁消除(去除无意义同步)。-XX:+DoEscapeAnalysis(JDK6u23+默认开启)。


📌 下一步学习14-Java新特性(8-21) — 掌握现代Java语法与API