JVM底层攻坚

目录

[1. 什么是JVM?](#1. 什么是JVM?)

[2. 为什么要有JVM?](#2. 为什么要有JVM?)

[3. .class文件是干什么的?](#3. .class文件是干什么的?)

[3.1. 举个例子:](#3.1. 举个例子:)

[3.2. .class 文件的特点:](#3.2. .class 文件的特点:)

[3.3. 为什么需要 .class 文件?](#3.3. 为什么需要 .class 文件?)

[4. JVM运行流程](#4. JVM运行流程)

[5. JVM运行时数据区](#5. JVM运行时数据区)

[5.1. JVM运行时数据区 vs Java内存模型(JMM)](#5.1. JVM运行时数据区 vs Java内存模型(JMM))

[5.1.1. JVM运行时数据区(Runtime Data Areas)](#5.1.1. JVM运行时数据区(Runtime Data Areas))

[5.1.2. Java内存模型(JMM)](#5.1.2. Java内存模型(JMM))

[5.1.3. JVM运行时数据区 vs JMM:对比总结](#5.1.3. JVM运行时数据区 vs JMM:对比总结)

[5.2. JVM运行时数据区包含什么?](#5.2. JVM运行时数据区包含什么?)

[5.3. 堆(线程共享)](#5.3. 堆(线程共享))

[5.3.1. 作用](#5.3.1. 作用)

[5.3.2. 堆的两个区域](#5.3.2. 堆的两个区域)

[5.4. Java虚拟机栈(线程私有)](#5.4. Java虚拟机栈(线程私有))

[5.4.1. 作用](#5.4.1. 作用)

[5.5. 本地方法栈(线程私有)](#5.5. 本地方法栈(线程私有))

[5.5.1. 什么是本地方法?](#5.5.1. 什么是本地方法?)

[5.6. 程序计数器(线程私有)](#5.6. 程序计数器(线程私有))

[5.6.1. 作用:](#5.6.1. 作用:)

[5.6.2. 为什么要有程序计数器?](#5.6.2. 为什么要有程序计数器?)

[5.6.3. 关键特性:](#5.6.3. 关键特性:)

[5.6.4. 特殊情况:本地方法](#5.6.4. 特殊情况:本地方法)

[5.7. 方法区(线程共享)](#5.7. 方法区(线程共享))

[5.7.1. 作用:](#5.7.1. 作用:)

[5.7.2. 为什么需要方法区?](#5.7.2. 为什么需要方法区?)

[5.7.3. 关键特性:](#5.7.3. 关键特性:)

[5.8. 什么是线程私有 / 线程共享?](#5.8. 什么是线程私有 / 线程共享?)

[5.8.1. ✅ 线程私有(Thread-Private)](#5.8.1. ✅ 线程私有(Thread-Private))

[5.8.2. ✅ 线程共享(Thread-Shared)](#5.8.2. ✅ 线程共享(Thread-Shared))

[5.8.3. JVM 运行时数据区划分(按线程属性分类)](#5.8.3. JVM 运行时数据区划分(按线程属性分类))

[6. java内存布局中两种经典异常](#6. java内存布局中两种经典异常)

[6.1. Java堆溢出](#6.1. Java堆溢出)

[6.1.1. 介绍](#6.1.1. 介绍)

[6.1.2. 演示代码](#6.1.2. 演示代码)

[6.2. 虚拟机栈/本地方法栈溢出](#6.2. 虚拟机栈/本地方法栈溢出)

[6.2.1. 介绍](#6.2.1. 介绍)

[6.2.2. 演示代码](#6.2.2. 演示代码)

[6.2.3. 总结对比](#6.2.3. 总结对比)

[7. JVM类加载](#7. JVM类加载)

[7.1. 类加载过程](#7.1. 类加载过程)

[7.1.1. 加载](#7.1.1. 加载)

[7.1.2. 连接(Linking)](#7.1.2. 连接(Linking))

[7.1.2.1. 验证(Verification)](#7.1.2.1. 验证(Verification))

[7.1.2.2. 准备(Preparation)](#7.1.2.2. 准备(Preparation))

[7.1.2.3. 解析(Resolution)](#7.1.2.3. 解析(Resolution))

[7.1.3. 初始化(Initialization)](#7.1.3. 初始化(Initialization))

[7.2. 双亲委派模型](#7.2. 双亲委派模型)

[7.2.1. 什么是双亲委派模型?](#7.2.1. 什么是双亲委派模型?)

[7.2.1.1. Bootstrap ClassLoader(启动类加载器)](#7.2.1.1. Bootstrap ClassLoader(启动类加载器))

[7.2.1.2. Extension ClassLoader(扩展类加载器)](#7.2.1.2. Extension ClassLoader(扩展类加载器))

[7.2.1.3. Application ClassLoader(应用程序类加载器)](#7.2.1.3. Application ClassLoader(应用程序类加载器))

[7.2.1.4. 自定义 ClassLoader](#7.2.1.4. 自定义 ClassLoader)

[7.2.1.5. 双亲委派机制的工作流程](#7.2.1.5. 双亲委派机制的工作流程)

[7.2.1.6. 优点:](#7.2.1.6. 优点:)

[7.2.2. 破坏双亲委派模型](#7.2.2. 破坏双亲委派模型)

[7.2.2.1. 为什么需要破坏双亲委派模型?](#7.2.2.1. 为什么需要破坏双亲委派模型?)

[7.2.2.2. SPI(Service Provider Interface)机制的需求](#7.2.2.2. SPI(Service Provider Interface)机制的需求)

[7.2.2.2.1. 什么是SPI?](#7.2.2.2.1. 什么是SPI?)

[7.2.2.2.2. 问题来了:类加载器的"逆向依赖"](#7.2.2.2.2. 问题来了:类加载器的“逆向依赖”)

[7.2.2.2.3. 如果坚持双亲委派会发生什么?](#7.2.2.2.3. 如果坚持双亲委派会发生什么?)

[7.2.2.2.4. 解决方案:破坏双亲委派!](#7.2.2.2.4. 解决方案:破坏双亲委派!)

[7.2.2.3. 模块化与热部署(如 OSGi、Tomcat、Spring Boot DevTools)](#7.2.2.3. 模块化与热部署(如 OSGi、Tomcat、Spring Boot DevTools))

[7.2.2.3.1. 需求场景:](#7.2.2.3.1. 需求场景:)

[7.2.2.3.2. 双亲委派的问题:](#7.2.2.3.2. 双亲委派的问题:)

[7.2.2.3.3. 解决方案:每个模块有自己的 ClassLoader,且不遵循双亲委派](#7.2.2.3.3. 解决方案:每个模块有自己的 ClassLoader,且不遵循双亲委派)

[7.2.2.4. 自定义类加载源(加密、网络、数据库等)](#7.2.2.4. 自定义类加载源(加密、网络、数据库等))

[7.2.2.4.1. 需求场景:](#7.2.2.4.1. 需求场景:)

[7.2.2.4.2. 双亲委派的问题:](#7.2.2.4.2. 双亲委派的问题:)

[7.2.2.4.3. 解决方案:自定义 ClassLoader,直接加载,不委派](#7.2.2.4.3. 解决方案:自定义 ClassLoader,直接加载,不委派)

[8. 垃圾回收相关](#8. 垃圾回收相关)

内存VS对象

[8.1. 死亡对象的判断算法](#8.1. 死亡对象的判断算法)

[8.1.1. 引用计数算法](#8.1.1. 引用计数算法)

[8.1.2. 可达性算法分析](#8.1.2. 可达性算法分析)

[8.1.2.1. 为什么需要可达性分析算法?](#8.1.2.1. 为什么需要可达性分析算法?)

[8.1.2.2. 可达性分析算法核心思想:](#8.1.2.2. 可达性分析算法核心思想:)

[8.1.2.3. 什么是GC Roots?](#8.1.2.3. 什么是GC Roots?)

[8.2. 垃圾回收算法](#8.2. 垃圾回收算法)

[8.2.1. 标记-清除算法](#8.2.1. 标记-清除算法)

[8.2.1.1. 原理:](#8.2.1.1. 原理:)

[8.2.1.2. 存在问题:](#8.2.1.2. 存在问题:)

[8.2.2. 复制算法](#8.2.2. 复制算法)

[8.2.2.1. 原理:](#8.2.2.1. 原理:)

[8.2.2.2. 复制算法流程:](#8.2.2.2. 复制算法流程:)

[8.2.3. 标记-整理算法](#8.2.3. 标记-整理算法)

[8.2.3.1. 原理:](#8.2.3.1. 原理:)

[8.2.3.2. 使用场景:](#8.2.3.2. 使用场景:)

[8.2.4. 分代算法](#8.2.4. 分代算法)

[8.2.4.1. 原理:](#8.2.4.1. 原理:)

[8.2.4.2. 使用场景:](#8.2.4.2. 使用场景:)

[8.2.5. 对比:](#8.2.5. 对比:)

[8.2.6. Minor GC 和Full GC区别是什么?](#8.2.6. Minor GC 和Full GC区别是什么?)

[8.2.6.1. 作用区域不同:](#8.2.6.1. 作用区域不同:)

[8.2.6.2. 触发条件不同](#8.2.6.2. 触发条件不同)

[8.2.6.3. 执行频率与耗时](#8.2.6.3. 执行频率与耗时)

[8.2.6.4. 对应用的影响](#8.2.6.4. 对应用的影响)

[8.3. 垃圾收集器](#8.3. 垃圾收集器)

[8.3.1. Serial GC(串行收集器)](#8.3.1. Serial GC(串行收集器))

[8.3.2. Parallel GC(并行收集器,又称"吞吐优先 GC")](#8.3.2. Parallel GC(并行收集器,又称“吞吐优先 GC”))

[8.3.3. CMS(Concurrent Mark-Sweep,并发标记清除)](#8.3.3. CMS(Concurrent Mark-Sweep,并发标记清除))

[8.3.4. G1 GC(Garbage-First,JDK 9+ 默认)](#8.3.4. G1 GC(Garbage-First,JDK 9+ 默认))

[8.3.5. ZGC(Z Garbage Collector,JDK 11+)](#8.3.5. ZGC(Z Garbage Collector,JDK 11+))

[8.3.6. Shenandoah GC(OpenJDK 社区项目)](#8.3.6. Shenandoah GC(OpenJDK 社区项目))

[8.3.7. 主流GC对比](#8.3.7. 主流GC对比)

[9. 总结:一个对象的一生](#9. 总结:一个对象的一生)

[10. JMM](#10. JMM)

[10.1. 主内存和工作内存](#10.1. 主内存和工作内存)

[10.2. 8 大内存交互操作(JMM 定义的底层操作)](#10.2. 8 大内存交互操作(JMM 定义的底层操作))

[10.3. JMM如何保证三大特性?](#10.3. JMM如何保证三大特性?)

[10.3.1. 原子性(Atomicity)](#10.3.1. 原子性(Atomicity))

[10.3.2. 可见性(Visibility)](#10.3.2. 可见性(Visibility))

[10.3.3. 有序性(Ordering)](#10.3.3. 有序性(Ordering))

[10.3.4. happens-before?](#10.3.4. happens-before?)

[10.3.4.1. happens-before是什么?](#10.3.4.1. happens-before是什么?)

[10.3.4.2. 为什么需要happens-before?](#10.3.4.2. 为什么需要happens-before?)

[10.3.4.3. happens-before 的六大核心规则](#10.3.4.3. happens-before 的六大核心规则)

[10.4. volatile型变量的特殊规则](#10.4. volatile型变量的特殊规则)

[10.4.1. 保证此变量对所有线程的可见性](#10.4.1. 保证此变量对所有线程的可见性)

[10.4.2. 使用volatile变量的语义是禁止指令重排序](#10.4.2. 使用volatile变量的语义是禁止指令重排序)

[10.4.3. 总结:volatile 的三大特性(Java 内存模型角度)](#10.4.3. 总结:volatile 的三大特性(Java 内存模型角度))


1. 什么是JVM?

JVM(Java Virtual Machine,Java虚拟机)是运行Java字节码的虚拟机,负责将.class文件解释或编译成机器码并在不同平台上执行,实现"一次编写,到处运行"。

2. 为什么要有JVM?

要有JVM,主要是为了实现 跨平台性安全性

  1. 跨平台(Write Once, Run Anywhere):Java程序编译成与平台无关的字节码(.class文件),JVM负责在不同操作系统上解释或编译这些字节码,屏蔽底层差异。
  2. 内存管理与安全:JVM自动管理内存(如垃圾回收),防止内存泄漏;同时通过类加载器、字节码验证等机制保障运行时安全。
  3. 性能优化:JVM包含即时编译器(JIT),可动态优化热点代码,提升执行效率。

简言之:JVM让Java程序安全、高效地在任何支持它的设备上运行。

3. .class文件是干什么的?

.class 文件是 Java 源代码编译后生成的二进制文件 ,它包含的是 Java 字节码(bytecode),而不是直接能在 CPU 上运行的机器码。

3.1. 举个例子:

你写了一个 Java 源文件 Hello.java

java 复制代码
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

javac 命令编译它:

复制代码
javac Hello.java

就会生成一个 Hello.class 文件。

3.2. .class 文件的特点:

  • 平台无关 :它不针对 Windows、Linux 或 macOS,而是专为 JVM 设计的中间代码(字节码)。
  • 由 JVM 执行 :运行时,JVM 会加载 .class 文件,通过解释器或 JIT(即时编译器)将其转换为当前机器的本地机器码来执行。
  • 结构规范:遵循《Java 虚拟机规范》,包含类名、方法、字段、字节码指令等信息。

3.3. 为什么需要 .class 文件?

因为 Java 的设计哲学是 "一次编写,到处运行"

源代码 → .class(字节码)→ 由不同平台上的 JVM 执行 → 实现跨平台。

💡简单说:.class 是 Java 程序交给 JVM 的"通用语言",JVM 是它的"翻译官+执行者"。

4. JVM运行流程

  1. 程序在执行之前需要先把Java代码转换为字节码(.class文件)
  2. JVM需要把字节码通过类加载器(ClassLoader) 把文件加载到内存中的运行时数据区(Runtime Data Area)
  3. 字节码(.class)文件时JVM的一套指令集规范,并不能直接交给底层的操作系统去执行,因此需要特定的命令解析器**执行引擎(Execution Interface)**将字节码翻译为底层系统指令再交由CPU去执行
  4. 而这个过程需要调用其他语言的接口**本地库接口(Native Interface)**来实现整个程序的功能。
java 复制代码
.class文件
    ↓
类加载子系统
    ├── 加载(Loading)
    ├── 验证(Verification)
    ├── 准备(Preparation)
    ├── 解析(Resolution)
    └── 初始化(Initialization)
    ↓
运行时数据区(Runtime Data Areas)
    ├── 方法区(Method Area)- 类信息、常量、静态变量
    ├── 堆(Heap)- 对象实例
    ├── Java虚拟机栈(Java Stack)- 栈帧、局部变量表、操作数栈
    ├── 本地方法栈(Native Method Stack)
    └── 程序计数器(Program Counter Register)
    ↑↓    ↑
执行引擎(Execution Engine)
    ├── 解释器(Interpreter)- 逐行解释字节码
    ├── JIT编译器(Just-In-Time Compiler)- 热点代码编译为本地代码
    └── 垃圾回收器(Garbage Collector)- 堆内存管理
    ↓
本地方法接口(JNI)→ 本地方法库(Native Libraries)
    ↑
    ── 虚线 ← 动态类加载(反射、动态代理、JSP等)

总结来说,JVM主要分为以下四个部分:

  • 类加载器
  • 运行时数据区
  • 执行引擎
  • 本地库接口

5. JVM运行时数据区

5.1. JVM运行时数据区 vs Java内存模型(JMM)

5.1.1. JVM运行时数据区(Runtime Data Areas)

这是 JVM规范中定义的内存结构,描述的是 JVM在运行Java程序时,如何在内存中组织和管理数据。它属于 实现层面 的概念,关注的是 物理内存的划分。

根据《Java虚拟机规范》,JVM运行时数据区主要包括以下部分:

  1. 方法区(Method Area)
    存储:类信息、常量、静态变量、即时编译器编译后的代码等。
    在JDK 8之前由"永久代(PermGen)"实现;JDK 8+ 改为"元空间(Metaspace)",使用本地内存。
    线程共享。
  2. 堆(Heap)
    存储:几乎所有对象实例和数组。
    是垃圾回收(GC)的主要区域。
    线程共享。
  3. 虚拟机栈(Java Virtual Machine Stack)
    每个线程私有,生命周期与线程相同。
    存储:栈帧(Stack Frame),包含局部变量表、操作数栈、动态链接、方法返回地址等。
    每次方法调用都会创建一个栈帧。
    线程私有。
  4. 本地方法栈(Native Method Stack)
    为执行本地(native)方法服务(如C/C++代码)。
    有些JVM将它与虚拟机栈合并。
    线程私有。
  5. 程序计数器(Program Counter Register
    记录当前线程正在执行的字节码指令地址。
    如果执行的是native方法,则为undefined。
    线程私有,是唯一不会发生OutOfMemoryError的区域。

总结JVM运行时数据区:

它是JVM内部的内存布局模型,回答的是"Java程序运行时,数据存在哪里? "的问题,属于实现细节

5.1.2. Java内存模型(JMM)

JMM 是 Java语言规范中定义的并发内存模型 ,它不描述物理内存结构 ,而是定义了 多线程环境下,线程如何与主内存交互,以及可见性、有序性、原子性等语义规则

核心目标:

  • 解决多线程并发中的三大问题:
    1. 可见性(Visibility):一个线程对共享变量的修改,对其他线程是否立即可见?
    2. 有序性(Ordering):指令是否可能被重排序?
    3. 原子性(Atomicity):操作是否不可中断?

JMM 的抽象结构(注意:这是逻辑模型,不是物理内存!):

  • 主内存(Main Memory):所有线程共享,存储所有变量(包括实例字段、静态字段等)。
  • 工作内存(Working Memory):每个线程私有,保存该线程使用的变量副本。

⚠️ 注意: JMM中的"主内存" ≠ JVM堆;"工作内存" ≠ 虚拟机栈。

它们只是抽象概念,用于描述线程与共享变量之间的交互规则。

JMM 的关键规则(通过"happens-before"原则实现):

  • 程序顺序规则
  • 监视器锁规则(synchronized)
  • volatile变量规则
  • 线程启动/终止规则
  • 传递性等

总结JMM

它是并发编程的语义规范 ,回答的是"多线程如何安全地读写共享变量? "的问题,属于语言层面的契约,与具体JVM实现无关。

5.1.3. JVM运行时数据区 vs JMM:对比总结

|-------------|-------------------------------------|------------------------|
| 维度 | JVM运行时数据区 | Java内存模型(JMM) |
| 定位 | JVM内部内存结构(实现层面) | 并发内存访问规范(语言层面) |
| 目的 | 描述Java程序运行时数据的存储位置 | 定义多线程下内存操作的可见性、有序性、原子性 |
| 是否物理存在 | 是(对应真实内存区域) | 否(是抽象逻辑模型) |
| 关注点 | 内存如何划分(堆、栈、方法区等) | 线程如何与共享变量交互 |
| 线程共享/私有 | 明确划分(如堆共享,栈私有) | 主内存共享,工作内存私有(逻辑概念) |
| 与硬件关系 | 受操作系统和硬件影响 | 屏蔽底层差异,提供统一并发语义 |
| 典型问题 | OutOfMemoryError、StackOverflowError | 线程安全、指令重排序、缓存一致性 |

5.2. JVM运行时数据区包含什么?

  • 堆(线程共享)
  • Java虚拟机栈(线程私有)
  • 本地方法栈(线程私有)
  • 程序计数器(线程私有)
  • 方法区(线程共享)

5.3. 堆(线程共享)

5.3.1. 作用

程序中创建的所有对象都是保存在堆中。

我们常见的JVM参数设置-Xms 10m最小启动内存是针对堆的,-Xmx10m最大运行内存也是针对堆的。

ms是memory start简称,mx是memory max简称

5.3.2. 堆的两个区域
java 复制代码
JVM 堆(Heap)
├── 新生代(Young Generation) //存放新创建的对象,因此这里 GC 频繁,但效率高
│   ├── Eden //新对象首先分配在这里
│   ├── Survivor 0(S0) //用于在 GC 时"暂存"存活对象,两个大小相等的区域,总有一个是空的
│   └── Survivor 1(S1) //用于在 GC 时"暂存"存活对象,两个大小相等的区域,总有一个是空的
└── 老年代(Old Generation) //存放长期存活的对象

堆中分为两个区域:

新生代和老生代,新生代存放新建的对象,当经过一定GC次数之后还存活的对象会存放入老生代。新生代还有三个区域:一个Eden+两个Survivor(S0/S1)

垃圾回收的时候会将Endn中存活的对象放到一个未使用的Survivor中,并把当前的Endn和正在使用的Survivor清除掉

5.4. Java虚拟机栈(线程私有)

5.4.1. 作用

Java虚拟机栈的生命时期和线程相同,Java虚拟机栈描述的是Java方法执行的内存模型。**每个方法在执行的同时会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法返回地址等信息。**咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。

  1. **局部变量表:**存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间是在编译期间完成分配
  2. **操作栈:**每个方法会生成一个先进后出的操作栈
  3. **动态链接:**指向运行时常量池的方法引用
  4. **方法返回地址:**PC寄存器的地址

5.5. 本地方法栈(线程私有)

本地方法栈和虚拟机栈类似,只不过Java虚拟机栈是给JVM使用的,而本地方法栈是给本地方法使用的。

5.5.1. 什么是本地方法?
  • "本地方法"指的是用 非 Java 语言(如 C/C++)编写的方法 ,通过 JNI(Java Native Interface) 调用。
  • 当 Java 代码调用一个 native 方法(例如 System.currentTimeMillis() 底层就是 native),JVM 需要切换到本地环境执行。
  • 本地方法栈就是用来支持这些 native 方法的调用和执行的。
  • 它的结构和行为由具体的 JVM 实现决定(有些 JVM 直接把本地方法栈和 Java 虚拟机栈合二为一)。

5.6. 程序计数器(线程私有)

5.6.1. 作用:

程序计数器用于记录当前线程正在执行的字节码指令的地址(或下一条要执行的指令地址)。

5.6.2. 为什么要有程序计数器?

Java 程序是多线程的。JVM 在运行时会频繁地在多个线程之间切换(线程调度)。

当一个线程被暂停后,稍后恢复执行时,必须知道从哪里继续执行

5.6.3. 关键特性:

|--------------------------------|--------------------------------------------|
| 特性 | 说明 |
| 线程私有 | 每个线程都有自己的程序计数器,互不影响。 |
| 占用内存极小 | 只存一个地址(比如 4 或 8 字节)。 |
| 不会发生内存溢出(OutOfMemoryError) | JVM 规范明确指出:程序计数器是唯一一个不会发生 OOM 的运行时数据区。 |
| 生命周期 = 线程生命周期 | 线程创建时创建,线程结束时销毁。 |

5.6.4. 特殊情况:本地方法

当线程正在执行一个 native 方法 (如 System.currentTimeMillis())时,

由于 native 方法不是由 JVM 执行的字节码,而是由操作系统或 C/C++ 代码执行,

此时 程序计数器的值是未定义的(undefined) ,通常被置为 undefined空(null)

✅ 换句话说:程序计数器只记录 Java 字节码的执行位置,不记录 native 代码的位置。

5.7. 方法区(线程共享)

5.7.1. 作用:

方法区是 JVM 中用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的运行时内存区域。

你可以把它理解为 "类的元数据仓库" ------ 所有类的"描述信息"都放在这里,而不是对象本身(对象放在堆里)。

5.7.2. 为什么需要方法区?

Java是一个面向对象的语言,程序运行时需要频繁地使用类的结构信息,比如:

  • 这个类有哪些方法?
  • 字段类型是什么?
  • 静态变量的值是多少?
  • 方法的字节码在哪里?

这些与类相关、但不属于具体对象 的信息,不能放在堆(因为堆存的是对象实例),也不能放在栈(因为栈是线程私有的,而类信息是全局共享的)。

因此,JVM 设计了方法区 来统一管理这些全局共享的类元数据

举例:

java 复制代码
public class Student {
    public static String school = "MIT";  // 静态变量
    public int age;                       // 实例变量

    public void study() { }               // 方法
}
  • school 的值("MIT")存在方法区;
  • study() 方法的字节码、方法名、参数类型等元信息也存在方法区;
  • new Student().age 这样的实例变量,则存在中。
5.7.3. 关键特性:

|--------------------------------|-----------------------------------------------------------------------------------------------------|
| 特性 | 说明 |
| 线程共享 | 所有线程共享同一个方法区,类信息只需加载一次。 |
| 在虚拟机启动时创建 | JVM 启动时就初始化方法区,用于加载核心类(如 java.lang.Object)。 |
| 可能发生内存溢出(OutOfMemoryError) | 如果加载的类太多(如动态生成大量类),方法区空间不足,会抛出 java.lang.OutOfMemoryError: Metaspace(或 PermGen space,取决于 JVM 版本)。 |
| 垃圾回收目标之一 | 虽然回收频率较低,但方法区也会进行垃圾回收,主要回收:废弃的常量、不再使用的类(需满足特定条件)。 |

5.8. 什么是线程私有 / 线程共享?

5.8.1. ✅ 线程私有(Thread-Private)
  • 每个线程都有自己独立的一份副本,其他线程无法访问。
  • 用于保存线程自身的执行状态,确保多线程并发执行时互不干扰。
  • 生命周期 = 线程的生命周期:线程创建时分配,线程结束时回收。
5.8.2. ✅ 线程共享(Thread-Shared)
  • 所有线程共用同一份数据区域
  • 用于存储程序运行过程中需要被多个线程共同访问的数据(如对象、类信息等)。
  • 生命周期 = JVM 的生命周期:JVM 启动时创建,JVM 退出时销毁。
5.8.3. JVM 运行时数据区划分(按线程属性分类)

根据《Java 虚拟机规范》,JVM 的运行时数据区如下:

|-------------------------------------------|---------------|-----------------------------|
| 区域 | 线程私有 / 共享 | 说明 |
| 程序计数器(Program Counter Register) | ✅ 线程私有 | 记录当前线程正在执行的字节码指令地址 |
| Java 虚拟机栈(Java Virtual Machine Stack) | ✅ 线程私有 | 存储 Java 方法调用的栈帧(局部变量、操作数栈等) |
| 本地方法栈(Native Method Stack) | ✅ 线程私有 | 存储 native 方法(如 C/C++)调用的栈帧 |
| 堆(Heap) | ✅✅ 线程共享 | 存放所有对象实例和数组(new 出来的对象) |
| 方法区(Method Area) | ✅✅ 线程共享 | 存储类信息、常量、静态变量、JIT 代码等 |

📌 注意:方法区在逻辑上属于堆的一部分,但通常被单独讨论。

6. java内存布局中两种经典异常

6.1. Java堆溢出

6.1.1. 介绍
  • 内存区域: Java堆是JVM所管理的内存中的最大一块。它被所有线程共享,在虚拟机启动时创建。**此内存区域的唯一目的就是存放对象示例,**几乎所有的对象实例都有可以在这分配
  • **异常类型:**java.lang.OutOfMemoryError: Java heap space
  • 产生原因:
    • **内存泄露:**对象已经不再被应用程序使用,但由于代码逻辑错误(例如:被静态集合、缓存等无意中持有引用),垃圾回收器无法回收他们。随着时间的推移,泄露的对象会占满堆空间,最终出发OOM
    • 内存溢出: 应用程序确实需要那么多内存。例如,加载了海量数据,或者进行了大规模的缓存,而堆内存设置通过 -Xms-Xmx 参数)不足以支撑这些正常的业务需求。
  • 解决思路
    • 首先使用内存分析工具(如 Eclipse MAT, JProfiler)检查是否是内存泄漏。找到泄漏对象的 GC Roots 引用链,定位泄漏代码。
    • 如果不是泄漏,则意味着程序正常需要更多内存,应调高堆内存参数(例如 -Xmx2048m)。
    • 检查代码中是否存在一些对象生命周期过长、不必要的缓存等。
6.1.2. 演示代码

此处的代码通过创建大量大对象并持有其引用,来模拟堆内存溢出

java 复制代码
/**
 * Java堆内存溢出演示
 * <p>
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * 限制Java堆大小为20MB,不可扩展;并在出现OOM时Dump出内存堆转储快照。
 */
public class HeapOOM {

    static class OOMObject {
        // 创建一个约64KB的对象,便于快速占满内存
        private byte[] placeholder = new byte[64 * 1024];
    }

    public static void main(String[] args) {
        // 为什么需要使用list? 核心作用:维持对象的强引用,防止被回收
        //JVM 的垃圾回收器(GC)只会回收 不可达对象(即没有任何活跃引用指向的对象)。
        //只要一个对象被 强引用(如变量、集合元素)所持有,它就不会被 GC 回收。
        List<OOMObject> list = new ArrayList<>(); // 可加初始容量,但非必需

        while (true) {
            list.add(new OOMObject());
        }
        // 不建议 catch OOM,因可能无法执行
    }
}
java 复制代码
//表示 JVM 的堆内存已耗尽,无法为新对象分配空间
java.lang.OutOfMemoryError: Java heap space
//因为我设置了 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError,JVM 在抛出 OOM 时自动将当前堆内存快照保存为文件 java_pid70575.hprof(文件名含进程 ID)。
Dumping heap to java_pid70575.hprof ...
// 堆转储文件大小约 24.5 MB,耗时 12 毫秒。注意:虽然限制堆为 20MB,但 dump 文件可能略大,因为包含元数据、对象头等。
Heap dump file created [24484597 bytes in 0.012 secs]
//错误发生在 HeapOOM.java 第 16 行:new byte[64 * 1024](即创建 OOMObject 实例时)
//调用链:main 方法第 24 行 → new OOMObject() → 构造器中分配 byte 数组
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.project.heap.HeapOOM$OOMObject.<init>(HeapOOM.java:16)
                                             at com.project.heap.HeapOOM.main(HeapOOM.java:24)
// Monitor Ctrl-Break 是 IntelliJ IDEA 启动的后台监控线程(用于支持调试、热加载、中断等)。
// 当主程序耗尽所有堆内存后,连这个监控线程也无法分配哪怕一个 File 或 String 对象。
// 它在尝试初始化网络相关类(如 NetProperties)时需要加载配置文件,于是创建 File 对象 → 触发 OOM。
Exception in thread "Monitor Ctrl-Break" java.lang.OutOfMemoryError: Java heap space
at java.base/java.io.UnixFileSystem.resolve(UnixFileSystem.java:118)
at java.base/java.io.File.<init>(File.java:368)
at java.base/sun.net.NetProperties.loadDefaultProperties(NetProperties.java:69)
at java.base/sun.net.NetProperties$1.run(NetProperties.java:50)
at java.base/sun.net.NetProperties$1.run(NetProperties.java:48)
at java.base/java.security.AccessController.executePrivileged(AccessController.java:776)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:318)
at java.base/sun.net.NetProperties.<clinit>(NetProperties.java:47)
                                            at java.base/java.net.SocketImpl.lambda$usePlainSocketImpl$0(SocketImpl.java:69)

6.2. 虚拟机栈/本地方法栈溢出

6.2.1. 介绍
  • 内存区域
    • 虚拟机栈:为每个即将运行的 Java 方法(非Native)创建一个"栈帧",用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用和完成对应着栈帧在虚拟机栈中的入栈和出栈。
    • 本地方法栈 :与虚拟机栈作用非常相似,其区别只是为虚拟机使用到的 Native 方法服务。
  • 异常类型
    1. StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的最大深度(例如,无限递归),将抛出此错误。
    2. OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间(例如,在创建新线程时),则抛出此错误。
  • 产生原因
    • StackOverflowError 更常见,主要由无限递归方法调用层次过深导致。
    • OutOfMemoryError 相对少见。在32位系统上,如果为每个线程分配的栈内存越大(通过 -Xss 参数),那么可创建的线程数就越少,在创建大量线程时就可能耗尽内存。在64位系统中,进程内存空间几乎无限,此问题较少见。
  • 解决思路
    • 对于 StackOverflowError,检查代码逻辑,特别是递归调用,确保有正确的终止条件。
    • 使用 -Xss 参数调整栈内存大小(通常不需要,治标不治本)。
    • 对于线程创建的 OOM,可能需要优化应用架构,减少线程数量或调整栈大小。
6.2.2. 演示代码

此处的代码通过无限递归来模拟栈溢出错误

java 复制代码
package com.project.heap;

/**
 * 虚拟机栈和本地方法栈溢出演示(StackOverflowError)
 * <p>
 * VM Args: -Xss128k 将每个线程的栈内存设置为128KB,使溢出更快发生。
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    // 通过递归调用模拟栈溢出
    public void stackLeak() {
        stackLength++;
        stackLeak(); // 递归调用自身,没有终止条件
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (StackOverflowError e) {
            // 预期捕获的异常
            System.out.println("stack length: " + oom.stackLength);
            throw e; // 打印信息后再次抛出,便于观察
        }
    }
}
java 复制代码
stack length: 38913
Exception in thread "main" java.lang.StackOverflowError
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
	at com.project.heap.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
......
6.2.3. 总结对比

|------------|-------------------------------------|-----------------------------------------------------|
| 特性 | Java 堆溢出 | 虚拟机栈溢出 |
| 异常类型 | OutOfMemoryError: Java heap space | StackOverflowError (常见) / OutOfMemoryError (少见) |
| 发生区域 | 堆 | 栈(虚拟机栈/本地方法栈) |
| 产生原因 | 1. 内存泄漏 2. 内存需求过大 | 1. 无限递归/调用过深 2. 创建线程过多 |
| 演示代码核心 | 创建对象并持有引用 | 无限递归方法调用 |
| 关键VM参数 | -Xms , -Xmx | -Xss |

7. JVM类加载

7.1. 类加载过程

在日常的编码过程中,其实和程序员最为密切的就是类加载的过程

对于一个类来说,它的生命周期是:

  1. 加载
  2. 连接
    1. 验证
    2. 准备
    3. 解析
  3. 初始化
7.1.1. 加载

"加载"(Loading)阶段是整个"类加载"(Class Loading)过程中的一个阶段,它和类加载Class Loading是不同的,一个是加载Loading另一个是Class Loading,所以不要把二者搞混淆了

在加载Loading阶段,Java虚拟机需要完成以下三件事:

  1. JVM 通过**类的全限定名(Fully Qualified Name)**获取其二进制字节流(通常来自 .class 文件、网络、数据库等)
  2. 将这些字节流转换成方法区中的运行时数据结构
  3. 同时,在堆中创建一个代表该类的 java.lang.Class 对象,作为访问该类元数据的入口。
7.1.2. 连接(Linking)

连接阶段负责将加载的类合并到 JVM 的运行时状态中,它又细分为三个子阶段:

7.1.2.1. 验证(Verification)

确保加载的类字节码符合 JVM 规范,防止恶意代码或损坏的类文件危害 JVM 安全。验证包括文件格式验证、元数据验证、字节码验证和符号引用验证等。

7.1.2.2. 准备(Preparation)

为类的静态变量(static fields)分配内存,并设置默认初始值(如 0、null、false 等),但不会执行任何显式的初始化代码(如 static 块或赋值语句)。

7.1.2.3. 解析(Resolution)

将常量池中的符号引用(Symbolic References)转换为直接引用(Direct References),例如将类、方法、字段的名称转换为具体的内存地址或偏移量。

7.1.3. 初始化(Initialization)

这是类生命周期中真正执行 Java 代码的阶段。 JVM 会执行类的 <clinit> 方法,该方法由编译器自动生成,包含所有静态变量的显式赋值语句和静态代码块(static {})中的逻辑。初始化阶段是类加载过程的最后一步,只有在此阶段完成后,类才真正"准备好"被使用。

7.2. 双亲委派模型

7.2.1. 什么是双亲委派模型?

首先在 JVM 中,类加载器负责将 .class 文件加载到内存中。Java 默认提供了以下几种类加载器**(按层级从高到低)**:

7.2.1.1. Bootstrap ClassLoader(启动类加载器)
  • 用 C++ 编写,是 JVM 的一部分。
  • 负责加载 <JAVA_HOME>/lib 目录下的核心类库(如 rt.jar)。
  • 无法在 Java 代码中直接引用。
7.2.1.2. Extension ClassLoader(扩展类加载器)
  • sun.misc.Launcher$ExtClassLoader 实现。
  • 负责加载 <JAVA_HOME>/lib/ext 目录下的 JAR 包。
7.2.1.3. Application ClassLoader(应用程序类加载器)
  • sun.misc.Launcher$AppClassLoader 实现。
  • 负责加载用户类路径(classpath)下的类。
7.2.1.4. 自定义 ClassLoader
  • 开发者继承 java.lang.ClassLoader 实现自己的类加载逻辑。
7.2.1.5. 双亲委派机制的工作流程

当一个类加载器收到类加载请求 的时候,会先把这个请求委托给父类加载器加载只有当父类加载器无法完成加载工作时,子类加载器才会尝试自己加载。

流程如下:

  1. CustomClassLoader.loadClass("com.example.MyClass")
  2. 委托给父类 → AppClassLoader
  3. AppClassLoader 委托给 ExtClassLoader
  4. ExtClassLoader 委托给 Bootstrap ClassLoader
  5. 如果 Bootstrap 找不到,逐级向下尝试,直到某一级能加载 ,或最终抛出 ClassNotFoundException
7.2.1.6. 优点:
  • **安全性:**防止用户自定义类冒充核心类
java 复制代码
package com.project;

/**
 * @className: String
 * @author: 顾漂亮
 * @date: 2025/10/16 16:14
 */
public class String {
    static {
        System.out.println("你被我劫持了!");
    }
}
  • **避免重复加载:**确保一个类在JVM中只被加载一次,比如A类和B类都有一个父类C,那么当A启动时就会将C类加载起来,那么在B类进行加载时就不需要重复加载C类了
7.2.2. 破坏双亲委派模型
7.2.2.1. 为什么需要破坏双亲委派模型?

表面上看,双亲委派模型(Parent Delegation Model)设计得非常合理------安全、唯一、稳定。但现实世界的复杂性决定了:它不能覆盖所有场景 。在某些特定需求下,必须打破它,否则系统根本无法工作。

下面我将从 三大典型场景 出发,结合原理和实例,彻底讲清楚 "为什么需要破坏双亲委派"

7.2.2.2. SPI(Service Provider Interface)机制的需求
7.2.2.2.1. 什么是SPI?

SPI是Java提供的"接口上层定义,实现由下游提供"的机制

典型实例:

  • JDBC(java.sql.Driver
  • 日志框架(SLF4J 的绑定)
  • Dubbo 的扩展机制
7.2.2.2.2. 问题来了:类加载器的"逆向依赖"

JDBC 为例:

|----------------------------|---------------------------|----------------------------|
| 组件 | 由哪个类加载器加载? | 说明 |
| java.sql.DriverManager | Bootstrap ClassLoader | 属于 JDK 核心类(rt.jar) |
| com.mysql.cj.jdbc.Driver | AppClassLoader | 用户放在 classpath 中的 MySQL 驱动 |

💡 关键矛盾
DriverManager(顶层)需要加载 Driver(底层实现),但 Bootstrap 无法加载 classpath 下的类 (它只能加载 <JAVA_HOME>/lib)!

7.2.2.2.3. 如果坚持双亲委派会发生什么?
  • DriverManager 调用 Class.forName("com.mysql.cj.jdbc.Driver")
  • 此时使用的是 Bootstrap ClassLoader 的上下文
  • Bootstrap 去找 com.mysql... → 找不到 → 抛 ClassNotFoundException
  • JDBC 根本无法工作!
7.2.2.2.4. 解决方案:破坏双亲委派!

JDK 从 1.6 开始,在 DriverManager 内部使用 线程上下文类加载器(Thread Context ClassLoader)

java 复制代码
// DriverManager.java(简化版)
Class.forName(driverClassName, true,
              Thread.currentThread().getContextClassLoader());

Thread.currentThread().getContextClassLoader() 默认就是 AppClassLoader

✅ 这样,高层的 Bootstrap 代码,就能通过"借用"底层的 AppClassLoader,加载用户提供的实现类

📌 这是 官方认可的、合法的"破坏双亲委派"方式

7.2.2.3. 模块化与热部署(如 OSGi、Tomcat、Spring Boot DevTools)
7.2.2.3.1. 需求场景:
  • 一个应用包含多个模块(如插件)
  • 每个模块可能依赖不同版本的同一个类库(如 log4j 1.x 和 2.x)
  • 要求模块之间 类隔离,互不干扰
  • 支持运行时 动态加载/卸载/更新模块
7.2.2.3.2. 双亲委派的问题:
  • 所有类最终都由 AppClassLoader 加载
  • 同一个类只能加载一次
  • 无法实现版本隔离和热替换
7.2.2.3.3. 解决方案:每个模块有自己的 ClassLoader,且不遵循双亲委派

Apache Tomcat 为例:

  • Tomcat 自定义了 WebAppClassLoader
  • 当 Web 应用需要加载类时:
    • 优先加载 WEB-INF/classes 和 WEB-INF/lib 下的类
    • 只有找不到时,才委托给父加载器(AppClassLoader)
  • 这就是 "子优先"模型(Child-First)明确破坏了双亲委派

✅ 结果:

  • 不同 Web 应用可以使用不同版本的 Spring、Hibernate
  • 应用重启时,可以卸载旧的 ClassLoader,释放内存(避免 PermGen / Metaspace 泄漏)
7.2.2.4. 自定义类加载源(加密、网络、数据库等)
7.2.2.4.1. 需求场景:
  • 类文件被加密存储,运行时解密加载
  • 从远程服务器动态下载类(如热补丁)
  • 从数据库或 ZIP 包中读取类字节码
7.2.2.4.2. 双亲委派的问题:
  • 父加载器(Bootstrap/Ext/App)根本不知道这些类的存在
  • 如果先委托给父加载器,必然失败
  • 即使失败后再自己加载,也浪费性能,且可能被父加载器缓存干扰
7.2.2.4.3. 解决方案:自定义 ClassLoader,直接加载,不委派

例如:

java 复制代码
public class NetworkClassLoader extends ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve) {
        // 直接从网络加载,不委托父类
        byte[] bytes = downloadFromNetwork(name);
        return defineClass(name, bytes, 0, bytes.length);
    }
}

✅ 这样可以实现 完全自主的类加载逻辑,适用于安全加固、动态更新等场景。

8. 垃圾回收相关

在运行时数据区中,对于程序计数器、虚拟机栈、本地方法栈 这三部分而言,其**生命周期与线程相关,随着线程而生,随着线程灭亡。**并且这三个区域的内存分配与回收具有确定性,因此当方法结束或者线程结束时,内存就自然跟着线程回收了。因此我们下面主要讨论的是有关JVM堆和方法区两个区域。

Java堆中几乎存放所有对象的实例,垃圾回收器在对堆进行垃圾回收前,首先判断这些对象哪些还存"活着",哪些对象已经"死去"。

内存VS对象

在Java中,所有对象都是要存在内存中的(也可以说内存中存储的是一个个对象),因此我们将内存回收,也可以叫做对死亡对象的回收。

8.1. 死亡对象的判断算法

8.1.1. 引用计数算法

给对象添加一个引用,每当有一个地方引用它时,计数器就+1;当引用实效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。

引用计数算法很简单,判定效率也很高,在大部分情况下都是一个不错的算法。比如python语言就采用引用计数算法来进行内存管理。

但是在JVM中没有选用引用计数算法来管理内存,最主要的原因就是引用计数算法无法解决对象的循环引用问题。

示例:

java 复制代码
package com.project;

/**
 * @className: Test
 * @author: 顾漂亮
 * @date: 2025/10/16 19:42
 */
//-XX:+PrintGC  //需要添加虚拟选项
public class Test {
    public Object instance = null;
    public static int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB];
    public static void testGC() {
        Test test1 = new Test();
        Test test2 = new Test();
        test1.instance = test2;
        test2.instance = test1;
        test1 = null;
        test2 = null;
        // 强制JVM进行垃圾回收
        System.gc();
    }
    public static void main(String[] args) {
        testGC();
    }
}
java 复制代码
[0.001s][warning][gc] -XX:+PrintGC is deprecated. Will use -Xlog:gc instead.
[0.005s][info   ][gc] Using G1
// 发生垃圾回收
[0.037s][info   ][gc] GC(0) Pause Full (System.gc()) 14M->4M(40M) 1.514ms

[0.043s][info ][gc] GC(0) Pause Full (System.gc()) 14M->4M(40M) 1.482ms

这是最关键的一行,描述了一次 Full GC 事件。拆解如下:

|-----------------|--------------------------------------------------------|
| 部分 | 含义 |
| GC(0) | 这是第 0 次 GC 事件(从 0 开始计数) |
| Pause Full | 发生了一次 Full GC(停止所有应用线程,清理整个堆) |
| (System.gc()) | 触发原因是代码中调用了 System.gc() |
| 14M->4M | GC 前堆内存使用量为 14 MB ,GC 后降到 4 MB回收了约 10 MB |
| (40M) | 当前堆的总容量为 40 MB(即最大可使用空间) |
| 1.482ms | 整个 GC 暂停耗时 1.482 毫秒 |

8.1.2. 可达性算法分析

可达性分析算法(Reachability Analysis) 是现代 Java 虚拟机(JVM)判断对象是否"存活"(即是否可以被垃圾回收)的核心机制。它解决了早期"引用计数法"无法处理循环引用的问题。

8.1.2.1. 为什么需要可达性分析算法?

在垃圾回收中,最关键的问题是:如何判断一个对象是否还"有用"?

  • 如果对象已经没用了(即程序再也访问不到它),就可以安全回收。
  • 早期有些语言(如 Python 的部分实现)使用引用计数法:每个对象记录被引用的次数,为 0 就回收。
    • ❌ 但引用计数法无法处理 A 引用 B,B 又引用 A 的循环引用情况(即使外部没人用它们,引用计数也不为 0)。

可达性分析算法通过从根出发遍历对象图,从根本上解决了这个问题

8.1.2.2. 可达性分析算法核心思想:

从一组称为 "GC Roots" 的对象作为起点,向下搜索所有能被访问到的对象。能被访问到的对象就是"存活"的;其余对象就是"垃圾",可以被回收。

这就像从树根(GC Roots)出发,能长出的树枝(对象)就是活的;断掉的、够不着的树枝就是死的。

8.1.2.3. 什么是GC Roots?

GC Roots是一组必须活跃的引用,它们不会被当作垃圾回收。是可达性分析的起点。

主要包括:

|--------------------|-------------------------------------------------------|
| 类型 | 说明 |
| 虚拟机栈中的局部变量 | 方法调用栈帧中的引用(比如上面方法里的 Test obj = new Test();中的 obj) |
| 本地方法栈中的 JNI 引用 | Native 代码中持有的 Java 对象引用 |
| 方法区中的静态变量 | 类的 static字段引用的对象 |
| 方法区中的常量 | 比如字符串常量池中的引用("hello") |
| 活跃线程 | 线程对象本身也是 GC Root |

上述图片中,Object5-Object7,没有连接到GC Roots,因此可以被判断为可回收对象,但是Object1-Object4依然属于存活状态,不会被垃圾回收

8.2. 垃圾回收算法

根据上面的死亡对象判断算法,我们可以将死亡对象标记出来,标记出来之后我们就可以进行垃圾回收操作了!下面将会介绍JVM四种垃圾回收算法原理

8.2.1. 标记-清除算法
8.2.1.1. 原理:

分两个阶段:

  • 标记 :从 GC Roots 出发,遍历并标记所有存活对象
  • 清除:遍历整个堆,回收未被标记(即死亡)的对象所占内存。
8.2.1.2. 存在问题:
  1. **效率问题:**本质上,标记和清除这两个过程效率都不高
  2. **空间问题:**标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前出发另一次垃圾收集
8.2.2. 复制算法
8.2.2.1. 原理:

由于新生代中绝大多数对象都是"朝生夕死"的,新生代通常被划分为一块较大的 Eden 空间和两块较小的 Survivor 空间(From 和 To)。GC 时,将 Eden 和 From Survivor 中的存活对象复制到 To Survivor ;但若对象年龄达到阈值或 To 区空间不足,则这些对象会直接晋升到老年代。最后,清空 Eden 和 From Survivor 区域。

一般来说:Eden:Survivor From :Survivor To = 8:1:1(可以通过参数动态配置)

8.2.2.2. 复制算法流程:
  1. 第一次 Minor GC
    Eden 满 → 扫描 Eden,将存活对象复制到 一个空的 Survivor 区(设为 S0),S1 保持空闲。
  2. 第二次 Minor GC
    扫描 Eden + S0(此时 S0 是 From),存活对象复制到 S1(To),清空 Eden 和 S0;之后 S1 成为 From,S0 成为 To。
  3. 后续 Minor GC
    每次都在 Eden + 当前 From Survivor 中找存活对象,复制到 To Survivor,然后交换 From/To 角色。
  4. 对象晋升
    • 每次 GC 后,存活对象 age +1;
    • 若 age ≥ MaxTenuringThreshold(默认15),下次 GC 时直接晋升老年代
    • 或提前因 Survivor 空间不足、动态年龄判定等原因晋升。
8.2.3. 标记-整理算法
8.2.3.1. 原理:

先像标记-清除一样标记存活对象,然后将所有存活对象向内存一端移动,最后清理边界以外的内存。

相当于"压缩"内存,消除碎片。

8.2.3.2. 使用场景:

用于老年代 回收(如 Serial Old、Parallel Old);

适用于对象存活率高、不适合复制算法的区域;

虽比标记-清除慢(因需移动对象),但能避免内存碎片,适合长期运行的应用。

8.2.4. 分代算法
8.2.4.1. 原理:

不是一种独立算法,而是基于对象生命周期的策略:

  • 新生代(Young Gen) :对象刚创建,用复制算法快速回收。
  • 老年代(Old Gen) :长期存活对象,用标记-清除标记-整理
  • 可能还有永久代/元空间(存放类元数据)。
8.2.4.2. 使用场景:

几乎所有现代 JVM GC 都采用分代思想,如:

  • Parallel GC(吞吐优先)
  • CMS(低延迟,老年代用标记-清除)
  • G1(分区+分代,兼顾吞吐与延迟)
  • ZGC/Shenandoah(不分代或弱分代,超低延迟)

适用于通用 Java 应用,尤其对象生命周期差异明显的场景。

8.2.5. 对比:
  • 新生代 → 复制算法(快,对象死得快)
  • 老年代 → 标记-整理 或 标记-清除(对象活久,避免复制开销)
  • 整体架构 → 分代收集(结合不同算法优势)
8.2.6. Minor GC 和Full GC区别是什么?
8.2.6.1. 作用区域不同:

|--------------|-------------------------------------------------------------|
| 类型 | 回收区域 |
| Minor GC | 仅回收新生代(Young Generation) (包括 Eden + Survivor 区) |
| Full GC | 回收整个堆内存 : 新生代 + 老年代(Old Generation)+ 方法区(Metaspace/永久代) |

有些资料说 Full GC = Major GC,但严格来说:

  • Major GC 通常指 只回收老年代(较少用);
  • Full GC全局回收,影响更大。
8.2.6.2. 触发条件不同

|--------------|-----------------------------------------------------------------------------------------------------------------------------------|
| 类型 | 触发条件 |
| Minor GC | Eden 区满(无法为新对象分配内存) |
| Full GC | 多种情况,例如: • 老年代空间不足 • 方法区(Metaspace)空间不足 • 显式调用 System.gc()(不保证) • Minor GC 前,老年代空间担保失败 • CMS GC 并发模式失败(Concurrent Mode Failure) |

8.2.6.3. 执行频率与耗时

|--------------|-----------------|---------------------|------------------------|
| 类型 | 频率 | 耗时 | 停顿(Stop-The-World) |
| Minor GC | 非常频繁(毫秒级发生) | (通常几毫秒到几十毫秒) | 有,但时间短 |
| Full GC | 较少发生(应尽量避免) | (几百毫秒到数秒,甚至更久) | 有,长时间停顿,严重影响应用响应 |

8.2.6.4. 对应用的影响
  • Minor GC
    正常现象,几乎所有 Java 应用都会频繁发生,无需过度担忧
  • Full GC
    通常意味着内存压力大、配置不合理或内存泄漏,会导致:
    • 应用卡顿("卡死"几秒)
    • 服务超时、用户体验下降
    • 高并发系统可能雪崩

🎯 调优目标减少 Full GC 次数,甚至避免发生
Minor GC 只清理"年轻人"(新生代),快而频繁;
Full GC 清理"全家"(整个堆+方法区),慢而危险,应尽量避免。

8.3. 垃圾收集器

在 Java 虚拟机(JVM)中,垃圾收集器(Garbage Collector, GC)负责自动管理堆内存,回收不再使用的对象。不同垃圾收集器在吞吐量、延迟、内存占用、CPU 开销 等方面各有侧重。以下是 JDK 8 到 JDK 21 中常见的垃圾收集器及其核心特点分析(截至 2025 年):

8.3.1. Serial GC(串行收集器)
  • 工作方式单线程 执行所有 GC 操作(包括 Minor GC 和 Full GC),会 Stop-The-World(STW)
  • 算法
    • 新生代:复制算法
    • 老年代:标记-整理
  • 适用场景
    • 单核 CPU 或资源受限环境(如嵌入式、桌面应用)
    • 小堆(< 100MB)
  • 启用参数-XX:+UseSerialGC
  • 优点:简单、内存开销小
  • 缺点:停顿时间长,不适合服务端

📌 典型用途:客户端程序(如 JavaFX 应用)

8.3.2. Parallel GC(并行收集器,又称"吞吐优先 GC")
  • 工作方式 :多线程并行执行 GC,但依然 STW
  • 算法
    • 新生代:多线程复制
    • 老年代:多线程标记-整理
  • 适用场景
    • 后台计算、批处理任务
    • 对吞吐量要求高,对延迟不敏感
  • 启用参数-XX:+UseParallelGC(JDK 8 默认 GC)
  • 优点:高吞吐(99% 时间用于应用,1% 用于 GC)
  • 缺点:Full GC 停顿可能达秒级

📌 调优目标:最大化 CPU 利用率,适合离线任务

8.3.3. CMS(Concurrent Mark-Sweep,并发标记清除)
  • 工作方式
    • 大部分老年代 GC 与应用线程并发执行,减少停顿
    • 初始标记重新标记 阶段仍 STW
  • 算法
    • 新生代:Serial 或 ParNew(多线程)
    • 老年代:并发标记 + 并发清除(不整理内存 → 有碎片)
  • 适用场景:曾用于低延迟 Web 应用
  • 启用参数-XX:+UseConcMarkSweepGC
  • 缺点
    • 内存碎片 → 可能触发 Full GC
    • CPU 开销大
    • JDK 14 起废弃,JDK 17 彻底移除
  • 结论 :❌ 不再推荐使用
8.3.4. G1 GC(Garbage-First,JDK 9+ 默认)
  • 核心思想 :将堆划分为多个 固定大小 Region,优先回收垃圾最多的 Region("Garbage-First")
  • 工作方式
    • 并发标记(与应用线程并发)
    • STW 阶段较短(如初始标记、混合回收)
  • 算法
    • 整体采用 标记-复制 + 标记-整理(在回收 Region 时压缩)
    • 支持 可预测停顿时间 (通过 -XX:MaxGCPauseMillis 设定目标)
  • 适用场景
    • 堆大小 4GB ~ 64GB 的通用服务(如 Web、微服务)
    • 需要平衡吞吐与延迟
  • 启用参数-XX:+UseG1GC(JDK 9+ 默认)
  • 优点
    • 无内存碎片
    • 可控停顿(通常 50~200ms)
  • 缺点:小堆下开销较大

📌 当前最通用的生产级 GC

8.3.5. ZGC(Z Garbage Collector,JDK 11+)
  • 目标超低延迟 ,停顿时间 < 10ms,且与堆大小无关(支持 TB 级堆)
  • 工作方式
    • 几乎所有 GC 阶段 并发执行
    • 使用 着色指针(Colored Pointer)读屏障(Load Barrier) 实现并发
  • 算法:并发标记 + 并发复制(带压缩)
  • 适用场景
    • 实时系统(金融交易、游戏、RAG 聊天助手等)
    • 大堆(>16GB)且要求高响应性
  • 启用参数-XX:+UseZGC
  • JDK 支持
    • JDK 11~14:实验性(需 -XX:+UnlockExperimentalVMOptions
    • JDK 15+:正式生产可用
  • 优点:停顿极短、无碎片、可扩展性强
  • 缺点:CPU 开销略高,小堆收益不明显

📌 低延迟场景首选

8.3.6. Shenandoah GC(OpenJDK 社区项目)
  • 目标 :与 ZGC 类似,实现 低停顿垃圾回收
  • 工作方式 :并发标记 + 并发复制(使用 Brooks Pointer 实现转发)
  • 适用场景:低延迟应用(Red Hat 系主导)
  • 启用参数-XX:+UseShenandoahGC
  • JDK 支持
    • OpenJDK 12+(但 Oracle JDK 不包含)
    • 需使用特定发行版(如 Red Hat、Adoptium)
  • 与 ZGC 对比
    • 停顿时间相近(<10ms)
    • Shenandoah 更早支持小堆优化

📌 ZGC 的有力竞争者,但生态略小

8.3.7. 主流GC对比

|------------|---------------|---------|-----------|----------|------------|---------------|
| GC 名称 | 停顿时间 | 吞吐量 | 堆大小建议 | 是否并发 | JDK 默认 | 推荐场景 |
| Serial | 高(秒级) | 低 | < 100MB | ❌ | 否 | 客户端、小工具 |
| Parallel | 高(Full GC 秒级) | | 任意 | ❌ | JDK8 | 批处理、离线计算 |
| CMS | 中(50~200ms) | 中 | < 4GB | ✅(部分) | 否 | ❌ 已废弃 |
| G1 | 中(50~200ms) | 中高 | 4GB~64GB | ✅ | JDK9+ | 通用 Web 服务 |
| ZGC | < 10ms | 中 | 8GB~TB | ✅ | 否 | 超低延迟系统 |
| Shenandoah | < 10ms | 中 | 1GB~TB | ✅ | 否 | 低延迟(OpenJDK) |

技术选型建议:

  • 通用服务(Spring Boot、微服务)G1 GC
  • 低延迟/实时交互(聊天、交易)ZGC(JDK17+ 首选)
  • 批处理/大数据Parallel GC
  • 小内存容器Serial 或 Parallel
  • 避免使用:CMS(已淘汰)

9. 总结:一个对象的一生

  1. 我是一个普通的Java对象,我出生在Eden区,在Eden区我看到了很多和我长得很像的小兄弟,我们在Eden区中玩了很长时间,有一天Eden区中的人实在是太多了,我就被迫去了Survivor区中的"From"区
  2. 自从去了Survivor区,我就开始飘了,有时候在"From"区,有时候在"To"区,居无定所。
  3. 直到18岁,爸爸说我现在是成年人了,该去社会上闯荡闯荡。于是我就去了老年代那边,在老年代里,我生活了很多年(每次GC加一岁)然后被回收了。

10. JMM

JMM 是 Java Memory Model (Java 内存模型)的缩写,它是 Java 语言规范中定义的一套规则,用于描述 多线程环境下,线程如何以及何时可以看到其他线程写入共享变量的值 ,以及 如何同步对共享变量的访问

简单来说,JMM 解决的是 多线程并发编程中的可见性、有序性和原子性问题

10.1. 主内存和工作内存

  • 所有共享变量 (实例字段、静态字段等,不包括局部变量)都存储在主内存中。
  • 每个线程都有自己的工作内存 ,它保存了该线程使用到的变量的副本
  • 线程对变量的所有操作(读、写)都必须在工作内存中进行,不能直接操作主内存。
  • 不同线程之间无法直接访问对方的工作内存,变量值的传递必须通过主内存。

10.2. 8 大内存交互操作(JMM 定义的底层操作)

|---------------|----------------------|
| 操作 | 作用 |
| lock(锁定) | 作用于主内存变量,标识该变量被某线程独占 |
| unlock(解锁) | 释放主内存变量的锁定 |
| read(读取) | 从主内存读取变量值到工作内存 |
| load(载入) | 将 read 的值放入工作内存的变量副本 |
| use(使用) | 工作内存变量传给执行引擎(如运算) |
| assign(赋值) | 执行引擎结果赋值给工作内存变量 |
| store(存储) | 将工作内存变量传回主内存前的准备 |
| write(写入) | 把 store 的值写入主内存 |

10.3. JMM如何保证三大特性?

10.3.1. 原子性(Atomicity)

定义: 原子操作是指那些不可分割的操作,即它们要么完全执行,要么根本不执行,不会出现部分执行的状态。

  • 基本读写操作的原子性 : 对于 int 类型的变量,如 int x = 10;,这样的赋值操作在Java中是原子性的,意味着它会作为一个整体被执行,不会被其他线程中断。
  • 复杂操作的原子性 : 然而,对于复合操作(例如 i++),即使它们看似简单,实际上是由多个原子步骤组成的(读取-i的当前值、增加1、将新值写回),因此不是原子性的。为了保证这类操作的原子性,可以使用 synchronized 关键字或者 java.util.concurrent.atomic 包中的类,比如 AtomicInteger
10.3.2. 可见性(Visibility)

定义: 可见性指的是一个线程对共享变量所做的修改能够被另一个线程看到的能力。

  • volatile : 使用 volatile 关键字修饰的变量,在写操作后会立即刷新到主内存,并且每次读操作都会从主内存重新加载该变量的最新值,从而确保了不同线程间的可见性。
  • synchronized: 当一个线程退出同步代码块或方法时,它会强制将工作内存中的所有变量同步回主内存,这同样保证了变量的可见性。同时,进入同步代码块前也会更新工作内存中的变量值,以确保获取最新的数据。
  • final : 被 final 修饰的字段一旦初始化完成,其值就是固定的,无需担心由于重排序导致的可见性问题。
10.3.3. 有序性(Ordering)

定义: 有序性涉及程序中指令的执行顺序是否与源代码中的顺序一致。

  • 默认情况下, 编译器和处理器可能会对指令进行重排序以优化性能,但这可能导致多线程环境下的一些问题。例如,假设你有两条没有依赖关系的语句 A 和 B,编译器或CPU可能改变它们的执行顺序而不影响单线程程序的行为,但在并发环境中可能导致意外结果。
  • volatile 和 synchronized: 这些机制可以通过插入"内存屏障"来防止特定类型的重排序,从而维持一定的执行顺序。此外,JMM 提供了 happens-before 规则来明确哪些操作必须在其他操作之前完成,以维护程序的正确性。
10.3.4. happens-before?
10.3.4.1. happens-before是什么?

happens-before(先行发生)是 Java 内存模型(JMM)中定义的一套规则,用于判断多线程环境下两个操作之间是否存在"顺序"和"可见性"的保证。

🧠 一句话理解:

如果操作 A happens-before 操作 B,那么:

  1. A 的执行结果对 B 是可见的(B 能看到 A 写的值);
  2. A 在逻辑上"先于" B 发生(即使 CPU 或编译器重排序,JMM 也会保证效果如同 A 先执行)。

⚠️ 注意:happens-before 不是"时间先后",而是一种"内存可见性 + 顺序约束"的逻辑关系。

10.3.4.2. 为什么需要happens-before?

在多线程程序中:

  • CPU 有缓存(每个线程可能看到不同副本);
  • 编译器和处理器会重排序指令以优化性能;
  • 如果没有规则约束,一个线程写的值,另一个线程可能永远看不到,或者看到"半成品"。

happens-before 就是 JMM 提供的"契约":只要你的代码满足这些规则,Java 就保证线程间的数据可见性和执行顺序是正确的。

10.3.4.3. happens-before 的六大核心规则

|----------------------|-------------------------------------------------|-------------------------------------------------|
| 规则 | 说明 | 例子 |
| 1. 程序顺序规则 | 同一线程内,前面的操作 happens-before 后面的操作 | a = 1; b = 2;a=1hbb=2 |
| 2. 监视器锁规则 | 对同一把锁,解锁 happens-before 后续加锁 | synchronized块之间 |
| 3. volatile 变量规则 | volatile 写 happens-before 后续 volatile 读 | volatile flag = true; → 其他线程读 flag 能看到之前所有写 |
| 4. 线程启动规则 | Thread.start()happens-before 新线程中的任何操作 | 主线程 start() 后,子线程能看到 start 前的数据(需配合其他规则) |
| 5. 线程 join 规则 | 线程中的所有操作 happens-before join()返回 | t.join()后,主线程能看到 t 中的所有修改 |
| 6. 传递性 | A hb B,B hb C ⇒ A hb C | 串联多个规则形成完整可见性链 |

10.4. volatile型变量的特殊规则

volatile 是 Java 中一个用于修饰变量的关键字,它提供了一种轻量级的同步机制 ,主要用于解决多线程环境下的可见性问题

10.4.1. 保证此变量对所有线程的可见性

在 Java 内存模型中,所有变量都存储在主内存中,线程操作变量时会将其拷贝到自己的工作内存。

对于 普通变量 ,线程对它的修改不一定立即写回主内存 ,其他线程也不一定立即从主内存重新读取 ,因此一个线程的修改对其他线程可能不可见

volatile 变量通过内存屏障机制,保证:

  • 写操作会立即对其他线程可见(语义上等价于写入主内存并使其他线程缓存失效);
  • 读操作会直接从主内存(或等效的最新状态)读取 ,从而看到最新值。
    因此,volatile 解决了多线程下的可见性问题

但是volatile变量的运算在并发下一样是不安全的。原因在于Java里面的运算并非原子操作,代码实例:

java 复制代码
public class Main {
    public static volatile int num = 0;
    public static void increase(){
        num++;
    }
    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(num); // 预期为10000,实际为9982
    }
}

问题就在于num中,实际上num++等同于num=num+1。volatile保证了取值时是正确的,但是在执行+1的时候,其他线程可能已经把num值增大了,这样+1后会把较小的数值同步回主内存中。

由于volatile关键字只保证可见性,我们仍然需要通过加锁(synchronized或者lock)来保证原子性。

java 复制代码
package com.project;
public class Main {
    public static volatile int num = 0;
    public static synchronized void increase(){ // 进行加锁
        num++;
    }
    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 100000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(num);
    }
}
10.4.2. 使用volatile变量的语义是禁止指令重排序

Java 内存模型(JMM)允许编译器和处理器对指令进行重排序,以优化性能。但在多线程环境下,这种重排序可能导致程序行为异常。

volatile 通过插入内存屏障(Memory Barrier)来禁止对 volatile 变量的读写操作被重排序:

  • 在 volatile 写操作之前的所有普通读写操作,不能被重排到写操作之后;
  • 在 volatile 读操作之后的所有普通读写操作,不能被重排到读操作之前。

这保证了一定程度的有序性(ordering)

单例模式经典问题:

java 复制代码
package com.project;

/**
 * @className: Singleton
 * @author: 顾漂亮
 * @date: 2025/10/18 14:43
 */
public class Singleton {
    //没有 volatile 时,其他线程可能会受到重排序的影响,拿到不完整的对象
    //有了 volatile 后,保证了对象完整创建后才赋值给 instance,其他线程就不会拿到半成品了
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 防止重排序导致其他线程看到未初始化的对象
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        System.out.println(Singleton.getInstance());
    }
}

对于 instance = new Singleton();这句代码看起来是一行,但实际上包含了三个步骤:

  1. 分配内存空间给对象
  2. 在分配的内存中初始化对象(调用构造函数)
  3. 把 instance 这个变量指向这个内存地址

正常顺序应该是:1 → 2 → 3,但由于指令重排序,处理器可能把它优化成:1 → 3 → 2

这就出问题了!当执行完步骤3但还没执行步骤2的时候,instance 已经不是null了,但对象还没有完全创建好。如果这时候另一个线程来获取实例,就会拿到一个"半成品"对象,使用时就会出错。因此,此处需要加上volatile关键字明确告诉处理器:别优化这个变量相关的操作顺序,严格按照我写的代码顺序执行!**简单来说,指令重排序就是计算机为了提高效率而重新安排代码执行顺序的行为,大部分时候是好事,但在某些特殊场景(比如单例模式)下会造成问题,需要用 volatile 来禁止这种优化。**所以加上 volatile 后:保证了创建对象的步骤严格按照 1→2→3 的顺序执行、防止了其他线程获取到"半成品"对象

10.4.3. 总结:volatile 的三大特性(Java 内存模型角度)
  1. 可见性:修改立即对其他线程可见。
  2. 有序性:禁止指令重排序。
  3. 不保证原子性:复合操作仍需同步。

📌 记住:volatile 是"轻量级的 synchronized",但不能替代 synchronized。

相关推荐
编程岁月3 小时前
java面试-0215-HashMap有序吗?Comparable和Comparator区别?集合如何排序?
java·数据结构·面试
木井巳3 小时前
[Java数据结构与算法]详解排序算法
java·数据结构·算法·排序算法
没有bug.的程序员5 小时前
分布式架构未来趋势:从云原生到智能边缘的演进之路
java·分布式·微服务·云原生·架构·分布式系统
毕业设计制作和分享7 小时前
springboot150基于springboot的贸易行业crm系统
java·vue.js·spring boot·后端·毕业设计·mybatis
小梁努力敲代码12 小时前
java数据结构--List的介绍
java·开发语言·数据结构
摸鱼的老谭12 小时前
构建Agent该选Python还是Java ?
java·python·agent
lang2015092813 小时前
Spring Boot 官方文档精解:构建与依赖管理
java·spring boot·后端
夫唯不争,故无尤也13 小时前
Tomcat 启动后只显示 index.jsp,没有进入你的 Servlet 逻辑
java·servlet·tomcat
zz-zjx13 小时前
Tomcat核心组件全解析
java·tomcat