2674 字
约 8 分钟
13
S05-B06 · JVM 内存结构入门:堆、栈、元空间与对象的一生
2024-12-31
2026-11-09

同一个 OutOfMemoryError 可能写 Java heap space,也可能写 Metaspace,处理路径完全不同:前者查谁在持有对象,后者查谁在生成类。很多人看到 OOM 先加 -Xmx,加了没效果,因为满的根本不是堆。这篇先把五个运行时区域各自装什么讲清楚,再走一遍对象从 new 到回收的路径,最后用三个能亲手跑出来的报错把"区域—参数—报错"对上号。


1. 报错文本先定位区域

报错文本 区域 常见诱因 先看什么
Java heap space 堆 大对象、集合无上限、缓存不淘汰 堆直方图、支配树
Metaspace 方法区/元空间 动态生成类、类加载器泄漏 已加载类数、加载器占用
unable to create new native thread 本机线程 线程池无界、每请求起线程 ulimit -u、Threads 计数
Direct buffer memory 堆外直接内存 NIO allocateDirect、Netty -XX:MaxDirectMemorySize、NMT

后两种跟 -Xmx 没关系。把 -Xmx 从 2G 调到 8G 对 unable to create new native thread 毫无帮助,有时更糟:堆越大,留给线程栈的地址空间越紧张。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.Arrays.copyOf(Arrays.java:3537)

栈顶几帧只是最后一次分配失败的位置,不是根因。


2. 五个区域:谁共享、谁私有

区域 存什么 共享/私有 上限由谁控制 溢出表现
堆 对象实例、数组 全线程共享 -Xmx/-Xms Java heap space
虚拟机栈 栈帧:局部变量表、操作数栈、返回地址 线程私有 -Xss StackOverflowError
本地方法栈 native 方法的调用帧 线程私有 -Xss(HotSpot 与虚拟机栈合并) 同上,或 native 崩溃
方法区 / 元空间 类元数据、字段方法描述、Class 对象 全线程共享 -XX:MaxMetaspaceSize(默认无上限) Metaspace
程序计数器 当前执行的字节码位置 线程私有 固定 不会 OOM

两点容易记混:方法区是规范,元空间是实现,JDK 8 起永久代移除,元数据搬到本地内存,默认不受 -Xmx 约束;程序计数器是唯一不会 OOM 的区域,每线程一个,大小固定。


3. 对象的一生

路径:new → 栈上引用 → 堆上分配(可能先走 TLAB)→ 逃逸分析判断能否标量替换 → 可达性分析 → 回收。

// 逃逸分析:p 没有逃出方法,JIT 可把它拆成标量,甚至不分配
static int sum(int a, int b) {
    Point p = new Point(a, b);  // 未返回、未存字段、未对其他线程可见
    return p.x + p.y;
}

逃逸分析的收益取决于 JIT 是否真做标量替换,加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations 能看到结果,别凭想象断言"这对象在栈上"。

判断存活用可达性分析,不用引用计数(解决不了循环引用)。GC Roots 常见几类:

GC Root 类别 例子
虚拟机栈中的局部变量 正在执行的方法里的引用
方法区中的静态字段 static final Map CACHE
方法区中的常量引用 常量池里的对象引用
本地方法栈中的 JNI 引用 native 代码持有的句柄
活动线程、系统类加载器 线程本身及其加载的类
同步监视器持有的对象 synchronized 的锁对象

"静态集合持有引用"之所以泄漏,是因为集合本身挂在 GC Root 上,里面元素都被判为存活——哪怕业务早就不用了。


4. 为什么分代

依据是对象寿命分布极不均匀:绝大多数对象创建后很快死亡(弱分代假说),少数活得非常久。

代 存放 回收算法 回收频率
新生代 Eden 新对象 复制 高
Survivor S0/S1 熬过一次 GC 的对象 复制 高
老年代 熬过多次 GC 的对象、大对象 标记-整理 / 标记-清除 低

不分代则每次 GC 扫全堆并搬运全部存活对象;分代后多数 GC 只扫一小块,成本随存活比例下降。

# 观察分代与晋升,JDK 17 统一日志
java -Xms512m -Xmx512m -Xlog:gc*,gc+heap=debug:file=/tmp/gc.log -jar app.jar

5. 栈溢出的两种成因

StackOverflowError 只有一种报错,成因有两种:

成因 现象 处理
递归过深或无终止条件 栈帧数量爆炸 改迭代、补终止条件、显式栈
单帧过大 局部变量表里塞了超大数组或大量局部变量 拆方法、把大对象挪出方法体
public class StackOverflow {
    static int depth = 0;
    static void dig() { depth++; dig(); }          // 无终止条件
    public static void main(String[] a) {
        try { dig(); } catch (StackOverflowError e) { System.out.println("depth=" + depth); }
    }
}
java -Xss256k StackOverflow.java    # 默认 -Xss 通常 512k~1M,按 JDK/平台核对

depth 绝对值没意义(跟 -Xss、帧大小、内联都有关),有意义的是同一 -Xss 下改代码后的变化方向。


6. 元空间为什么会涨

元空间存类元数据,涨的来源三类:

  1. 动态生成类:Proxy、CGLIB、字节码增强、脚本引擎(每次 eval 可能生成新类);
  2. 反射与动态调用:MethodHandle、LambdaMetafactory 生成的隐藏类;
  3. 类加载器泄漏:自定义 ClassLoader 被引用不放,它加载的所有类都无法卸载。
// 每次新建 ClassLoader 并持有它,元空间只涨不落
while (true) {
    ClassLoader cl = new URLClassLoader(new URL[]{path}, null);
    Class<?> c = cl.loadClass("com.example.Plugin");
    HOLDER.put(c.getName() + System.nanoTime(), cl);   // 静态 Map 一存就卸不掉
}

类卸载条件很苛刻:该类所有实例已回收、Class 对象无引用、加载它的 ClassLoader 已回收。三者缺一,元空间就不回落。同参数反复生成代理会命中缓存,真正涨的是不断变化的接口组合或新建的 ClassLoader。


7. 字符串常量池与 intern

String a = "abc";                     // 常量池
String b = new String("abc");         // 堆上新对象
System.out.println(a == b);           // false
System.out.println(a == b.intern());  // true
写法 对象在哪 == 与字面量比较
"abc" 字符串常量池 同一引用
new String("abc") 堆 false
new String("abc").intern() 返回常量池引用 true
"a" + "b" 编译期折叠为 "ab" true
变量拼接 s1 + s2 运行时 StringBuilder 产物在堆 false

JDK 7 起常量池从永久代移到堆,-XX:StringTableSize 可调大以降低冲突。别把 intern() 当缓存:它只增不减,大量唯一字符串 intern 就是一次缓慢的堆泄漏。


8. 直接内存与堆外

ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024 * 1024);  // 堆外 64MB

直接内存在堆外,句柄对象 DirectByteBuffer 在堆里,靠 Cleaner 在回收时释放。堆压力大、GC 不频繁时堆外反而先满。

  • 上限:-XX:MaxDirectMemorySize,不设时约等于 -Xmx(按版本核对);
  • 观察:jcmd <pid> VM.native_memory summary,需先开 NMT。

9. 最小配置示例

java -Xms2g -Xmx2g \
     -XX:MaxMetaspaceSize=256m \
     -XX:MaxDirectMemorySize=512m \
     -Xss512k \
     -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/dump \
     -Xlog:gc*:file=/opt/logs/gc.log:time,uptime:filecount=5,filesize=20m \
     -jar app.jar
参数 建议起点 说明
-Xms = -Xmx 相等 避免运行期反复扩缩堆
-XX:MaxMetaspaceSize 256m 起 不设即无上限,出问题时吃满机器
-Xss 512k 线程总量 = 线程数 × 栈大小
-XX:MaxDirectMemorySize 按 NIO 用量 不设易误判"堆没满怎么还 OOM"

10. 确认到底哪块满了

jcmd <pid> VM.native_memory summary    # 需先开 NMT
jcmd <pid> GC.class_histogram          # 类直方图,谁实例最多
jcmd <pid> GC.heap_info                # 各代使用量
jcmd <pid> VM.metaspace                # 元空间按类加载器拆分

VM.metaspace 按 ClassLoader 列占用,排元空间问题最直接。堆内用 GC.class_histogram 找大类,再靠堆转储看引用链。


11. 最小可跑示例:三个报错

public class HeapOOM {
    public static void main(String[] a) {
        var list = new java.util.ArrayList<byte[]>();
        while (true) list.add(new byte[1024 * 1024]);
    }
}
java -Xmx64m HeapOOM.java                              # Java heap space
java -Xss256k StackOverflow.java                       # StackOverflowError
java -XX:MaxMetaspaceSize=32m MetaOOM.java             # Metaspace

三个报错文本不同,处理方向也不同。跑一遍比读十遍文档管用:第三个不会在 Full GC 后回落。


12. 排错表

现象 更可能的原因 处理
堆 OOM 但直方图无大对象 对象多而碎,或堆外被误读成堆 GC.heap_info 与 NMT 交叉看
加了 -Xmx 仍 OOM 满的是元空间、直接内存或线程数 按第 1 节报错文本定位区域
容器 OOMKilled 却无 Java 报错 容器限额小于 -Xmx,内核直接杀 堆上限给元空间与栈留余量
元空间只涨不落 ClassLoader 被静态引用 VM.metaspace 找占用方,断引用
返回后内存不释放 ThreadLocal 未 remove,值被线程持有 在 finally 里 remove()
StackOverflowError 停在框架深处 递归解析或循环依赖导致深度失控 抓栈看重复帧,改迭代
Direct buffer memory 但堆很空 堆外到上限,句柄回收滞后 设上限,或改池化 ByteBuffer

13. 边界表

场景 该看什么 本文不覆盖
GC 停顿长 -Xlog:gc* 停顿分布 收集器内部实现
内存缓慢上涨 堆直方图 + 引用链 泄漏对象的业务含义
容器内存限额 cgroup 指标 + -XX:MaxRAMPercentage 编排层策略
线程数暴涨 线程栈 + 池配置 异步框架的调度模型

14. 自测

  1. unable to create new native thread 该调哪个参数?为什么调 -Xmx 反而可能更糟?
  2. 元空间默认有没有上限?不设的话后果是什么?
  3. 为什么引用计数不能替代可达性分析?
  4. -Xss 调小后 StackOverflowError 来得更早,说明了什么?
  5. intern() 为什么不适合当缓存?
  6. 容器里进程被杀而 JVM 没打印 OOM,先比较哪两个值?
S05-B06 · JVM 内存结构入门:堆、栈、元空间与对象的一生
https://www.clxhxhhr.top/posts/6124/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。