jvm——时不我待

大家做开发其实大部分对底层使用不多,代码只要成功返回就行,对 JVM 的理解停留在"写代码 -> 编译 -> 运行",但这远远是不够多,在高并发、低延迟多赛场上,不懂 JVM 底层就像开着法拉利却不知道如何换挡

第一部分:对象布局 (Object Layout) 与 压缩指针

创建一个对象不仅仅是分配内存,它有着严格的二进制结构。理解这个结构对于优化内存占用至关重要。

1. 对象头 (Object Header)

部分 大小 (64-bit + Compressed Oops) 内容
Mark Word 8 字节 哈希码、GC 分代年龄、锁状态标志 (偏向锁/轻量级锁/重量级锁)、线程持有锁记录
Klass Pointer 4 字节 类型指针,指向方法区中类的元数据 (Class 对象)
Array Length 4 字节 (仅数组对象有) 数组长度
对齐填充 0~7 字节 保证对象总大小是 8 字节的倍数

2. 压缩指针 (Compressed Oops)

  • 问题:64 位指针占 8 字节,如果每个对象都存 8 字节指针,内存占用会增加 50%。
  • 解决 :HotSpot 默认开启 -XX:+UseCompressedOops
    • 将 64 位指针压缩成 32 位 (4 字节)。
    • 原理:对象地址 = 基地址 + (压缩指针 * 缩放因子,通常是 8)。
    • 限制:堆内存不能超过 ~32GB。超过后指针无法压缩,对象头变大,内存占用激增。

垃圾收集

在尽可能短的时间内,回收尽可能多的内存,同时让应用线程感知不到它的存在。这就有一个问题:吞吐量 (Throughput)延迟 (Latency)内存占用 (Memory Footprint) 很难兼得。JVM 的发展史,就是一部不断打破这个不可能三角的历史

核心算法基石:标记-整理 vs. 分代理论

在说任何话之前,必须理解两个支撑所有现代 GC 的理论:

1. 分代假说 (Generational Hypothesis)

  • 弱分代假说:绝大多数对象都是"朝生夕死"的。
  • 强分代假说:对象存活时间越长,未来存活的概率越大。
  • 架构
    • Young Gen (新生代) :存放新对象。采用 复制算法 (Copying),速度极快,但浪费一半空间。
    • Old Gen (老年代) :存放长寿命对象。采用 标记-整理 (Mark-Compact)标记-清除 (Mark-Sweep)

2. 三色标记法 (Tri-color Marking)

这是解决"标记过程中对象引用变化"的核心算法(用于 CMS, G1, ZGC)。

  • 白色:未访问,可能是垃圾。
  • 灰色:已访问,但子节点未扫描。
  • 黑色:已访问,且子节点已扫描(安全)。
  • 问题:如果在标记过程中,黑色对象引用了白色对象,而灰色对象断开了对白色对象的引用,白色对象会被误删。
  • 解决
    • CMS : 使用 增量更新 (Incremental Update)
    • G1/ZGC : 使用 SATB (Snapshot At The Beginning)Read Barrier

主流收集器深度解析

1. CMS (Concurrent Mark Sweep) ------ 时代的过渡者
  • 定位:低延迟,老年代收集器。
  • 算法:标记 - 清除 (Mark-Sweep)。
  • 特点
    • 并发标记/清理:大部分工作与用户线程并行。
    • 缺点
      1. 碎片化:标记 - 清除不整理内存,导致大量碎片,触发 Full GC 时退化为单线程 Serial Old,造成长时间 STW。
      2. 浮动垃圾:并发清理期间产生的新垃圾无法当次回收。
      3. CPU 敏感:并发阶段抢占 CPU 资源,可能导致应用变慢。
  • 现状 :JDK 9 废弃,JDK 14 移除。不建议在新项目中使用。
2. G1 (Garbage First) ------ 当前的默认王者 (JDK 8u20+ / JDK 11+)
  • 定位:兼顾吞吐量和延迟,大堆内存首选。
  • 革命性改变抛弃物理分代,采用逻辑分代 + Region 分区
    • 堆内存被划分为多个大小相等的 Region (通常 1MB - 32MB)。
    • 每个 Region 动态扮演 Eden, Survivor, Old, Humongous (大对象) 的角色。
  • 核心算法
    • 可预测停顿模型 :用户设定 MaxGCPauseMillis (如 200ms),G1 根据历史数据,优先回收垃圾最多的 Region (Garbage First),确保在时间内完成。
    • SATB (Snapshot At The Beginning):解决并发标记时的对象消失问题。记录标记开始时的对象引用快照。
    • Remembered Set (RSet):每个 Region 维护一个 RSet,记录"谁引用了我"。避免全堆扫描,实现跨 Region 引用的高效追踪。
  • 流程
    1. Initial Mark (STW, 极短): 标记 GC Roots 直接关联的对象。
    2. Concurrent Mark (并发): 遍历对象图,使用 SATB 记录变化。
    3. Final Mark (STW, 较短): 处理 SATB 记录的剩余变动。
    4. Live Counting & Evacuation (STW, 可控): 筛选 Region,将存活对象复制到其他 Region (天然无碎片)。
  • 适用场景:堆内存 > 4GB,对延迟有要求但不需要极致微秒级。
3. ZGC (Z Garbage Collector) ------ 颠覆者 (JDK 11 实验 / JDK 15+ 生产)
  • 定位超低延迟 (< 1ms STW),支持 TB 级堆内存。
  • 核心黑科技染色指针 (Colored Pointers) + 读屏障 (Read Barriers)
  • 原理深度拆解
    • 染色指针 :利用 64 位指针中的高位 bits (约 4-5 位) 直接存储对象状态(如:是否被移动过、属于哪个代)。
      • 以前:状态存在对象头里 -> 需要访问对象内存才能知道状态。
      • 现在 :状态存在指针里 -> 无需访问对象内存,仅看指针就知道要不要重映射。
    • 读屏障
      • 当应用线程读取对象引用时,插入一段汇编代码(读屏障)。
      • 检查指针的"颜色位"。如果对象已被 GC 移动,读屏障会立即修正指针地址,指向新位置。
      • 效果:GC 移动对象时,不需要暂停所有线程 (STW),应用线程在访问时自动"自我修正"。
  • 优势
    • STW 时间恒定,与堆大小无关 (无论 1GB 还是 1TB,STW 都在亚毫秒级)。
    • 无碎片 (基于 Region 复制)。
  • 代价
    • CPU 开销略高 (每次读对象都要执行屏障指令,吞吐量比 G1 低 10%-15%)。
    • 需要较新的 CPU 架构支持。
  • 适用场景:对延迟极度敏感 (高频交易、实时推荐),堆内存巨大 (> 32GB)。
4. Shenandoah ------ 红帽的开源方案
  • 定位:与 ZGC 类似,低延迟。
  • 区别
    • 不使用染色指针 (兼容 32 位系统)。
    • 使用 Brooks Pointers (在每个对象头里多放一个转发指针)。
    • 写屏障 (Write Barrier) 为主,ZGC 是读屏障。
  • 现状:JDK 12+ 内置。性能与 ZGC 互有胜负,取决于负载类型。
如何选择合适的 GC:建议 不具有法律效益
G1 :传统电商后台 (堆 4GB - 8GB)

理由:G1 在大堆下表现稳定,能平衡吞吐和延迟

复制代码
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  # 目标停顿 200ms
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用 45% 时启动并发标记
-XX:ConcGCThreads=2       # 并发线程数
ZGC (JDK 17+) 高频交易系统 / 实时广告竞价 (堆 16GB+, 延迟敏感)

哪怕只有 10ms 的 STW 都可能导致交易失败。ZGC 的亚毫秒级停顿是必须的。

复制代码
-XX:+UseZGC
-Xms16g -Xmx16g           # 固定堆大小,避免动态扩容
-XX:+ZGenerational        # JDK 21+ 开启分代 ZGC (大幅提升吞吐量)
  • :JDK 21 引入了 Generational ZGC,解决了早期 ZGC 吞吐量低的问题,全能型选手。
**Parallel GC:**批处理 / 数据分析 (堆 32GB+, 追求吞吐量)

Parallel GC (JDK 8 默认) 或 G1 (调优吞吐量) ;离线任务不在乎单次停顿 1 秒,只在乎整体跑完要多久。Parallel GC 的多线程并行回收吞吐量最高。

复制代码
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=0    # 不关心停顿,只关心总时间
-XX:GCTimeRatio=99        # 99% 时间用于业务,1% 用于 GC

调试与监控:看透 GC 的黑盒

统一日志格式 (Unified Logging)

JDK 9+ 引入了统一的日志标签系统,极其强大

  • -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags
  • gc*: 包含所有 GC 相关标签
  • safepoint: 记录安全点信息(为什么 STW?谁阻塞了?)
  • 分析技巧 :搜索 Pause Young (Normal) (G1 Evacuation Pause) 查看年轻代回收时间;搜索 To-space exhausted 查看是否晋升失败

2. JFR (Java Flight Recorder)

咱各种工具:不要只用文本日志,用 JFR 录制运行过程。

  • -jfr:filename=recording.jfr,start=manual,stop=manual

在 JDK Mission Control (JMC) 中打开:

  • GC Phase 视图:直观看到哪个阶段耗时最长。
  • TLAB Allocation:查看对象分配速率,判断是否分配过快导致频繁 Young GC。
  • Old Object Sample:直接定位哪些对象长期存活占用了老年代。

3. 咱项目上常见 GC 问题诊断

  • 频繁 Young GC
    • 原因:Eden 区太小,或对象分配速率过高。
    • 对策:增大 -Xmn (G1 中自动调整,可尝试增大 Region 数),检查代码是否有大量临时对象创建。
  • 频繁 Full GC / Metaspace OOM
    • 原因:动态类加载过多 (如 Groovy, CGLib),元空间不足。
    • 对策:-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
  • G1 Mixed GC 耗时过长
    • 原因:一次回收的 Region 太多,或者单个 Region 内存活对象太多(复制成本高)。
    • 对策:调整 -XX:G1MixedGCLiveThresholdPercent-XX:G1HeapWastePercent

总结

  1. JDK 8 用户:如果是新系统,尽量升级到 JDK 17/21。如果必须留在 JDK 8,大堆请用 G1 (需手动开启),小堆可用 ParallelGC。慎用 CMS。
  2. JDK 17/21 用户无脑选 ZGC (特别是 JDK 21 的分代 ZGC)。它已经解决了吞吐量的短板,是目前的最优解。
  3. 核心心法
    • GC 调优的本质是减少对象分配,而不是调整 GC 参数。
    • 最好的 GC 是没有 GC 。通过对象池、复用、逃逸分析减少 new,比任何调参都有效。
    • 监控先行:没有数据支撑的调优就是瞎猜。

编写 GC 友好的代码 (GC-Friendly Coding)

核心哲学最好的 GC 调优,是减少对象分配。

GC 的压力 = 对象分配速率 × 对象存活率。如果你能控制这两者,GC 参数只是锦上添花。

1. 拒绝"循环内新建" (The Loop Killer)

这是最常见的性能杀手。在高频循环中 new 对象,会瞬间填满 Eden 区,触发频繁的 Young GC。

复制代码
//下面不对,咱们只说不对的
public long sumList(List<Integer> list) {
    long sum = 0;
    for (Integer i : list) {
        // 陷阱:每次循环都创建一个新的 StringBuilder
        // 如果 list 有 100 万个元素,就创建 100 万个对象!
        String s = new StringBuilder().append("Value: ").append(i).toString();
        sum += i; 
        // s 变成垃圾,等待回收
    }
    return sum;
}

如果连 StringBuilder 都不需要(只是计算),直接去掉字符串操作。如果必须格式化,考虑使用线程局部变量 (ThreadLocal<StringBuilder>) 在多线程环境下复用。

2.警惕自动装箱 (Auto-Boxing Trap)

Java 的集合框架 (List<Integer>, Map<String, Object>) 只能存对象。当你用 int 赋值给 Integer 时,JVM 会调用 Integer.valueOf(),产生一个新对象(除非在缓存范围内 -128~127)。

复制代码
// 创建了 10,000 个 Integer 对象
long sum = 0;
for (int i = 0; i < 10000; i++) {
    sum += i; // 这里没问题
    // 但如果放入集合:
    // list.add(i); -> 每次 add 都 new 一个 Integer (超出缓存范围)
}

//正确的姿势
//内存占用减少 5-10 倍,GC 压力几乎为零
//第三方库如 FastUtil, HPPC, 或 JDK 21+ 的 Primitive Collections (未来特性)
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;

// 底层是 int[] 数组,完全无装箱开销
IntList list = new IntArrayList();
for (int i = 0; i < 10000; i++) {
    list.add(i); // 直接存 int,无对象创建
}

3. 字符串拼接的真相

  • JDK 8 : + 拼接在编译期转换为 StringBuilder

  • JDK 9+ : 引入了 StringConcatFactoryinvokedynamic,性能更好,但在循环中依然危险

    String result = "";
    for (String s : largeList) {
    result += s; // 每次循环都创建新的 String 对象 (String 是不可变的)
    // 第一次:new String(0+1)
    // 第二次:new String(1+2) ... 复制所有字符
    // 复杂度 O(N^2)
    }

4. 对象池 (Object Pooling) ------ 双刃剑

对于重量级对象(如数据库连接、Netty 的 ByteBuf、复杂的解析器),可以使用对象池。

  • 工具:Apache Commons Pool, Netty Recycler。
  • 警告
    • 对于轻量级对象(如简单的 POJO),不要 pooling!现代 JVM 的分配速度极快(指针碰撞),而池化带来的代码复杂度和维护成本往往超过收益。
    • 只有当对象构造成本极高(如涉及系统调用、大数组分配)时,才考虑池化。

5. 弱引用与软引用 (Weak/Soft Reference)

用于实现本地缓存,避免 OOM。

  • WeakReference: GC 发生时立即回收。

  • SoftReference: 内存不足时才回收。

  • 场景:图片缓存、大报表缓存。

    Map<String, SoftReference<BigImage>> cache = new ConcurrentHashMap<>();

    public BigImage getImage(String key) {
    SoftReference<BigImage> ref = cache.get(key);
    if (ref != null) {
    BigImage img = ref.get();
    if (img != null) return img;
    }
    // 重新加载并放入缓存
    BigImage img = loadFromDisk(key);
    cache.put(key, new SoftReference<>(img));
    return img;
    }

字节码执行引擎 (Execution Engine)

这是 JVM 最"智能"的大脑。Java 之所以能"一次编译,到处运行"且性能接近 C++,全靠这套机制。

特性 解释器 (Interpreter) JIT 编译器 (Just-In-Time)
工作方式 读一行字节码,执行一行机器指令 将热点字节码整体编译成本地机器码
启动速度 极快 (无需编译) 慢 (需要编译时间)
执行速度 慢 (每次都要翻译) 极快 (直接执行机器码,可做深度优化)
适用场景 冷代码、只执行一次的代码 热点代码 (HotSpot)

策略:JVM 启动时先用解释器快速运行。当某段代码被频繁执行(达到阈值),JIT 编译器介入,将其编译为本地代码。后续执行直接调用本地代码。

2. 分层编译 (Tiered Compilation)

现代 HotSpot (JDK 7+) 不再是非黑即白,而是有 4 个层级,动态切换:

  • Level 0: 解释执行。
  • Level 1: C1 编译器 (Client Compiler),做简单优化 (如方法内联),编译速度快。
  • Level 2: C1 编译器,做更多优化 (如值编号)。
  • Level 3: C2 编译器 (Server Compiler),做激进优化 (如逃逸分析、锁消除、循环展开),编译慢但生成的代码质量极高。

流程

  1. 方法开始执行 -> Level 0 (解释)。
  2. 调用次数增加 (invokecounter) -> 编译到 Level 1/2 (C1)。
  3. 成为超级热点 (backedge counter + invokecounter) -> 提交给 C2 编译到 Level 3。
  4. 如果 C2 编译太慢,先继续用 C1 代码跑,编译好后替换(On-Stack Replacement, OSR

查看分层编译状态 -XX:+PrintCompilation

3. 核心优化技术 (JIT 的黑魔法)

A. 方法内联 (Method Inlining) ------ 最重要的优化

  • 原理:将小方法的代码直接复制到调用处,消除方法调用的栈帧开销。

  • 连锁反应:内联后,编译器可以看到更广阔的代码视野,从而进行更深度的优化(如常量折叠、死代码消除)。

  • 限制 :默认只对非常小的方法内联。可以通过 -XX:InlineSmallCode 调整。

  • 陷阱private, static, final 方法容易内联;virtual 方法(多态)难内联,除非 JIT 能确定具体类型(类层次分析 CHA)。

    interface Shape { double area(); }
    class Circle implements Shape { ... }
    class Square implements Shape { ... }

    void test(Shape s) { s.area(); } // 虚方法调用

  • 优化 :如果 JIT 通过 CHA (Class Hierarchy Analysis) 发现当前程序中 Shape 只有一个实现类 Circle,它会将虚调用直接转换为直接调用,甚至内联 Circle.area()

  • 守护假设 :JIT 会生成一段"守护代码"。如果后来加载了新的类 Triangle 实现了 Shape,之前的优化假设失效,JVM 会触发 Deoptimization,回退到解释执行或重新编译。

C. 逃逸分析与标量替换 (回顾)

  • 如前所述,如果对象不逃逸,JIT 直接把它拆解成寄存器或栈上的标量,堆分配指令直接消失

4. 现场演示:观察 JIT 优化

复制代码
public class JitDemo {
    public static void main(String[] args) {
        int sum = 0;
        // 预热
        for (int i = 0; i < 10000; i++) {
            sum += calculate(i);
        }
        
        // 正式测试
        long start = System.nanoTime();
        for (int i = 0; i < 100000000L; i++) {
            sum += calculate(i);
        }
        long end = System.nanoTime();
        System.out.println("Result: " + sum + ", Time: " + (end-start)/1e6 + " ms");
    }

    // 这是一个容易被内联的小方法
    public static int calculate(int x) {
        return x * 2 + 1;
    }
}

开启打印编译信息,关闭偏向锁干扰

java -XX:+PrintCompilation -XX:-UseBiasedLocking JitDemo

你会看到 calculate 方法很快被编译(例如 1% 4 make not entrant 然后 4 b 4 JitDemo::calculate)。如果你把 calculate 改得非常复杂,或者让它调用一个未知的虚方法,你会发现它长时间停留在解释执行阶段,或者编译层级较低。

5. 反优化 (Deoptimization)

JIT 是基于"猜测"优化的。如果猜测错了(比如原本以为只有一个子类,结果动态加载了新类),JVM 必须:

  1. 丢弃编译好的本地代码。
  2. 将执行栈帧转换回解释器状态(重建栈帧信息)。
  3. 重新解释执行。
  • 代价:非常高。所以尽量避免在热路径上触发类动态加载或多态剧烈变化。

终极总结:全栈性能心法

结合我们之前聊的 无锁编程JVM 内存模型GC 收集器执行引擎,这里是高性能 Java 的终极心法:

  1. 代码层面 (Source Code)

    • 减少对象分配(复用、基本类型集合、避免循环 new)。
    • 保持代码简单,方便 JIT 内联和分析(避免过度复杂的反射、动态代理)。
    • 使用 volatile 要谨慎,理解其内存屏障代价。
  2. JVM 层面 (Runtime)

    • 选对 GC:低延迟选 ZGC (JDK 21+),通用选 G1。
    • 给 JIT 时间:生产环境启动后要有"预热"过程,让分层编译完成,达到 Level 3 优化后再压测。
    • 固定堆大小-Xms == -Xmx,避免堆动态伸缩带来的 GC 开销。
  3. 监控层面 (Observability)

    • 不要猜!使用 JFR 录制,用 Async-Profiler 画火焰图。
    • 关注 Allocation Rate (分配速率) 和 GC Pause Time
相关推荐
Xzq2105092 小时前
IP协议——网络层协议
服务器·网络·tcp/ip
..过云雨2 小时前
【负载均衡oj项目】04. oj_server题目信息获取、界面渲染、负载均衡、后台交互功能
运维·c++·html·负载均衡·交互
2401_900151542 小时前
用Python和Twilio构建短信通知系统
jvm·数据库·python
一水鉴天2 小时前
整体设计自动化部署方案定稿(部分):统一工程共生坊三层架构设计 20260315(豆包助手)
运维·架构·自动化
wait a minutes2 小时前
【大模型】本地怎么通过kilo code调用Qwen免费模型
linux·运维·服务器
badwomen__2 小时前
硬件预取:让CPU提前把数据准备好
服务器·性能优化
小沛92 小时前
从“会敲 Arthas 命令”到“会做线上诊断”:我做了一个 Arthas Agent 工具
java·jvm·spring·agent
江畔何人初2 小时前
Argo CD 的核心架构组件与作用
linux·服务器·云原生·kubernetes
CDN3602 小时前
中小站安全方案|360高防服务器+CDN搭配使用,防护效果翻倍
运维·服务器·安全