文章
JVM 学习笔记:内存结构与性能调优
牛耕田
暂存笔记,持续补充中。JVM 是 Java 后端的「内功」,面试深挖的重灾区。
一、运行时数据区
┌────────────────────────────────────────────┐
│ 线程共享 │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 堆 Heap │ │方法区 │ │ 直接内存 │ │
│ │ │ │(元空间) │ │ Direct Mem │ │
│ └──────────┘ └──────────┘ └────────────┘ │
├────────────────────────────────────────────┤
│ 线程私有 │
│ 程序计数器 │ 虚拟机栈 │ 本地方法栈 │
└────────────────────────────────────────────┘
| 区域 | 是否共享 | 作用 | 是否会 OOM |
|---|---|---|---|
| 程序计数器 | 私有 | 记录当前字节码行号 | 不会(唯一不抛 OOM 的区域) |
| 虚拟机栈 | 私有 | 存栈帧(局部变量表、操作数栈) | 会(StackOverflowError / OOM) |
| 堆 | 共享 | 存对象实例 | 会(最常见) |
| 方法区(元空间) | 共享 | 类元信息、常量、静态变量 | 会 |
| 直接内存 | 共享 | NIO DirectByteBuffer |
会 |
JDK 8 变化:永久代(PermGen)被移除,改为元空间(Metaspace),从堆内存移到本地内存,默认不设上限(受物理内存限制),因此类加载过多会导致元空间 OOM。
二、堆内存结构
堆 = 新生代(Young) + 老年代(Old/Tenured)
新生代 = Eden + Survivor0 + Survivor1 默认比例 8 : 1 : 1
对象分配与晋升:
- 新对象优先在 Eden 分配(大对象直接进老年代)。
- Eden 满触发 Minor GC,存活对象复制到 Survivor(
-XX:SurvivorRatio)。 - 每熬过一次 Minor GC,年龄 +1(
-XX:MaxTenuringThreshold,默认 15)。 - 年龄达阈值或 Survivor 中同年龄对象超过一半,晋升老年代。
- 老年代满触发 Full GC(会 STW,代价高)。
TLAB:每个线程在 Eden 中预分配一小块私有区域(Thread Local Allocation Buffer),避免分配对象时的指针碰撞加锁竞争。
三、垃圾回收算法
| 算法 | 思路 | 优缺点 |
|---|---|---|
| 标记-清除 | 标记存活对象,清除其余 | 实现简单;产生内存碎片 |
| 标记-复制 | 存活对象复制到另一半 | 无碎片、效率高;浪费一半空间(用于新生代) |
| 标记-整理 | 存活对象向一端移动 | 无碎片;移动成本高(用于老年代) |
分代收集思想:新生代对象「朝生夕死」,用复制算法;老年代对象存活率高,用标记-清除/整理。
如何判断对象可回收:
引用计数法(循环引用无法解决,JVM 不采用)。- 可达性分析:从 GC Roots 出发,不可达即可回收。
- GC Roots 包括:虚拟机栈中引用的对象、方法区静态属性、常量引用、本地方法栈 JNI 引用、同步锁对象。
四、垃圾回收器演进
| 收集器 | 分代 | 特点 | 适用 |
|---|---|---|---|
| Serial / Serial Old | 单线程 | 简单,STW 长 | 客户端 / 小内存 |
| ParNew | 新生代 | Serial 多线程版 | 配合 CMS |
| Parallel Scavenge / Old | 多线程 | 吞吐量优先,JDK 8 默认 | 后台批处理 |
| CMS | 老年代 | 低延迟,并发标记清除,有碎片 | 已废弃(JDK 14 移除) |
| G1 | 不分代(逻辑分代) | 可预测停顿,Region 化管理,JDK 9+ 默认 | 大堆、低延迟 |
| ZGC / Shenandoah | 不分代 | 停顿 < 10ms,支持超大堆 | 超大内存、超低延迟 |
G1 核心概念:
- 堆被划分为大小相等的 Region(默认 2048 块),每个 Region 动态扮演 Eden/Survivor/Old。
- 通过
-XX:MaxGCPauseMillis设定目标停顿时间,G1 优先回收「回收收益最高」的 Region(Garbage First 名称由来)。 - Humongous Region:超过 Region 一半大小的大对象单独存放。
五、常用调优参数
# 堆大小(建议 -Xms 与 -Xmx 设相同,避免动态扩容抖动)
-Xms4g -Xmx4g
# 新生代大小与比例
-Xmn1g -XX:SurvivorRatio=8
# 元空间
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
# GC 收集器(JDK 8 用 G1)
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
# OOM 时 dump 堆快照,线上必备
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
# GC 日志
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags
黄金法则:先测量再调优。没有监控数据就调参,等于蒙眼开车。
六、类加载机制
加载过程:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。
| 阶段 | 关键动作 |
|---|---|
| 加载 | 读取 class 二进制流,生成 Class 对象 |
| 验证 | 文件格式、元数据、字节码、符号引用校验 |
| 准备 | 为静态变量分配内存并设零值(static int a = 1 此时 a = 0) |
| 解析 | 符号引用 → 直接引用 |
| 初始化 | 执行 <clinit>,静态变量赋真实值、静态代码块执行 |
双亲委派模型:
Bootstrap ClassLoader(C++ 实现,加载 rt.jar / java.base)
↑
Extension / Platform ClassLoader
↑
Application ClassLoader(加载 classpath)
↑
自定义 ClassLoader
流程:收到加载请求 → 先委派给父加载器 → 父加载器无法完成才自己加载。
好处:
- 安全:防止用户自定义
java.lang.String覆盖核心类。 - 避免重复加载:同一个类只被加载一次。
打破双亲委派的场景:SPI(JDBC 驱动)用线程上下文类加载器;Tomcat 为实现应用隔离自定义 WebAppClassLoader。
七、线上问题排查工具
| 工具 | 用途 |
|---|---|
jps |
列出 Java 进程 |
jstat -gc <pid> 1000 |
实时看 GC 频率与堆占用 |
jmap -heap <pid> |
堆内存详情 |
jmap -dump:live,format=b,file=heap.hprof <pid> |
导出堆快照 |
jstack <pid> |
导出线程栈,排查死锁/CPU 飙高 |
jinfo -flags <pid> |
查看 JVM 启动参数 |
jcmd <pid> GC.heap_info |
综合诊断(推荐) |
| Arthas | 阿里开源,在线诊断神器 |
典型场景:
- CPU 100%:
top -Hp <pid>找到最耗 CPU 线程 → 转 16 进制 →jstack里搜nid=0x...。 - 内存泄漏:连续观察老年代占用只增不减 → dump 堆 → MAT 分析支配树找大对象。
- 频繁 Full GC:
jstat看 FGC 次数,多为元空间不足或大对象频繁创建。
八、OOM 的几种类型
| 错误 | 原因 |
|---|---|
Java heap space |
堆内存不足,对象过多 / 泄漏 |
Metaspace |
类加载过多(动态代理、热部署) |
GC overhead limit exceeded |
GC 耗时占比超 98% 却回收不到 2% |
Unable to create new native thread |
线程数超系统限制 |
Direct buffer memory |
直接内存溢出(NIO) |
九、小结
- 内存模型:线程私有(栈/PC/本地栈)+ 线程共享(堆/方法区)。
- GC 核心:可达性分析 + 分代收集,新生代复制、老年代标记整理。
- 现代默认选 G1;超大堆选 ZGC。
- 调优顺序:监控 → 定位 → 参数 → 验证,永远不要凭感觉调参。