JVM(Java Virtual Machine,Java虚拟机)是Java程序“一次编写,到处运行”这一特性的实现者。

JVM 主要由四个子系统组成。

  1. 类加载子系统 (Class Loader Subsystem): 负责将硬盘上的 .class 文件加载到内存中。
  2. 运行时数据区 (Runtime Data Areas): 也就是我们常说的“JVM 内存”,用于存储数据和中间结果。
  3. 执行引擎 (Execution Engine): 负责解释命令,提交给操作系统执行。包含解释器、 JIT 编译器和垃圾回收器(GC)
  4. 本地库接口 (Native Interface): 融合不同编程语言为 Java 所用(通常是 C/C++)。那我为啥不用python?

类加载子系统

流程

它的核心职责包括:

  1. 加载(Loading):查找并读取类的二进制数据(.class)
  2. 连接(Linking):验证、准备、解析类
  3. 初始化(Initialization):执行类初始化逻辑

.class文件为二进制文件,里面包含0xCAFEBABE这个魔数,版本号,常量池,访问标志,代码等等。经过一番研究发现.class虽然与汇编代码样似,但是实际上根本不一样,因为class根本不是让人看的,汇编人类还是可以编写的,但不会有正常人编写class。所以了解即可,除非你干的是逆向。我们最多用到javap -c xxx.class看一些编译器的优化操作,而java -c -v xxx.class则是过于繁琐。

类的完整生命周期(注意所有的类都会连接,但是只有被触发的类才会走到初始化这一步)

1
加载 → 连接 (验证 → 准备 → 解析) → 初始化 → 使用 → 卸载

从加载到初始化归类加载子系统负责,

加载就是将字节码形态的.class装载到JVM内存里面,字节码可以既可以来自本地,也可以来自网络和动态代理。

连接分为验证安全性,为静态变量分配内存并附上默认值(并非类自定义的值),将符号引用(字符串形态)解析为直接引用(内存地址、偏移量)

初始化

<clinit>(class initialization method)

  • javac 编译器自动生成
  • 用于完成 类级别初始化
  • 只负责:
    • static 变量的显式赋值
    • static {} 静态代码块
  • 类初始化(Initialization)阶段由 JVM 调用
  • 一个类最多只有一个 <clinit> 方法

初始化触发条件:

  • new 创建对象
  • 访问 static 非 final 变量
  • 调用静态方法
  • 反射调用
  • JVM 启动主类
  • 子类初始化,父类先初始化

实现

JVM 内置类加载器:

  1. Bootstrap ClassLoader: 祖宗级。用 C++ 写的,加载核心库(rt.jar,如 String, System)。Java 代码无法直接获取它。
  2. Extension/Platform ClassLoader: 父级。加载扩展库。(属于历史遗留问题,现在的maven和gradle已经很好地管理第三方库了)在Java8中ExtClassLoader加载JAVA_HOME/lib/ext所有的库,而Java9+中则变为PlatformClassLoader加载<JAVA_HOME>/jmods,但是仅限 JDK 内置模块和仅加载官方签名模块。
  3. Application ClassLoader: 应用级。加载 classpath 下你自己写的类。

双亲委派模型 (Parent Delegation Model):

注意这个双亲和数据结构一个问题,都是政治正确导致的中性词语,本质上仍为上下级、父子这种一一相对的关系,parent也并未parents,双亲只是一个很烂容易误导初学者理解的翻译。

总结起来这个就是为了安全,防止篡改核心库的类加载设计,一切加载先交给父加载器,失败再回退到当前加载器。

运行时数据区

也就是我们常说的JVM内存,OOM警告!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
┌──────────────────────┐
│   程序计数器(PC)      │ 线程私有
├──────────────────────┤
│   虚拟机栈             │ 线程私有
├──────────────────────┤
│   本地方法栈           │ 线程私有
├──────────────────────┤
│   Java 堆            │ 线程共享
├──────────────────────┤
│   方法区              │ 线程共享
└──────────────────────┘

关键划分原则:是否线程私有 (感谢ai大人的古法ascii画图)

想必都学过计组,PC就是那个PC: 执行 Java 方法 → 存字节码行号 执行 Native 方法 → 值为 Undefined

虚拟机栈就是类似汇编的PROC区域 本地方法栈也是个傻逼翻译,native method,就是非java语言的,c,cpp写的方法 在 HotSpot 虚拟机中,直接就把本地方法栈和虚拟机栈合二为一了。

Java堆,就是最大的一坨内存,用来存放对象实例分代架构(经典版):为了高效回收,堆被划分为:

  • 新生代 (Young Generation)
    • Eden 区:大部分对象出生的地儿。
    • Survivor 区 (S0/S1):经历过 GC 幸存下来的对象存放地。
  • 老年代 (Old Generation):存放生命周期长、大的对象。

方法区:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。(JDK7-为“永久代 (Permanent Generation)”使用堆内存,JDK8+为“元空间 (Metaspace)”,直接使用本地物理内存

最后补充一下Java堆和物理内存的区别,JVM只是内存的“二房东”。提前划分好一块,然后自己搞GC,不让操作系统参与。

执行引擎

解释/编译

解释器:将字节码转换为机器码,启动快,无需编译

JIT 编译器 (Just-In-Time Compiler):A. 热点探测 (Hot Spot Detection)

  1. JVM 会监控代码的运行情况。如果某段代码(一个方法或一个循环体)运行频率非常高,就会被标记为**“热点代码”**。
  2. 编译优化 JIT 会把这些“热点代码”直接编译成本地机器指令,并存在内存的 Code Cache (就是在内存里面的一块,不是在java堆里面)中。下次执行时,CPU 直接跑机器码,速度接近 C/C++。
  3. C1 与 C2 编译器 现代 JVM(HotSpot)内置了两个 JIT 编译器:
  • C1 (Client Compiler): 优化简单,耗时短。追求启动速度和基本的优化。
  • C2 (Server Compiler): 耗时长,但优化极深。它会根据代码运行的统计信息进行激进优化(如逃逸分析)。

逃逸分析:如果一个对象只在当前方法内部使用,且没有返回,也没有赋值给外部变量,那它就是“不逃逸”的。

如果不逃逸会优化:

  1. 栈上分配 (Stack Allocation)
  • 优化: 既然对象只在方法里用,执行引擎直接在**栈(Stack)**上分配内存。
  • 好处: 方法执行完,栈帧弹出,对象直接消失。完全不需要 GC 介入,性能极大提升。
  1. 标量替换 (Scalar Replacement)
  • 概念: “标量”是指不可再拆分的数据(如 int, long 等基本类型)。“聚合量”是指可以拆分的对象(如包含 x, y 坐标的 Point 对象)。
  • 优化: 如果一个对象不会逃逸,执行引擎可能根本不创建这个对象,而是把这个对象拆解掉,将其成员变量恢复成原始的局部变量存放在栈上。
  • 好处: 节省了对象头(Object Header)的内存消耗(通常 12-16 字节),且基本类型的操作效率比对象高得多。
  1. 同步消除 (Lock Elision / Lock Coarsening)
  • 优化: 如果执行引擎发现一个同步锁对象(synchronized)只会在当前线程内被访问,不可能被其他线程竞争。
  • 好处: 执行引擎会直接把锁删掉。因为单线程环境下的加锁、解锁是纯粹的性能浪费。

垃圾回收

最常被问到的GC。垃圾回收(GC)主要回收 JVM 堆(Heap),元空间也有很小概率会被回收,废弃常量和无用的类会被回收,但是无用的类很难被满足(不如好好想想加大元空间大小)。

JVM 不使用引用计数法(引用为0则删除),而是使用 可达性分析算法

基本原理:从一系列被称为 “GC Roots” 的根对象开始,像走迷宫一样向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,就说明这个对象是不可达的,即为垃圾。

哪些可以作为 GC Roots?

  1. 虚拟机栈(栈帧中的局部变量表)引用的对象。
  2. 方法区中类静态属性、常量引用的对象。
  3. 本地方法栈中 JNI(Native方法)引用的对象。
  4. 所有被同步锁(synchronized)持有的对象。

清理垃圾策略:

  1. 标记 - 清除 (Mark-Sweep):把垃圾标记出来,直接原地抹掉。 缺点:会产生大量的内存碎片
  2. 标记 - 复制 (Mark-Copying):把内存分成两块,每次只用一块。清理时,把活着的对象全部“搬家”到另一块,剩下的全清空。 优点:没有碎片,速度极快。 缺点:浪费空间,始终有一半内存是空着的。
  3. 标记 - 整理 (Mark-Compact):标记后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。 优点:没有碎片,也不浪费空间。 缺点:移动对象很费劲,需要更新所有指向这些对象的指针,效率较低。

分代回收

JVM 发现,绝大多数 Java 对象都是“短命鬼”(比如方法里的临时变量),而极少数对象(如连接池、Spring Bean)能活很久。

于是,堆内存被划分为 新生代老年代,采用不同的策略:

新生代 (Young Generation) —— “高频清理”

  • 特点:对象死得快,存活率低。
  • 结构:Eden 区 + 两个 Survivor 区 (S0, S1),默认比例 8:1:1
  • 策略:采用 复制算法
    • 新对象在 Eden 出生。
    • Minor GC 时,把 Eden 和 S0 里的活口扔进 S1,清空 Eden 和 S0。
    • 对象每横跳一次,年龄 +1。
    • 当S0空的时候,Minor GC就会把活口扔进 S1,清空 Eden 和 S1,实现左右横条

注:Eden为伊甸园的意思 Minor GC在Eden满的时候触发

老年代 (Old Generation)

  • 特点:对象生命周期长,空间大。
  • 策略:采用 标记-整理标记-清除 算法。
  • 晋升机制:对象年龄达到阈值(默认 15)或者对象太大(大数组),直接进入老年代。当老年代满了,触发 Major GC / Full GC

Full GC 触发(整堆回收,含新生代 + 老年代 + 元空间):老年代/元空间空间不够,新生代晋升老年代失败等

Major GC 触发(仅老年代回收):老年代占用达到阈值,大对象 / 大量对象直接进入老年代导致空间紧张,多次 Minor GC 后对象持续晋升,老年代逼近上限

注:元空间虽然不在java堆,但是满了也会触发Full GC是想通过类卸载来释放元空间

垃圾回收器

Parallel GC:追求最高吞吐量

Minor GC(新生代回收)

  1. STW 暂停应用程序线程。
  2. 从根节点(GC Roots,包括线程栈、静态变量等)开始,多线程并发标记存活对象。
  3. 使用复制算法,将存活对象从 Eden 区复制到 Survivor 区。
  4. 清空 Eden 区和已使用的 Survivor 区。
  5. 恢复应用程序线程。

Full GC(老年代回收,通常伴随 Minor GC)

  1. STW 暂停应用程序线程(暂停时间较长,可能数百毫秒至秒级)。
  2. 多线程并发标记所有存活对象(标记 - 整理 算法)。
  3. 标记阶段:从 GC Roots 追踪存活对象。
  4. 整理阶段:将存活对象压缩到内存一端,消除碎片。
  5. 清空剩余空间。
  6. 恢复应用程序线程。

CMS(Concurrent Mark Sweep)针对老年代设计,旨在减少暂停时间。

Minor GC(新生代回收,使用 ParNew)

  1. STW 暂停应用程序线程。
  2. 多线程从 GC Roots 标记存活对象。
  3. 使用复制算法,将存活对象从 Eden 区复制到 Survivor 区。
  4. 清空 Eden 区和已使用的 Survivor 区。
  5. 恢复应用程序线程。

Major GC(老年代回收)

  1. 初始标记(STW):短暂暂停,标记 GC Roots 直接引用的对象。
  2. 并发标记:应用程序继续执行,多线程从初始标记的对象追踪所有存活对象。
  3. 重新标记(STW):短暂暂停,修正并发标记期间的引用变化(使用增量更新机制)。
  4. 并发清除:应用程序继续执行,清除标记为垃圾的对象(不进行内存整理,导致碎片)。
  5. 如果碎片过多,可能触发 Full GC(退化为 Serial Old 的单线程 标记 - 整理 算法)。

当然如果你一想就会发现CMS很逆天,Full GC既然已经STW了,为什么还要用Serial Old呢,明明这个也会STW为什么不并行整理呢,事实上这就是没人原因维护这个屎坑了, ParOld在JDK1.6+才有。其设计哲学就是低停顿,但是都已经沦落到Full GC就证明设计失败了,所以就直接摆烂了,这种设计被弃用还是普大喜奔的。

注意:CMS 已于 JDK 14 移除,不推荐新项目使用。


G1 GC 执行过程

G1(Garbage-First)将堆分为多个 Region(通常 1-32MB),逻辑上分代(Eden、Survivor、Old),回收优先处理垃圾最多的 Region。过程包括年轻代回收和混合回收,支持暂停时间目标控制。

Young GC(年轻代回收)

  1. STW 暂停应用程序线程(通常数十至数百毫秒)。
  2. 多线程从 GC Roots 标记存活对象。
  3. 使用复制算法,将存活对象从 Eden 和 Survivor Region 复制到新的 Survivor 或 Old Region。
  4. 清空原 Eden 和 Survivor Region。
  5. 恢复应用程序线程。

Concurrent Marking Cycle(并发标记周期,用于老年代准备)

  1. 初始标记(STW,通常借用 Young GC 完成):标记 GC Roots 直接引用的对象。
  2. 根区域扫描(并发):扫描 Survivor Region 中的根引用。
  3. 并发标记:应用程序继续执行,多线程追踪所有存活对象,记录跨 Region 引用(使用 Remembered Set)。
  4. 重新标记(STW):修正并发标记期间的引用变化(使用 Snapshot-At-The-Beginning)。
  5. 清理(STW):计算每个 Region 的存活率,更新统计信息。

Mixed GC(混合回收,包含年轻代和部分老年代 Region)

  1. STW 暂停应用程序线程。
  2. 选择垃圾最多的 Region(基于优先级)。
  3. 多线程复制存活对象到新 Region(整理碎片)。
  4. 清空原 Region。
  5. 恢复应用程序线程(可能分多次 Mixed GC 以控制单次暂停)。

ZGC 执行过程(分代模式,JDK 21+ 默认)

ZGC(Z Garbage Collector)设计为低暂停收集器,使用着色指针(Colored Pointers)和读屏障(Load Barriers)实现并发回收。JDK 21 后引入分代,支持年轻代和老年代独立回收,暂停时间通常 <10ms。

Generational Minor GC(年轻代回收)

  1. STW 暂停(通常 <1ms):根扫描,标记 GC Roots 引用的年轻代对象。
  2. 并发标记和重定位:应用程序继续执行,使用读屏障追踪和更新引用,将存活对象重定位到新地址(多视图内存映射)。
  3. STW 最终标记(<1ms):修正根引用。
  4. 清空原年轻代空间。

Generational Major GC(老年代/全局回收)

  1. STW 初始标记(<1ms):标记 GC Roots 直接引用的对象。
  2. 并发标记:应用程序继续执行,使用读屏障和着色指针标记存活对象。
  3. 并发重定位准备:计算重定位集。
  4. 并发重定位:应用程序继续执行,重定位存活对象到新地址(读屏障确保引用更新)。
  5. STW 最终重映射(<1ms):更新根引用。
  6. 清空原空间。

ZGC有一些黑话术语听不懂,我也懒得听懂了,我还年轻

TODO