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、监控)