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