🔴 凌晨3点的 OOM 崩溃
2024-12-15 03:17 手机疯狂震动——生产环境告警:订单服务 java.lang.OutOfMemoryError: Java heap space
03:18 尝试重启,服务启动后5分钟又挂。用户无法下单。
03:45 终于定位:一个 static List<OrderDTO> 在"导出订单"功能中不断 add,但从未清空——每次导出就往这个 List 里加几万条数据,GC 根本回收不了(因为 static 变量是 GC Root)。
04:10 修复上线:用 try-finally 确保每次导出后清理,改用有容量上限的缓存。服务恢复。
下面我们就跟踪这个故事里的 OrderDTO 对象,看它从出生到"该死不死" 的完整过程——理解了这条线,JVM 内存模型的每一个知识点都会落在它该在的位置。
① 对象住在哪:JVM 四大内存区域
事故里的 new OrderDTO() 被放进堆——但堆不是唯一区域,栈、方法区、PC 各有分工
JVM 进程内存布局
堆 Heap(共享 · GC 管理)— 所有 new 出来的对象
年轻代 Young Gen
Eden(80%) + S0(10%) + S1(10%)
老年代 Old Gen
长期存活的对象(age>15)
User对象、Order对象、List、Map、Spring Bean实例、缓存的DTO……
方法区 Metaspace(共享)
类信息(Class 元数据)
常量池(String常量、final值)
static 变量
JDK8+ 用物理内存,不再占堆
线程栈 × N(线程私有)
T1 栈: create()→insert()→...
T2 栈: getUser()→validate()
T3 栈: 完全独立的执行空间
每个线程一个,存局部变量/方法栈帧
程序计数器 PC Register(线程私有)
当前线程执行到第几行字节码(书签)
直接内存 Direct Memory(可选)
NIO Buffer、Netty 堆外内存
堆 + 方法区 = 线程共享区域 ← 并发问题的根源
绿色=线程私有(绝对安全),橙/紫=线程共享(需要考虑并发)
📌 跟故事的关系
故事中的 static List<OrderDTO> cache :static 变量在方法区 ,它是 GC Root;OrderDTO 对象在堆 ,被 cache 引用着。方法区 + 堆 = 线程共享区域,这就是并发问题的根源,也是泄漏的温床。
一句话记忆法
区域 存什么 类比 线程安全?
堆 new 出来的对象 银行的公共保险箱 ❌ 共享,需注意
栈 局部变量、方法参数 柜员私人抽屉 ✅ 天然安全
方法区 类信息、static 变量 规章制度手册 ❌ 共享,static 要注意
程序计数器 执行到哪行 书签 ✅ 天然安全
📌 到这里你知道了
OrderDTO 对象住在堆 里,引用它的 static 变量住在方法区 里。那么 new OrderDTO() 这行代码执行时,JVM 具体做了什么?→ 看 ②
② 对象怎么出生:new 背后的 7 步
事故代码里 cache.add(new OrderDTO()) ,每次 add 都触发一次 new——那 JVM 在背后做了什么?
public class MemoryDemo {
public void demo () {
User user = new User ("Tom" , 25 );
// 看起来就一行代码,实际 JVM 做了这些事:
}
}
// ============ JVM 视角的执行过程 ============
//
// 【步骤1】类加载检查
// JVM 先看 Metaspace 里有没有 User.class 的信息
// 如果没有 → 执行类加载(从jar/class文件读取字节码)
// 如果有 → 跳过(类只加载一次)
//
// 【步骤2】分配内存
// 计算对象大小(对象头12B + 字段4B+4B+对齐 = 约24B)
// 在堆的 Eden 区划分一块内存
// 方式:指针碰撞(内存规整)或空闲列表(内存碎片)
// 并发安全:TLAB(每个线程预分配一小块)或 CAS
//
// 【步骤3】零值初始化(内存清零)
// 除了对象头之外的部分全部设为 0
// 这就是为什么 int 默认 0、boolean 默认 false、引用默认 null
//
// 【步骤4】设置对象头(Object Header)
// Mark Word:哈希码、GC分代年龄、锁状态标志
// Class Pointer:指向 Metaspace 中 User.class 的元数据
// ← synchronized 的锁信息就存在这里!
//
// 【步骤5】执行 <init>(构造方法)
// 调用 User("Tom", 25) 构造器
// name = "Tom",age = 25
//
// 【步骤6】栈上建立引用
// 局部变量表 user 入栈 → 存的是堆中对象的地址(如 0x7F8A)
//
// 【步骤7】构造完成
// user 变量 → 指向堆 Eden 区的 User 对象
🏗️ 类比:盖一栋房子
📐 类加载检查 :先看有没有这栋楼的施工图纸(Class),没有就去找设计院(ClassLoader)拿。
🏗️ 分配内存 :在空地上划一块地(Eden 区),大小按图纸计算。
🧹 零值初始化 :清理工地(所有属性归零),这就是为什么入住前所有东西都是空的。
📋 对象头 :在门口钉上房产证(Mark Word + Class Pointer),记录这栋房子的身份信息。
🎨 构造方法 :按业主要求装修(name="Tom", age=25)。
🔑 引用建立 :把钥匙(地址)交给业主变量(user),业主通过钥匙找到房子。
📌 到这里你知道了
每次 new OrderDTO() ,JVM 在堆的 Eden 区分配内存、清零、设置对象头、调构造方法,然后在栈上建引用。对象出生在 Eden 区 ——那它后来怎么跑到老年代去了?→ 看 ③
③ 对象怎么长大:从 Eden 到老年代
事故里那个 OrderDTO 被 static List 一直引用着,每次 Minor GC 都活下来,age 不断 +1,最终晋升老年代
堆内存分代结构 + 对象流转
年轻代 Young Generation
Eden(伊甸园)
新
新
新
新
新
新
新
所有 new 的对象先到这
Survivor0
age2
age3
age1
熬过GC的
Survivor1
⬜⬜
空闲/交换
老年代 Old Gen
老
老
老
老
老
老
Spring Bean 单例
缓存数据、连接池
ThreadLocal 等
Minor GC 存活 →
age+1
age>15 晋升 →
GC 触发时机
Minor GC:Eden 满了 → 清理年轻代(快,几ms)→ 存活对象复制到 Survivor
Major GC:老年代满了 → 清理老年代(慢,几百ms → STW!)
Full GC:整个堆清理 → 最慢!通常意味着系统卡顿
对象从 Eden 出生 → Minor GC 幸存者进 Survivor → 熬过 15 次 GC → 晋升老年代
🏨 类比:公司工位制度
🟡 Eden = 临时工位 :新人入职(new 对象)先坐这里。临时工位满了就清理——还在干活的(被引用的)调到正式区,干完走人的直接清退。
🟢 Survivor = 试用期区 :每次清理后还活着的,搬到这里,工龄 +1。
🟤 Old Gen = 正式员工区 :工龄超过15次清理(age>15)→ 转正。正式员工很少被清理(老年代 GC 频率低),但一旦清理(裁员),动静很大(STW 卡顿)。
📌 到这里你知道了
正常对象:Eden 出生 → 方法结束,引用断开 → Minor GC 清理,故事结束。但事故里的 OrderDTO 没有被清理 ——因为 static List 一直引用着它,age 不断涨,最终晋升老年代。GC 怎么判断一个对象"该不该死"?→ 看 ④
④ 该死怎么判:GC 可达性分析
GC 从 GC Roots 出发追踪引用链——能走到的活着,走不到的该死。那故事里 static List 持有的 OrderDTO,GC 能走到吗?
可达性分析算法(GC Roots → 引用链 → 搜寻存活对象)
GC Roots
什么是Roots?
① 栈中局部变量
② static 变量
③ 活跃线程
④ JNI 引用
⑤ 同步锁持有
user 变量(栈)
cache(静态)
main线程
User
✅ 可达
Order
✅ 可达
List
✅ 可达
OldDTO
❌ 不可达
将被GC回收
TempObj
❌ 不可达
将被GC回收
引用断了!
GC 从 Roots 出发追踪引用链——能走到的对象存活,走不到的(如 OldDTO)标记为垃圾回收
什么是 GC Roots?
GC Roots 是 GC 的起点 ——JVM 认为这些一定还活着,从它们出发能追溯到的对象都不会被回收。
常见 GC Roots:
① 栈中的局部变量 (正在被方法使用的对象)
② 静态变量 (static 字段引用的对象,只要类还在就活着)
③ 活跃的线程 (线程本身不会被回收)
④ 同步锁 monitor (synchronized 持有的对象)
⑤ JNI 全局引用 (C/C++ 代码持有的 Java 对象)
⚠️ 内存泄漏的本质
内存泄漏 = 对象不再使用了,但因为还有引用链连着 GC Roots ,GC 以为它还活着,不回收。
最典型:static List 或 ThreadLocal 持续添加数据但从不清理。这些引用永远连着 GC Roots(static 变量),即使业务代码不再使用这些对象,GC 也回收不了。
📌 到这里你知道了
事故的根本原因:static List<OrderDTO> cache 是 GC Root → cache 引用的每个 OrderDTO 都可达 → GC 认为"还活着" → 不回收 → 越积越多。这就是内存泄漏 ——对象已经不用了,但 GC 走不到"该死"的结论。泄漏积累到最后会发生什么?→ 看 ⑤
⑤ 为什么死不了:从泄漏到 OOM 的完整过程
④ 知道了 GC 判断"该死"的机制——但如果 GC 判断"不该死"(泄漏),对象就越积越多,最终堆撑不住
阶段1:正常
堆使用率 40%,Minor GC 正常工作,每次几毫秒。每次导出 add 几万条 OrderDTO,但导出完方法结束,引用断开,Minor GC 把它们清掉。
阶段2:泄漏开始
某次代码修改,把 OrderDTO 放进了 static List ——static 是 GC Root,引用链永远不断。堆使用率缓慢上升:40% → 60% → 80%。每次导出 add 几万条,但再也没有代码 clear。
阶段3:频繁 Full GC
老年代满了 → Full GC 清理 → 但 static List 是 GC Root,它引用的 OrderDTO 清不掉 → 堆使用率短暂下降后又涨回来 → 再次 Full GC。
用户感受:系统变卡 (每次 Full GC 的 STW 几百ms)。
阶段4:OOM 崩溃
Full GC 后仍然腾不出足够空间 → java.lang.OutOfMemoryError: Java heap space → 线程抛出异常 → 如果没被 catch → 应用崩溃。
OOM 时间线:堆使用率变化
80%危险线
Full GC
Full GC
!
OOM!
正常
泄漏堆积
频繁Full GC
崩溃
堆使用率缓慢上升 → 频繁 Full GC(每次短暂下降又涨回来)→ 最终 OOM
📌 到这里你完全理解了开头的事故
① OrderDTO 被放进 static List(GC Root)→ ② GC 认为它还活着,不回收 → ③ 越积越多,堆满 → ④ 频繁 Full GC → ⑤ OOM 崩溃。这就是凌晨3点事故的完整因果链。那怎么修?→ 看 ⑥
OOM 的排查思路
1. 加 JVM 参数:-XX:+HeapDumpOnOutOfMemoryError —— OOM 时自动 dump 堆快照
2. 用 MAT / VisualVM 分析 dump 文件 —— 找出占用最大的对象
3. 看 Dominator Tree —— 谁引用了大量对象?通常是一个 static 集合或缓存
4. 对症修复:移除泄漏引用 / 改用 WeakHashMap / 加 LRU 上限 / 改用 Guava Cache/Caffeine
⑥ 怎么修:三种常见泄漏 ❌ → ✅
⑤ 知道了事故因果链,现在看具体怎么修——坑1 就是开头故事的那个 static List
坑1:static 集合无限增长(就是开头故事的那个!)
凌晨3点崩溃的元凶——static 变量是 GC Root(④讲过),List 只增不减 = 永远不会被 GC
@Service
public class BadCacheService {
// ❌ static List 是 GC Root,永远回收不了!
private static final List <OrderDTO > cache =
new ArrayList <>();
public void addToCache (OrderDTO order) {
cache.add (order); // 只增不减 → OOM!
}
public List <OrderDTO > exportOrders () {
return new ArrayList <>(cache);
}
// 没有 clear()!没有容量上限!
// 每次导出都 add 几万条 → 堆被吃光
}
@Service
public class GoodCacheService {
// ✅ 方案1:用 Caffeine 设容量上限 + TTL 自动过期
private final Cache <String , OrderDTO > cache =
Caffeine .newBuilder ()
.maximumSize (10_000 ) // 最多1万条
.expireAfterWrite (30 , TimeUnit .MINUTES)
.build ();
public void addToCache (OrderDTO order) {
cache.put (order.getId(), order);
}
// ✅ 方案2:如果必须用 static List,导出完一定清空
public void exportAndClean () {
List <OrderDTO > data = loadFromDB ();
try {
doExport (data);
} finally {
data.clear (); // ← ★ 无论成败都清空!
}
}
}
PRO TIP
判断原则:static 集合 = 永久居民 。只要类没卸载,static 引用的对象就永远不会被 GC。如果你必须用 static 缓存,一定设容量上限 (Caffeine/Guava Cache)或定期清理 (try-finally + clear)。
坑2:ThreadLocal 忘记 remove
线程池中的线程会被复用——ThreadLocal 不 remove,上一个请求的数据被下一个请求继承,既是泄漏又是安全问题!
public class BadUserContext {
// ❌ 线程池中的线程会被复用,ThreadLocal 不 remove
// → 上一个请求的 User 还在,下一个请求继承了
private static final ThreadLocal <User > currentUser =
ThreadLocal .withInitial (() -> null );
public static void set (User user) {
currentUser.set (user);
// 没有 finally remove!
// 线程回到线程池 → ThreadLocal 还持有上一个 User
// → 内存泄漏 + 用户数据串号!
}
public static User get () {
return currentUser.get ();
}
}
public class GoodUserContext {
private static final ThreadLocal <User > currentUser =
ThreadLocal .withInitial (() -> null );
// ✅ 在 Filter/Interceptor 中 set + finally remove
public static void set (User user) {
currentUser.set (user);
}
public static User get () {
return currentUser.get ();
}
// ★ 必须在 finally 中调用!
public static void remove () {
currentUser.remove ();
}
}
// 在 Spring 拦截器中使用:
public class UserInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion (...) {
GoodUserContext .remove (); // ← ★ 请求结束必须清!
}
}
PRO TIP
ThreadLocal 泄漏双重危害:① 内存泄漏 (ThreadLocalMap 的 Entry 是弱引用,但 value 是强引用,线程不结束就回收不了);② 数据串号 (线程池复用时,下一个请求可能拿到上一个请求的用户数据)。黄金法则:try { tl.set(x) } finally { tl.remove() }
坑3:流/连接没关闭 → 堆外内存泄漏
不仅是堆内存——InputStream/Connection 不关闭会占用操作系统文件描述符和堆外内存,最终导致 OOM
@Service
public class BadFileReader {
// ❌ InputStream 没关闭 → 文件描述符泄漏
// → "Too many open files" 或 Direct Buffer OOM
public String readFile (String path) {
InputStream is = new FileInputStream (path);
String content = new String (is.readAllBytes ());
// ← 没有 is.close()!
// 每次调用打开一个文件,但从不关闭
// → 系统文件描述符被耗尽
return content;
}
}
@Service
public class GoodFileReader {
// ✅ try-with-resources 自动关闭(推荐)
public String readFile (String path) {
try (InputStream is = new FileInputStream (path)) {
return new String (is.readAllBytes ());
} // ← ★ 自动调用 is.close()
catch (IOException e) {
throw new RuntimeException (e);
}
}
// ✅ 数据库连接也必须 try-with-resources
public User queryUser (Long id) {
try (Connection conn = dataSource.getConnection ();
PreparedStatement ps = conn.prepareStatement (SQL)) {
ps.setLong (1 , id);
try (ResultSet rs = ps.executeQuery ()) {
return rs.next () ? mapToUser (rs) : null ;
}
} // conn/ps/rs 全部自动关闭
}
}
⚠️ 内存泄漏速查表
泄漏类型 表现 排查工具 修复
static 集合 堆使用率持续涨 MAT Dominator Tree 设容量上限 / finally clear
ThreadLocal 堆涨 + 数据串号 jmap + 逻辑排查 finally remove()
流/连接 Too many open files lsof / ulimit -a try-with-resources
监听器/回调 对象不释放 MAT + 代码审查 注销监听器
⑦ 面试高频速答
前面 ①-⑥ 已经把 JVM 内存模型讲完了一条线,这些题可以快速验证你的理解
Q1:JVM 有哪些内存区域?各存什么?
四大区域:堆 (new 的对象,GC 管理,线程共享)、栈 (局部变量和方法调用,线程私有)、方法区/Metaspace (类信息和 static 变量,线程共享)、程序计数器 (当前执行位置,线程私有)。
线程私有的区域(栈、PC)永远不会 OOM(除了 StackOverflow),线程共享的区域(堆、方法区)才会。
Q2:为什么局部变量线程安全?
因为局部变量存在每个线程自己的栈 上。Thread-1 的 create() 方法的局部变量 order,和 Thread-2 的完全隔离——物理内存在不同的地址。
而成员变量(实例字段)和 static 变量存在堆/方法区 ——所有线程共享同一块内存,这就是并发冲突的来源。
Q3:对象什么时候可以被 GC 回收?
当对象不可达 时——即从所有 GC Roots 出发,没有任何引用链能到达这个对象。最常见的情况是:方法的局部变量(GC Root 之一)随着方法结束被弹出栈 → 它引用的对象失去了到 Root 的路径 → 变成垃圾。
注意:赋值为 null(user = null )只是手动断开引用,帮助 GC 更快判断。正常情况下方法结束后自动断开。
Q4:Minor GC 和 Full GC 的区别?
Minor GC :只清理年轻代(Eden + Survivor)。触发条件是 Eden 满。频率高但速度快(几ms),因为年轻代大部分对象都是朝生夕死的。
Full GC :清理整个堆(年轻代 + 老年代)。触发条件包括老年代满、Metaspace 不足、System.gc() 调用等。频率低但速度慢(几百ms甚至几秒),因为要扫描整个堆。Full GC 频繁说明系统有内存问题。
Q5:什么是内存泄漏?怎么排查?
对象不再使用但无法被 GC 回收——因为有引用链连着 GC Roots。常见原因:static 集合无限增长、ThreadLocal 没 remove、监听器/回调没注销、连接没关闭。
排查:jmap -dump 导出堆快照 → MAT 分析 → 看 Dominator Tree → 找到占用最大的引用链 → 修复。
Q6:synchronized 的锁信息存在哪里?
存在对象头(Object Header) 的 Mark Word 中。每个对象在堆里都有一个对象头(约12字节),其中 Mark Word 记录了:哈希码、GC 分代年龄、锁状态标志位。
当线程获取锁时,会修改 Mark Word 中的锁标志位——这就是 synchronized 的底层实现。锁升级过程:无锁 → 偏向锁 → 轻量级锁(CAS自旋)→ 重量级锁(OS互斥量),都体现在 Mark Word 的变化中。
Q7:ThreadLocal 为什么会内存泄漏?怎么解决?
ThreadLocal 的内部存储是 ThreadLocalMap,其 Entry 的 key 是弱引用(ThreadLocal 对象),但 value 是强引用。当 ThreadLocal 变量被回收后,key 变成 null,但 value 还在——这就是泄漏的根源。
在线程池环境下更严重:线程不销毁 → ThreadLocalMap 一直存在 → value 永远回收不了。
解决: 每次使用后 threadLocal.remove() ,放在 finally 块中保证执行。推荐在 Filter/Interceptor 的 afterCompletion 中统一 remove。
Q8:new 一个对象时 JVM 做了什么?
7 步:① 类加载检查 (Metaspace 有没有这个 Class?)→ ② 分配内存 (堆的 Eden 区划一块地,TLAB/CAS 保证并发安全)→ ③ 零值初始化 (所有字段归零,所以 int 默认0)→ ④ 设置对象头 (Mark Word + Class Pointer)→ ⑤ 执行构造方法 (赋值字段)→ ⑥ 栈上建引用 (局部变量指向堆中地址)→ ⑦ 构造完成 。
面试只需说前5步即可拿分,但提到对象头(锁信息存在这)和 TLAB(并发分配)是加分项。