JVM 内存模型 — 一个对象的一生

跟踪一个 OrderDTO:它住在哪 → 怎么被创建 → 怎么长大 → 该死为什么死不了 → 怎么修

堆/栈/方法区 对象创建过程 GC 可视化 OOM 排查
🔴 凌晨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 在背后做了什么?
📁 运行过程
📄 MemoryDemo.java ←
━━━━━━━━━━
步骤追踪
① 类加载检查
② 分配内存
③ 零值初始化
④ 设置对象头
⑤ 调构造方法
⑥ 栈上建引用
⑦ 构造完成
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 ListThreadLocal 持续添加数据但从不清理。这些引用永远连着 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
📄 BadCacheService.java
@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
📄 GoodCacheService.java
@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,上一个请求的数据被下一个请求继承,既是泄漏又是安全问题!

❌ 错误版本
📁 filter
📄 BadUserContext.java
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(); } }
✅ 正确版本
📁 filter
📄 GoodUserContext.java
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

❌ 错误版本
📁 util
📄 BadFileReader.java
@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; } }
✅ 正确版本
📁 util
📄 GoodFileReader.java
@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 fileslsof / ulimit -atry-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(并发分配)是加分项。

📁 运行原理图解 / 02-JVM内存模型 — 配套阅读:① Java请求全链路 · ③ Spring运行真相 · ⑥ Java并发编程图解