JVM 内存模型与 OOM 排查:从入门到实战

JVM 内存模型与 OOM 排查:从入门到实战

这篇文章会带你从零开始理解 JVM 内存模型和 OOM 排查。

读完后你会明白:

复制代码
 JVM 运行时数据区分为哪几块?各自负责什么?
 堆和栈的关系是什么?对象在内存中是怎么流转的?
 新生代、老年代、元空间分别在哪儿?
 什么场景会导致 OOM?有几种类型的 OOM?
 线上 OOM 了怎么排查?怎么定位到具体代码?
 不同类型的 OOM 分别怎么解决?

第一部分:JVM 内存的整体架构

一、JVM 启动时做了什么

当你执行 java YourApp 时,JVM 做的第一件事就是向操作系统申请一块内存,然后在这块内存里划分出几个区域,各司其职。

markdown 复制代码
JVM 进程启动
    │
    ├── 向操作系统申请内存
    │
    └── 划分为 5 大块运行时数据区
        ├── 程序计数器
        ├── 虚拟机栈
        ├── 本地方法栈
        ├── 堆
        └── 方法区(元空间)

这 5 块区域,就是 JVM 运行时数据区的全部。 所有 Java 代码运行过程中的数据,都存在这 5 块地方。

二、先分清线程私有和线程共享

markdown 复制代码
JVM 运行时数据区
│
├── 线程私有(每个线程独享一份,互不干扰)
│   ├── 程序计数器
│   ├── 虚拟机栈
│   └── 本地方法栈
│
└── 线程共享(所有线程共同使用)
    ├── 堆
    └── 方法区(元空间)

为什么有的私有有的共享?

css 复制代码
线程私有的逻辑:
  每个线程执行自己的代码,有自己的调用链
  A 线程调用到第几行了,B 线程不需要知道
  A 线程的方法局部变量,B 线程也不应该访问
  → 各管各的,互不干扰

线程共享的逻辑:
  对象是大家都能访问的
  A 线程创建的 User 对象,B 线程也可以拿到引用
  类的信息是全局唯一的,不可能每个线程各存一份
  → 必须共享

三、逐个认识这 5 块区域

区域 1:程序计数器
java 复制代码
作用:记录当前线程正在执行的字节码指令的地址

通俗理解:就是个"书签"
  线程执行到第几行代码了,用它来标记
  CPU 切换线程时(上下文切换),切回来后靠它知道从哪继续执行

特点:
  ├── 线程私有
  ├── 占用内存极小(几乎可以忽略)
  ├── 唯一不会抛 OOM 的区域
  └── 如果执行的是 native 方法,计数器值为空(undefined)
区域 2:虚拟机栈
css 复制代码
作用:每个方法调用时,在栈中创建一个"栈帧"

通俗理解:就像一摞盘子
  调用方法A → 压一个盘子
  方法A调用方法B → 再压一个盘子
  方法B执行完 → 弹出盘子
  方法A执行完 → 弹出盘子

特点:
  ├── 线程私有(每个线程有自己的栈)
  ├── 方法调用时压栈,方法返回时弹栈
  └── 栈太深会抛 StackOverflowError(比如无限递归)

栈帧里面装了什么?

css 复制代码
一个栈帧的结构:
┌─────────────────────┐
│  局部变量表           │ ← 方法里定义的变量(int a, String name)
│  操作数栈             │ ← 做运算用的临时空间(a + b 的中间结果)
│  动态链接             │ ← 指向方法区中该方法的引用
│  方法返回地址          │ ← 方法执行完后回到哪里
└─────────────────────┘
区域 3:本地方法栈
java 复制代码
作用:给 native 方法用的栈

什么是 native 方法?
  Java 底层很多功能是用 C/C++ 实现的
  Java 层面只是声明了方法签名,真正实现在本地代码里
  比如:Thread.start0()、Object.hashCode()

为什么单独一个栈?
  native 方法的调用机制和 Java 方法不同
  需要单独的栈来管理

特点:
  ├── 线程私有
  └── 和虚拟机栈的作用类似,只是服务对象不同
区域 4:堆
arduino 复制代码
作用:存放几乎所有的对象实例

通俗理解:一个大仓库
  你代码里 new 出来的对象,都放在这里
  所有线程都能访问堆里的对象

特点:
  ├── 线程共享
  ├── JVM 管理的内存中最大的一块
  ├── 是 GC(垃圾回收)的主要工作区域
  └── 堆内存不足时抛 OOM(Java heap space)⭐ 最常见
区域 5:方法区
arduino 复制代码
作用:存储类的元数据信息

通俗理解:类的"档案馆"
  每个类加载进 JVM 后,它的结构信息存在这里
  不是存对象实例,而是存"类的描述信息"

里面存什么:
  ├── 类的全限定名(com.xxx.User)
  ├── 父类名、实现的接口
  ├── 字段信息(名称、类型、修饰符)
  ├── 方法信息(方法的字节码)
  ├── 运行时常量池
  ├── 静态变量(static 修饰的)
  ├── final 常量
  └── 类加载器的引用

特点:
  ├── 线程共享
  └── 在 HotSpot 虚拟机中,方法区的实现就是元空间(Metaspace)

第二部分:堆的内部结构

一、堆不是一块铁板,内部有分区

scss 复制代码
堆 (Heap)
├── 年轻代 (Young Generation)
│   ├── Eden(伊甸区)         ← 新对象首选分配在这里
│   ├── Survivor S0(幸存区 0) ← 存活下来的对象搬到这里
│   └── Survivor S1(幸存区 1) ← 和 S0 交替使用
│
└── 老年代 (Old Generation)      ← 长期存活的对象最终到这里

默认比例:

ini 复制代码
Eden : S0 : S1 = 8 : 1 : 1
年轻代 : 老年代 = 1 : 2

可以通过 JVM 参数调整:
  -XX:NewRatio=2        → 老年代:年轻代 = 2:1
  -XX:SurvivorRatio=8   → Eden:S0:S1 = 8:1:1

二、为什么要这么分区?不分不行吗?

不分区的话,每次 GC 都要扫描整个堆,效率太低。

arduino 复制代码
分区的好处:

大部分对象是"朝生夕灭"的
  → 比如方法里的临时变量、循环中的中间对象
  → 用完就没人引用了,很快就该回收

所以:
  新对象放 Eden → 频繁回收这个小区域就行
  长期存活的放老年代 → 回收频率降低
  互不干扰,各管各的

三、对象在堆内的一生

sql 复制代码
对象的生命周期:

① new User() → 分配在 Eden 区
                    │
② 第一次 Young GC(Eden 满了触发)
   ├── 没人引用了 → 直接回收
   └── 还有人引用 → 挪到 Survivor S0
                        │
③ 第二次 Young GC
   ├── 没人引用了 → 回收
   └── 还有人引用 → 挪到 Survivor S1
                        │
④ 反复在 S0 和 S1 之间挪动
   每挪一次,年龄 +1(存在对象头里)
                        │
⑤ 年龄达到阈值(默认 15)
   → 晋升到老年代
                        │
⑥ 在老年代里
   ├── 某次 Full GC 发现没人引用了 → 回收
   └── 一直被引用 → 直到程序结束
markdown 复制代码
用图来表示对象的移动路径:

Eden → S0 → S1 → S0 → S1 → ... → 老年代
       └───── 年轻代内部反复移动 ─────┘        最终归宿

第三部分:栈和堆的关系------一个对象的完整旅程

一、当你写下 new User() 时,内存里发生了什么

java 复制代码
User user = new User();
user.name = "张三";

这一行代码涉及了三个内存区域的协作:

ini 复制代码
        类加载阶段                    对象创建阶段
┌─────────────────────┐         ┌──────────────────┐
│       方法区         │         │       堆          │
│  ┌───────────────┐  │         │  ┌────────────┐  │
│  │ User 类的元数据 │◄─│─ ─ ─ ─ │─│ User 实例    │  │
│  │ - 字段信息     │  │ 对象头  │  │ - name=张三  │  │
│  │ - 方法字节码   │  │ 指针指向│  │ - age=0      │  │
│  │ - static 变量 │  │ 元数据  │  └──────▲───────┘  │
│  └───────────────┘  │         └─────────│─────────┘
└─────────────────────┘                   │
                                          │ 引用地址
┌─────────────────────┐                   │
│     虚拟机栈          │                   │
│  ┌───────────────┐  │                   │
│  │ 栈帧           │  │                   │
│  │ user ────────────────────────────────┘
│  │ (存的是地址)   │  │
│  └───────────────┘  │
└─────────────────────┘

三个区域各存什么:

sql 复制代码
方法区 → 存 User 类的"蓝图"(类有哪些字段、方法)
堆     → 存 new User() 造出来的"实例"(具体的对象数据)
栈     → 存 user 变量,它只是个引用地址,指向堆中的实例

二、对象分配的详细过程

sql 复制代码
你写了 new User(),JVM 内部做了什么?

第1步:类加载检查
  → User 类有没有被加载过?
  → 没有 → 先走类加载流程(加载、链接、初始化)
  → 有 → 跳过

第2步:分配内存
  → 从堆的 Eden 区分配一块空间给这个对象
  → 有两种分配策略:
     ├── 指针碰撞(内存规整时用)
     │   像排队一样,指针往空闲方向挪一段距离
     │   适合 Serial、ParNew 这类 GC
     │
     └── 空闲列表(内存碎片化时用)
         维护一个列表记录哪些区域空闲
         适合 CMS 这类 GC

第3步:初始化零值
  → 把这块内存的内容全部清零
  → 所以 int 默认是 0,boolean 默认是 false

第4步:设置对象头
  → 写入 HashCode、GC 分代年龄、锁状态、类元数据指针

第5步:执行构造方法
  → 你写的 User() 构造方法里的代码这时候才执行

三、线程安全问题------多个线程同时 new 对象怎么办

markdown 复制代码
多个线程同时往 Eden 分配对象,指针还没挪完另一个线程也来挪
→ 内存分配冲突

JVM 的解决方案:
├── 方案1:CAS + 失败重试(乐观锁)
│   你挪我也挪,谁失败了谁重来
│
└── 方案2:TLAB(Thread Local Allocation Buffer)⭐ 默认方案
    每个线程在 Eden 区预先划一小块私有区域
    分配时优先在自己的 TLAB 里分,不用加锁
    TLAB 用完了再申请新的
arduino 复制代码
TLAB 的分配流程:

线程A要 new 对象
  → 先看 TLAB 里还有没有空间
  ├── 有 → 直接在 TLAB 里分配,不用加锁 ✅
  └── 没有 → 申请新的 TLAB(需要 CAS)
               → 在新 TLAB 里分配

第四部分:元空间------方法区的实现

一、元空间和永久代的关系

arduino 复制代码
方法区是一个"规范",不同虚拟机有不同实现

HotSpot 虚拟机:
├── JDK 7 及之前 → 永久代(PermGen)
└── JDK 8 及之后 → 元空间(Metaspace)⭐ 现在用的

为什么要用元空间替代永久代?

markdown 复制代码
永久代的问题:

1. 大小难预估
   永久代通过 -XX:MaxPermSize 设死大小
   你不可能在项目启动前算出运行时会加载多少类
   设小了 → OOM,设大了 → 浪费

2. 容易 OOM
   项目大量使用反射、动态代理、CGLib(Spring 全家桶)
   动态生成的类都放永久代,很容易撑爆
   报错:PermGen space

3. GC 效率低
   永久代的回收要跟老年代一起做 Full GC
   耦合在一起,不够灵活

4. 字符串常量池也在里面
   String.intern() 滥用直接撑爆永久代
   (Java 7 已经把字符串常量池挪到堆里了)

元空间的优势:

复制代码
元空间直接使用本地内存(Native Memory)
不是向 JVM 申请的,而是直接向操作系统申请

好处:
├── 不受 -Xmx 限制(堆设 2G,元空间不在这 2G 里面)
├── 空间基本充足(受物理内存限制)
├── 不用预估大小了
└── 减少了因为类加载导致的 OOM

JVM 启动时的内存分配:

┌─────────────────────────────────────────────────────┐
│                   操作系统内存                        │
│                                                     │
│  ┌──────────────────────────┐  ┌─────────────────┐ │
│  │  JVM 堆内存               │  │  本地内存         │ │
│  │  (-Xmx 控制)            │  │  ┌────────────┐ │ │
│  │  ┌─────────┬───────────┐ │  │  │ 元空间      │ │ │
│  │  │ 年轻代   │ 老年代     │ │  │  └────────────┘ │ │
│  │  └─────────┴───────────┘ │  │                 │ │
│  └──────────────────────────┘  └─────────────────┘ │
│                                                     │
└─────────────────────────────────────────────────────┘

元空间不在堆里,在堆外面,用的是本地内存

二、方法区里存的 vs 堆里存的------为什么要分开

arduino 复制代码
问:为什么方法区和堆要分开?不都是存数据吗?

答:因为数据的"性质"完全不同。

方法区存的是:
  类的结构描述信息(类名、字段、方法、字节码)
  这些信息是"类级别"的
  一个类只有一份,所有线程共享
  生命周期跟类一样长(类加载后一直存在)

堆存的是:
  对象实例(你 new 出来的具体数据)
  这些信息是"实例级别"的
  每次 new 都是一份新数据
  生命周期随对象而定(没人引用了就可以回收)

类比:
  方法区 → 菜谱(一份菜谱可以做无数道菜)
  堆     → 做出来的菜(每道菜都是独立的个体)
  菜谱和做出来的菜,当然要分开放

第五部分:直接内存------不属于 JVM 但确实在吃内存

一、为什么需要直接内存

先理解一个前提:你的 Java 代码不能直接操作硬件。

sql 复制代码
操作系统把内存分为两块:

内核空间(Kernel Space)
  → 操作系统内核用的
  → 网卡、磁盘的数据先到这里
  → 你的程序不能直接访问

用户空间(User Space)
  → 你的 Java 程序用的
  → JVM 堆、栈都在这里
  → 你写的代码只能操作这里

传统 IO 的问题:

复制代码
传统 IO 读文件发送到网络:

磁盘文件
   │
   │ ① 第一次复制
   ↓
内核空间(操作系统读进来)
   │
   │ ② 第二次复制(复制到用户空间)
   ↓
用户空间(JVM 堆内存)← 你在这里处理数据
   │
   │ ③ 第三次复制(复制回内核空间)
   ↓
内核空间(准备发送到网卡)
   │
   │ ④ 第四次复制
   ↓
网卡(发出去)

文件 1GB → 这几次复制就是几 GB 的数据搬运
CPU 大量时间花在复制数据上,而不是处理业务逻辑

直接内存的设计动机:

arduino 复制代码
NIO + 直接内存(减少复制):

磁盘文件
   │
   │ ① 只复制一次
   ↓
直接内存(内核空间和用户空间共享)
   │  JVM 可以直接读这块内存里的数据
   │  不用再复制到堆里
   │
   │ ② 直接发送
   ↓
网卡

少了往 JVM 堆复制的那一步,这就是"零拷贝"的核心思想

二、为什么不能让 JVM 堆直接跟硬件交互

复制代码
JVM 堆里的对象受 GC 管理:

GC 会做这些事:
├── 移动对象(整理内存碎片)
├── 压缩内存
└── 整个堆的地址可能随时变化

问题:
  操作系统正在往这块地址写数据
  GC 突然把这块地址的对象挪走了
  操作系统往旧地址写 → 数据丢失或写到垃圾地方

所以堆内存不能直接给操作系统用。
直接内存不受 GC 移动管理,操作系统可以放心读写。

三、直接内存的实际使用场景

arduino 复制代码
场景1:大文件上传/下载
  传统:读文件到 byte[] → byte[] 在堆里 → 大文件撑爆堆
  直接内存:MappedByteBuffer 直接映射文件,不在堆里

场景2:高性能网络框架 Netty
  每个连接都需要读写缓冲区
  用堆内存:每次发送都要复制到内核
  Netty 默认用直接内存:减少复制,性能翻倍

场景3:Kafka 为什么快
  大量消息读写,用了零拷贝(sendfile)
  数据不经过 JVM 堆,直接从磁盘到网卡

场景4:Elasticsearch
  Lucene 底层用 MappedByteBuffer 读索引文件
  直接映射到内存,不走堆
  

堆内存 → 给你写业务逻辑用的,GC 帮你管理,但有复制开销

直接内存 → 给高性能 IO 用的,绕过 GC,零拷贝快,但要自己管理释放**


第六部分:OOM------每块区域都会爆

一、OOM 全景图

arduino 复制代码
OOM 类型全景图
│
├── 堆 OOM(Java heap space)⭐ 最常见
│   对象太多,GC 回收不掉
│
├── 栈相关
│   ├── StackOverflowError(递归太深、栈帧太大)
│   └── OOM: unable to create new native thread(线程太多)
│
├── 元空间 OOM(Metaspace)
│   类太多,类加载器没释放
│
├── 直接内存 OOM(Direct buffer memory)
│   NIO 分配的内存没释放
│
└── GC overhead limit exceeded
    GC 花了 98% 时间但只回收了 2% 内存

这几种 OOM 互相独立,任何一个爆了其他的可能都很健康。

二、堆 OOM------最常见,最高频

什么场景会触发?
复制代码
堆 OOM 的本质:
对象太多 + GC 回收不掉 = 堆被撑爆

注意后半句 --- GC 也回收不掉
不是说对象多就会 OOM,是大量对象被引用着、无法被回收才会

典型场景 1:内存泄漏

java 复制代码
// 最经典的内存泄漏
static List<Object> cache = new ArrayList<>();

public void process() {
    while (true) {
        cache.add(new Object());  // 不断往 static 集合里加
        // 从来不清理 → 对象被 static 引用 → GC 回收不掉 → OOM
    }
}

典型场景 2:大对象

java 复制代码
// 一次性加载超大文件到内存
List<Row> allRows = new ArrayList<>();
while (excelReader.hasNext()) {
    allRows.add(excelReader.next());  // 100 万行全部加载到堆里 → OOM
}

典型场景 3:生产者-消费者速度不匹配

scss 复制代码
场景:调用远程服务获取数据,循环调用,处理速度跟不上

for (门店 : 千家门店) {
    数据 = 远程调用(门店);   // 进来的速度快
    处理并整合(数据);         // 出去的速度慢
}
数据积压在内存里 → OOM

典型场景 4:缓存不当

java 复制代码
// 自己搞了个 HashMap 做缓存,key 永远不过期
Map<String, Object> cache = new HashMap<>();

public Object get(String key) {
    if (!cache.containsKey(key)) {
        Object data = queryFromDB(key);
        cache.put(key, data);  // 只进不出 → 缓存无限膨胀 → OOM
    }
    return cache.get(key);
}
堆 OOM 发生时,GC 日志里会看到什么
scss 复制代码
[GC (Allocation Failure) [PSYoungGen: 204800K->25600K(245760K)] 
    204800K->25800K(808960K), 0.0123456 secs]

[Full GC (Ergonomics) [PSOldGen: 557056K->557056K(563200K)] 
    582656K->582656K(808960K), 0.1234567 secs]

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

关键看:
  Young GC 触发了 → 回收不掉 → 对象晋升老年代
  老年代满了 → Full GC → 回收不掉
  → OOM

三、栈相关的错误

StackOverflowError------递归太深
java 复制代码
public void loop() {
    loop();  // 每调一次压一个栈帧
             // 栈深了 → StackOverflowError
}
复制代码
为什么会爆?

每个线程有自己的栈空间(默认 512KB~1MB)
每次方法调用,往栈里压一个栈帧
无限递归 → 栈帧不断压入 → 栈空间耗尽 → StackOverflowError

这不是 OOM,是另一个错误
线程创建失败------native thread OOM
yaml 复制代码
每个线程需要一块独立的栈内存(默认 1MB)

线程数      栈总内存       结果
─────────────────────────────────
500         500MB          ✅ 正常
1000        1GB            ✅ 正常
5000        5GB            ⚠️ 接近系统极限
8000        8GB            ❌ 操作系统内存耗尽
                            → unable to create new native thread

为什么栈是线程私有的,线程多了还会互相影响?

复制代码
类比:公司有 100 平米的办公室

每个员工有自己专属的工位(栈是私有的,不共享)
每个工位占 1 平米

工位 100 个 → 刚好塞满 → ✅
工位 101 个 → 没地方了 → ❌

不是因为要共用工位,是空间总共就这么大
每个工位都要占物理空间

栈是私有的(逻辑层面)→ 别人不能用你的
栈内存是向操作系统申请的(物理层面)→ 每个栈都占真实内存
栈的大小不会自动缩小 → 后面的线程直接创建不了

什么场景会创建太多线程?

scss 复制代码
├── 线程池配置不合理,maxPoolSize 没限制
├── 每次请求 new Thread()(别笑,真有人这么写)
├── 第三方库内部疯狂创建线程
└── 任务积压,线程池不断扩容

四、元空间 OOM

markdown 复制代码
元空间存的是类的元数据。
你每加载一个类,元空间就要存一份这个类的结构信息。

你以为的类数量:
  你写的代码编译成 .class 文件,数量是固定的

实际的类数量:
  你写的类                      比如 500 个
  + Spring AOP 生成的代理类       每个被切的类可能 1~2 个
  + MyBatis 生成的 Mapper 代理类  每个 Mapper 接口一个
  + CGLib 生成的子类             每个需要代理的类一个
  + 反射、序列化框架生成的类      动态生成
  + Groovy/JSP 热部署            每次修改生成新类

实际类数量轻松翻好几倍

典型场景:

java 复制代码
场景1:AOP 切面太泛
  @Pointcut("execution(* com.xxx.service..*.*(..))")
  切了所有 service 方法 → 每个 service 生成代理类 → 元空间膨胀

场景2:热部署/热加载
  每次修改代码 → 重新加载类 → 生成新的 Class 对象
  旧的 Class 对象如果没有被正确卸载 → 元空间只增不减

场景3:类加载器泄漏(最隐蔽)
  Tomcat 热部署 → 每次重新部署生成新的 WebAppClassLoader
  旧的 ClassLoader 没释放 → 它加载的所有类都回收不掉

元空间 OOM 和堆 OOM 的本质区别:

arduino 复制代码
堆 OOM:
  对象太多 + 被引用住 + GC 回收不掉

元空间 OOM:
  类太多 + 类加载器被引用住 + 类卸载不掉

共同点:
  都是"应该回收的东西回收不掉"

五、直接内存 OOM

ruby 复制代码
直接内存不归 GC 管。
申请不到就直接 OOM,没有"GC 救一下"的机会。

限制来源:
├── JVM 参数 -XX:MaxDirectMemorySize(默认跟 -Xmx 一样)
└── 操作系统的物理内存(最终上限)

谁先到谁先触发 OOM。

典型场景:

scss 复制代码
场景1:泄漏(最常见)
  每次请求分配 1MB 直接内存 → 用完忘了释放
  累计泄漏到操作系统扛不住 → OOM

  典型代码:Netty ByteBuf 用完没 release()

场景2:一次性申请太大
  ByteBuffer.allocateDirect(2 * 1024 * 1024 * 1024)
  直接申请 2GB → 操作系统给不了 → OOM

跟堆 OOM 的对比:

复制代码
堆 OOM:
├── 有 GC 帮你兜底,满了先回收
├── 回收不掉才 OOM
└── GC 日志里有迹可循

直接内存 OOM:
├── 没有 GC 帮你回收
├── 申请不到就直接 OOM
└── 很隐蔽,GC 日志看不出异常

第七部分:OOM 排查实战

一、排查的前置条件------必须提前配置

bash 复制代码
# 这三个参数必须在启动时配上,否则 OOM 发生后什么都查不到
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/dump.hprof
-XX:OnOutOfMemoryError="kill -9 %p"   # OOM 后自动重启

为什么必须提前配?

复制代码
OOM 发生后:
  如果没配 HeapDump → 堆快照拿不到 → 无法分析
  如果进程直接挂了 → 什么信息都没有 → 只能靠猜

二、排查的标准流程

arduino 复制代码
第1步:看日志,定位是哪块内存爆了

  OOM 日志关键词:
  ├── Java heap space                → 堆爆了
  ├── Metaspace                      → 元空间爆了
  ├── GC overhead limit exceeded     → GC 救不了了
  ├── Direct buffer memory           → 直接内存爆了
  └── unable to create new native thread → 线程太多了
sql 复制代码
第2步:看 GC 日志,判断问题模式

  新生代爆了 + 老年代空闲
  → 对象创建速度太快,Young GC 来不及回收
  → 找循环里的 new 操作

  老年代持续增长 + Full GC 越来越频繁
  → 对象被引用住,无法回收(内存泄漏)
  → 找 static 集合、缓存、监听器

  老年代突然打满
  → 某次操作突然加载了大量数据
  → 找批量查询、文件加载
复制代码
第3步:看最近是否有新代码上线

  有 → 新代码大概率是元凶
  ├── 对比新旧代码的差异
  ├── 快速回滚止血
  └── 回滚后观察内存是否恢复正常

  没有 → 继续深入分析

第4步:分析 Heap Dump(如果拿到了 hprof 文件)

  工具:Eclipse MAT 或 JVisualVM

  看什么:
  ├── Dominator Tree → 谁占的内存最多(按 Retained Heap 排序)
  ├── Leak Suspects  → 工具自动分析可能的泄漏点
  └── 大对象         → 找到具体是什么数据占了那么多内存
markdown 复制代码
第5步:定位到代码,确认根因

  根因类型:
  ├── 代码 Bug
  │   └── 循环中不断创建对象但不释放
  │       → 修复代码,hotfix 上线
  │
  ├── 架构问题
  │   └── 该流式处理的场景用了批量处理
  │       → 改造为分批/流式处理
  │
  └── 配置不合理
      └── 堆大小、GC 策略不适合当前业务
          → 调整 JVM 参数

三、排查工具速查表

lua 复制代码
工具            用途                          命令
──────────────────────────────────────────────────────────
jps            查看 Java 进程 PID             jps -l
jstat          查看 GC 状态                   jstat -gcutil <pid> 1000
jmap           导出堆快照                     jmap -dump:format=b,file=dump.hprof <pid>
jstack         导出线程栈(排查死锁等)         jstack <pid>
MAT            分析 hprof 文件                 GUI 工具
Arthas         在线诊断(阿里开源)            java -jar arthas-boot.jar

第八部分:兜底策略

一、代码层

csharp 复制代码
├── 分页/分批处理
│   不要一次性加载全量数据
│   分批查询、分批处理
│
├── 流式处理
│   边读边处理边释放,不要全部加载到内存
│   比如用 EasyExcel 流式写入代替一次性导出
│
├── 合理使用缓存
│   用 LRU 策略自动淘汰
│   用 Redis 替代本地缓存(大对象放堆外)
│
└── 及时释放资源
    连接、流、Channel 用完就关
    try-with-resources 写法

二、JVM 层

ini 复制代码
├── 堆大小合理配置
│   不是越大越好(太大会导致 Full GC 时间过长)
│   一般建议 2G~4G,具体看业务
│
├── 必须配 HeapDump 参数
│   -XX:+HeapDumpOnOutOfMemoryError
│   不配就是盲人摸象
│
├── 配置合适的 GC 策略
│   小堆(<4G)→ Parallel GC
│   大堆(>8G)→ G1 GC
│   对延迟敏感 → ZGC
│
└── 元空间大小设置
    -XX:MetaspaceSize=256m
    -XX:MaxMetaspaceSize=512m

三、架构层

markdown 复制代码
├── 异步队列削峰
│   数据进 MQ,消费者按自己的速度处理
│   生产者和消费者解耦
│
├── 导出让中间件做
│   EasyExcel 流式写入,不要全量加载到 List
│   或者异步导出:提交任务 → 后台生成文件 → 完成后通知下载
│
└── 监控告警
    堆使用率超过 80% 就报警
    别等 OOM 了才知道出问题
    GC 频率突增也要报警

第九部分:总结

markdown 复制代码
1. JVM 内存的 5 大块
   ├── 线程私有:程序计数器、虚拟机栈、本地方法栈
   └── 线程共享:堆、方法区(元空间)

2. 堆的内部结构
   ├── 年轻代(Eden + S0 + S1)
   └── 老年代
   新对象在 Eden,存活的逐步晋升到老年代

3. 栈和堆的关系
   ├── 方法区放"类的蓝图"
   ├── 堆放"对象实例"
   └── 栈放"引用地址"

4. 元空间 vs 永久代
   元空间用本地内存,不受 JVM 堆大小限制

5. 直接内存
   绕过堆,直接向操作系统申请,用于高性能 IO

6. OOM 全景
   ├── 堆 OOM(最常见)
   ├── 栈溢出 / 线程创建失败
   ├── 元空间 OOM
   ├── 直接内存 OOM
   └── GC overhead limit exceeded

7. OOM 排查流程
   看日志 → 看 GC 日志 → 看最近上线 → 分析 Heap Dump → 定位代码

8. 兜底策略
   代码层(分批、流式)+ JVM 层(参数配置)+ 架构层(MQ、监控)
相关推荐
REDcker1 小时前
个人博客网站建设指南 Markdown资产化与静态站选型部署
前端·后端·博客·markdown·网站·资产·建站
Supersist2 小时前
【设计模式03】使用模版模式+责任链模式优化实战
后端·设计模式·代码规范
Fox爱分享2 小时前
字节二面:10亿数据毫秒级查手机尾号后4位,答不出“异构索引”直接挂?
java·后端·面试
折哥的程序人生 · 物流技术专研2 小时前
《Java面试85题图解版(二)》进阶深化上篇:并发编程 + JVM
java·开发语言·后端·面试
Mahir082 小时前
MySQL 数据一致性的基石:三大日志( redo log/undo log/binlog)与两阶段提交(Prepare 阶段和Commit 阶段)深度解密
数据库·后端·mysql·面试
L0CK2 小时前
Redis 内存淘汰策略
后端
zhengzizhe2 小时前
ReBAC 与 Google Zanzibar:权限系统的未来
后端·架构
用户8356290780513 小时前
使用 Python 自动创建 Excel 折线图
后端·python
梅兮昂3 小时前
Cloudflare Tunnel 实践教程
后端