JVM

JVM组成部分

JVM是什么

JVM是Java虚拟机,解决的核心问题是一次编写,到处运行。JVM通过在操作系统之上建立一个抽象的计算机,让字节码文件可以跨平台执行。

JVM组成结构

  • 类加载器:加载字节码文件到内存,进行加载、链接、初始化操作,生成对应的Class对象。
  • 运行时数据区:JVM的内存管理区域,包含共享区域(堆、方法区)、线程私有区域(虚拟机栈、本地方法栈、程序计数器)。
  • 执行引擎:将字节码翻译为操作系统可以执行的机器指令,包含解释器、JIT编译器两种方式。
  • 本地方法接口(JNI):一套标准接口,允许Java代码调用c/c++等语言实现的本地方法。
  • 本地方法库:用c/c++等编写并编译好的动态链接库,提供Java无法直接完成的系统级操作,通过JNI被JVM加载和调用。

类加载机制

一个类的生命周期:加载 -》 验证 -》准备 -》解析》初始化 -》使用 -》卸载

双亲委派模型是类加载机制的核心,当一个类加载器收到加载请求时,自己不先尝试加载,而是逐级向上委派给父加载器,直到最顶层的Bootstrap ClassLoader。只有父加载器无法加载该类时,子加载器才尝试加载。

  • 启动类加载器:Bootstrap ClassLoader。
  • 扩展类加载器:Extension ClassLoader。
  • 应用类加载器:Application ClassLoader。

为什么需要双亲委派模型?

  • 安全:保证Java核心类库由启动类加载器加载,避免被篡改。
  • 唯一:优先交给父加载器加载,避免重复加载。

如何打破双亲委派模型? 通过自定义类加载器,打破“父加载器无法直接加载子加载器可见类”。 实现方式:每个线程都有一个contextClassLoader,默认是应用类加载器。核心库的代码通过Thread.currentThread().getContextClassLoader()拿到线程上下文加载器,然后用它来加载子类,从而打破“父加载器无法直接加载子加载器可见类”。

类的生命周期:

  • 加载:根据全限定名找到字节码,在堆中生成Class对象。
  • 验证:检查字节码是否安全合规,防止恶意代码。
  • 准备:为静态变量分配内存并赋予默认值。普通静态变量设置为0、null或false,编译器静态变量(static final修饰的基本类型或String类型)设置为代码中指定值。
  • 解析:把符号引用转成能直接定位的内存引用,如类引用、方法引用等。
  • 初始化:执行类的构造器方法,为静态变量赋值、执行静态代码块。
  • 使用:程序通过Class对象创建实例或调用方法。
  • 卸载:该类的Class对象被回收,方法区数据清除。

运行时数据区

运行时数据区是JVM的内存管理区域,规定了程序在运行时的数据该放在哪里、由谁共享、何时创建销毁等。

JVM运行时数据区

[!NOTE] 线程安全 线程共享

JVM中最大的一块内存,几乎所有对象实例都在此分配。从GC视角,堆采用分代设计,将内存分为:

  • 新生代:包含Eden区和两个Survivor区,比例为:8:1:1。绝大多数对象诞生在Eden区,熬过垃圾回收的对象会晋升到Survivor区,再从Survivor区晋升到老年代(每熬过一次Minor GC,则对象的GC年龄+1,达到默认值15时,晋升到老年代)。
    • TLAB:线程本地分配缓冲区,全称:Thread Local Allocation Buffer。TLAB是JVM为加速多线程下对象分配而设计的核心优化,在Eden区为每个线程划分一块独享空间,让线程在自己的独享空间内分配内存,与其他线程互不干扰。
  • 老年代:存放长期存活对象,如缓存、数据库连接池等。

因为大部分对象都“朝生夕死”,所以在不同生命周期的对象采用不同的垃圾回收算法。新生代大部分对象生命周期较短,适合标记复制算法。老年代对象生命周期相对较长,适合标记整理或标记清除算法。

对象分配流程:

  • 线程创建对象,先检查TLAB剩余空间是否足够,足够则直接在TLAB内指针碰撞,完成分配。
  • 若TLAB剩余空间不够:
    • 申请新TLAB分配:申请一块更大的TLAB,在新TLAB分配。
    • 在Eden区分配:对象大小适中,但新申请TLAB不划算,则在Eden区的公共区域通过同步操作分配。
    • 在老年区分配:对象较大,则跳过Eden区,直接在老年代分配,避免在新生代频繁复制。

方法区

[!NOTE] 线程安全 线程共享

存储类的元数据、运行时常量池、静态变量、JIT编译后的代码缓存等。

  • 类的元数据:存放类的全限定名、字段描述、方法描述等。
  • 运行时常量池:存放字面量和符号引用。(动态链接就靠它)
  • 静态变量:static修饰的变量。从JDK1.7起,字符串常量池和静态变量从原来的方法区移动到了堆中,但逻辑上还是方法区。
  • JIT编译后的代码缓存:热点方法被JIT编译称本地机器码后,缓存在方法区。

演变历史:

  • JDK1.7:在堆内实现为永久代,容易OOM。
  • JDK1.8:改为使用本地内存的元空间,可以直接使用本地系统内存,仅受物理内存限制。同时,原来在永久代的字符串常量池和静态变量转移到了堆中。

虚拟机栈

[!info] 线程安全 线程私有

每个线程创建时,JVM会为其分配一个私有的虚拟机栈,内部由一个个栈帧组成。每个方法被执行时,都会创建一个栈帧入栈,方法结束后出栈。

栈帧的结构:

  • 局部变量表:存放方法参数和局部变量,以槽位Slot为最小单位。
  • 操作数栈:一个后进先出的栈,用于计算的临时工作区。例如计算 int a = 1+2; 先压入1、2,执行iadd指令相加,结果放回栈顶,再存入局部变量表。
  • 动态链接:指向方法区中的运行时常量池对该方法的符号引用,并替换为直接引用。(多态)
  • 方法返回地址:记录方法调用后的下一条指令地址,正常退出或异常退出都要恢复调用者现场。

栈常见错误:

  • StackOverFlowError:栈溢出,通常由于方法递归深度太大导致。
  • OutOfMemoryError:栈无法申请足够内存时。

程序计数器

[!info] 线程安全 线程私有

程序计数器是当前线程所执行的字节码行号指示器,CPU时间片在各线程间切换,每个线程都有自己的“进度条”。

  • 当线程正在执行一个Java方法,程序计数器记录的是当前字节码指令的地址。
  • 当线程正在执行一个Native方法,程序计数器的值为空。

为什么需要程序计数器?

  • 流程控制:字节码指令执行时,解释器根据计数器取下一行指令。
  • 线程恢复:多线程抢占CPU时,任何线程被挂起后再恢复,是程序计数器来告诉线程“上一次执行到哪一行”的。
  • 异常处理:抛出异常时,JVM通过当前计数器位置查找异常表,确定该在哪一行catch。

本地方法栈

[!info] 线程安全 线程私有

本地方法栈是为Native方法调用服务的栈,它让c、c++代码能在JVM内部安全运行。通过JNI,Java可以调用c、c++实现的函数,而本地方法栈就为这些Native方法提供栈环境。

执行引擎

执行引擎是JVM的心脏,它负责把字节码翻译成操作系统能执行的机器指令并运行。但为了既快又灵活,采用了“解释执行+编译执行”混合驱动的设计。

执行引擎组成部分:

  • 解释器:逐条将字节码翻译为本地机器指令并执行。类比:同声传译。
  • JIT编译器:将热点代码一次性编译成本地机器码,后续直接运行。类比:翻译后的文字稿。
  • 垃圾回收器:严格说垃圾回收器也属于执行引擎的一部分,负责自动内存回收。

为什么要用解释器+JIT编译器混合模式? 为了找到一种性能平衡,所以采用了混合模式:

  • 解释器:负责“预热”、“兜底”。程序刚启动时,快速响应。当JIT编译失败时,退回解释执行保证正确性。
  • JIT编译器:负责“加速”。收集到足够热点信息后,对真正的性能瓶颈进行深度优化。 这也是说Java是编译和解释共存的原因。

如何识别热点代码? HotSpot采用热点探测, 主要基于计数器:

  • 方法调用计数器:记录每个方法被调用的次数。
  • 回边计数器:记录循环体执行的次数。热点循环会触发OSR(栈上替换),将正在解释执行的循环替换为编译好的机器码。 需注意,为避免临时热点代码造成的资源浪费,计数器会随时间进行半衰减的“降温”,只保留高频使用的热点代码。

JIT编译器采用分层编译,C1快速预热+收集数据 -》C2精准优化。

  • C1编译器:编译速度快,生成代码质量中等,适合需要快速处理的短任务。
  • C2编译器:编译耗时长,但会进行大量基于运行时信息的激进优化(如逃逸分析、方法内联)等,生成代码执行效率极高,适合长时间运行的服务器应用。

JVM垃圾回收

垃圾回收(Garbage Collection),简称GC,是JVM自动管理内存的机制。它会自动识别不再使用对象,并释放它们占用的空间。

GC解决的核心问题

  • 提高系统健壮性:把内存管理的负担从人交给机器,让程序不会因忘记释放内存而慢慢撑爆。
  • 支撑“一次编写,到处运行”:内存管理交给JVM,是跨平台保障的一部分。

核心矛盾:GC时必须暂停用户线程,即STW。因为不能在一个正在变化的对象关系图上找垃圾。GC的进化史,都是在让STW时间更短、更可控。

寻找垃圾的两种方式

  • 引用计数法(Java不用):每个对象有一个计数器,有引用则+1,引用失效则减1,引用为0则表示可以回收。但存在循环引用问题时会导致永远无法回收。
  • 可达性分析(Java采用):从一组GC Roots根对象出发,沿着引用链搜索。不在引用链上的对象,就是不可达对象,表示可以回收。

GC Roots对象:

  • 虚拟机栈(栈帧中局部变量表)引用的对象
  • 方法区中静态变量引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • 所有被同步锁持有的对象

四种引用类型

不是所有引用都希望永远绑着对象不放,有时我们希望对象在内存紧张时可以被回收,有时可能只想跟踪对象是否被回收了。 这四种引用不是改变可达性分析的根,而是定义了引用强度的等级。

引用类型 GC回收策略 何时回收 典型用途
强引用 永不回收 —— 最普通的对象引用
软引用 内存不足时回收 即将OOM 内存敏感的高速缓存
弱引用 GC时必定回收 下一次GC WeakHashMap、ThreadLocal
虚引用 任何时候,仅收通知 对象被回收时通知 管理堆外内存(DirectByteBuffer)

强引用: 常见的对象引用都是强引用,例如Object o = new Object

软引用:SoftReference 用SoftReference包裹对象,当系统内存充足时,GC不会回收。当内存不足即将OOM时,JVM会把软引用对象全部回收。

弱引用:WeakReference 用WeakReference包裹对象,一旦GC发生,不管内存是否充足,弱引用指向的对象必定被回收。它的生命周期只存活到下一次GC前。

  • 典型用途:ThreadLocal中的ThreadLocalMap的key是ThreadLocal对象,是弱引用。

虚引用:PhantomReference 用PhantomReference包裹对象,get永远返回null。它的唯一作用是:当指向的对象被GC回收时,虚引用会被放入关联的ReferenceQueue中,起到通知作用。

常见垃圾回收算法

  • 标记-清除:先标记,再统一清除。缺点是会产生内存碎片。
  • 标记-复制:把内存分两块,用一块,存活的对象复制到另一块。整体清空当前块。解决了内存碎片问题,但造成空间浪费。
  • 标记-整理:标记后,让存活对象往一端移动,直接清理边界外的内存。

常见垃圾回收器

收集器 作用区域 算法 线程模型 核心目标
Serial 新生代 标记-复制 单线程 简单高效,Client模式首选
ParNew 新生代 标记-复制 多线程 Serial的多线程版,配合CMS
Parallel Scavenge 新生代 标记-复制 多线程 吞吐量优先
Serial Old 老年代 标记-整理 单线程 配合Serial,CMS后备
Parallel Old 老年代 标记-整理 多线程 配合Parallel Scavenge
CMS 老年代 标记-清除 多线程 最短停顿时间
G1 全堆 混合(复制+整理) 多线程 可预测的停顿时间

CMS

[!NOTE] 介绍 CMS是一款老年代垃圾收集器,核心目标是收集老年代的垃圾。在JDK9被废弃、JDK14彻底移除。

核心原理:基于标记-清除算法,把最耗时的标记过程交给GC线程和用户线程并发执行,只在无法并发的关键节点短暂STW。

执行阶段:

阶段 STW? 做什么 耗时
初始标记 只标记 GC Roots 直接关联的对象 极短
并发标记 从直接关联对象出发,遍历整个对象图,并通过卡表标记脏页。
并发预清理 提前扫描卡表脏页并尽量尽量等待一次Minor GC,为重新标记阶段减负。
重新标记 修正并发标记期间因用户线程运行而变动的标记 较短
并发清除 清理垃圾对象

CMS的三色标记法:

  • 白色:未被扫描过的对象,标记结束后,白色为可回收对象。
  • 灰色:对象本身已被访问,但它引用的子对象还没扫描。
  • 黑色:对象及其所有子引用全部扫描完毕,是确定存活的对象。

标记过程:

  • 在初始标记阶段,STW。从GC Roots出发,把直接关联的对象标记为灰色。
  • 在并发标记阶段,从标记为灰色的对象出发,把自身标记为黑色。然后扫描它引用的对象,标记为灰色。如此,层层扫描。
    • 因用户线程也在执行,CMS会通过写屏障+卡表记录哪些对象的引用发生了变化,在赋值指令后立即执行:找到卡页,并标记为脏页。写屏障是记录动作,卡表是记录结果。
    • 并发预清理:主动扫描卡表中的脏页,把其中涉及的对象重新扫描和标记,尽可能的修复漏标。
    • 可终止的并发预清理:尽量等待一次Minor GC,减少重新标记阶段需要扫描的新生代对象。
  • 在重新标记阶段,STW。重新扫描 新生代中所有存活对象、GC Roots、卡表中所有仍为脏页的。

卡表介绍: 卡表是一个位图(bitmap),将整个堆内存按固定大小(通常为512字节)划分为卡页。每个卡页对应卡表中的一个位。当这个位置被标记为1,表示这个卡页脏了。

G1

[!NOTE] 介绍 G1,Garbage-First,优先回收价值最高的垃圾,在JDK9成为默认垃圾回收器。追求目标是:在可控的停顿时间内,获得尽可能高的吞吐量,特别适合大堆。

G1把整个堆分成大小相等的Region,默认约2048个,每块1-32MB,取2的幂次。 每个Region可以在不同时间扮演不同的角色:

  • Eden:新生代
  • Survivor:S0、S1
  • Old:老年代
  • Humongous:巨型对象,当一个对象超过Region的一半大小时,被视为巨型对象。

核心目标:G1会根据历史数据,推测回收一个Region需要的时间,然后在此次停顿时间内,只回收预估可以完成的价值最高的Region。可以通过-XX:MaxGCPauseMillis设定停顿时间。

标记阶段:

  • 初始标记:伴随Minor GC,标记GC Roots直接关联对象,因为Minor GC会将新生代的活对象复制到Survivor区。所以就以Survivor Region作为根区间。
  • 根区间:扫描所有Survivor Region,找出所有Survivor -》老年代的引用,为后续并发标记提供老年代入口。
  • 并发标记:使用STAB快照,通过写前屏障将被删除的引用记录到STAB队列。全程并发,会产生浮动垃圾。
  • 最终标记:处理STAB队列中剩余的引用变更,完成最终标记修正。STW极短。

STAB:开始标记前,先拍个快照。并发标记期间只要有引用断开,就把断开前指向的那个对象记下来并标记为灰色。宁可多留一些浮动垃圾,也绝不错回收。

RSet:Remembered Set,是每个Region的备忘录,专门记录其他Region中的哪些卡页引用了本Region内的对象。把这些跨Region的引用维护好,在回收一个Region时,只扫描与他相关的其他Region即可,而不是整个堆。

回收类型:

  • Minor GC:当Eden区满后,把活对象复制到新的urvivor或晋升到老年代Region。
  • Mixed GC:当老年代堆占用达45%后,G1会启动一个并发标记周期,计算出每个老年代Region的垃圾比例,然后选取垃圾最多的老年代Region和所有年轻代Region一起回收。Mixed GC分多次进行,每次回收一小批。
  • Full GC:当对象分配过快,或巨型对象无法找到空间分配,则会退化为Serial Old单线程收集,会导致较长时间的STW。

GC调优

FAQ

为什么说Java是解释和编译并存?

Java同时采用解释执行和编译执行(JIT即时编译),是一种性能优化平衡策略,保证启动速度和执行效率。

  • 解释器:逐行翻译字节码为机器码,并立即执行,不会保存结果。类比:同声传译。
  • 编译器:针对热点代码进行一次性的完整翻译,并保存结果,后续直接复用。类比:翻译后的文字稿。

Java是如何识别热点代码的?


JVM 知识图谱
知识笔记 · Java 虚拟机

JVM 全景

Java 虚拟机解决"一次编写,到处运行"的核心问题。本页梳理三大体系:组成结构(类加载器、运行时数据区、执行引擎)、垃圾回收(寻找/算法/收集器)与执行引擎的解释 + JIT 混合模式,并附知识图谱与高频问答。

知识图谱

类加载 运行时数据区 执行引擎 垃圾回收 子项
点击节点查看详情 · 可拖拽 · 滚轮缩放

JVM 组成结构

类加载器把字节码加载进内存;运行时数据区管理内存;执行引擎把字节码翻译成机器指令;JNI 与本地方法库支撑对 C/C++ 的调用。

类加载器

加载 → 验证 → 准备 → 解析 → 初始化
  • 三层:Bootstrap → Extension → Application
  • 双亲委派:逐级向上委派,父加载不了子才加载
  • 目的:安全(核心类不被篡改)、唯一(避免重复加载)
  • 打破方式:自定义加载器 / 线程上下文加载器 getContextClassLoader()

运行时数据区

共享区 + 线程私有区
  • 共享 :堆、方法区
  • 私有 :虚拟机栈、本地方法栈、程序计数器
  • 规定数据放哪、谁共享、何时创建销毁

执行引擎

解释器 + JIT + 垃圾回收器
  • 把字节码翻译成机器指令并运行
  • 混合模式:解释器预热/兜底,JIT 加速热点
  • 分层编译:C1 快速预热 → C2 激进优化

运行时数据区 · 五块内存

最大内存 · 对象实例
  • 分代:新生代(Eden:S0:S1 = 8:1:1)+ 老年代
  • 对象熬过 Minor GC,GC 年龄 +1,达 15 晋升老年代
  • TLAB:线程本地分配缓冲,Eden 内每线程独享空间
  • 新生代用标记-复制;老年代用标记-整理/清除

方法区

类元数据 · 常量池 · 静态变量
  • 存类元数据、运行时常量池、静态变量、JIT 代码缓存
  • JDK1.7 永久代(易 OOM)→ JDK1.8 元空间(本地内存)
  • 1.7 起字符串常量池、静态变量移到堆中

虚拟机栈

栈帧 · 方法调用
  • 每方法一栈帧:局部变量表、操作数栈、动态链接、返回地址
  • 递归过深 → StackOverflowError
  • 无法申请内存 → OutOfMemoryError

程序计数器

字节码行号指示器
  • 记录当前线程执行的字节码地址(Native 方法时为空)
  • 作用:流程控制、线程恢复、异常处理
  • 唯一不会 OOM 的区域

本地方法栈

Native 方法服务
  • 为 Native 方法提供栈环境
  • 通过 JNI 调用 C/C++ 实现的函数

执行引擎 · 解释 + 编译混合

解释器 vs JIT

同声传译 vs 文字稿
  • 解释器:逐条翻译执行,负责预热与兜底
  • JIT:热点代码一次性编译为机器码,后续直接运行
  • 这正是"Java 编译与解释共存"的原因

热点探测

HotSpot 计数器
  • 方法调用计数器 + 回边计数器(循环)
  • 热点循环触发 OSR(栈上替换)
  • 计数器半衰减"降温",只留高频热点

分层编译

C1 → C2
  • C1:编译快、质量中等,适合短任务
  • C2:激进优化(逃逸分析、方法内联),适合长跑服务

垃圾回收 · 寻找 / 算法 / 收集器

核心矛盾是 STW(Stop-The-World):不能在变化的对象图上找垃圾,GC 进化史就是让 STW 更短更可控。

寻找垃圾

可达性分析(Java 采用)
  • 引用计数法:循环引用无法回收,Java 不用
  • 可达性分析:从 GC Roots 沿引用链搜索,不可达即可回收
  • GC Roots:栈局部变量、静态变量、常量、JNI 引用、同步锁对象

四种引用

引用强度等级
  • :永不回收(普通引用)
  • SoftReference:内存不足时回收(缓存)
  • WeakReference:下次 GC 必回收(ThreadLocal)
  • PhantomReference:仅回收通知(堆外内存)

回收算法

三种基本算法
  • 标记-清除:有内存碎片
  • 标记-复制:无碎片但浪费空间(新生代)
  • 标记-整理:存活对象向一端移动(老年代)

常见垃圾回收器

收集器区域算法线程核心目标
Serial新生代标记-复制单线程简单高效,Client 首选
ParNew新生代标记-复制多线程Serial 多线程版,配 CMS
Parallel Scavenge新生代标记-复制多线程吞吐量优先
Serial Old老年代标记-整理单线程配 Serial,CMS 后备
Parallel Old老年代标记-整理多线程配 Parallel Scavenge
CMS老年代标记-清除多线程最短停顿(JDK14 移除)
G1全堆复制+整理多线程可预测停顿(JDK9 默认)

CMS

标记-清除 · 三色标记
  • 五阶段:初始标记STW → 并发标记 → 并发预清理 → 重新标记STW → 并发清除
  • 三色:白(可回收)/灰(待扫子引用)/黑(扫描完毕)
  • 写屏障 + 卡表记录并发期引用变化(脏页 = 位图标 1)

G1

Garbage-First · Region 化
  • 堆分 ~2048 个 Region(1-32MB),角色:Eden/Survivor/Old/Humongous
  • SATB 快照 + 写前屏障;RSet 记录跨 Region 引用
  • Mixed GC:老年代占用达 45% 触发并发标记周期
  • -XX:MaxGCPauseMillis 设定目标停顿

高频问答

为什么说 Java 是解释和编译并存?
Java 同时用解释执行与 JIT 编译,是性能平衡策略。解释器逐行翻译字节码并立即执行、不保存结果(同声传译);编译器对热点代码一次性完整翻译并保存复用(文字稿)。两者结合兼顾启动速度与执行效率。
Java 如何识别热点代码?
HotSpot 基于计数器做热点探测:方法调用计数器统计方法被调次数,回边计数器统计循环体执行次数;热点循环触发 OSR(栈上替换)把解释执行的循环换成机器码。计数器会随时间半衰减降温,只保留高频热点。
CMS 与 G1 的关键区别?
CMS 只管老年代、用标记-清除(有碎片)、追求最短停顿,JDK14 已移除;G1 管整个堆、Region 化、复制+整理(无碎片)、可设定目标停顿时间,JDK9 起为默认。CMS 用写屏障+卡表修正漏标,G1 用 SATB 快照+RSet 缩小扫描范围。
为什么程序计数器不会 OOM?
程序计数器只存一个字节码行号(很小的固定空间),且生命周期随线程,不随对象分配增长,因此是 JVM 规范中唯一不会抛出 OutOfMemoryError 的区域。
源文档:JVM · kit-docs。本页为结构化知识梳理与交互式知识图谱,便于复习与检索。