文章

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

对象分配与晋升

  1. 新对象优先在 Eden 分配(大对象直接进老年代)。
  2. Eden 满触发 Minor GC,存活对象复制到 Survivor(-XX:SurvivorRatio)。
  3. 每熬过一次 Minor GC,年龄 +1(-XX:MaxTenuringThreshold,默认 15)。
  4. 年龄达阈值或 Survivor 中同年龄对象超过一半,晋升老年代
  5. 老年代满触发 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

流程:收到加载请求 → 先委派给父加载器 → 父加载器无法完成才自己加载。

好处

  1. 安全:防止用户自定义 java.lang.String 覆盖核心类。
  2. 避免重复加载:同一个类只被加载一次。

打破双亲委派的场景: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 GCjstat 看 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。
  • 调优顺序:监控 → 定位 → 参数 → 验证,永远不要凭感觉调参。