今天继续昨天聊的内存区域与异常,如果说线程私有内存区域是每个线程的 "专属工作台",那线程共享内存区域(Java 堆、方法区、运行时常量池)就是 JVM 的 "公共资源区"------ 所有线程都能访问这里的资源,也是 OOM 异常的 "重灾区"。我职业生涯中遇到的内存问题,80% 都出在这些区域:早年做电商订单系统,因内存泄漏导致 Java 堆 OOM,系统每 3 小时崩溃一次;后来做动态代理框架,因大量生成代理类触发方法区 PermGen 溢出;用 NIO 处理大文件时,又踩过直接内存溢出的坑。线程共享内存和直接内存是 JVM 内存管理的核心,读懂它们的结构、溢出场景,是从 "会写代码" 到 "能保障系统稳定" 的关键一步。

一、Java 堆
Java 堆是 JVM 中最大的内存区域,也是所有线程共享的核心区域 ------几乎所有对象实例都在这里分配内存,它的核心使命是 "存储对象、支撑 GC"。如果把 JVM 比作一个 "工厂",Java 堆就是工厂的 "原材料仓库",所有生产(代码执行)需要的 "原材料"(对象)都存在这里,仓库满了(堆内存不足),工厂就会停工(抛出 OOM)。
1. 堆的核心结构
Java 堆的核心设计逻辑是 "分代收集"------ 基于 "对象朝生夕死" 的特点,将堆分为新生代 (存储新创建的对象)和老年代(存储存活时间长的对象),不同代用不同的 GC 算法,提升回收效率:
- 新生代 (约占堆内存的 1/3):
- Eden 区(伊甸园):新对象优先分配到这里,占新生代的 80%(默认);
- Survivor 0 区(S0)、Survivor 1 区(S1):也叫 "幸存者区",各占新生代的 10%,用于存储 Minor GC 后存活的对象,两者始终有一个为空(复制算法的需要);
- 老年代(约占堆内存的 2/3):存储经历多次 Minor GC 仍存活的对象(如缓存对象、单例对象),用标记 - 整理算法回收。
我常跟新人说:理解堆的分代结构,就能看懂 GC 日志 ------ 比如日志里的GC (Allocation Failure) [Eden: 512M->0M(1024M)],就是 Eden 区满了触发 Minor GC,把存活对象移到 Survivor 区。
2. 溢出场景
Java 堆溢出是最常见的内存异常,核心原因有两类:内存泄漏 (对象无用但无法被 GC 回收)、内存溢出(对象有用但堆内存不足以分配)。
内存泄漏导致堆溢出(最常见)
代码模拟 "无用对象被静态集合引用,无法 GC,最终堆溢出":
java
import java.util.ArrayList;
import java.util.List;
// 模拟内存泄漏导致Java堆OOM
public class HeapOOMDemo {
// 静态集合:持有大量无用对象的引用,导致GC无法回收
private static final List<Object> LEAK_LIST = new ArrayList<>();
// 自定义大对象:占用堆内存
static class BigObject {
// 每个对象占用约1MB内存
private byte[] data = new byte[1024 * 1024];
}
public static void main(String[] args) {
// JVM参数:-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
// -Xms/-Xmx:堆初始/最大内存设为20M,限制堆大小
// -XX:+HeapDumpOnOutOfMemoryError:OOM时自动生成堆转储文件,方便分析
try {
while (true) {
LEAK_LIST.add(new BigObject()); // 不断添加大对象到静态集合
}
} catch (OutOfMemoryError e) {
System.out.println("Java堆溢出!");
e.printStackTrace();
throw e; // 抛出异常,方便观察
}
}
}
运行结果与分析:
运行后很快抛出:
Java堆溢出!
java.lang.OutOfMemoryError: Java heap space
at com.example.HeapOOMDemo$BigObject.<init>(HeapOOMDemo.java:13)
at com.example.HeapOOMDemo.main(HeapOOMDemo.java:22)
同时生成java_pidxxx.hprof堆转储文件 ------ 用jhat或MAT(Memory Analyzer Tool)分析这个文件,能清晰看到LEAK_LIST持有大量BigObject实例,这些对象明明无用(没有业务逻辑使用),却因静态引用无法被 GC 回收,最终导致堆溢出。我早年做电商系统时,就是因为一个静态 Map 缓存了订单对象却不清理,导致每天凌晨堆溢出,分析堆转储文件后,给 Map 加了过期清理逻辑,问题立刻解决。
内存溢出(对象有用但堆不足)
如果对象是业务必需的,但堆内存设置过小,也会触发堆 OOM------ 比如大数据量的报表生成,需要加载 100 万条数据到内存,堆只设了 512M,必然溢出。这种场景的解决方法不是 "优化代码",而是 "合理调大堆内存"。
3. 堆 OOM 的排查与调优心法
我总结的堆 OOM 排查 "三板斧",能解决 90% 的堆内存问题:
- 生成并分析堆转储文件 :
- 添加 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,OOM 时自动生成堆转储文件; - 用 MAT 打开文件,查看 "支配树(Dominator Tree)",找到占用内存最多的对象(通常是泄漏的根源);
- 添加 JVM 参数
- 调整堆内存参数 :
- 核心参数:
-Xms(堆初始内存,建议和-Xmx设为相同,避免堆动态扩展)、-Xmx(堆最大内存,如-Xmx4G); - 新生代参数:
-XX:NewRatio=2(新生代:老年代 = 1:2,默认)、-XX:SurvivorRatio=8(Eden:S0:S1=8:1:1,默认); - 实战经验:电商系统建议设
-Xms8G -Xmx8G -XX:NewRatio=1(新生代和老年代各 4G),因为订单、用户等短生命周期对象多,调大新生代能减少 Minor GC;
- 核心参数:
- 优化代码逻辑 :
- 内存泄漏:清理无用的静态引用、关闭未释放的资源(如数据库连接、IO 流)、使用弱引用(WeakReference)存储缓存;
- 内存溢出:分批处理大数据(如分页加载报表数据)、使用内存映射文件(MMap)替代直接加载大文件到堆。
二、JVM 的类信息仓库
方法区也是线程共享区域,核心作用是存储类的元信息、静态变量、常量、方法字节码------ 如果说 Java 堆存储 "对象实例",那方法区就存储 "对象的模板(类信息)"。新手容易忽略方法区,但它的溢出问题同样致命,且不同 JDK 版本的实现差异,是排查问题的最大坑。
1. 方法区的实现
HotSpot 对方法区的实现分两个阶段,这是新手最易踩坑的点:
- JDK 1.7 及以前 :用 "永久代(PermGen)" 实现方法区,永久代是堆的一部分,受
-XX:PermSize和-XX:MaxPermSize限制,溢出时抛OOM: PermGen space; - JDK 1.8 及以后 :移除永久代,用 "元空间(Metaspace)" 替代,元空间直接使用本地内存(不在堆里),受系统物理内存限制,溢出时抛
OOM: Metaspace。
我曾在 JDK 1.7 升级到 1.8 时踩过坑:升级后还设置-XX:MaxPermSize参数,结果发现完全无效,后来才知道 JDK 1.8 已改用元空间,需用-XX:MaxMetaspaceSize限制大小。
2. 溢出场景
方法区溢出的核心原因是加载的类数量过多(如动态生成代理类、热部署频繁加载类),超出方法区容量。
实战案例:动态生成类导致方法区溢出
用 CGLib 动态生成大量代理类,模拟方法区溢出(JDK 1.7 环境):
java
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
// 模拟动态生成类导致方法区(PermGen)溢出
public class MethodAreaOOMDemo {
public static void main(String[] args) {
// JVM参数(JDK 1.7):-XX:PermSize=10M -XX:MaxPermSize=10M
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyClass.class);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
try {
while (true) {
// 不断生成MyClass的代理类,加载到方法区
enhancer.create();
}
} catch (OutOfMemoryError e) {
System.out.println("方法区(PermGen)溢出!");
e.printStackTrace();
}
}
static class MyClass {}
}
运行结果与分析(JDK 1.7):
方法区(PermGen)溢出!
java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:791)
at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:467)
...
这是因为 CGLib 生成的每个代理类都是一个新的 Class 对象,会存储到方法区的永久代中,不断生成新类最终导致 PermGen 溢出。我做动态代理框架时曾遇到这个问题,解决方案是:
- JDK 1.7:调大
-XX:MaxPermSize(如设为 128M),或复用代理类(避免重复生成); - JDK 1.8:设置
-XX:MaxMetaspaceSize=128M(默认无上限,可能占满本地内存),核心还是复用类、减少动态生成类的数量。
3. 方法区核心参数(分版本)
| JDK 版本 | 核心参数 | 作用 | 实战建议 |
|---|---|---|---|
| 1.7 及以前 | -XX:PermSize=64M |
永久代初始大小 | 至少设 64M,动态代理场景设 128M |
| 1.7 及以前 | -XX:MaxPermSize=128M |
永久代最大大小 | 避免设过大(如超过 256M),浪费内存 |
| 1.8 及以后 | -XX:MetaspaceSize=64M |
元空间初始大小(触发 GC 的阈值) | 设为 64M 即可 |
| 1.8 及以后 | -XX:MaxMetaspaceSize=128M |
元空间最大大小 | 必须设置,避免占满本地内存 |
三、运行时常量池
运行时常量池是方法区的一部分,核心作用是存储编译期生成的字面量(如字符串 "abc")、符号引用(如类 / 方法的引用) ,且支持动态常量(如String.intern())。
1. 核心特性
运行时常量池的关键特性是 "动态性"------ 不是只有编译期的常量才会存入,运行时也能新增常量(比如String.intern()):
- 编译期常量:如
final String s = "abc","abc" 会在编译期存入常量池; - 运行时常量:如
String s = new String("abc").intern(),会把 "abc" 加入常量池(若不存在)。
2. 溢出场景:常量过多导致方法区溢出
虽然运行时常量池是方法区的子集,但大量动态添加常量(如循环调用String.intern())也会触发方法区溢出:
java
// 模拟常量池溢出(JDK 1.7)
public class ConstantPoolOOMDemo {
public static void main(String[] args) {
// JVM参数:-XX:PermSize=10M -XX:MaxPermSize=10M
List<String> list = new ArrayList<>();
int i = 0;
try {
while (true) {
// 不断生成新字符串并intern,存入常量池
list.add(String.valueOf(i++).intern());
}
} catch (OutOfMemoryError e) {
System.out.println("常量池溢出!i=" + i);
e.printStackTrace();
}
}
}
运行后会抛出PermGen space溢出,原因是大量字符串常量占满了永久代。JDK 1.7 后,字符串常量池被移到 Java 堆中,此时该代码会抛出Java heap space溢出 ------ 这也是 JDK 1.7 "去永久代" 的关键变化之一。
四、直接内存
直接内存(Direct Memory)是堆外的内存区域 ,不属于 JVM 运行时数据区的核心分类,但因 NIO 的广泛使用,成为 OOM 的常见场景。它的核心特点是:不受 JVM 堆大小限制,但受系统物理内存限制。
1. 核心作用
Java NIO 引入直接内存的目的是 "减少数据拷贝":
- 传统 IO:数据从磁盘→内核缓冲区→用户缓冲区(Java 堆)→再写回内核缓冲区→网络 / 磁盘,两次拷贝;
- NIO 直接内存:数据从磁盘→直接内存→网络 / 磁盘,一次拷贝,大幅提升大文件读写效率。
我做文件传输系统时,用直接内存替代堆内存,文件传输速度提升了 50%------ 但也因此踩了直接内存溢出的坑。
2. 溢出场景
直接内存溢出的核心原因是分配的直接内存总量超过系统限制 ,且 JVM 默认不限制直接内存大小(默认等于堆的最大内存-Xmx)。
NIO 分配大量直接内存导致溢出
java
import java.nio.ByteBuffer;
// 模拟直接内存溢出
public class DirectMemoryOOMDemo {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
// JVM参数:-Xmx20M -XX:MaxDirectMemorySize=10M
// -XX:MaxDirectMemorySize:限制直接内存最大为10M
try {
while (true) {
// 分配直接内存(ByteBuffer.allocateDirect)
ByteBuffer buffer = ByteBuffer.allocateDirect(_1MB);
}
} catch (OutOfMemoryError e) {
System.out.println("直接内存溢出!");
e.printStackTrace();
}
}
}
运行结果与分析:
直接内存溢出!
java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:658)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
at com.example.DirectMemoryOOMDemo.main(DirectMemoryOOMDemo.java:14)
这是因为ByteBuffer.allocateDirect会分配直接内存,不断分配最终超出-XX:MaxDirectMemorySize的限制。我做 NIO 文件服务器时,因未设置该参数,导致直接内存占满服务器物理内存(64G),系统直接卡死 ------ 后来设置-XX:MaxDirectMemorySize=32G,并在使用完直接内存后手动释放(通过反射调用Cleaner的clean()方法),问题解决。
3. 直接内存核心参数与优化
- 核心参数:
-XX:MaxDirectMemorySize(限制直接内存最大值,建议设为堆内存的 1/2~1/1); - 优化技巧:
- 使用完直接内存后,手动释放(NIO 的
ByteBuffer不会被 GC 自动回收,需触发 Cleaner); - 批量处理大文件时,复用直接内存缓冲区,避免频繁分配 / 释放。
- 使用完直接内存后,手动释放(NIO 的
五、共享内存溢出的核心原则
二十余年的内存问题排查经验,我总结出 3 个核心原则:
- 堆溢出先查泄漏,再调参数:堆 OOM 80% 是内存泄漏(无用对象未回收),20% 是内存不足(调大堆),优先用 MAT 分析堆转储文件,找到泄漏根源;
- 方法区溢出看 JDK 版本:JDK 1.7 查 PermGen 参数,JDK 1.8 查 Metaspace 参数,核心是减少动态生成类的数量;
- 直接内存溢出必设 MaxDirectMemorySize:NIO 场景一定要设置该参数,避免直接内存占满系统物理内存。
最后小结:
核心回顾
- 线程共享内存区域:Java 堆(存储对象,溢出抛
Java heap space)、方法区(存储类信息,JDK1.7 前抛PermGen space,JDK1.8 后抛Metaspace)、运行时常量池(方法区子集,支持动态常量); - 直接内存(堆外):NIO 使用,不受堆限制,溢出抛
Direct buffer memory,需设置-XX:MaxDirectMemorySize; - 排查核心:堆 OOM 分析转储文件,方法区 OOM 看 JDK 版本调参数,直接内存 OOM 限制最大容量。