欢迎光临
我们一直在努力

黑马点评背后的JVM江湖:从一个外卖订单看懂Java虚拟机

当你在黑马点评上下单一份黄焖鸡米饭时,JVM在背后做了什么? —— 一篇用外卖场景串讲JVM核心原理的深度长文

阅读时间:约50分钟 | 涵盖:内存模型 / 类加载 / GC算法 / JIT编译 / 性能调优 / OOM排查


📑 目录

  • 序章:一份外卖订单的JVM之旅
  • 第一幕:JVM内存模型 — 餐厅的"后厨布局"
  • 第二幕:堆内存与对象生命周期 — 食材从采购到上桌
  • 第三幕:垃圾回收机制 — 垃圾分类的艺术
  • 第四幕:类加载机制 — 菜谱是怎么进厨房的
  • 第五幕:执行引擎 — 厨师怎么做菜的
  • 第六幕:字符串常量池 — 食材的标准化管理
  • 第七幕:OOM排查实战 — 餐厅爆单了怎么办
  • 第八幕:JVM调优实战 — 把餐厅效率拉满
  • 参考文献

  • 🎬 序章:一份外卖订单的JVM之旅

    在这里插入图片描述

    假设你正在学习Java开发,跟着黑马程序员的教程做了一个"黑马点评"项目——一个类似大众点评的本地生活服务平台。你用Spring Boot搭建后端,Redis做缓存,MySQL存数据,跑在JVM上。

    现在,用户打开App,搜索"黄焖鸡米饭",点击一家店,下单,支付。

    这一个看似简单的操作,在JVM内部引发了一场"风暴":

  • 类加载:OrderController、OrderService、OrderMapper等几十个类被加载到JVM的方法区
  • 对象创建:Order对象、Shop对象、User对象在堆内存的Eden区诞生
  • 方法调用:每调用一个方法,就在虚拟机栈上创建一个栈帧,方法执行完栈帧销毁
  • 垃圾回收:那些临时创建的查询对象(分页参数、DTO转换对象)很快变成垃圾,等待GC清理
  • JIT编译:OrderService.createOrder()被调用几千次后,JIT编译器把它编译成机器码,性能飙升
  • 今天,我们就化身成一个Java对象,在JVM的世界里来一次"深度游"。 你会发现,JVM不是一个抽象的概念,而是一个精密运转的"餐厅后厨"——有采购(内存分配)、有厨师(执行引擎)、有保洁(垃圾回收)、有菜谱管理(类加载)。

    为什么学JVM?

    你可能觉得:“我写业务代码,了解JVM有什么用?”

    用处大了。当你的黑马点评项目遇到这些问题时,不懂JVM你只能干瞪眼:

    • 应用运行越来越慢 → 可能是GC频繁(内存泄漏)
    • 启动时报 OutOfMemoryError → 堆/栈/元空间不够
    • 某个接口偶尔超时 → 可能是Full GC的Stop-The-World
    • 线上CPU飙高 → 可能是死循环或频繁GC
    • 类加载冲突 → 不同ClassLoader加载了同名类

    不懂JVM的Java程序员,就像不懂发动机原理的司机——平时没事,出了故障就抓瞎。

    好,让我们开始这场JVM之旅!


    🏗️ 第一幕:JVM内存模型 — 餐厅的"后厨布局"

    在这里插入图片描述

    1.1 先搞清楚:JDK、JRE、JVM的关系

    很多初学者分不清这三个概念,用餐厅来类比:

    概念类比说明
    JDK (Java Development Kit) 整个餐厅(含厨房+办公区) 开发+运行的完整工具包,包含javac编译器、jdb调试器、jconsole监控工具等
    JRE (Java Runtime Environment) 只有厨房 运行Java程序的最小环境,包含JVM + 核心类库(java.lang, java.util等)
    JVM (Java Virtual Machine) 厨房里的"烹饪系统" 执行字节码的虚拟机,是JRE的核心组件

    📌 JDK 11+的变化: 从JDK 11开始,Oracle不再单独提供JRE下载。JDK本身就包含了运行时环境。这是因为模块化(Project Jigsaw)后,可以用 jlink 工具自定义裁剪出最小运行时。

    1.2 JVM运行时数据区全景

    JVM的内存分为两大类:线程私有(每个线程独享一份)和线程共享(所有线程共用)。

    表1:JVM运行时数据区分类

    区域线程共享/私有存储内容异常大小参数
    程序计数器 (PC Register) 私有 当前执行的字节码指令地址 无(唯一不会OOM的区域) 无参数
    虚拟机栈 (VM Stack) 私有 栈帧(局部变量表、操作数栈、动态链接、返回地址) StackOverflowError / OOM -Xss256k
    本地方法栈 (Native Stack) 私有 Native方法的调用栈 StackOverflowError / OOM -Xoss(通常不设置)
    堆 (Heap) 共享 对象实例、数组 OOM: Java heap space -Xms512m -Xmx512m
    方法区 (Method Area) 共享 类元数据、常量池、静态变量、JIT代码缓存 OOM: Metaspace -XX:MetaspaceSize=256m

    1.3 虚拟机栈详解 — “每道菜的制作过程”

    虚拟机栈是JVM中与方法调用最密切相关的区域。每当一个方法被调用,JVM就创建一个**栈帧(Stack Frame)**压入栈中;方法执行完毕,栈帧弹出销毁。

    栈帧包含四个核心组件:

    ① 局部变量表(Local Variable Table)

    存储方法的参数和局部变量。对于实例方法,索引0存的是 this 引用。

    以黑马点评的 BlogController.getBlogById(Long id) 为例:

    局部变量表:
    [0] this → BlogController实例的引用
    [1] id → Long类型的参数(实际是long基本类型+自动装箱)

    基本类型(int, long, float, double, byte, char, short, boolean)直接存值,引用类型存对象在堆中的地址。

    ② 操作数栈(Operand Stack)

    方法执行时的"工作台"。字节码指令在这里进行计算。比如执行 a + b:

    iload_1 // 把局部变量1(a)压入操作数栈
    iload_2 // 把局部变量2(b)压入操作数栈
    iadd // 弹出栈顶两个值,相加,结果压回栈顶
    istore_3 // 弹出栈顶值,存入局部变量3(c)

    ③ 动态链接(Dynamic Linking)

    指向运行时常量池中该栈帧所属方法的引用。在Java中,方法调用分为:

    • 静态解析:编译期就能确定调用哪个方法(private方法、static方法、final方法)
    • 动态绑定:运行时才能确定调用哪个方法(多态,比如 Animal.speak() 实际调用的是 Dog.speak())

    ④ 返回地址(Return Address)

    方法正常退出时,返回到调用方的下一条指令地址。异常退出时,通过异常处理器表确定。

    1.4 栈帧的生命周期 — 以黑马点评为例

    用户请求: GET /api/blog/123

    调用链:
    BlogController.getBlogById(123) ← 栈帧1入栈
    -> BlogService.getBlogById(123) ← 栈帧2入栈
    -> BlogMapper.selectById(123) ← 栈帧3入栈
    -> SqlSession.selectOne(…) ← 栈帧4入栈
    -> Connection.prepareStatement() ← 栈帧5入栈
    <- 返回PreparedStatement ← 栈帧5出栈
    <- 返回Blog对象 ← 栈帧4出栈
    <- 返回Blog ← 栈帧3出栈
    <- 返回Blog ← 栈帧2出栈
    <- 返回ResponseEntity<Blog> ← 栈帧1出栈

    🍳 餐厅类比: 栈帧就像一道菜的"制作工序单"。每开始做一道菜,就开一张工序单(入栈);菜做好了,工序单归档(出栈)。如果工序太复杂(递归调用太深),工序单堆得太高,就会StackOverflowError——就像厨房操作台堆不下了。

    1.5 虚拟机栈的大小与异常

    // StackOverflowError 示例:无限递归
    public class StackOverflowDemo {
    private int count = 0;

    public void recursive() {
    count++; // 每调用一次,栈帧+1
    recursive(); // 永远不退出
    }
    // 默认 -Xss256k 约能递归 2000-3000 次
    // -Xss1m 约能递归 10000+ 次
    }

    表2:不同-Xss参数下的递归深度(近似值)

    -Xss 参数栈帧大小递归深度(近似)说明
    128k 128KB ~1000-1500 生产环境不推荐
    256k 256KB ~2000-3000 JDK默认值
    512k 512KB ~5000-7000 适中
    1m 1MB ~10000-15000 栈深调用场景
    2m 2MB ~20000+ 浪费内存

    🥩 第二幕:堆内存与对象生命周期 — 食材从采购到上桌

    在这里插入图片描述

    2.1 堆内存的分代模型

    堆是JVM中最大的一块内存区域,所有对象实例都在这里分配。为了高效管理内存和GC,堆被分为:

    年轻代(Young Generation) — “食材采购区”

    • Eden区(伊甸园):新对象在这里诞生。绝大多数对象"朝生夕灭"——比如一次查询创建的DTO对象、分页参数对象等
    • Survivor 0(S0)和 Survivor 1(S1):从Eden中存活下来的对象被复制到这里。两个S区交替使用

    老年代(Old Generation) — “常备食材库”

    • 在Survivor区存活了足够多次(默认15次)的对象被"晋升"到这里
    • 存放长期存活的对象:Spring Bean(单例)、数据库连接池、线程池、缓存对象等

    表3:堆内存分代参数

    参数说明推荐值黑马点评建议
    -Xms 初始堆大小 与-Xmx相同 512m-2g
    -Xmx 最大堆大小 根据应用内存 512m-2g
    -Xmn 年轻代大小 堆的1/3到1/2 256m
    -XX:SurvivorRatio Eden:S区比例 8(即8:1:1) 8
    -XX:MaxTenuringThreshold 晋升老年代的年龄阈值 15(最大值) 15
    -XX:PretenureSizeThreshold 大对象直接进老年代的阈值 根据场景 1m

    2.2 对象的"一生" — 从出生到死亡

    在这里插入图片描述

    一个Java对象在JVM中的完整生命周期:

    ① 对象创建(在Eden区)

    当你写 new Blog() 时,JVM做了这些事:

  • 类加载检查:检查 Blog 类是否已加载。如果没有,先触发类加载
  • 分配内存:在Eden区找一块足够大的空间
    • 指针碰撞(Bump the Pointer):如果内存是规整的(使用Serial、ParNew等带Compact的收集器),用一个指针标记已用和未用的边界,移动指针即可
    • 空闲列表(Free List):如果内存不规整(使用CMS等基于Mark-Sweep的收集器),维护一个空闲列表,从列表中找一块够大的空间
  • 初始化零值:将分配的内存空间初始化为零值(int=0, boolean=false, 引用=null)。这就是为什么Java中变量有默认值
  • 设置对象头:在对象头部写入:
    • Mark Word:哈希码、GC分代年龄、锁状态标志、线程持有的锁
    • 类型指针:指向方法区中的类元数据
    • 数组长度:如果是数组,还需要记录长度
  • 执行 <init> 方法:执行构造函数,按程序员的意愿初始化
  • ② 对象在Eden中存活

    新创建的Blog对象在Eden区"生活"。在黑马点评中,一次查询请求可能会创建几十个临时对象:

    // 一次查询创建的对象(都是短命的)
    PageDTO pageDTO = new PageDTO(1, 10); // 分页参数
    QueryWrapper<Blog> wrapper = new QueryWrapper<>(); // 查询条件
    List<Blog> blogList = blogMapper.selectList(wrapper); // 结果列表
    BlogVO blogVO = BeanUtil.copyProperties(blog, BlogVO.class); // VO转换

    这些对象大多在方法返回后就不再被引用,变成垃圾。

    ③ Minor GC — “食材筛选”

    当Eden区满了,触发Minor GC(也叫Young GC):

  • 标记Eden和S0(或S1)中存活的对象
  • 将存活对象复制到S1(或S0)
  • 清空Eden和原来的S0
  • 存活对象的年龄+1
  • ④ 晋升老年代

    当对象的年龄达到 MaxTenuringThreshold(默认15),或者Survivor区放不下了,对象被晋升到老年代。

    ⑤ Major GC / Full GC

    当老年代满了,触发Major GC(也叫Full GC)。Full GC会同时清理年轻代和老年代,通常耗时较长(几十毫秒到几秒),期间应用会暂停(Stop-The-World)。

    2.3 TLAB — “每人一个小灶台”

    在多线程环境中,多个线程同时在Eden区分配对象会产生竞争。JVM通过TLAB(Thread Local Allocation Buffer) 来解决这个问题:

    • 每个线程在Eden区预先分配一小块私有内存(默认是Eden的1%)
    • 线程创建对象时,先在自己的TLAB中分配,无需加锁
    • TLAB用完了,再从Eden中申请新的TLAB

    这就像餐厅给每个厨师分配了一个小灶台,不用每次做菜都去公共灶台排队。

    -XX:+UseTLAB # 启用TLAB(默认开启)
    -XX:TLABSize=512k # 设置TLAB大小
    -XX:-ResizeTLAB # 禁止自动调整TLAB大小

    2.4 对象的内存布局

    一个Java对象在堆中占用的内存由三部分组成:

    表4:Java对象内存布局

    部分大小(64位JVM)内容
    对象头 (Object Header) 12-16字节 Mark Word (8字节) + Klass Pointer (4/8字节)
    实例数据 (Instance Data) 取决于字段 对象的字段值(int=4, long=8, 引用=4/8字节)
    对齐填充 (Padding) 0-7字节 使对象总大小为8字节的倍数

    Mark Word的结构(64位JVM,无锁状态):

    |————————————————————–|
    | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |
    |————————————————————–|

    黑马点评对象大小估算:

    • Blog 对象(10个字段):~64字节
    • User 对象(8个字段):~48字节
    • Shop 对象(12个字段):~80字节
    • 一个 ArrayList(空):~48字节
    • 一个 HashMap.Node:~48字节

    🧮 算笔账: 如果一次查询创建100个临时对象,平均每个64字节,一次查询就是6.4KB。QPS=100时,每秒在Eden区分配640KB。Eden区256MB约能撑400秒(约7分钟)才触发一次Minor GC。这就是为什么大多数Web应用的Minor GC间隔是几分钟到几十分钟。


    ♻️ 第三幕:垃圾回收机制 — 垃圾分类的艺术

    在这里插入图片描述

    3.1 什么是"垃圾"?

    在JVM中,垃圾就是不再被任何引用指向的对象。判断对象是否存活有两种算法:

    ① 引用计数法(Reference Counting)

    给每个对象维护一个引用计数器。有人引用它,计数+1;引用失效,计数-1。计数为0就是垃圾。

    • 优点:实现简单,判断效率高
    • 缺点:无法解决循环引用问题

    // 循环引用示例
    class Node {
    Node next;
    }
    Node a = new Node(); // a引用计数=1
    Node b = new Node(); // b引用计数=1
    a.next = b; // b引用计数=2
    b.next = a; // a引用计数=2
    a = null; // a引用计数=1(b.next还引用着)
    b = null; // b引用计数=1(a.next还引用着)
    // 引用计数都不为0,但实际上这两个对象已经不可达了!

    JVM不使用引用计数法。

    ② 可达性分析法(Reachability Analysis)— JVM实际使用的算法

    从一组GC Roots出发,沿着引用链向下搜索。能到达的对象是存活的,不能到达的就是垃圾。

    可以作为GC Roots的对象:

    • 虚拟机栈中引用的对象(局部变量表中的引用)
    • 方法区中类静态属性引用的对象(static 变量)
    • 方法区中常量引用的对象(static final 变量)
    • 本地方法栈中JNI引用的对象
    • 被同步锁(synchronized)持有的对象
    • JVM内部的引用(基本数据类型对应的Class对象、系统类加载器等)

    3.2 对象的"两次机会" — finalize()

    即使对象被判定为不可达,它还有一次"自救"的机会——finalize() 方法。GC第一次标记后,如果对象重写了 finalize() 且之前没调用过,JVM会把它放入一个队列,由一个低优先级的Finalizer线程执行 finalize()。如果在 finalize() 中重新建立了引用(比如 this 赋值给某个类变量),对象就能"复活"。

    ⚠️ 强烈不推荐使用finalize()! 原因:

  • 执行时机不确定,可能导致资源释放不及时
  • 可能导致对象复活,增加GC负担
  • JDK 9+ 已标记为 @Deprecated
  • 推荐使用 try-with-resources 或 Cleaner 替代
  • 3.3 三种GC算法

    ① 标记-清除(Mark-Sweep)

    分两步:先标记存活对象,再清除未标记的对象。

    • 优点:实现简单
    • 缺点:产生内存碎片;清除后内存不连续,大对象可能找不到足够的连续空间

    ② 标记-整理(Mark-Compact)

    先标记存活对象,然后将所有存活对象向内存一端移动,清理边界外的内存。

    • 优点:没有内存碎片
    • 缺点:移动对象需要更新所有引用,效率较低

    ③ 复制算法(Copying)

    将内存分为两块,每次只使用一块。GC时,将存活对象复制到另一块,然后清空当前块。

    • 优点:没有碎片;只复制存活对象,效率高(适合存活率低的场景)
    • 缺点:浪费50%的内存空间

    实际应用:年轻代用复制算法(因为大部分对象朝生夕灭),老年代用标记-整理或标记-清除。

    3.4 分代收集策略

    JVM的分代收集结合了上述算法的优点:

    区域算法原因
    年轻代 复制算法 90%+的对象在Eden中死亡,只需复制少量存活对象
    老年代 标记-整理 或 标记-清除 存活率高,复制算法浪费空间

    年轻代的优化: Eden:S0:S1 = 8:1:1(而不是1:1:1)。这样只浪费10%的内存空间(S0或S1中总有一个是空的),而Eden区可以使用80%的空间。这就是"Appel式回收"。

    3.5 GC收集器全家福

    在这里插入图片描述

    JVM提供了多种GC收集器,每种都有不同的设计目标。让我们对比一下:

    表5:GC收集器全面对比

    收集器代算法线程STW特点适用场景
    Serial Young 复制 单线程 完全STW 简单高效 小堆、客户端模式
    Serial Old Old 标记-整理 单线程 完全STW CMS的后备方案 小堆
    ParNew Young 复制 多线程 完全STW Serial的多线程版 配合CMS
    Parallel Scavenge Young 复制 多线程 完全STW 关注吞吐量 后台计算
    Parallel Old Old 标记-整理 多线程 完全STW JDK8默认 后台计算
    CMS Old 标记-清除 并发 部分STW 低延迟 已废弃(JDK9)
    G1 Both 分Region 并发 可控STW 平衡型 JDK9+默认
    ZGC Both 着色指针 并发 <1ms 超低延迟 大堆、低延迟
    Shenandoah Both Brooks指针 并发 <1ms 类似ZGC OpenJDK

    3.6 G1收集器详解 — 黑马点评的最佳选择

    G1(Garbage-First)是JDK 9+的默认GC收集器,也是黑马点评这类Web应用的最佳选择。

    G1的核心设计:

    G1将整个堆划分为多个大小相等的Region(默认2048个Region),每个Region可以是Eden、Survivor、Old或Humongous(存大对象)。

    G1堆布局示例(2048个Region,每个1MB):
    [E][E][S][E][O][O][E][H][H][O][S][E][O][O][E][E]…
    | | | | | | | |__| | | | | | | |
    年轻代 大对象 老年代

    G1的特点:

    • 可预测的停顿:通过 -XX:MaxGCPauseMillis=200 设置目标停顿时间,G1会优先回收价值最大的Region
    • 不需要连续的Region:大对象可以跨多个Region存放
    • 全局标记:并发标记阶段标记整个堆的存活对象
    • Mixed GC:同时回收年轻代和部分老年代Region

    G1的关键参数:

    -XX:+UseG1GC # 启用G1
    -XX:MaxGCPauseMillis=200 # 目标最大停顿时间(毫秒)
    -XX:G1HeapRegionSize=1m # Region大小(1-32MB,2的幂)
    -XX:InitiatingHeapOccupancyPercent=45 # 老年代占比超过45%触发并发标记
    -XX:G1NewSizePercent=5 # 年轻代最小比例
    -XX:G1MaxNewSizePercent=60 # 年轻代最大比例


    📦 第四幕:类加载机制 — 菜谱是怎么进厨房的

    在这里插入图片描述

    4.1 类的生命周期

    一个类从被加载到JVM中,到被卸载,经历以下阶段:

    ① 加载(Loading)

    通过类的全限定名(如 com.heima.dianping.service.BlogService)找到对应的 .class 文件,读取字节码,在方法区创建对应的 Class 对象。

    ② 链接(Linking)

    • 验证(Verification):检查字节码是否符合JVM规范(文件格式、元数据、字节码、符号引用验证)
    • 准备(Preparation):为类的静态变量分配内存并设置零值(static int count = 10 此时设为0,不是10)
    • 解析(Resolution):将符号引用替换为直接引用

    ③ 初始化(Initialization)

    执行类构造器 <clinit>() 方法,按代码顺序执行静态变量赋值和静态代码块。

    public class BlogService {
    static int count = 10; // 准备阶段count=0,初始化阶段count=10
    static final String NAME = "DP"; // 编译期常量,准备阶段就=DP
    static {
    System.out.println("BlogService loaded!");
    }
    }

    ④ 使用(Using)

    创建实例、调用方法、访问字段。

    ⑤ 卸载(Unloading)

    类被卸载需要同时满足三个条件:

  • 该类的所有实例都已被GC
  • 该类的ClassLoader已被GC
  • 该类的 Class 对象没有被任何地方引用
  • 4.2 类加载器的双亲委派模型

    JVM有三层类加载器,采用双亲委派模型(Parent Delegation Model):

    Bootstrap ClassLoader (C++实现)
    ↑ 加载 rt.jar 中的核心类 (java.lang.*, java.util.*)
    Extension ClassLoader (Platform ClassLoader, JDK9+)
    ↑ 加载 jre/lib/ext 中的扩展类
    Application ClassLoader (System ClassLoader)
    ↑ 加载 classpath 中的应用类
    Custom ClassLoader (自定义)
    ↑ Tomcat ClassLoader, Spring Boot Fat-JAR Loader, etc.

    双亲委派的工作流程:

    当一个类加载器收到加载请求时:

  • 先委托给父加载器去加载
  • 父加载器无法加载时,才自己尝试加载
  • 为什么需要双亲委派?

    防止核心类被篡改。比如有人写了一个 java.lang.String 类,如果没有双亲委派,应用类加载器可能会加载这个假的String类,导致安全问题。有了双亲委派,Bootstrap ClassLoader会优先加载rt.jar中的真正String类。

    4.3 打破双亲委派的场景

    虽然双亲委派是推荐的模型,但在某些场景下需要打破它:

    场景打破方式原因
    SPI机制 Thread.getContextClassLoader() 核心类需要加载应用实现(如JDBC驱动)
    Tomcat 每个Web应用独立的ClassLoader 不同应用可以使用不同版本的类库
    热部署 自定义ClassLoader重新加载 代码修改后不重启生效
    OSGi 网状委托结构 模块化,双向依赖

    4.4 类加载实战

    # 查看类加载信息
    java -verbose:class -jar dianping.jar

    # 输出示例:
    # [Loaded com.heima.dianping.BlogController from file:/app/classes/]
    # [Loaded com.heima.dianping.service.BlogService from file:/app/classes/]
    # [Loaded java.lang.Object from /jdk/jre/lib/rt.jar]


    🔥 第五幕:执行引擎 — 厨师怎么做菜的

    在这里插入图片描述

    5.1 三种执行方式

    JVM的执行引擎有三种执行字节码的方式:

    ① 解释执行(Interpreter)

    逐条读取字节码指令,翻译成机器码执行。

    • 优点:启动速度快(不需要编译),内存占用小
    • 缺点:执行速度慢(每条指令都要翻译一次)
    • 类比:同声传译——听到一句翻译一句,速度快但不精确

    ② 编译执行(JIT, Just-In-Time Compilation)

    将热点代码(被频繁调用的方法或循环)编译成机器码,存入代码缓存。下次执行时直接用机器码,不需要再翻译。

    • 优点:执行速度接近原生代码
    • 缺点:编译需要时间和内存;启动时比解释执行慢
    • 类比:先把菜谱背下来,做菜时不用再翻书

    ③ 混合执行(Mixed Mode)— JVM的默认模式

    JVM同时使用解释器和JIT编译器:

    • 启动时用解释器快速启动
    • 运行时识别热点代码,用JIT编译器编译
    • 这就是 Tiered Compilation(分层编译)

    5.2 分层编译详解

    JDK 8+默认开启分层编译(-XX:+TieredCompilation),将编译分为5个级别:

    层级名称说明触发条件
    Level 0 解释执行 收集基本性能数据 默认
    Level 1 C1编译,无profiling 快速编译,不收集性能数据 简单方法
    Level 2 C1编译,有限profiling 编译+收集部分调用/分支计数 中等热点
    Level 3 C1编译,完全profiling 编译+收集完整性能数据 热点方法
    Level 4 C2编译 完全优化编译 高度热点

    编译阈值(默认):

    -XX:CompileThreshold=10000 # 方法被调用10000次后触发JIT编译
    -XX:OnStackReplacePercentage=1400 # OSR编译阈值

    5.3 C2编译器的优化手段

    C2编译器(Server Compiler)做了大量优化:

    • 方法内联(Inlining):把小方法的代码直接嵌入调用者,消除方法调用开销
    • 逃逸分析(Escape Analysis):分析对象是否逃出方法/线程。如果没有:
      • 栈上分配:对象在栈上分配,方法结束自动释放,不需要GC
      • 标量替换:把对象拆成基本类型变量
      • 同步消除:如果对象只在单线程中使用,去除同步锁
    • 循环展开(Loop Unrolling):减少循环判断次数
    • 空值检查消除(Null Check Elimination):如果能证明引用不为null,去除空值检查
    • 内联缓存(Inline Cache):缓存虚方法的目标,加速多态调用

    // 逃逸分析示例
    public int calculate() {
    Point p = new Point(1, 2); // p没有逃出方法
    return p.x + p.y;
    // C2优化后:直接 int x=1, int y=2; return x+y;
    // 不需要创建Point对象,不需要GC
    }

    5.4 JIT编译的观察

    # 打印JIT编译信息
    java -XX:+PrintCompilation -jar dianping.jar

    # 输出示例:
    # 78 1 3 java.lang.String::charAt (29 bytes)
    # 123 2 4 com.heima.dianping.service.BlogService::getBlogById (45 bytes)
    # 150 3 3 java.util.HashMap::hash (20 bytes)


    📝 第六幕:字符串常量池 — 食材的标准化管理

    在这里插入图片描述

    6.1 String的特殊地位

    String是Java中最常用的类,也是面试的"常客"。它的特殊之处在于不可变性(Immutable) 和 常量池机制。

    // String的不可变性
    public final class String {
    private final char[] value; // JDK8用char[],JDK9+用byte[]
    // final + private = 不可变
    }

    为什么String要设计成不可变?

  • 安全性:String常用于网络连接、文件路径、类名等,不可变保证这些值不会被恶意修改
  • 线程安全:不可变对象天然是线程安全的
  • 哈希缓存:不可变使得hashCode可以缓存,HashMap中作为key效率更高
  • 6.2 字面量 vs new String()

    String s1 = "Hello"; // 字面量,存入字符串常量池
    String s2 = "Hello"; // 从常量池中取,和s1是同一个对象
    String s3 = new String("Hello"); // 在堆中创建新对象,不在常量池中

    s1 == s2 // true (同一个引用)
    s1 == s3 // false (不同的对象)
    s1.equals(s3) // true (值相同)

    字符串常量池的位置变迁:

    • JDK 6及之前:在永久代(PermGen)中
    • JDK 7+:移到了堆中(因为永久代空间太小,容易OOM)

    6.3 String.intern() — 慎用!

    String s1 = new String("Hello"); // 堆中创建
    s1.intern(); // 把"Hello"放入常量池(如果不存在)
    String s2 = "Hello"; // 从常量池取
    s1 == s2 // JDK7+: false (s1指向堆中对象,s2指向池中)

    ⚠️ 黑马点评中的陷阱: 如果你在循环中对大量不同的字符串调用 intern(),常量池会膨胀,导致GC压力增大甚至OOM。比如拼接Redis key "blog:" + blogId,每调用一次intern就往常量池加一个新字符串。

    6.4 String拼接的性能

    // 方式1:+ 号拼接(JDK9+编译器优化为invokedynamic)
    String result = "blog:" + id + ":detail"; // OK in JDK9+

    // 方式2:StringBuilder(推荐在循环中使用)
    StringBuilder sb = new StringBuilder();
    for (Blog blog : blogList) {
    sb.append(blog.getTitle()).append(",");
    }

    // 方式3:String.join(JDK8+)
    String result = String.join(",", titles);

    // 方式4:String.format
    String key = String.format("blog:%d:detail", id);


    💥 第七幕:OOM排查实战 — 餐厅爆单了怎么办

    在这里插入图片描述

    7.1 常见的OOM类型

    表6:JVM常见OOM类型与原因

    OOM类型错误信息常见原因排查工具
    堆内存不足 Java heap space 内存泄漏、堆太小 MAT, jmap
    元空间不足 Metaspace 类加载过多、动态代理 jstat, -verbose:class
    栈溢出 StackOverflowError 无限递归、栈帧太大 jstack
    直接内存不足 Direct buffer memory NIO Buffer泄漏 -XX:MaxDirectMemorySize
    GC开销超限 GC overhead limit exceeded GC时间占比>98% GC日志
    无法创建线程 unable to create new native thread 线程数超限 ulimit, /proc/sys

    7.2 黑马点评OOM实战案例

    场景: 黑马点评上线后,用户量增长,某天凌晨突然告警,应用OOM崩溃。

    ① 第一步:收集现场

    # 查看GC日志
    tail -1000 /tmp/gc.log | grep "Full GC"

    # 输出:
    # 2025-07-13T02:15:30.123+0800: [Full GC (Allocation Failure)
    # [PSYoungGen: 256000K->256000K(262144K)]
    # [ParOldGen: 262144K->262144K(262144K)]
    # 518144K->518144K(524288K)]

    ② 第二步:获取Heap Dump

    # 方式1:OOM时自动生成(推荐在启动参数中加上)
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/tmp/heapdump.hprof

    # 方式2:手动触发
    jmap -dump:format=b,file=/tmp/heapdump.hprof <PID>

    # 方式3:jcmd(推荐,对线上影响最小)
    jcmd <PID> GC.heap_dump /tmp/heapdump.hprof

    ③ 第三步:用MAT分析Heap Dump

    Eclipse MAT(Memory Analyzer Tool)是最强大的堆分析工具:

  • 打开 heapdump.hprof
  • 查看 Leak Suspects Report(泄漏嫌疑报告)
  • 查看 Dominator Tree(支配树)—— 按对象占用内存排序
  • MAT分析结果:

    Dominator Tree:
    Problem Suspect 1:
    com.heima.dianping.controller.BlogController.cache
    Retained Heap: 450MB (85% of total)
    Shallow Heap: 128 bytes

    问题代码:
    private static List<Blog> cache = new ArrayList<>();

    public List<Blog> getBlogs() {
    List<Blog> blogs = blogMapper.selectAll();
    cache.addAll(blogs); // 每次查询都往cache里加,从不清理!
    return blogs;
    }

    ④ 第四步:修复

    // 修复前(内存泄漏)
    private static List<Blog> cache = new ArrayList<>();

    // 修复后(使用Redis缓存)
    @Autowired
    private StringRedisTemplate redisTemplate;

    public List<Blog> getBlogs() {
    // 先查Redis缓存
    String cacheKey = "blog:list";
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
    return JSON.parseArray(cached, Blog.class);
    }
    // 缓存未命中,查数据库
    List<Blog> blogs = blogMapper.selectAll();
    // 写入Redis,设置30分钟过期
    redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(blogs), 30, TimeUnit.MINUTES);
    return blogs;
    }

    7.3 常用JVM诊断工具

    表7:JVM诊断工具对比

    工具类型功能使用场景
    jps 命令行 查看Java进程 找到目标PID
    jstat 命令行 GC统计信息 监控GC频率、堆使用率
    jmap 命令行 堆dump、内存映射 获取heap dump
    jstack 命令行 线程dump 排查死锁、CPU飙高
    jcmd 命令行 综合诊断 替代jps+jmap+jstack
    MAT GUI 堆分析 OOM根因分析
    VisualVM GUI 可视化监控 实时监控+采样
    JProfiler GUI 性能分析 方法级耗时分析
    Arthas 命令行 在线诊断 线上不重启排查

    🔧 第八幕:JVM调优实战 — 把餐厅效率拉满

    在这里插入图片描述

    8.1 什么时候需要调优?

    不是所有应用都需要JVM调优。以下情况才需要:

    • GC停顿时间过长,影响用户体验(P99延迟超标)
    • GC频率过高,CPU被GC占用过多
    • 应用频繁OOM
    • 堆内存使用率持续很高

    8.2 调优的基本原则

  • 不要过早调优:先确认是JVM问题,不是代码问题
  • 一次只调一个参数:否则无法判断是哪个参数的效果
  • 先调内存大小,再调GC算法:大部分情况下,合理的内存配置就够了
  • 监控先行:没有数据支撑的调优是盲目的
  • 8.3 黑马点评生产环境JVM参数

    # 基础参数
    -server # 服务器模式
    -Xms4g -Xmx4g # 堆大小(初始=最大,避免动态扩展)
    -Xmn2g # 年轻代2G(堆的50%)
    -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m # 元空间

    # GC参数
    -XX:+UseG1GC # 使用G1收集器
    -XX:MaxGCPauseMillis=200 # 目标最大停顿200ms
    -XX:G1HeapRegionSize=4m # Region大小
    -XX:InitiatingHeapOccupancyPercent=45 # 老年代45%触发并发标记

    # 监控与诊断
    -XX:+HeapDumpOnOutOfMemoryError # OOM时自动dump
    -XX:HeapDumpPath=/data/logs/heapdump.hprof
    -XX:+PrintGCDetails # 打印GC详情
    -XX:+PrintGCDateStamps # GC时间戳
    -Xloggc:/data/logs/gc.log # GC日志路径
    -XX:+UseGCLogFileRotation # GC日志轮转
    -XX:NumberOfGCLogFiles=5 # 保留5个GC日志文件
    -XX:GCLogFileSize=20M # 每个GC日志20MB

    # 性能优化
    -XX:+UseCompressedOops # 压缩指针(堆<32G时)
    -XX:+UseCompressedClassPointers # 压缩类指针
    -XX:+TieredCompilation # 分层编译
    -XX:+OptimizeStringConcat # 优化字符串拼接

    8.4 GC日志分析

    GC日志是调优的重要依据。关键指标:

    [GC pause (G1 Evacuation Pause) (young), 0.0154321 secs]
    [Parallel Time: 12.3 ms, GC Workers: 8]
    [Eden: 1024.0M(1024.0M)->0.0B(1024.0M)] // Eden清空
    [Survivor: 128.0M->128.0M] // Survivor保持
    [Old: 512.0M->640.0M] // Old增长(对象晋升)
    [Heap: 1664.0M(4096.0M)->768.0M(4096.0M)] // 总堆使用下降

    需要关注的指标:

    指标健康范围告警阈值说明
    Minor GC频率 5-30分钟/次 <1分钟/次 太频繁说明年轻代太小或对象分配太多
    Minor GC耗时 <50ms >100ms 与存活对象数量成正比
    Full GC频率 <1次/天 >1次/小时 太频繁说明有内存泄漏或老年代太小
    Full GC耗时 <200ms >1s 与堆大小成正比
    GC后老年代使用率 <70% >80% 持续增长说明有内存泄漏

    8.5 常见JVM问题与解决方案

    表8:常见JVM问题排查速查表

    现象可能原因排查手段解决方案
    Full GC频繁 内存泄漏、老年代太小 MAT分析heap dump 修复泄漏、增大老年代
    Minor GC频繁 年轻代太小、对象分配速率高 jstat -gcutil 增大年轻代、优化代码
    GC停顿时间长 堆太大、存活对象多 GC日志分析 换G1/ZGC、减小堆
    CPU飙高 死循环、频繁GC、锁竞争 top + jstack + GC日志 修复代码
    应用卡顿 Full GC STW、锁等待 GC日志 + jstack 换低延迟GC、优化锁
    OOM: Metaspace 类加载泄漏、CGLIB代理过多 -verbose:class 增大Metaspace、修复泄漏
    OOM: heap space 内存泄漏、堆太小 MAT分析dump 修复泄漏、增大堆

    8.6 不同场景的JVM配置建议

    场景堆大小GC选择关键参数
    开发环境 256m-512m 默认Parallel -XX:+PrintGCDetails
    测试环境 512m-2g G1 -XX:MaxGCPauseMillis=200
    生产(Web应用) 4g-8g G1 全套生产参数
    生产(低延迟) 8g+ ZGC -XX:+UseZGC
    生产(批处理) 8g+ Parallel -XX:+UseParallelGC -XX:GCTimeRatio=99
    微服务(容器) 根据容器 G1 -XX:MaxRAMPercentage=75.0

    🌟 结语

    从一份外卖订单出发,我们走过了JVM的每一个核心区域:

    • 虚拟机栈是"制作工序单",记录每道菜的制作过程
    • 堆内存是"食材仓库",新食材在年轻代,常备食材在老年代
    • 垃圾回收是"保洁阿姨",定期清理用完的食材
    • 类加载是"菜谱管理",确保厨房里有正确的菜谱
    • 执行引擎是"厨师团队",解释器是新手(现学现做),JIT是老手(烂熟于心)
    • 字符串常量池是"标准化食材库",避免重复采购

    JVM调优不是银弹,代码质量才是根本。 90%的性能问题,根源在于代码(内存泄漏、N+1查询、不合理的缓存策略),而不是JVM参数。把代码写好,JVM的默认参数往往就够了。

    但当你的应用真的需要调优时,希望这篇文章能给你一把"手术刀",让你在JVM的世界里游刃有余。

    “理解JVM,不是为了炫技,而是为了在凌晨三点收到告警时,能从容地打开GC日志,找到问题,修复它,然后安心地回去睡觉。”


    📚 参考文献

  • Tim Lindholm, Frank Yellin, et al. “The Java Virtual Machine Specification, Java SE 8 Edition.” Oracle, 2014. https://docs.oracle.com/javase/specs/jvms/se8/html/
  • Kathy Sierra, Bert Bates. “Head First Java.” O’Reilly Media, 2005.
  • 周志明. 《深入理解Java虚拟机:JVM高级特性与最佳实践》(第3版). 机械工业出版社, 2019.
  • Scott Oaks. “Java Performance: In-Depth Advice for Tuning and Programming Java Code.” O’Reilly Media, 2014.
  • Aleksey Shipilev. “JVM Anatomy Quarks.” 2017-2022. https://shipilev.net/jvm/anatomy-quarks/
  • Oracle. “Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide.” https://docs.oracle.com/en/java/javase/17/gctuning/
  • Bengt Rutisson et al. “JEP 378: Text Blocks” and G1 GC Documentation. OpenJDK. https://openjdk.org/jeps/378
  • Eclipse Foundation. “Eclipse Memory Analyzer (MAT) Documentation.” https://www.eclipse.org/mat/
  • Alibaba. “Arthas: Java Diagnostic Tool.” https://arthas.aliyun.com/
  • Marcus Hirt, Klara Ward. “Oracle JRockit: The Definitive Guide.” Packt Publishing, 2010.

  • *📝 本文由AI助手撰写 | JVM知识来源于《深入理解Java虚拟机》及JVM官方文档

    赞(0)
    未经允许不得转载:171主机测评 » 黑马点评背后的JVM江湖:从一个外卖订单看懂Java虚拟机
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址