JVM 运行时全景:从类加载到 GC 的底层原理与调优指南
目录
一、运行时数据区域
1.线程私有的
(1)栈
①本地方法栈
②虚拟机栈:存储方法调用的栈帧(方法运行时的内存模型)
(2)程序计数器
2.线程共享
(1)方法区
(2)堆
二、对象是如何被分配空间的
1.规整的
①指针碰撞
2.不规整的
②空闲列表
三、 对象组成(内存布局)
1.对象头
①markword(标记字)
②类型指针
③数组长度
2.实例数据
3.对齐填充
四、 如何访问对象
1.句柄
2.直接指针
五、 引起内存溢出的场景
1. 什么是内存溢出(OOM)?和内存泄漏有什么区别?
2. Java 中哪些区域会发生 OOM?对应的错误是什么?
3. 你遇到过哪些实际的内存溢出场景?如何解决的?
4. 如何排查内存溢出问题?
5. JVM 参数如何优化避免 OOM?
6. 如何避免内存泄漏?
7. 如果线上系统突然出现 OOM,你会如何紧急处理?
8. 经典陷阱题:String.substring() 在 Java 1.6 和 1.7+ 的区别?
六、JVM常见命令
1. 进程查看与基本信息
2. 内存分析
3. 线程分析
4. 实战场景
5. 关键面试点
七、 JVM调优案例
八、 垃圾删除
1.先判断哪些是垃圾(判断生死)
再谈引用
2.回收三个假说?
3.收集算法?
1)标记清除
工作原理:
2)标记复制
工作原理:
3)标记整理算法
工作原理:
4.收集器
九、 内存分配+回收规则
十、 类加载机制
1.类加载过程
十一、 双亲委派
1.双亲委派(⭐)
2.破坏双亲委派
双亲委派的工作流程
十二、 编译优化
JVM 是一个虚拟的计算机,它运行在操作系统之上,负责执行 Java 字节码(.class 文件)。JVM 屏蔽了底层操作系统和硬件的差异,使得 Java 程序可以在不同平台上运行而无需修改。
一、运行时数据区域

1.线程私有的
(1)栈
①本地方法栈
②虚拟机栈:存储方法调用的栈帧(方法运行时的内存模型)
栈帧:栈帧(Stack Frame) 是 Java 虚拟机栈(JVM Stack)中的基本单位。
每当一个方法被调用时,JVM 就会创建一个对应的栈帧,并将其压入当前线程的虚拟机栈中。当方法执行完毕后,这个栈帧就会被弹出并销毁。
虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Nat非Java)方法服务。
(2)程序计数器
程序计数器指向下一行要执行的代码行号——唯一一个没有规定OOM(OutOfMemoryError--内存空间不足),即在程序计数器这个区域中没有明确规定会造成OOM异常的情况。
2.线程共享
(1)方法区
存储类信息、常量、静态变量、即时编译器编译后的代码缓存等。
在JVM启动时创建,逻辑上属于堆的一部分。
(2)堆
存放对象实例和数组(Java中几乎所有对象实例都在这里分配内存)
是垃圾回收(GC)的主要区域,细分为:
新生代(Young Generation):新创建的对象
老年代(Old Generation):存活时间长的对象
(可选)大对象直接进入老年代
二、对象是如何被分配空间的
TLAB (默认是开启的)是一种线程局部分配加速机制;真正决定“怎么把对象放进内存”的底层算法有两种——指针碰撞和空闲列表。
💡如果有 TLAB(默认是开启的)• JVM 先在 Eden 里用一次 指针碰撞 划出整块 TLAB 给线程;• 之后线程在自己的 TLAB 里继续用 指针碰撞 给对象分配空间,无锁、极快;• TLAB 放不下时,再“回退”到 Eden 公共区,用 指针碰撞 或 空闲列表 重新分配(取决于 GC 算法)💡如果关闭 TLAB• 线程每次都在 Eden 公共区直接用 指针碰撞 或 空闲列表,并用 CAS 保证线程安全。
1.规整的
①指针碰撞
通过TLAB(Thread-Local Allocation Buffer)避免多线程竞争,维护一个指针(称为“分界指针”),指向已分配内存和空闲内存的交界地址。
TLAB:是 JVM 为每个线程在堆中分配的私有内存区域,用于无锁快速分配对象。
2.不规整的
②空闲列表
加锁或使用无锁数据结构(如CAS(Compare-And-Swap)操作)
维护一个链表,记录所有空闲内存块的起始地址和大小。
特性 | 指针碰撞 | 空闲列表 |
内存状态 | 规整(连续空闲) | 不规整(离散空闲) |
分配速度 | 极快(O(1)) | 较慢(O(n),依赖查找策略) |
碎片问题 | 无 | 可能产生外部碎片 |
适用场景 | 新生代(存活对象少) | 老年代(存活对象多) |
三、 对象组成(内存布局)
1.对象头
①markword(标记字)
一个有着动态定义的数据结构:存储对象的运行状态信息,如哈希表、锁状态、GC分代年龄等等。
②类型指针
指向对象所属类的元数据,JVM通过这个指针确定对象是哪个类的实例。
③数组长度
如果是数组的话,还额外存储了数组的长度。
2.实例数据
存储对象的字段值,包括从父类继承的字段,也就是对象真正存储的有效信息。
3.对齐填充
保证对象总长是8字节的整数倍。
四、 如何访问对象
HotSpot = 官方 JVM + JIT 编译器 + 多种 GC + 自适应优化器
句柄池在早期存在32位机,但是现在句柄池被淘汰了,不管是32还是64位机,官方的JDK只剩下直接指针一种。
1.句柄
- Java 堆中会划分出一块内存作为句柄池(Handle Pool)。
- 每个句柄包含两个指针:
a.对象实例数据地址 —— 指向 堆 里真正的对象本体(实例数据+对象头)。
b.对象所属类的元数据地址 —— 指向 元空间(JDK8 以前叫永久代)里的 Klass 结构,保存字段布局、方法表、常量池等类信息。
- 引用变量(reference)存储的是句柄地址,而不是对象实际地址。
工作流程:引用变量 → 找到句柄 → 通过句柄.a 拿到对象 → 通过句柄.b 拿到类元数据。
优点:在对象移动时优势明显,即只需要修改句柄中的指针。
2.直接指针
- 引用变量直接存储对象本体的地址。
- 对象本身在堆中,包含对象头和实例数据。
- 对象头中包含指向类元数据的指针(在方法区/元空间)。
优点:定位更快
五、 引起内存溢出的场景
1. 什么是内存溢出(OOM)?和内存泄漏有什么区别?
- 回答要点:
- 内存溢出(OOM):程序申请内存时,JVM 没有足够的空间分配(堆/栈/方法区等),抛出 OutOfMemoryError。
- 内存泄漏(Memory Leak):对象不再使用,但因错误引用无法被 GC 回收,长期积累导致 OOM。
- 关键区别:内存泄漏是原因,内存溢出是结果。
2. Java 中哪些区域会发生 OOM?对应的错误是什么?
- 经典回答(结合 JVM 内存结构):
- 堆内存(Heap):java.lang.OutOfMemoryError: Java heap space(对象实例过多、大数组、内存泄漏)
- 方法区/元空间(Metaspace):java.lang.OutOfMemoryError: Metaspace(动态生成类过多,如 CGLIB 代理)
- 栈内存(Stack):java.lang.StackOverflowError(递归调用过深)(注意:StackOverflowError 是 OOM 的一种)
- 直接内存(Direct Memory):java.lang.OutOfMemoryError: Direct buffer memory(NIO 的 ByteBuffer.allocateDirect() 未限制)
- 线程栈:java.lang.OutOfMemoryError: unable to create new native thread(线程数超过系统限制,严格来说属于系统资源耗尽)。
3. 你遇到过哪些实际的内存溢出场景?如何解决的?
- 回答技巧:结合真实案例(如果没有,用经典场景):
- 案例1:内存泄漏场景:静态 HashMap 缓存数据,未清理过期条目。解决:改用弱引用(WeakHashMap)或定时清理策略(如 LRU)。
- 案例2:大文件处理场景:一次性读取 1GB 文件到内存,导致堆溢出。解决:改用流式读取(如 BufferedReader 逐行处理)。
- 案例3:高并发下的 OOM场景:线程池任务队列堆积,每个任务持有大对象。解决:限制队列大小(如 new ThreadPoolExecutor 指定 RejectedExecutionHandler)。
4. 如何排查内存溢出问题?
- 标准流程:
- 确认错误类型:通过日志看是哪种 OOM(堆/元空间/直接内存)。
- 导出堆转储(Heap Dump):JVM 参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof。
- 分析工具:
-
- MAT(Memory Analyzer Tool):查找占用内存最大的对象。
- JVisualVM:可视化查看对象引用链。
- 定位代码:根据引用链找到泄漏点(如静态集合、未关闭连接)。
- 复现与验证:通过压力测试或模拟场景验证修复效果。
5. JVM 参数如何优化避免 OOM?
- 高频参数:
- 堆内存:-Xms512m -Xmx1024m(初始堆和最大堆,建议设为相同值避免动态扩容)。
- 元空间:-XX:MaxMetaspaceSize=256m(防止动态类加载耗尽内存)。
- 直接内存:-XX:MaxDirectMemorySize=256m(限制 NIO 直接内存)。
- GC 策略:-XX:+UseG1GC(G1 适合大堆和低延迟场景)。
6. 如何避免内存泄漏?
- 代码层面的预防措施:
- 及时释放资源:用 try-with-resources 关闭文件、数据库连接。
- 谨慎使用静态集合:避免用 static Map 缓存数据,或用弱引用。
- 监听器与回调:在对象销毁时注销监听(如 Android 的 Activity 泄漏)。
- 避免长生命周期对象引用短生命周期对象(如全局单例持有 Activity 引用)。
7. 如果线上系统突然出现 OOM,你会如何紧急处理?
- 应急方案:
- 快速重启服务:临时恢复可用性(但需后续排查)。
- 保留现场:
-
- 添加 JVM 参数生成堆转储文件。
- 通过 jmap -dump:format=b,file=/tmp/heap.hprof 手动导出堆内存。
- 降级策略:关闭非核心功能(如限流、禁用缓存)。
- 监控报警:配置 Prometheus + Grafana 监控堆内存使用率。
8. 经典陷阱题:String.substring() 在 Java 1.6 和 1.7+ 的区别?
- 答案:
- Java 1.6:substring 会共享原字符串的 char[],可能导致原大字符串无法被 GC(内存泄漏)。
- Java 1.7+:substring 创建新的 char[],避免内存泄漏。
- 面试点:考察对 JDK 底层实现变化的了解。
六、JVM常见命令
1. 进程查看与基本信息
- jps:查看 Java 进程(-l 显示主类,-v 显示 JVM 参数)
- jinfo:查看/动态修改 JVM 参数(如 jinfo -flags )
2. 内存分析
- jmap:
- jmap -heap :查看堆内存分配
- jmap -histo :统计对象内存占用
- jmap -dump:format=b,file=heap.hprof :导出堆转储(OOM 分析)
- jstat:监控 GC 情况(如 jstat -gc 1000 5,每 1 秒输出 1 次,共 5 次)
3. 线程分析
- jstack:
- jstack :导出线程栈(查死锁、CPU 飙高)
- jstack -l :显示锁信息(分析竞争)
- jcmd(Java 7+ 全能命令):
- jcmd Thread.print:等效于 jstack
- jcmd GC.heap_info:查看堆状态
4. 实战场景
- OOM 排查:jmap -dump + MAT 分析
- GC 问题:jstat -gc 观察 GC 次数/耗时
- 死锁/高 CPU:jstack + top -Hp
5. 关键面试点
- 区分命令用途:
- jmap 看内存,jstack 看线程,jstat 看 GC。
七、 JVM调优案例
根据应用特点、硬件资源和性能目标,调整 JVM 运行参数、GC 算法、内存布局、JIT 编译选项等,使 Java 程序在吞吐量、延迟、内存占用之间达到期望平衡点 的过程。
八、 垃圾删除
1.先判断哪些是垃圾(判断生死)
脑门刻字法/引用计数法:计数+1-1,为零就回收(但是容易出现循环嵌套导致一个循环的垃圾计数用不为0)。
平地长树法/可达性分析:选定一系列的对象作为根。
再谈引用
引用类型 | 定义与作用 | 垃圾回收时机 | 典型用途 | 是否会导致内存泄漏 |
强引用 | 最常见的引用类型,通过new创建的对象默认就是强引用 | 只要强引用存在,永远不会被回收 | 普通对象引用 | 否(除非引用未释放) |
软引用 | 描述一些还有用但非必须的对象,使用SoftReference包装 | 内存不足时会被回收 | 缓存(如图片缓存、网页缓存) | 否 |
弱引用 | 描述非必须对象,比软引用更弱,使用WeakReference包装 | 下一次垃圾回收时就会被回收 | 缓存、监听器、ThreadLocal 的键 | 否 |
虚引用 | 最弱的引用类型,几乎不影响对象生命周期,使用PhantomReference包装 | 随时可能被回收,主要用于跟踪回收 | 管理堆外内存、对象回收前的清理工作 | 否 |
2.回收三个假说?
分代收集理论:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
1)强分代假说:熬过越多垃圾收集过程的对象越难消亡。
2)弱分代假说:大部分对象朝生暮死。
3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
3.收集算法?
1)标记清除
- 简单(优点)
- 空间碎片问题(缺点)
- stop the world(最大的缺点--迄今为止还没有解决,只能尽量减短stw的时间)
工作原理:
- 标记阶段:首先,垃圾收集器遍历所有根节点,并标记所有从根节点直接或间接可达的对象为“存活”。
- 清除阶段:然后,垃圾收集器扫描整个内存空间,回收所有未被标记为“存活”的对象所占用的内存。
2)标记复制
- 没有空间碎片(优点)
- 仅适用于收集效率高的场景(缺点)
- 只有一半的有效空间
工作原理:
- 标记阶段:与标记-清除类似,首先标记所有存活的对象。
- 复制阶段:将所有存活的对象复制到内存的另一端,然后清除整个原内存空间。
3)标记整理算法
- 没有空间碎片(优点)
- 需要空间移动对象(缺点)
工作原理:
- 标记阶段:与标记-清除类似,首先标记所有存活的对象。
- 整理阶段:将所有存活的对象向内存的一端移动,然后清除边界以外的内存。
4.收集器
收集器 = “一个完整的垃圾回收实现”
它把算法、内存布局、触发时机、并发策略、调优参数打包成一个可插拔的“模块”,替 JVM 自动完成“找垃圾 → 清垃圾”的全过程。
每个收集器并不需要“具备”所有算法;
它只会按区域/阶段需要,选 1~2 种算法来实现自己的回收策略。
JDK8及以前更早的版本,用的是Parallel Scavenge(新生代)+ Serial Old(老年代);
在JDK9及以后的版本,用的都是G1( Garbage-First)
垃圾收集器 | 作用区域 | 收集算法 | 收集器类型 | 特点 | 适用场景 |
Serial 收集器(串行回收器) | 新生代 | 复制算法 | 单线程 | 简单高效,进行垃圾收集时会暂停所有用户线程,直到收集结束 | 客户端应用、小内存环境 |
ParNew 收集器(并行回收器) | 新生代 | 复制算法 | 多线程 | 多线程并行收集,减少垃圾收集时间,提高收集效率 | 多核 CPU 环境、对吞吐量有要求的应用 |
Parallel Scavenge 收集器(并行回收器) | 新生代 | 复制算法 | 多线程 | 关注吞吐量,可设置期望的停顿时间和吞吐量大小,通过自适应调整堆大小来提高吞吐量 | 后台运算型应用,如大数据处理、科学计算等 |
Serial Old 收集器(串行回收器) | 老年代 | 标记-整理算法 | 单线程 | 单线程收集,进行垃圾收集时会暂停所有用户线程 | 客户端应用、小内存环境 |
Parallel Old 收集器(并行回收器) | 老年代 | 标记-整理算法 | 多线程 | 多线程并行收集,关注吞吐量,可与 Parallel Scavenge 收集器配合使用 | 后台运算型应用,如大数据处理、科学计算等 |
CMS 收集器(并发回收器) | 老年代 | 标记-清除算法 | 多线程 | 以获取最短停顿时间为目标,减少垃圾收集时的停顿时间,但会产生内存碎片 | 对响应时间有要求的应用,如 Web 应用、实时交易系统等 |
Garbage First 收集器(并发回收器) | 全堆 | 标记-整理算法(整体);标记复制(局部) | 多线程 | 面向服务器的垃圾收集器,关注停顿时间和吞吐量,可预测的停顿时间,通过将堆划分为多个区域来管理内存,无内存碎片,但是负载高 | 大内存、多核 CPU 的服务器应用,如大型电商平台、云计算平台等(Java堆容量平衡点为6G-8G) |
九、 内存分配+回收规则
新生代分区:
①1 个 Eden 区
所有新对象默认先放这里;99% 的对象“朝生夕死”,一次 Minor GC 就能清空整个 Eden。
②2 个 Survivor 区(习惯叫 S0、S1,或 From、To)
只存上一轮 GC 幸存的对象;
(1)对象优先在Eden(新生代)区域
(2)大对象直接进老年代
(3)长期存活的进入到老年代
(4)动态年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄
(5)空间分配担保:虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间
空间分配担保用来确保 Minor GC 后晋升上来的对象一定有地方放,防止中途因老年代空间不足而 “晋升失败”,进而不得不临时触发一次 Full GC 甚至 OOM。
十、 类加载机制
1.类加载过程
加载-验证-准备-解析-初始化
- 加载:通过类名获取字节流并生成 Class 对象。
- 验证:检查字节码格式与安全性。
- 准备:为类变量分配内存并设默认值。
- 解析:将符号引用转为直接引用。
- 初始化:执行静态代码块和变量赋值。
把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
“主动引用”与“被动引用”——只跟 “初始化” 这一步有关《Java 虚拟机规范》用“有且只有”限定的是 触发 () 的 6 个场景:【其他被动引用不会触发初始化】
编号 | 场景(官方原文) | 日常代码示例 |
1 | new 一个类的对象 | new Foo(); |
2 | 访问非编译期常量的静态字段 | int x = Foo.a; |
3 | 调用静态方法 | Foo.staticMethod(); |
4 | 反射调用 | Class.forName("Foo"); |
5 | 子类初始化时,先初始化父类 | class Bar extends Foo { … } |
6 | 启动类(main 方法所在的类) |
十一、 双亲委派
1.双亲委派(⭐)
目的是保证 Java 类型体系中基础类型的安全性和避免类的重复加载。
1)启动类加载器:负责固定路径下的固定jar包的加载
2)扩展类加载器:对Java语言的扩展
3)应用程序类加载器:负责加载用户路径下(即我们写的程序)
2.破坏双亲委派
双亲委派的工作流程
应用加载器收到请求 → 自下而上依次向上委派 → 启动、扩展均未命中 → 回到应用加载器自己在 classpath 中尝试加载 → 找到即返回,否则抛 ClassNotFoundException。
假设应用程序中需要加载一个com.example.MyClass类,流程如下:
- 应用类加载器接到加载 com.example.MyClass 的请求。
- 按双亲委派模型,先向上委托:• 启动类加载器在 %JAVA_HOME%/lib 查找,未找到,返回失败。• 扩展类加载器在 %JAVA_HOME%/lib/ext 查找,也未找到,返回失败。
- 应用类加载器回到自身,在应用程序的 classpath 中查找:• 找到则加载并返回 Class 对象;• 未找到则抛出 ClassNotFoundException。
破坏双亲委派好处:
虽然双亲委派模型带来了诸多好处,但在一些特殊场景下,需要打破这个模型。
例如,在 Java 的模块化系统(Java 9 引入的 JPMS)中,以及像 Tomcat、OSGi(模块热加载) 这样的框架中。以 Tomcat 为例,为了实现 Web 应用之间的类隔离,不同的 Web 应用可能需要加载不同版本的同一个类,这时就需要打破双亲委派机制,让每个 Web 应用的类加载器优先加载自己路径下的类。
十二、 编译优化
JVM 的编译优化 = 运行时 JIT 把热点字节码“翻译+改写成最优机器码”的全过程,通过分层编译 + 十余种微观优化,让 Java 在启动后很快逼近甚至超越静态语言的性能。
技术 | 作用 | 效果 |
方法内联 | 把调用点换成方法体本身 | 省一次压栈/出栈,触发更多后续优化 |
逃逸分析 | 判断对象是否逃出方法/线程 | 栈上分配、锁消除、标量替换,减少 GC 压力 |
常量传播 | 把已知常量直接代入表达式 | 去掉冗余计算,配合分支消除 |
循环展开 | 把循环体复制多份 | 减少循环控制指令,提高流水线并行度 |
分支预测 | 根据运行时统计预判哪个分支更可能被执行 | 减少 CPU 跳转惩罚 |
同步消除 | 去掉不必要的 monitor enter/exit | 单线程场景下显著提升性能 |