Java · JVM 运行时架构

HotSpot 虚拟机 — 运行时数据区 (堆分代 + 线程私有) / 垃圾回收 / JIT 分层编译: "一次编写, 到处运行"的引擎

类元数据装入 字节码 HotSpot JVM 进程 — 承载字节码的托管运行时 类加载子系统 .class 文件 Loading Linking 验证/准备/解析 Init <clinit> 双亲委派: 先问父加载器 加载结果 → 方法区 Java Heap 堆 — 线程共享 · GC 主战场 Young 新生代 (~1/3) Eden 新对象诞生地 TLAB 线程私有分配 S0 from S1 to Old 老年代 (~2/3) 长期存活对象 大对象直接分配 PretenureSize Full GC 主战场 Minor GC (复制算法): Eden+S0 存活对象 → S1, 年龄 +1; age ≥ 15 → 晋升 Old Major / Full GC: 整堆或老年代 · 标记-整理 · STW 昂贵 GC Roots: 栈帧局部变量 · 静态字段 · JNI 全局引用 · 常量 方法区 / Metaspace (JDK 8+ · 本地内存) 类元数据 · 运行时常量池 — 不再挤占堆, 无 PermGen OOM 虚拟机栈 (私有) 栈帧: 局部变量表 · 操作数栈 动态链接 · 返回地址 -Xss · 深度超限 → SOE 每方法调用压入一帧 PC 寄存器 当前字节码行号 线程私有 唯一不会 OOM 的区域 本地方法栈 JNI / native C/C++ 栈帧 HotSpot 中与虚拟机栈合一 执行引擎 — 解释 + JIT 分层编译 (Tiered Compilation) 解释器 逐条执行字节码 启动快 · 即时生效 JIT · C1 (Client) 热点轻量优化 编译快 · Tier 1-3 JIT · C2 (Server) 逃逸分析 · 内联 · 去虚化 峰值性能 · Tier 4 垃圾回收器 GC 并发标记 · 分代回收 演进见右侧 → GC 收集器演进 — 停顿越来越短 Serial — 单线程 STW · 嵌入式时代 Parallel — 多线程吞吐优先 · JDK 8 默认 CMS — 并发标记清除 · JDK 14 移除 G1 — Region 化 · 可预测停顿 · 9+ 默认 ZGC — 着色指针+读屏障 · 亚毫秒停顿 Shenandoah — 并发整理 · 低延迟 JDK 21+: Generational ZGC — 低延迟下重拾分代 GC 与调优要点 分代假设: 绝大多数对象朝生夕死 Minor GC 便宜频繁 · Full GC 昂贵 吞吐 (GCTimeRatio) ↔ 停顿 (MaxGCPauseMillis) 先定目标: 吞吐型 or 延迟敏感型 对象分配速率是 GC 压力的源头 JIT 关键机制 热点探测: 方法调用计数器 + 回边计数器 触发阈值 → 字节码升格为本地机器码 逃逸分析 → 栈上分配 · 标量替换 · 锁消除 C2 优化激进: 猜错可退优化 (deopt) 计数器半衰: 冷代码回落解释执行 JMM — Java 内存模型 happens-before · volatile · final 语义 工作内存 ↔ 主内存 — 并发正确性基石 Legend 堆 / 元空间 (线程共享) 线程私有区域 执行引擎 / JIT 边界 / 调优 JMM / 约束 辅助组件

运行时数据区

  • • 线程共享: Heap (Eden / S0 / S1 / Old) 与 Metaspace (类元数据)
  • • 线程私有: 虚拟机栈 (栈帧) / PC 寄存器 / 本地方法栈
  • • "对象在堆, 引用在栈" — 排查内存问题的第一心智模型
  • • 栈溢出 SOE 与堆溢出 OOM 的方向完全不同

分代垃圾回收

  • • 分代假设: 绝大多数对象朝生夕死 → 新生代用复制算法最划算
  • • Minor GC: 存活对象 Eden → Survivor 来回复制, age ≥ 15 晋升 Old
  • • Full GC 整堆整理、STW 最昂贵 — 调优的终点是减少 Full GC
  • • G1 Region 化、ZGC 着色指针把停顿推进到亚毫秒

JIT 分层编译

  • • 解释器起步 (启动快) → C1 快速优化 → C2 峰值优化, 代码越跑越快
  • • 热点探测: 方法调用计数 + 回边计数, 只编译值得编译的代码
  • • 逃逸分析: 未逃逸对象栈上分配, GC 压力直接消失
  • • "Java 慢" 的印象多停留在 JIT 尚未成熟的预热期

💡 一句话理解

JVM 把"内存怎么管、代码怎么执行快"这两件最难的事从程序员手里接管了: 堆分代 + GC 负责"对象生老病死", JIT 分层编译 负责"越跑越快"。理解运行时数据区是排查一切 Java 内存问题的地图 —— "对象在堆, 引用在栈, 类信息在 Metaspace"。

🧠 必知必会 必考 & 必会

运行时数据区
线程共享: 堆(对象)、Metaspace(类元数据); 线程私有: 虚拟机栈(栈帧)、PC 寄存器、本地方法栈。OOM 发生在堆/Metasaspace, StackOverflowError 发生在栈 —— 方向完全不同。
User u = new User();      // 关键: 引用在栈帧, 对象本体在堆
void f() { f(); }         // 栈深度超限 → StackOverflowError
// 堆/Metaspace 耗尽 → OutOfMemoryError, 方向完全不同
对象创建五步
类检查 → 分配内存(TLAB 线程私有缓冲, 指针碰撞/空闲列表)→ 零值初始化 → 设置对象头 → <init>。分配本身极快, 因为 TLAB 免锁。
// 类检查 → TLAB 分配 → 零值 → 对象头 → <init>
Object o = new Object();  // TLAB 内指针碰撞, 免锁分配
// 关键: 线程私有 TLAB → Eden 分配无竞争, 这就是"极快"的根源
分代假设
绝大多数对象朝生夕死 → 新生代用复制算法(Eden+S0/S1 来回复制, 快且无碎片), 老年代用标记-整理。
void handle(String req) {
    byte[] buf = new byte[8192];  // 朝生夕死: 下次 Minor GC 即回收
    parse(buf, req);   // 关键: 复制算法只搬存活者 → 又快又无碎片
}
Minor / Full GC
Minor GC 只收新生代, 频繁而便宜(存活对象少, 复制量小); Full GC 整堆 + Metaspace, STW 昂贵 —— 调优的终极目标是减少 Full GC 频率。
jstat -gcutil <pid> 1000
#   S0  S1   E    O    YGC   FGC
#   0.0 95.2 80.1 42.3  1024     3   ← Minor 频繁便宜, Full 稀少昂贵
晋升条件
年龄阈值(默认 15, CMS 6)或 Survivor 放不下, 或大对象直接进 Old(PretenureSizeThreshold)。
// 每熬过一次 Minor GC 年龄+1, 默认 age ≥ 15 晋升 Old
byte[] big = new byte[8 * 1024 * 1024];
// 大对象可越过 young 直接进 Old (PretenureSizeThreshold 控制)
安全点 safepoint
GC 需要所有线程停在"安全点"才能开始; 长时间 counted loop / 大数组拷贝会拉长"到达安全点"的时间, 造成意外停顿。
for (int i = 0; i < N; i++) sum += i;  // int counted loop 回边缺安全点
// 关键: 长循环拖长"全员到齐" → 意外 STW; 拆段/long 索引可缓解
JIT 分层
解释器(Tier 0)起步 → C1(Tier 1-3, 快速轻优化)→ C2(Tier 4, 逃逸分析/内联/去虚化)。热点由方法调用计数器+回边计数器探测; 冷代码会退优化回解释。
static int add(int a, int b) { return a + b; }  // 起步: 解释执行
for (int i = 0; i < 100_000; i++) add(i, 1);
// 计数超阈值 → C1(Tier 3) → C2(Tier 4); 观察: -XX:+PrintCompilation

🏭 生产实战 real world

场景 1 · OOM 排查三板斧

线上 java.lang.OutOfMemoryError: Java heap space, 标准处置链路:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump.hprof -jar app.jar
# 1. MAT 打开 dump → Dominator Tree(支配树) → 找最大对象与引用链 GC Roots
# 2. 通常是: 无界缓存 / 一次查回百万行 SQL / ThreadLocal 未 remove / 大文件全量读入
# 3. 平时看趋势: jstat -gcutil <pid> 1000  → O 区持续上涨 = 泄漏或堆不够

场景 2 · 容器(K8s)里的堆大小

JVM 总内存 = 堆 + Metaspace + 线程栈*N + 直接内存 + JIT 代码缓存。容器 memory limit=2G 却设 -Xmx2g, 会被内核 OOMKilled(堆外部分超限):

resources: { limits: { memory: "2Gi" } }
# 堆设为 limit 的 70~75%, 给堆外留余量:
java -Xmx1400m -Xms1400m -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=256m -jar app.jar
# JDK 8u191+/10+ 已感知 cgroup, 但比例仍要自己控

场景 3 · G1 的停顿目标与大对象

G1 把堆切成 Region, 停顿由回收哪些 Region 决定。两个最常用的开关和一个最常见的坑:

java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms4g -Xmx4g -jar app.jar
# 坑: 对象 ≥ Region/2(4G 堆默认 Region 2M → 1M 即 humongous)直接进老年代,
# 批量读取大 List/大 JSON 反序列化会产生连串 humongous → 频繁 Full GC。
# 解法: 分页/流式处理, 或调大 Region: -XX:G1HeapRegionSize=8m

场景 4 · jstat 常态监控与告警口径

每秒一次 GC 概览, 告警口径定"FGC 频率"而非绝对值:

jstat -gcutil <pid> 1000
#  S0  S1   E    O     M   YGC  YGCT  FGC FGCT   GCT
#  0.0 95.2 80.1 42.3  95.0 1024 12.3   3  1.85  14.1
# 告警口径: FGC 增速 > 1次/小时 (P1); O 区持续 > 85% (P2); YGC 耗时环比翻倍 (P3)

场景 5 · 堆外内存泄漏: NMT 三步法

RSS 涨但堆平稳 → 泄漏在堆外, Native Memory Tracking 定位:

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory baseline       // 跑基线
# ……复现泄漏 30 分钟……
jcmd <pid> VM.native_memory summary.diff   // 对比增长项
# 常见凶手: Thread(线程栈,线程泄漏) / Class(动态代理不释放) / Other(DirectBuffer)

场景 6 · G1 调优三步走

顺序不能乱: 先堆大小 → 停顿目标 → 并发线程:

java -XX:+UseG1GC \
     -Xms6g -Xmx6g \                     // 1. 堆定宽(Xms=Xmx 防伸缩抖动)
     -XX:MaxGCPauseMillis=100 \             // 2. 停顿目标(不是保证, 是方向)
     -XX:ConcGCThreads=4 \                   // 3. 标记线程=核数/8, CPU 富余可加
     -Xlog:gc*:file=gc.log:time -jar app.jar
# 只改一个参数跑一轮压测, 三轮对比出结论 — 别一次改五个

场景 7 · JMH 微基准: 别让 JIT 骗你

"main 方法掐表"在 JVM 上毫无意义(死代码消除/预热/OSR), 微基准必须 JMH:

@Benchmark
@BenchmarkMode(Mode.Throughput)
public String measure() {
    return jsonSerializer.write(order);   // 返回值防死代码消除
}
# mvn test -Dtest=.*Benchmark -Djmh.fork=2 -Djmh.warmup=5 -Djmh.iter=5

场景 8 · 常驻 Agent 的开销评估

APM(skywalking)/诊断(arthas)走字节码增强, 有真实开销:

// 预发环境 A/B: 同负载压测 有/无 javaagent 两组
java -javaagent:/skywalking-agent.jar -jar app.jar
# 关注: 吞吐差 <10% 可接受; 超标则采样率降到 10% 或裁剪插件列表
# 生产 arthas 用完即卸: stop 后确认增强类已还原

场景 9 · 大对象/humongous 自检

G1 下大 List/大 JSON 反序列化会造成连串 humongous → 频繁 Full GC:

// gc.log 里搜 Humongous Reclaim 与 To-space exhausted:
grep -c "to-space exhausted" gc.log    // >0 即命中
# 解法按序: 分页/流式读 > 调大 Region(-XX:G1HeapRegionSize=8m) > 最后才是加堆

场景 10 · 启动加速: CDS/AppCDS

冷启动慢的大头是类加载, Class Data Sharing 直接共享已解析的类元数据:

// 1. 训练运行, 记录加载的类
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar --warmup
# 2. 之后每次启动带归档
java -XX:SharedArchiveFile=app.jsa -jar app.jar
# 常规效果: 启动 -20~40%, Serverless/FaaS 场景收益最大 (JDK17+ 还有 AOT/CRaC)

⚠️ 编码注意与常见坑 pitfalls

坑 1 · -Xmx = 容器 limit — 堆外(Metaspace/栈/DirectBuffer)被挤出 limit, 现象是 OOMKilled 而非 OutOfMemoryError。正解: 堆 ≤ limit 75%。
# 错: K8s limit=2Gi 却 -Xmx2g → 堆外被挤出 → OOMKilled(exit 137)
java -Xmx1400m -Xms1400m -XX:MaxMetaspaceSize=256m -jar app.jar  // 对: 堆 ≤ limit 75%
坑 2 · System.gc() — 显式触发 Full GC; RMI/部分框架会定时调用。正解: -XX:+DisableExplicitGC(注意 DirectBuffer 回收依赖它时改用 -XX:+ExplicitGCInvokesConcurrent)。
System.gc();  // 错: 显式 Full GC, 全线程 STW 打断所有请求
// 对: -XX:+DisableExplicitGC; DirectBuffer 依赖时改 +ExplicitGCInvokesConcurrent
坑 3 · 一次 SQL 拉全表 — 百万行 Entity 一瞬占满 Old → Full GC 风暴。正解: 分页/游标/流式(MyBatis cursor, JDBC fetchSize)。
List<Order> all = orderDao.findAll();  // 错: 百万行瞬间占满 Old
try (Cursor<Order> c = dao.scan(500)) { handle(c); }  // 对: 游标+fetchSize 流式
坑 4 · 无界缓存 — Map 当缓存只进不出 = 人为内存泄漏。正解: Caffeine(容量+TTL+W-TinyLFU)而非手写 HashMap。
static Map<String,Object> cache = new HashMap<>();  // 错: 只进不出
// 对: Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(...)
坑 5 · 预热期压测误判 — JIT 未升温时吞吐低, 直接扩容浪费资源。正解: 预热流量/JWarmup 类手段后再压测; 看监控要区分"冷启动"与"容量不足"。
# 错: 服务刚起就全量压测 → JIT 未升温, 低估容量盲目扩容
# 对: 先打 10min 预热流量(或 JWarmup), 再开始采集压测数据
坑 6 · 盲目关闭压缩指针 — 大堆下随手去掉 UseCompressedOops, 引用全部变 8 字节, 同量对象内存涨 20-40%。正解: 32GB 以下保持默认开启; 超 32GB 评估"32G 压缩指针 vs 48G 非压缩"的实际对象密度。
# 错: java -Xmx40g -XX:-UseCompressedOops   # 随手关, 引用 4B→8B
# 对: ≤32G 保持默认开启; 更大堆先算对象密度再决定
坑 7 · Metaspace 泄漏(动态代理) — CGLIB/反射每次生成新代理类, 元空间只涨不跌, 最终 OutOfMemoryError: Metaspace。正解: 代理类缓存复用(生成一次存 Map); 监控 LoadedClassCount 趋势。
Object proxy = enhancer.create();  // 错: 每次生成新代理类 → Metaspace 只涨不跌
// 对: PROXIES.computeIfAbsent(clazz, k -> build(k)); 生成一次复用
坑 8 · String.intern 滥用 — 海量动态字符串 intern 进常量池, 常量池同样吃堆且难回收。正解: intern 只用于"有限重复的固定词汇表"; 动态字符串正常分配, 靠 young GC 自然回收。
String k = ("user-" + id).intern();  // 错: 海量动态串塞常量池
String k = "user-" + id;             // 对: 正常分配, young GC 自然回收
坑 9 · 包装类用 == 比较 — Integer 缓存 -128~127 恰好命中就"碰巧对了", 值一大就错。正解: 包装类型一律 equals; 计数用 LongAdder/int 拆箱。
Integer a = 127, b = 127;  a == b;  // → true (缓存命中, 碰巧对了)
Integer x = 128, y = 128;  x == y;  // → false! 对: 一律用 equals
坑 10 · InheritableThreadLocal 配线程池失效 — 线程池复用线程, "继承"只发生在创建那刻, 后续任务拿的是老上下文。正解: 阿里 TTL(TransmittableThreadLocal)修饰任务, 或显式传参。
// 错: 池内线程只在其创建那刻继承, 后续任务拿到老上下文
// 对: TtlExecutors.getTtlExecutor(pool) 包装提交, 或任务显式传参
坑 11 · finalize 拖垮 GC — 带 finalize 的对象要等 Finalizer 线程处理, 回收延迟且优先级低, 突发时堆积。正解: try-with-resources + Cleaner(9+); 永不写 finalize。
protected void finalize() { close(); }  // 错: 等 Finalizer 线程, 回收延迟
try (Handle h = open()) { use(h); }      // 对: AutoCloseable + Cleaner(9+)
坑 12 · 静态集合持有运行期对象 — static Map 当"全局缓存"放请求对象, 类加载器级生命周期 = 永不释放。正解: 有界缓存(Caffeine); static 只放配置与不可变常量。
static Map<Long, Order> RECENT = ...;  // 错: 类级生命周期, 永不释放
// 对: Caffeine.maximumSize(1000); static 只放配置与不可变常量
坑 13 · RMI 的定时 System.gc() — 老 JVM 上 RMI DGC 每小时触发 Full GC, "没人调用也 FGC"。正解: -Dsun.rmi.dgc.server.gcInterval=36000000 拉长 + DisableExplicitGC 组合评估。
# 错: RMI DGC 默认每小时 System.gc() → "没人调用也 FGC"
# 对: -Dsun.rmi.dgc.server.gcInterval=36000000 + DisableExplicitGC 评估
坑 14 · 超大 dump 分析卡死笔记本 — 12G dump 直接 MAT 本地打开 OOM 的往往是分析机。正解: 服务器上命令行提 suspect 报告再拉小文件; MAT 配 -Xmx8g 只做索引。
# 错: 12G dump.hprof 拉到笔记本直接 MAT 打开 → 分析机先 OOM
# 对: 服务器命令行出 Leak Suspects 报告再拉小文件; MAT 本机只做索引
坑 15 · -Xmn 与 G1 抢方向盘 — G1 自己管理代大小, 手动 -Xmn 会削弱自适应。正解: G1 下别设 -Xmn/-XX:NewRatio; 交给 MaxGCPauseMillis 驱动。
# 错: -XX:+UseG1GC -Xms6g -Xmx6g -Xmn2g  # 手动固定年轻代
# 对: G1 不设 -Xmn/NewRatio, 交给 MaxGCPauseMillis 驱动自适应
坑 16 · Survivor 太小导致早衰 — S 区放不下存活对象 → 直接晋升 Old → Old 涨快 → Full GC 频繁。正解: 观察 gc.log 晋升量; 调 NewSize 或 SurvivorRatio(-8), 让"朝生夕死"死在 young。
# 错: S 区放不下存活对象 → 直接晋升 → Old 涨快 → Full GC 频繁
# 对: gc.log 看每轮晋升量, 调 -XX:NewSize / SurvivorRatio=8
坑 17 · DirectBuffer 未释放 — 堆外 DirectByteBuffer 忘 release, 堆内平稳堆外涨到 OOM(进程级)。正解: Netty 用引用计数 release/PooledByteBufAllocator; 自用包 try-finally。
ByteBuffer.allocateDirect(1 << 30);  // 错: 用完不管, 堆外只涨不跌
// 对: Netty buf.release() 引用计数; 自用 try-finally 显式释放
坑 18 · Full GC 原因不查就调参 — FGC 的诱因有 promotion failed / metadata / System.gc / humongous 多种, 药方完全不同。正解: 先看 gc.log 的 cause 字段, 再对症。
# 错: 一见 FGC 就加堆
grep "Pause Full" gc.log  # 对: 先看 cause — System.gc()/Metadata/promotion failed 药方不同
坑 19 · 升级 JDK 不回归默认 GC — 8→9+ 默认从 Parallel 变 G1, 吞吐型批任务可能变慢。正解: 升级清单里显式声明 -XX:+UseParallelGC 或按目标重选 GC。
# 错: JDK 8→11 直接升, 默认 Parallel→G1, 吞吐型批任务掉速
java -XX:+UseParallelGC -jar app.jar  // 对: 升级清单显式声明或按目标重选
坑 20 · 把压测当生产 — 压测数据整齐, 生产流量长尾(大客户的大订单)才触发 humongous/晋升风暴。正解: 用生产流量录制回放; 大 key 场景专门构造用例。
// 错: 压测全用 10 行小订单 → 大客户万行订单的 humongous 才炸
// 对: 生产流量录制回放 + 专门构造大 key/大单用例