JVM 底层彻底理解


一、JVM 到底解决了什么问题(先理解本质)

在 JVM 出现之前:

  • C / C++ 需要手动管理内存

  • 内存泄漏、野指针问题频发且难排查

  • 程序强依赖操作系统,跨平台成本高

这些问题的共同点只有一个:

复杂、易错、不可控。


JVM 的本质目标只有三点

一句话先记住:隔离、托管、优化。

  1. 屏蔽底层操作系统差异

    程序不再直接依赖操作系统,而是运行在 JVM 之上,实现“一次编写,到处运行”。

  2. 自动内存管理(GC)

    对象创建、回收由 JVM 统一管理,开发者只关心“是否还在使用”,不再和内存细节纠缠。

  3. 通过运行期优化获得接近原生的性能

    JVM 会在运行过程中识别热点代码并进行编译优化,长期运行下性能接近甚至等同原生程序。


核心结论

JVM 不是为了“极限快”,而是为了稳定、可控、可长期运行

它本质上是一个:

以空间换安全、以运行期分析换性能

的工程系统。


二、JVM 整体架构(为什么要分这些模块)

JVM 不是一个黑盒,而是一个高度分层、职责清晰的系统:

ClassLoader  →  Runtime Data Area  →  Execution Engine

一句话理解这条链路:

先把类弄进来 → 给数据找地方 → 把代码跑起来


JVM 为什么要分这三层?

因为 JVM 要同时解决三类完全不同的问题:
加载、存储、执行。


  1. 类加载器(ClassLoader)

    核心职责:把 class 文件变成 JVM 能用的 Class 对象

    • 负责类的加载、校验、链接、初始化
    • 支持按需加载,而不是一次性全部加载
    • 通过双亲委派机制保证核心类安全

    一句话记忆:

    类加载器决定“类从哪来、能不能用”

    高频点:

    1. 为什么需要双亲委派?

      一句话答案:

      防止核心类被篡改,保证类加载的安全性和一致性。

      关键点:

      • 优先由父加载器加载,避免自定义类覆盖 java.lang.*
      • 保证同一个类在 JVM 中的唯一性

    2. 自定义 ClassLoader 的典型场景?

      一句话答案:

      隔离、扩展、动态加载。

      典型场景:

      • 插件化系统(不同插件使用不同依赖)
      • 热加载 / 热部署
      • 应用隔离(如 Tomcat、多应用容器)

  2. 运行时数据区(Runtime Data Area)

    核心职责:给程序运行中的数据分配内存

    • 方法区:类元数据、常量、静态变量
    • 堆:对象实例
    • 虚拟机栈 / 本地方法栈:方法调用、局部变量
    • 程序计数器:线程执行位置

    一句话记忆:

    运行时数据区决定“数据放哪、生命周期多长”

    高频点:

    1. 堆和栈的区别?

      一句话答案:

      堆存对象,栈存方法调用。

      关键点:

      • 堆:线程共享,GC 管理,对象实例
      • 栈:线程私有,方法栈帧,自动回收

    2. 哪些区域线程私有,哪些线程共享?

      一句话答案:

      栈、程序计数器是私有的;堆和方法区是共享的。

      记忆口诀:

      “对象共享,调用私有”


    3. OOM 和 StackOverflowError 的根源?

      一句话答案:

      OOM 是内存不够,SOF 是调用太深。

      对应关系:

      • OOM:堆、方法区内存耗尽
      • StackOverflowError:递归或方法调用层级过深

  1. 执行引擎(Execution Engine)

    核心职责:把字节码真正跑起来

    • 解释执行:启动快
    • JIT 编译:热点代码转为机器码
    • 运行期优化:内联、逃逸分析、锁消除

    一句话记忆:

    执行引擎决定“怎么跑、跑得快不快”

    高频点:

    1. 解释执行 vs JIT?

      一句话答案:

      解释执行启动快,JIT 执行快。

      关键点:

      • 解释执行:逐条解释字节码
      • JIT:热点代码编译为机器码

    2. 什么是热点代码?

      一句话答案:

      被频繁执行、值得编译优化的代码。

      关键点:

      • JVM 通过计数器识别
      • 热点才会触发 JIT

    3. 为什么 Java 能越跑越快?

      一句话答案:

      因为 JVM 会对热点代码持续做运行期优化。

      核心逻辑:

      • 先解释执行
      • 再 JIT 编译
      • 最终稳定在高性能状态

JVM 为什么一定要“拆模块”?

如果不拆,会出现三个致命问题:

  1. 类加载和内存强耦合,无法支持热加载
  2. 内存和执行强耦合,无法做运行期优化
  3. 执行逻辑固化,JIT、GC 无法演进

而拆分之后,JVM 获得了三种能力:

  • 解耦:加载、存储、执行互不干扰
  • 可扩展:ClassLoader、GC、JIT 都可替换
  • 运行期优化:边跑边分析,动态决策

一句话总结

ClassLoader 负责“引入”
Runtime Data Area 负责“承载”
Execution Engine 负责“执行”

JVM 的所有高级特性(GC、JIT、热加载),
都建立在这三层解耦之上。


三、运行时数据区(JVM 运行时内存结构)

在这里插入图片描述

Image

JVM 的运行时数据区不是随便划的,它直接决定了:

  • GC 怎么做

  • 多线程是否安全

  • OOM 和 StackOverflowError 从哪来


JVM 内存只分两大类

一句话先记住:

  • 线程私有:只和当前线程有关

  • 线程共享:所有线程共同使用

这是理解 GC、并发安全、内存异常 的根本前提。


1. 程序计数器(最小,但不可或缺)

本质

  • 记录当前线程正在执行的字节码位置
  • 线程切换后能从正确位置继续执行

关键特点

  • 线程私有
  • 唯一不会发生 OOM 的内存区域

高频速答

  1. 为什么 Java 多线程切换不会乱?

    一句话答案:

    每个线程都有独立的程序计数器,保存自己的执行位置。


2. 虚拟机栈(排错最常见)

栈的基本单位:栈帧

每一次方法调用都会创建一个栈帧,包含:

  • 局部变量表

  • 操作数栈

  • 动态链接

  • 方法返回地址

局部变量表里有什么?

  • 基本数据类型
  • 对象引用(reference)

关键认知点:

对象在堆中,栈中只保存引用


两类典型异常(必须能区分)

  1. StackOverflowError

    • 方法递归过深

    • 栈帧不断入栈,空间耗尽

  2. OutOfMemoryError(与栈相关)

    • 线程创建过多

    • 每个线程都要分配栈空间


高频速答

  1. 栈为什么是线程私有的?

    一句话答案:

    方法调用链不能被多个线程共享,否则执行状态会混乱。


3. 本地方法栈(补充点)

  • native 方法服务

  • HotSpot 中通常与虚拟机栈合并实现

    主要存储 native 方法执行过程中使用的:

    • 参数
    • 局部变量
    • 返回值
    • JNI 调用相关的上下文信息
    • 底层平台的调用状态。

一般不单独深挖,但要知道它属于线程私有

native 方法是指:方法的实现不在 Java 代码中,而是由 Java 以外的语言(通常是 C / C++)实现的方法。


4. 堆(GC 的主战场)

堆的核心职责

  • 存放对象实例

  • 线程共享

  • GC 管理的主要区域


为什么堆要“分代”?

不是拍脑袋设计,而是基于一个长期统计结论:

绝大多数对象,存活时间非常短

因此 JVM 才采用分代模型来降低 GC 成本。


新生代结构与流转

新生代分为:

  • Eden
  • Survivor From
  • Survivor To

对象流转路径:

Eden → S0 → S1 → Old

每次 Minor GC:

  • 存活对象复制到 Survivor

  • 年龄加 1

  • 达到阈值后晋升老年代


老年代的特点

  • 对象存活时间长

  • GC 次数少,但代价高

  • Full GC 的主要区域


高频速答

  1. 为什么 Minor GC 快,Full GC 慢?

    一句话答案:

    新生代对象少且易回收,老年代对象多且存活时间长。


5. 方法区 / 元空间(类的存储区)

JDK 8 的关键变化

  • 移除永久代(PermGen)

  • 引入 Metaspace(元空间)

为什么要这样改?

永久代的问题:

  • 空间固定

  • 容易 OOM

  • 与堆强耦合

元空间的优势:

  • 使用本地内存

  • 可动态扩展

  • 更适合大量类加载(如 Spring、动态代理)


高频点

  1. 为什么永久代被移除?

    一句话答案:

    避免固定大小限制,提升类加载场景下的稳定性。


总结

计数器保执行,切线程不乱
栈管调用,堆管对象
新生代快收,老年代慢清
类信息不进堆,进元空间

这套内存模型,直接支撑了:

  • GC 设计
  • 并发模型
  • JVM 性能优化

四、对象从创建到回收的完整生命周期

常态:
“你 new 一个对象,JVM 到底干了什么?”


1. 对象创建全过程

标准五步,一步不能少:

  1. 类是否已加载

    没加载先触发类加载(加载、链接、初始化)

  2. 分配内存

    在堆中为对象分配空间

  3. 初始化零值

    实例字段设置默认值(保证对象可用)

  4. 设置对象头

    包含类指针、GC 信息、锁信息等

  5. 执行构造方法

    按代码逻辑完成初始化


高配点

  1. 对象创建的完整过程?

    一句话答案:

    检查类 → 分内存 → 清零 → 设对象头 → 调构造方法


2. 内存分配方式(为什么有两种)

对象分配的前提是:

堆内存是否连续。


  1. 指针碰撞

    适用场景:

    • 堆内存连续
    • 如 Serial、ParNew 收集器

    做法:

    • 维护一个指针
    • 分配时指针向前移动

    特点:

    • 实现简单
    • 分配速度快

  2. 空闲列表

    适用场景:

    • 堆内存不连续
    • 如 CMS、G1

    做法:

    • 维护可用内存块列表
    • 从合适位置分配

    特点:

    • 灵活
    • 维护成本更高

高频点

  1. 指针碰撞和空闲列表的区别?

    一句话答案:

    是否要求堆内存连续。


3. 对象创建为什么是线程安全的?

因为对象创建发生在多线程并发环境下。

JVM 主要提供两种方案:


  • 方案一:CAS + 重试

    • 通过原子操作更新内存指针
    • 失败则重试

    优点:通用
    缺点:高并发下有性能损耗


  • 方案二:TLAB(线程本地分配缓冲区)(默认)

    Thread Local Allocation Buffer

    核心思想:

    • 每个线程在堆中预分配一小块内存
    • 线程内对象分配无需加锁

    特点:

    • 无锁
    • 分配速度快
    • 大多数对象直接在 TLAB 中完成分配

高频点

  1. JVM 默认是否开启 TLAB?

    一句话答案:

    是的,默认开启,用于提升对象分配效率。


五、垃圾回收机制(GC 底层原理)

Image

GC 的核心只解决三件事:

对象死没死 → 怎么回收 → 何时回收得更优


1. 如何判断对象已死?

为什么不用引用计数?

引用计数的问题只有一句话:

循环引用无法回收

两个对象互相引用,但外部已不可达,

引用计数不为 0,却早该被回收。


JVM 的标准答案:可达性分析

核心思想:

从一组“必然存活”的对象出发,看能不能走到目标对象。

这组起点,叫 GC Roots


常见 GC Roots

  • 虚拟机栈中的引用
  • 类静态变量
  • 常量池中的引用
  • JNI(本地方法)引用

结论规则:

从 GC Roots 不可达的对象,才是可回收对象


高频点

  1. JVM 如何判断对象是否可回收?

    一句话答案:

    通过 GC Roots 做可达性分析,不可达即回收。


2. GC 算法为什么要“组合使用”?

因为不同区域的对象特性完全不同。


  • 新生代:复制算法

    特点:

    • 对象存活率低

    • 回收频繁

    优势:

    • 只复制存活对象
    • 回收速度快

    代价:

    • 需要额外空间(Survivor)

  • 老年代:标记-整理算法

    特点:

    • 对象存活率高
    • 不能频繁移动

    优势:

    • 避免内存碎片
    • 适合长期存活对象

    代价:

    • 回收成本高

核心结论

没有万能 GC 算法,只有场景最优组合。


高频点

  1. 为什么新生代和老年代用不同算法?

    一句话答案:

    因为对象存活率不同,回收策略必须不同。


3. G1 垃圾回收器(主流)

G1 的出现,本质是为了解决一个问题:

在大堆内存下,如何控制 GC 停顿时间?


G1 的核心设计思想

  • 把整个堆拆成多个 Region
  • Region 可以是新生代,也可以是老年代
  • 不再固定按代整体回收

一句话理解:

不按“代”收,按“价值”收


什么是“回收价值”?

综合考虑:

  • Region 中垃圾比例

  • 回收收益

  • 回收成本

优先回收 性价比最高 的 Region。


为什么 G1 停顿时间可控?

核心原因只有两点:

  • 每次只回收部分 Region

  • 可以根据目标停顿时间做回收计划

这也是 G1 名字的来源:
Garbage First(优先回收垃圾最多的区域)。


高频点

  1. G1 为什么能控制停顿时间?

    一句话答案:

    通过 Region 化和按回收价值选择回收范围。


总结

  1. 死没死看 Roots

  2. 算法选型看存活率

  3. G1 不按代,按价值收


六、内存泄漏

1. 内存泄漏的本质定义

一句话定义:

对象已经没有业务意义,但仍然被 GC Roots 间接或直接引用。

关键点要说清楚:

  • 不是“内存不够”

  • 而是引用关系断不开

  • GC 按规则工作,但规则失效于设计错误


高频点

  1. 什么是内存泄漏?

    一句话答案:

    对象该死但没死,因为还在 GC Roots 引用链上。


2. 常见泄漏场景的底层原因

  1. 静态引用(最基础)

    为什么会泄漏?

    • 静态变量生命周期 = JVM 生命周期
    • 天然属于 GC Roots

    一旦静态集合、静态缓存不断增长:

    对象永远不可回收


    高频点

    1. 为什么静态变量容易导致内存泄漏?

      一句话答案:

      因为它们是 GC Roots,生命周期过长。


  1. ThreadLocal 泄漏

    问题不在 ThreadLocal,而在线程池。

    底层结构:

    • key:ThreadLocal(弱引用)
    • value:业务对象(强引用)

    当:

    • ThreadLocal 被回收
    • 线程长期存活(线程池)

    结果:

    value 仍被线程引用,无法回收


    正确姿势(必须说)

    • 使用后 finally 中调用 remove()

    高频点

    1. ThreadLocal 为什么会内存泄漏?

      一句话答案:

      线程不结束,value 强引用还在。


  1. 连接、流未关闭

    典型对象:

    • JDBC 连接
    • Socket
    • IO 流

    问题本质:

    • 不仅占 JVM 堆
    • 还占用 操作系统资源

    这种泄漏,往往比 OOM 更危险


    高频点

    1. 为什么流不关闭问题严重?

      一句话答案:

      既泄漏内存,又泄漏系统资源。


3. 内存泄漏与 OOM 的关系

一句话因果关系:

内存泄漏是原因,OOM 是最终结果。

  • 少量泄漏 → 系统还能撑
  • 长期泄漏 → 必然 OOM

高频点

OOM 往往不是“突然发生”,
而是泄漏长期积累的必然结果。


七、逃逸分析(JIT 的核心优化能力)

让 JVM 判断“对象需不需要进堆”。


1. 什么是逃逸?

标准定义:对象是否可能被方法之外访问。


不逃逸的典型特征

  • 只在方法内部使用

  • 不作为返回值

  • 不赋值给外部变量

一句话记忆:

出不了方法的对象,就是不逃逸。


高频点

  1. 什么是逃逸分析?

    一句话答案:

    JVM 判断对象作用域是否超出方法范围。


2. JVM 为什么关心“逃逸”?

因为一旦确认 不逃逸

对象就没必要进堆。

直接收益:

  • 减少堆内存分配

  • 减少 GC 压力

  • 提升整体性能


高频点

  1. 逃逸分析的核心目的是什么?

    一句话答案:

    减少对象进入堆,从而减少 GC。


3. 三大核心优化

1. 栈上分配

结论型描述:

  • 对象直接分配在栈上
  • 方法结束自动销毁
  • 不参与 GC

适用前提:

  • 对象不逃逸

高频点

  1. 什么是栈上分配?

    一句话答案:

    不逃逸对象直接在栈上分配。

    JVM 在逃逸分析后,不再创建对象, 而是把对象字段拆成标量,可能分布在栈帧、操作数栈甚至寄存器中。


2. 标量替换(优化力度最大)

核心思想:

对象可以拆,就没必要存在。

做法:

  • 把对象拆成多个基本类型变量
  • 对象本身彻底消失

效果:

  • 零对象创建
  • 零 GC 压力

高频点

  1. 什么是标量替换?

    一句话答案:

    把对象拆成基本类型,彻底消除对象。


3. 锁消除

适用场景:

  • 锁对象不逃逸

  • 没有多线程竞争

JIT 判断后:

直接移除 synchronized 锁。


高频点

  1. JVM 为什么能安全地消除锁?

    一句话答案:

    因为逃逸分析确认不存在并发访问。


总结

逃不逃,看范围
不进堆,GC 少
栈分配、标量换、锁直接消


八、JVM 调优与问题排查


1. 常见 OOM 类型(先会分类)

  1. Java heap space

    一句话含义:

    堆中对象过多,GC 回收不过来。

    常见原因:

    • 内存泄漏
    • 大对象加载
    • 缓存无限增长

  2. Metaspace

    一句话含义:

    类加载太多,元空间被打满。

    常见原因:

    • 动态代理 / CGLIB 过多
    • 类加载器无法回收
    • 热部署、插件化使用不当

  3. GC overhead limit exceeded

一句话含义:

GC 拼命干活,但几乎回收不到内存。

本质判断:

系统已经处在“濒死状态”


高频点

  1. OOM 常见有哪些类型?

    一句话答案:

    堆、元空间、GC 过载。


2. 排查 OOM 的标准流程

这一步比“调参”重要十倍。


标准四步法

  1. 确认 OOM 类型

    先判断是堆、元空间,还是 GC 问题

  2. Dump 内存快照

    OOM 时或手动触发 Heap Dump

  3. 分析对象引用链

    找出占用最多、不可回收的对象

  4. 定位代码根因

    回到代码,解决引用问题,而不是只扩容


高频点

  1. 线上 OOM 你怎么排查?

    一句话答案:

    先定类型,再看 Dump,最后回到代码。


3. 常用 JVM 排查工具

不要求你精通,但必须知道用来干什么


  1. jps

    • 查看 JVM 进程

    • 快速确认目标进程 ID

    一句话:

    找 JVM 进程用的。


  2. jstack

    • 查看线程栈

    • 排查死锁、线程阻塞

    一句话:

    看线程状态。


  3. jmap

    • Dump 堆内存
    • 查看对象分布

    一句话:

    看堆里的对象。


  4. jvisualvm

    • 图形化监控
    • 内存、线程、GC 情况

    一句话:

    本地/测试环境快速分析。


  5. arthas

    • 线上诊断
    • 方法调用、参数、返回值
    • 无需重启

    一句话:
    线上救火神器。


总结

OOM 先分型

排查四步走

jstack 看线程,jmap 看对象,arthas 救线上


九、总结

  • JVM 不是简单的虚拟机,而是一个持续做运行期优化的系统

  • GC 的本质不是“回收内存”,而是分析对象之间的引用关系

  • 内存泄漏不是没 GC,而是对象仍在 GC Roots 引用链上

  • 逃逸分析是 JIT 性能优化的前提,没有它就没有高效优化

  • G1 通过 Region 化和按价值回收,成为当前服务端主流选择


十、JVM高频问题

10.1 对象为什么优先分配在 Eden,而不是一上来就进老年代?

问题:

既然对象最终都可能进入老年代,为什么 JVM 不直接把对象放到老年代?

解答:

因为 JVM 的一个核心经验假设是:

绝大多数对象,生命周期都很短

也就是说,很多对象只是:

  • 方法里临时创建
  • 请求处理中短暂使用
  • 一轮计算后立刻失效

如果这些对象一开始就进入老年代,会带来两个问题:

  1. 老年代膨胀过快
  2. Full GC 压力明显增大

而新生代恰恰就是为“短命对象”设计的:

  • 分配快
  • 回收频繁
  • 大量对象可以一次性清掉

所以对象优先进入 Eden,本质上是在做一件事:

让短命对象尽量死在便宜的区域里

这就是分代回收的价值。

重点结论:

对象优先进入 Eden,不是流程规定,而是因为“先放在便宜、适合快速回收的地方”成本最低。


10.2 什么对象会直接进入老年代?

问题:

是不是所有对象都必须经历 Eden → Survivor → Old 的过程?

解答:

不是。

虽然大多数对象都会先进入新生代,但在某些场景下,对象可能直接进入老年代。

常见情况有三类:

  1. 大对象
    比如特别大的数组、超大的字节缓冲区。
    因为这类对象如果先放到 Eden,再复制多次,成本太高。

  2. 长期存活对象晋升
    对象经过多次 Minor GC 仍然存活,达到晋升年龄阈值后进入老年代。

  3. Survivor 放不下
    如果 Survivor 区空间不足,部分对象可能提前晋升。

所以对象进入老年代,不一定代表“它很老”,有时只是:

继续在新生代折腾成本更高

重点结论:

老年代不只是“老对象存储区”,也可能是“大对象”或“提前晋升对象”的承载区。


10.3 对象晋升老年代的依据到底是什么?

问题:

对象什么时候会从新生代进入老年代?

解答:

最常见的依据是:

年龄

每次对象在 Minor GC 后仍然活着,年龄就会加 1。

当年龄达到阈值时,就会晋升到老年代。

这个阈值并不是死板固定地只看一个参数,因为 JVM 还可能做:

  • 动态年龄判断

也就是说,如果 Survivor 里某个年龄及以上的对象总大小,已经超过 Survivor 一半,那么这批对象可能提前晋升。

这样做的目的,是避免 Survivor 区反复挤压、复制成本过高。

重点结论:

对象晋升不只是“活够次数”,还取决于 Survivor 的空间压力和整体复制成本。


10.4 为什么 Minor GC 频繁不一定危险,而 Full GC 频繁通常很危险?

问题:

GC 一多是不是就说明 JVM 有问题?

解答:

不能一概而论。

Minor GC

新生代回收,本来就会比较频繁。

如果:

  • 回收时间短
  • 回收后空间释放明显
  • 对业务线程影响可控

那 Minor GC 多一点并不一定是坏事。


Full GC

Full GC 通常意味着:

  • 回收范围更大
  • 停顿更长
  • 代价更高

如果 Full GC 频繁,通常意味着以下几类问题:

  1. 老年代压力过大
  2. 对象晋升过快
  3. 大对象太多
  4. 内存泄漏
  5. 配置不合理
  6. 元空间压力过高

所以 JVM 调优里,一个非常关键的判断是:

不是看 GC 有没有,而是看它回收得值不值、停顿重不重、频率高不高

在压测引擎实践中也明确提到,Full GC 频繁会直接导致压测曲线波动、QPS 压不上去

重点结论:

Minor GC 频繁不一定有问题,但 Full GC 频繁通常说明内存结构或对象生命周期已经失衡。


10.5 什么情况下会触发 Full GC?

问题:

线上一旦出现 Full GC,通常意味着什么?

解答:

Full GC 不会无缘无故出现,常见触发条件通常有这些:

  1. 老年代空间不足
  2. 大对象或晋升对象放不下
  3. 显式调用 System.gc()
  4. 元空间不足
  5. 某些收集器在回收失败后退化为 Full GC
  6. 老年代碎片严重,无法分配连续空间

对于 G1 来说,还要特别注意一种情况:

  • 本来希望做可控停顿回收
  • 但回收赶不上分配速度
  • 最后可能退化为 Full GC

这也是很多线上服务最怕的场景之一。

重点结论:

Full GC 通常不是“正常回收动作”,而是 JVM 在告诉你:某个区域已经撑不住了。


10.6 Stop-The-World 为什么不可避免?

问题:

GC 为什么一定要 Stop-The-World?不能边跑业务边完全无停顿回收吗?

解答:

因为 GC 要先回答一个前提问题:

哪些对象还活着?

而对象引用关系在多线程程序里是持续变化的。

如果你在回收过程中,业务线程还在不断修改对象图,就会出现两个问题:

  1. 刚判断完对象可达性,引用关系又变了
  2. 回收结果可能不一致,甚至误删活对象

所以 JVM 必须在某些关键阶段让用户线程停下来,至少保证:

  • 对象图在这一刻是稳定的
  • Roots 枚举是可靠的
  • 某些关键标记阶段可以安全完成

不同收集器只是:

  • 缩短 STW
  • 拆散 STW
  • 把更多工作挪到并发阶段

而不是“完全消灭 STW”。

Oracle 的 G1 调优文档也强调,G1 的目标是控制停顿时间,不是让停顿彻底消失。
重点结论:

STW 不是 JVM 设计得不够先进,而是垃圾回收要获得一致对象视图时必须支付的代价。


10.7 G1 为什么能控制停顿时间?

问题:

为什么大家常说 G1 “停顿可控”?

解答:

因为 G1 不再像传统分代收集器那样,固定对一整块区域做统一回收。

它的核心变化是:

把堆拆成很多 Region,再按收益优先选择回收集合

这带来两个关键能力:

  1. 每次不必回收整个新生代或整个老年代
  2. 可以根据目标停顿时间,控制本轮回收多少 Region

所以 G1 的“可控”不是说:

  • 它一定很快
  • 它一定没有长停顿

而是说:

它会尽量按照你给的停顿目标做回收计划

当然,这只是“尽量”,不是绝对保证。

如果分配速度太快、活对象太多、回收赶不上分配,G1 也可能失控,甚至退化。

重点结论:

G1 的核心优势不是“绝对低延迟”,而是“基于 Region 和收益模型,尽量把停顿控制在目标范围内”。


10.8 JVM 为什么会出现“GC 很努力,但内存就是回不下来”?

问题:

有时候 GC 很频繁,但堆占用还是居高不下,这说明什么?

解答:

这通常意味着两种可能。

第一种:对象真的还活着

比如:

  • 缓存还在引用
  • 会话对象还在引用
  • 线程池任务链还在引用
  • 静态集合持续持有对象

GC 不能回收,不是因为它没工作,而是因为:

这些对象从 GC Roots 出发仍然可达


第二种:发生了内存泄漏或接近泄漏

典型特征是:

  • Full GC 后内存回落很少
  • 老年代占用一直升高
  • 时间越久越严重

这时经常会出现:

  • GC overhead limit exceeded
  • Full GC 越来越频繁
  • 最终 OOM

重点结论:

GC 回不下来,不代表 GC 差,往往意味着对象生命周期设计出了问题。


10.9 类加载器为什么会导致内存泄漏?

问题:

通常说内存泄漏,大家先想到的是“某个对象没被释放”。
那类加载器为什么也会导致内存泄漏?它泄漏的到底是什么?

解答:

类加载器当然也是对象,但它特殊在于:它不是一个普通对象,而是很多运行时资源的“入口”和“归属者”

一个类一旦被某个 ClassLoader 加载,和这个类相关的一整套运行时数据,通常都会跟这个类加载器绑定在一起,例如:

  • Class 元数据
  • 方法元数据
  • 静态变量
  • 运行时常量池
  • 反射缓存
  • 注解信息
  • 动态生成的代理类
  • Thread ContextClassLoader 关联资源
  • 某些框架内部缓存

所以一旦类加载器本身回收不掉,它加载过的这一批类及其关联资源往往也很难释放,最终就形成了内存泄漏。

为什么会“泄漏一大片”?

因为 GC 判断对象能不能回收,看的是引用链是否还连着

只要还有某个地方强引用着这个类加载器,比如:

  • 某个静态集合里还存着它加载的对象
  • 某个线程还在跑,线程上下文 ClassLoader 还是旧的
  • 某个 ThreadLocal 没清理
  • 某个 JDBC Driver、SPI 实现、日志组件还注册着
  • 某个缓存框架把旧类的 Class 或实例缓存住了

那么这个类加载器就不能回收。

而类加载器不能回收,就意味着:

  • 它加载的类不能回收
  • 这些类的静态字段不能回收
  • 这些类关联的反射信息、代理类、元数据也不能回收

所以类加载器泄漏最麻烦的地方就在于:

泄漏的不是一个对象,而是一整批类和它们背后的资源。

常见发生场景

这类问题最常出现在“类会被反复加载”的场景里:

  • 热部署
  • Tomcat / Spring Boot DevTools 反复重启
  • OSGi / 插件化架构
  • 脚本引擎、动态模块加载
  • 动态代理或字节码增强频繁生成类
  • 应用服务器反复发布/卸载 Web 应用

这些场景里,系统往往会创建新的类加载器来加载新版本代码。
如果旧类加载器因为某些引用没断开,就会一直残留在内存里。

随着反复部署:

  • 旧类加载器保留一份旧类
  • 新类加载器再加载一份新类
  • 一轮轮叠加

最终就会出现:

  • Metaspace 持续增长
  • Full GC 频繁但回收效果差
  • 多次重启后内存越来越高
  • 最后出现 OutOfMemoryError: Metaspace

为什么它不只是元空间问题?

很多人会觉得类加载器泄漏只会影响 Metaspace,其实不止。

因为类加载器加载的类可能还关联着堆里的对象,比如:

  • 静态缓存中的大对象
  • 单例 Bean
  • ThreadLocal 中的业务数据
  • 连接池、线程池、监听器等资源对象

所以类加载器泄漏经常会表现为:

  • Metaspace 增长
  • Java 堆也增长
  • GC 压力变大
  • 卸载类效果很差

也就是说,它既可能拖住类元数据,也可能拖住普通堆对象

一个典型例子

比如 Web 容器里部署了一个应用,容器给它分配了一个独立的 WebAppClassLoader
应用停止时,本来这个类加载器应该被回收。

但如果应用里有一个线程池中的线程一直没停,而且线程的 contextClassLoader 还指向这个旧的 WebAppClassLoader,那么:

  • 线程活着
  • 线程引用着旧类加载器
  • 旧类加载器引用着它加载的所有类
  • 所有这些类的静态数据和相关资源也一起活着

结果就是:应用虽然“卸载”了,但内存里的那一套东西其实还在。

重点结论

类加载器导致内存泄漏,本质上不是因为“类很多”,而是因为:

旧类加载器本该随着旧应用一起回收,但由于引用链没有断开,导致它以及它加载的整套类和资源都无法释放。

所以排查这类问题时,真正要看的不是“哪个对象大”,而是:

  • 哪个类加载器还活着
  • 谁在引用它
  • 哪些线程、静态变量、缓存、注册表没有清理

10.10 ThreadLocal 为什么在线程池里特别危险?

问题:

ThreadLocal 明明是线程隔离工具,为什么却经常和内存泄漏一起出现?

解答:

因为 ThreadLocal 最大的问题不是“线程本地”,而是:

线程活得太久

在线程池场景里,线程通常不会结束。

而 ThreadLocalMap 的结构特点又是:

  • key 是弱引用
  • value 是强引用

如果:

  • ThreadLocal 对象本身没了
  • 但线程还活着
  • 又没有及时 remove()

那 value 就可能继续留在这个线程里。

于是你会看到:

  • 单次请求看不出问题
  • 长时间运行后内存慢慢涨
  • Full GC 也回不掉

这也是很多线上服务里特别常见的隐性问题。

重点结论:

ThreadLocal 真正危险的不是“线程隔离”,而是“线程池线程长期存活,导致 value 被长期挂住”。


10.11 为什么线上 CPU 飙高时,不能只盯着 GC?

问题:

服务 CPU 一高,很多人第一反应就是“是不是 GC 出问题了”,这为什么不够?

解答:

因为 CPU 飙高可能来自很多地方,GC 只是其中一种。

常见来源包括:

  1. GC 线程忙
  2. 业务线程死循环
  3. 锁竞争导致自旋
  4. JIT 编译线程活跃
  5. 日志刷太猛
  6. 第三方库异常行为
  7. 频繁异常创建和堆栈打印

所以排查 CPU 飙高时,正确顺序通常是:

  1. 先看进程级 CPU
  2. 再看线程级 CPU
  3. jstack 对高 CPU 线程做栈分析
  4. 再判断是 GC、业务线程还是其他后台线程

这也是为什么很多大厂线上排障并不是一上来就看堆,而是:

先分清楚“谁在烧 CPU”

重点结论:

CPU 飙高不等于 GC 异常,真正关键的是先定位“高 CPU 的线程是谁”。


10.12 频繁 Full GC 时,第一反应应该看什么?

问题:

线上如果频繁 Full GC,最先应该检查什么?

解答:

不要一上来就调参数。

更有效的顺序通常是:

  1. 看老年代占用是否持续上升
  2. 看 Full GC 后内存是否明显回落
  3. 看是否存在大对象、缓存堆积、静态集合
  4. 看是否有长事务、长生命周期对象
  5. 看是否发生类加载膨胀或元空间压力
  6. 结合 Heap Dump 找真正的大对象引用链

其中最关键的判断是:

Full GC 后到底回没回下来

如果回得下来,说明更多是对象分配和代际配置问题。

如果回不下来,说明更像:

  • 内存泄漏
  • 生命周期过长
  • 不合理缓存
  • ThreadLocal / ClassLoader 问题

重点结论:

频繁 Full GC 最重要的不是“次数多”,而是“回收之后到底释放了多少”。


10.13 JVM 调优为什么不能一上来就改参数?

问题:

很多人一看到 GC 问题就开始改 -Xmx、改收集器、改暂停目标,这为什么经常没用?

解答:

因为 JVM 参数只是放大器,不是根因修复器。

如果真正的问题是:

  • 内存泄漏
  • ThreadLocal 没清
  • 缓存无限增长
  • 类加载器泄漏
  • 业务对象生命周期设计错误

那你把堆调大,只会让问题晚一点爆。

所以 JVM 调优真正正确的顺序通常是:

  1. 先确认现象
    是 OOM、频繁 GC、长停顿,还是 CPU 飙高

  2. 再确认区域
    是堆、元空间、直接内存、线程栈,还是 Code Cache

  3. 再找对象或线程证据
    Heap Dump、线程栈、NMT、GC 日志

  4. 最后才考虑调参数
    用参数去适配业务,而不是拿参数掩盖问题

重点结论:

JVM 调优不是“会背参数”,而是“先找根因,再决定参数有没有必要动”。


10.14 为什么说“GC 问题很多时候不是 GC 本身的问题”?

问题:

线上一旦出现 GC 抖动,为什么很多有经验的人第一反应不是换 GC?

解答:

因为 GC 常常只是结果,不是根因。

GC 抖动背后的真实来源,往往是这些:

  • 对象创建过快
  • 缓存设计失控
  • 大对象太多
  • 长生命周期对象过多
  • ThreadLocal 没清
  • 类加载器泄漏
  • 业务流量模型变化

也就是说:

GC 只是把“对象分配和生命周期问题”表现出来了

如果你只是换收集器,而不处理对象模型问题,那么通常只会:

  • 暂时缓解
  • 不会根治

在 CMS 和 ZGC 的实践文章里,也都强调了一个共同点:

真正影响停顿和 GC 表现的,不只是回收器本身,更是业务对象分布、存活比例和分配速率。

重点结论:

很多 GC 问题表面上是“回收器表现差”,本质上其实是“对象生命周期设计失衡”。


10.15 本章总结

JVM 相关问题看起来很多,但本质上都可以归纳为几类:

  1. 对象分配首先是生命周期问题,不是单纯内存空间问题
  2. Minor GC 和 Full GC 的意义完全不同,不能混着看
  3. STW 不是异常,而是 GC 获得一致对象视图的必要代价
  4. G1 的核心不是“绝对低延迟”,而是“按 Region 收、尽量控停顿”
  5. 内存泄漏不是 GC 失效,而是对象引用链没有断开
  6. ThreadLocal 和类加载器泄漏,是线上最隐蔽也最常见的两类 JVM 问题
  7. CPU 飙高、频繁 GC、内存上涨,背后根因往往不是 JVM 参数,而是对象生命周期和引用关系设计问题
  8. JVM 调优真正难的地方,不在参数,而在根因定位

JVM 真正难的地方,不在于:

  • 会不会背 Eden、Survivor、Old
  • 会不会说 G1、CMS、ZGC
  • 会不会列几条调优参数

而在于你能不能真正看清:

  • 对象为什么活得太久
  • GC 为什么回不动
  • Full GC 为什么会发生
  • 停顿为什么会拉长
  • 类和线程为什么把内存拖住
  • 线上现象背后到底是谁在制造压力

最终核心结论:

JVM 的使用难点,不在概念,而在于你能不能站在“对象分配、对象存活、GC 回收、类加载、线程生命周期”这几个层面,真正理解它为什么快、为什么慢、以及为什么会出问题。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐