JVM 内存结构与 GC 怎么理解:运行时数据区、元空间和排障案例

2次阅读
没有评论

JVM 内存和 GC 资料很多,但真正排查线上问题时,最容易卡住的反而是几个基础概念没有连起来:对象到底分配在哪、方法区和元空间是什么关系、为什么堆没满也会 Full GC、参数写了为什么没有生效。

我会把 JVM 内存先按“线程私有”和“线程共享”拆开,再看对象存活判定、垃圾收集算法和常见排障案例。这样比直接背收集器名字更容易落到工程里。

运行时数据区先分私有和共享

JVM 运行时内存大致可以这样理解:

| 区域 | 主要作用 | 线程关系 | 常见异常 |

| — | — | — | — |

| 程序计数器 | 记录当前线程执行到哪条字节码 | 线程私有 | 通常不会 OOM |

| 虚拟机栈 | 方法调用栈帧、局部变量表、操作数栈 | 线程私有 | StackOverflowError / OutOfMemoryError |

| 本地方法栈 | 为 native 方法服务 | 线程私有 | 类似虚拟机栈 |

| 堆 | 对象实例和数组,GC 主战场 | 线程共享 | OutOfMemoryError |

| 方法区 | 类信息、常量、静态变量、即时编译代码 | 线程共享 | OutOfMemoryError |

| 运行时常量池 | 方法区的一部分,存放字面量和符号引用 | 线程共享 | 随方法区 |

| 直接内存 | NIO、DirectByteBuffer 等堆外内存 | JVM 外部资源 | OutOfMemoryError |

这张表先解决一个常见误区:不是所有内存问题都等于“堆满了”。栈、元空间、直接内存都有自己的边界和异常表现。

比如 longdouble 在局部变量表里会占两个 slot;局部变量表大小在编译期就确定。再比如 NIO 直接内存不受 -Xmx 直接限制,排查时不能只盯堆使用率。

永久代为什么变成元空间

JDK 7 之前,HotSpot 常用永久代实现方法区,受 -XX:PermSize-XX:MaxPermSize 限制。老项目里常见的:

OutOfMemoryError: PermGen space

就和永久代空间不足有关。

JDK 7 已经把字符串常量池、类静态变量等逐步挪出永久代;JDK 8 起永久代被移除,改成元空间。

元空间有几个关键点:

  • 位于本地内存,不在 Java 堆里。
  • 默认可以随本机可用内存增长,但可以用 -XX:MaxMetaspaceSize 限制上限。
  • -XX:MetaspaceSize 更像触发 GC 的初始阈值,达到后会尝试卸载类型并自适应调整。
  • 还在 JDK 8 之后写 -XX:PermSize 基本没有意义,会被忽略或提示已经移除。

永久代难估上限,容易因为类加载、动态代理、热部署等场景踩 OOM。元空间换到本地内存后,默认容量弹性更大,但这不代表它永远不会出问题。类加载异常增长、ClassLoader 泄漏,仍然可能把元空间打爆。

一个对象创建时涉及哪里

看这行代码:

Object obj = new Object();

它至少涉及三类位置:

  • 栈:局部变量 obj 保存引用。
  • 堆:真正的对象实例数据通常在堆里。
  • 方法区/元空间:对象所属类的元数据在这里。

reference 怎么定位对象实例,有句柄和直接指针两种思路。HotSpot 常用直接指针:reference 直接指向堆里的对象,对象头再指向类型信息。好处是少一次寻址,访问快;代价是对象移动时需要更新引用。

理解这点有助于拆开“引用消失”和“对象回收”这两件事。方法结束后,栈帧弹出,局部变量引用消失,对象可能变成不可达;但对象并不是立刻释放,而是等下一次 GC 才会真正回收。

对象存活判定不是引用计数

很多语言会用引用计数判断对象是否可回收:引用一次加一,失效一次减一,计数为零就回收。但 Java 主流 GC 不靠这个,因为循环引用很难处理。

Java 使用可达性分析,从 GC Roots 出发遍历引用链。能从 Roots 走到的对象视为存活,走不到的对象才可能被回收。

常见 GC Roots 包括:

  • 虚拟机栈中局部变量表引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈 JNI 引用的对象。

引用强度也会影响回收行为:

| 引用类型 | 回收语义 |

| — | — |

| 强引用 | 只要还可达,通常不会被回收 |

| 软引用 | 内存不足时可能被回收 |

| 弱引用 | 下次 GC 时通常会被回收 |

| 虚引用 | 不影响对象生存,只用于收到回收通知 |

finalize() 理论上可以让对象在第一次标记后“自救”一次,但它不可靠,也不应该作为业务逻辑依赖。

方法区里的类什么时候能回收

方法区回收通常比堆对象更苛刻。一个类要被卸载,通常至少要满足:

  1. 这个类的所有实例都已经被回收。
  2. 加载这个类的 ClassLoader 已经可以被回收。
  3. 对应的 java.lang.Class 对象没有再被引用,无法通过反射访问。

这也是为什么热部署、插件化、脚本引擎、动态代理场景更容易出现类卸载问题。如果 ClassLoader 被某个静态引用链挂住,类元数据就可能长期留在元空间。

GC 算法要先看取舍

常见垃圾收集算法可以简单归纳成四类:

| 算法 | 思路 | 优点 | 缺点 |

| — | — | — | — |

| 标记-清除 | 标记垃圾,再清理 | 实现直接 | 容易产生内存碎片 |

| 复制算法 | 存活对象复制到另一块区域 | 效率高,无碎片 | 浪费一部分空间 |

| 标记-整理 | 标记后把存活对象向一端移动 | 无碎片 | 移动对象成本更高 |

| 分代收集 | 不同代用不同算法 | 贴合对象生命周期 | 需要更多策略参数 |

新生代对象大多朝生夕死,所以复制算法很合适。老年代对象存活时间更长,复制成本太高,常用标记-清除或标记-整理思路。

默认情况下,可以先记住:

  • 对象优先分配在 Eden。
  • Eden 满了触发 Minor GC。
  • 长期存活对象晋升到老年代。
  • 大对象可能直接进入老年代。
  • Survivor 放不下时,需要老年代做分配担保。

这比死背某个参数默认值更有用。线上看 GC 日志时,先判断对象为什么进入老年代、老年代为什么增长,再考虑调参。

CMS 和 G1 的关注点不同

CMS 的目标是减少老年代停顿,典型阶段包括:

  1. 初始标记:STW,只标记 GC Roots 直接关联对象。
  2. 并发标记:和用户线程一起跑,继续追踪引用。
  3. 重新标记:STW,修正并发期间变化。
  4. 并发清理:清理垃圾。

CMS 的问题也很典型:对 CPU 敏感、会产生浮动垃圾、标记清除带来碎片。

G1 则把堆拆成多个 Region,不再简单只按连续的新生代/老年代看全堆。它会尝试优先回收收益更高的 Region,用可预测停顿时间作为重要目标。理解 G1 时不要只套 CMS 的思路,要关注 Region、暂停目标和混合回收。

为什么堆没满也会 Full GC

一个很典型的案例:项目刚启动时,jstat -gc 看到老年代只用了很少,但 Full GC 已经发生多次。GC 日志里出现:

Full GC (Metadata GC Threshold)

这通常说明触发点不是老年代,而是元空间阈值。启动时大量加载类,默认 MetaspaceSize 比较小,达到阈值后 JVM 会尝试触发 GC 和类卸载。

如果确认启动阶段类加载稳定,且 Full GC 只是元空间阈值过低导致,可以适当提高初始阈值:

-XX:MetaspaceSize=200m

这类问题的关键是看触发原因,不要看到 Full GC 就马上去调 -Xmx

参数写了不等于生效

另一个常见案例:线上频繁 Full GC,运维说已经设置 -Xmx2g,但应用实际最大堆只有 900M 左右。

最稳的排查方式是让程序自己打印 JVM 看到的配置:

MemoryMXBean mxb = ManagementFactory.getMemoryMXBean();
System.out.println("Max:" + mxb.getHeapMemoryUsage().getMax() / 1024 / 1024 + "MB");
System.out.println(mxb.getHeapMemoryUsage());

常见原因有两个:

  1. JVM 参数位置写错。
  2. 容器内存限制没有被 JVM 正确感知。

参数位置尤其容易被忽略:

# 错误:-Xmx 在 jar 后面,可能被当成应用参数
java -server -jar app.jar -Xms2048m -Xmx2048m

# 正确:JVM 参数要放在 -jar 前面
java -server -Xms2048m -Xmx2048m -jar app.jar

容器场景还要看 JDK 版本。较新的 JDK 默认支持容器限制感知;老 Java 8 版本可能需要额外参数,或者干脆升级到更合适的运行时版本。

排障时先拿数据

JVM 问题不要只靠猜。至少先准备这些数据:

  • 启动命令和完整 JVM 参数。
  • GC 日志。
  • jstat -gc 的连续采样。
  • 线程快照:jstack
  • 堆快照:jmap 或线上工具导出的 hprof。
  • 容器内存限制和宿主机内存情况。
  • 应用启动日志和 OOM 堆栈。

常用工具可以这样分工:

| 工具 | 用途 |

| — | — |

| jps | 查看当前 HotSpot 进程 |

| jstat | 连续观察堆、GC 次数和耗时 |

| jinfo | 查看 JVM 参数 |

| jmap | 生成堆快照,查看对象分布 |

| jstack | 生成线程快照,排查死锁和高 CPU |

| VisualVM / MAT | 分析堆和对象引用链 |

如果线上权限有限,至少也要拿到启动参数、GC 日志和容器限制。没有数据就调参,基本是在赌。

小结

JVM 内存和 GC 可以按这个顺序理解:

  • 先分清线程私有区和线程共享区。
  • 再分清堆、元空间、直接内存分别受什么限制。
  • 对象引用消失不等于对象立即回收,要等 GC。
  • Java 主流 GC 靠可达性分析,不靠引用计数。
  • 新生代、老年代、元空间的触发原因不同,Full GC 不一定是堆满。
  • 参数写了不代表生效,要用 JVM 实际看到的值验证。
  • 排障先拿 GC 日志、JVM 参数、线程快照和堆快照,再谈调优。

真正有用的 JVM 知识,不是把所有收集器背全,而是在看到一次 Full GC 或一次 OOM 时,能判断它到底是哪个区域、哪个阈值、哪条引用链出了问题。

正文完
 0
bdspAdmin
版权声明:本站原创文章,由 bdspAdmin 于2026-06-10发表,共计3900字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)