ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

23 面试官问你 JVM 内存结构,其实是想确认你知不知道哪块内存会 OOM

23 面试官问你 JVM 内存结构,其实是想确认你知不知道哪块内存会 OOM 面试现场今天问的是 JVM。面试官JVM 的内存结构你熟悉吧说说运行的时候内存都怎么分的。候选人A分为堆、栈、方法区嗯还有程序计数器差不多就这些。——说完就停了等着面试官接话。面试官追问那这些区域哪些是线程私有的哪些是线程共享的候选人A呃……栈是线程私有的堆是共享的……剩下的记不太清了。面试官换了个角度我问你这几块区域里哪一块永远不会发生 OOM候选人A堆最容易 OOM栈也可能溢出……永远不 OOM 的——卡住了。候选人B接过了话筒程序计数器不会 OOM。因为它存的只是一个很小的、大小固定的东西——当前线程正在执行的那条字节码的行号。它的空间在创建线程的时候就定死了后面不会再增长虚拟机规范里压根没给它定义任何 OutOfMemoryError。像虚拟机栈是固定深度、堆是动态增长这些才可能申请不到内存。面试官点点头那你再说说JDK 8 之后方法区去哪了候选人B永久代没了换成元空间。永久代是堆里的一块元空间用的是本地内存不受堆的大小限制。你猜谁过了这次聊天。很多人背 JVM 内存结构张口就是堆、栈、方法区像报菜名。面试官真正想知道的是你脑子里有没有一张出错地图——哪块内存什么时候会炸、炸了报什么错、为什么是它炸。因为真到了线上排查这张地图才是救命的。一、先把运行时数据区这个概念拎正先纠正一个说法。JVM 规范里管的叫运行时数据区Runtime Data Area一共五块- 程序计数器Program Counter Register- 虚拟机栈VM Stack- 本地方法栈Native Method Stack- 堆Heap- 方法区Method Area注意规范只规定了有这么几块、每块干什么用具体怎么实现各家虚拟机自己定。HotSpot 的实现就和大家常说的堆栈方法区对得上但方法区这块HotSpot 在 JDK 8 前后换过一次实现这个后面细说。除了这五块还有一块直接内存Direct Memory它不属于运行时数据区但经常被拿出来一起问因为它是 NIO 和 Netty 身上的隐藏炸弹。这块单开一节讲。看懂这张图其实就抓住了这一章的主线先记住谁私有谁共享再记住每块内存对应的错误是什么。二、一条线划开线程私有 vs 线程共享这五块区域用一条线就能划成两半。线程私有的- 程序计数器- 虚拟机栈- 本地方法栈线程共享的- 堆- 方法区为什么这么分因为线程私有意味着每个线程创建的时候都要跟着初始化一份线程销毁了这块也跟着回收。别人看不见你这份。而共享意味着所有线程都在这块内存上读写所以共享区域才是并发问题的主战场——你写的可见性 bug、并发修改异常多半都出在堆和方法区上。打个比方整个 JVM 像一栋写字楼。线程私有的东西是每个人自己工位的抽屉——你的笔、你的便签别人打不开你也用不着担心别人跟你抢。程序计数器就是你桌上那张写着我读到哪一行了的便签虚拟机栈就是你工位上那一摞还没处理完的待办单每接一个活儿就摞一张干完了就抽走。线程共享的东西是大厅、会议室、公告栏——所有人都在里面走动、说话所以容易撞上、容易抢。堆就是大厅里堆货的仓库所有人都往里面堆东西方法区就是公告栏贴着类的信息、常量谁都能看。一楼大厅堆货堆会有几个问题堆太满放不下、谁先拿到货、货谁来清。这就是 GC 和并发要解决的事。三、程序计数器唯一一条不会 OOM 的街先说这块不起眼但最特殊的内存。它是什么可以把它看成当前线程正在执行的字节码的行号指示器。字节码解释器工作时就是靠改变这个计数器的值来选取下一条要执行的字节码指令。它干什么用分支、循环、跳转、异常处理、线程恢复这些都要靠它。最关键的是多线程切换——CPU 是分时间片轮流跑的A 线程跑一半被切走B 线程跑一会儿又切回 AA 怎么知道该从哪条字节码接着跑靠的就是自己的程序计数器。每个线程一份互不干扰。细节线程执行 Java 方法时计数器记录的是正在执行的虚拟机字节码指令的地址如果执行的是 native 方法本地方法这个计数器的值是空的Undefined因为 native 方法不是字节码没地址可记。为什么不会 OOM因为它的空间大小在创建线程的时候就确定了存的不过是一个地址程序跑起来它不会变大。虚拟机规范里这是唯一一个没有规定任何 OutOfMemoryError 情况的区域。不会 OOM 不是 JVM 大发慈悲是这个东西天生就没有增长这个属性——你不可能让一个固定大小的书签被撑爆。对就是个书签。书再厚书签也就那么一小片它只负责记你读到第几页不会因为书厚就爆掉。它也不存内容只存位置。它会不会 StackOverflowError也不会。溢出是装不下或者叠太高程序计数器既没有深度也没在堆东西它就是个水位的刻度。四、虚拟机栈StackOverflowError 的老家虚拟机栈是线程私有的它的生命周期和线程一样。它存什么每个方法在执行的时候JVM 会创建一个栈帧Stack Frame压进栈里。一个栈帧装四样东西- 局部变量表Local Variable Table- 操作数栈Operand Stack- 动态链接Dynamic Linking- 方法返回地址Return Address方法调用 入栈一个栈帧方法返回 把栈帧弹出去。所以方法调用链越深栈帧摞得越高。局部变量表里装啥编译期就能确定的那些数据类型——八种基本类型boolean/byte/char/short/int/float/long/double、对象引用reference 类型、还有returnAddress类型。它以变量槽Slot为最小单位一个 Slot 在 64 位机器上通常是 4 字节long和double这种 64 位的类型占两个连续 Slot。局部变量表的容量在编译期就确定好了写进 Code 属性里。它什么时候出问题两种情况报的错不一样。第一种栈深度超了。方法递归太深栈帧一层层往里摞摞到超过虚拟机允许的最大深度抛StackOverflowError。每个线程的栈能摞多高由启动参数-Xss决定比如-Xss1m就是给每个线程 1MB 的栈。第二种你的程序疯狂起线程。每个线程都要分一个栈线程本身还有开销起得太多操作系统资源不够抛OutOfMemoryError: unable to create native thread。这个错误名字里带 native thread看着像栈的问题其实是系统没法再给你造线程了。这里有个细节值得记HotSpot 的虚拟机栈不支持动态扩展。也就是说栈满了它就是满了不会像堆那样去申请更大的空间。所以栈这边主要是 StackOverflowError 的天下而不是 OutOfMemoryError。再打个比方。栈像一摞叠起来的盘子每进一个方法就往上摞一个。摞太高哗啦塌了就是 StackOverflowError。而每个线程都是一摞独立的盘子你同时开太多摞桌面操作系统资源放不下就是 unable to create native thread。本地方法栈和虚拟机栈作用几乎一样只不过它是给 native 方法也就是 C/C 写的那种方法用的。HotSpot 干脆把两者合在一起实现了所以面试时一般连在一起讲。五、堆对象扎堆的地盘堆是 JVM 管理的内存里最大的一块也是所有线程共享的。它存什么几乎所有的对象实例和数组都在堆上分配。为什么说几乎因为有了逃逸分析之后某些对象可以不在堆上——这个第 11 篇聊过这里只提一句如果 JIT 编译时发现某个对象只在方法内部用、外面的线程都看不到就可能做标量替换把这个对象的字段拆成一个个局部变量直接放到栈上连堆都不用碰。它怎么管初始大小用-Xms指定最大大小用-Xmx指定。堆是共享的所以是垃圾回收的主战场平时说的分代新生代、老年代主要是堆内部的事。它的典型错误OutOfMemoryError: Java heap space。为什么堆满了不是直接崩、而是报这个因为-Xmx一旦定死这块内存的上限就锁死了。当堆里全是要用的对象GC 跑了一遍又一遍还是回收不出足够的空间来放新对象JVM 就会抛Java heap space。所以排查堆 OOM第一件事往往是看是不是有对象泄漏——本该被回收的对象被某个静态集合、ThreadLocal、监听器一直引用着GC 回收不掉堆就越涨越高。堆里还分新生代和老年代用不同的回收策略。这块内容和 GC 收集器关系很大留到第 25 篇展开。六、方法区从永久代到元空间方法区是最容易讲糊涂的一块因为它有个规范和实现要分开的坑。规范里的方法区存已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。它是线程共享的。HotSpot 的实现这里才是重点。JDK 7 及以前HotSpot 用永久代Permanent Generation来实现方法区。永久代有个特点——它在逻辑上属于堆的一部分受-XX:MaxPermSize这类参数限制。所以那个年代方法区撑爆会报OutOfMemoryError: PermGen space。JDK 8 开始永久代被移除了改用元空间Metaspace。元空间最大的不同是——它不再用堆内存而是直接用本地内存Native Memory。这就意味着方法区的上限不再被堆的大小卡住而是受本机物理内存限制。你可以用-XX:MaxMetaspaceSize显式限制它不设的话默认没有上限。怎么理解这个改动永久代在堆里堆的大小是你用-Xmx定死的方法区就只能在这有限的空间里跟对象抢地方容易两边都不够用。挪到本地内存之后类元数据不再挤压堆调优也简单了。而且永久代的回收效率一直很差这也是它被换掉的原因之一。它的典型错误OutOfMemoryError: Metaspace。什么时候元空间会被撑爆最常见的场景是动态生成类——CGLIB 做代理、大量反射、脚本引擎、还有不断热部署加载新类的框架。类一个劲地加载、又卸载不掉元空间就一直涨。所以看到 Metaspace OOM第一反应应该是是不是在疯狂地生成类。顺带提一句JDK 7 的时候字符串常量池就已经从永久代挪到堆里了。所以 JDK 7 之后String.intern()出来的字符串是住在堆上的。这个后面聊 String 的时候再细说。七、直接内存JVM 管不着的一块直接内存不在那五块运行时数据区里它说的是 NIO 里用ByteBuffer.allocateDirect()分配出来的那块内存走本地内存绕开堆。为什么要用它做网络、文件 IO 的时候如果数据放在堆里JVM 得先把它从堆拷到一个堆外的缓冲区才能交给操作系统内核去 read/write。直接内存在堆外省掉了这一趟拷贝这是零拷贝能成立的基础之一。Netty 大量用它就是这个道理。代价是什么分配和回收比堆内存贵而且它不受-Xmx管受-XX:MaxDirectMemorySize限制这个参数不显式配的话HotSpot 的默认值一般跟堆的最大值-Xmx相同。它的典型错误OutOfMemoryError: Direct buffer memory。这里有个反直觉的点。DirectByteBuffer这个对象本身是住在堆里的真正那块大内存在堆外。堆里那个小对象被 GC 回收的时候会通过一个叫 Cleaner 的机制底层是虚引用PhantomReference去释放堆外那块内存。问题来了——如果堆够大、GC 迟迟不触发堆里那个小对象一直没被回收堆外那块大内存就迟迟还不了。表现出来就是堆看着没满机器的物理内存却飙升。排查这种问题时光看堆的 GC 日志是不够的得看进程的本地内存占用。一句话总结七个区域各自的地盘和脾气程序计数器不会 OOM虚拟机栈的老家是 StackOverflowError堆是 Java heap space元空间是 Metaspace直接内存是 Direct buffer memory。 面试官真正想听的答案问他 JVM 内存结构他想听的绝不是堆、栈、方法区这六个字。背这六个字任何一本入门书的第一章都有。他想听的是——你脑子里有没有那张出错地图以及你知不知道每个设计背后的取舍。第一层先给结论JVM 的运行时数据区一共五块程序计数器、虚拟机栈、本地方法栈、堆、方法区。前三块线程私有后两块线程共享。还有一个直接内存不属于运行时数据区但排查时经常一起看。记这块内存最有用的方式不是背名字而是记住每块内存对应的错误——堆是 Java heap space元空间是 Metaspace虚拟机栈是 StackOverflowError直接内存是 Direct buffer memory只有程序计数器没有任何 OOM 情况。第二层讲清私有 vs 共享和背后的原因私有的三块每创建线程就初始化一份跟着线程生死别人看不见共享的两块是所有线程的公共地盘所以并发可见性问题主要出在堆和方法区上。私有/共享这条线直接决定了哪里会出并发问题。第三层用两个坑证明你真懂一是 JDK 8 的方法区——永久代被移除、换成元空间、从堆挪到了本地内存所以报错从 PermGen space 变成了 Metaspace二是直接内存——它不受-Xmx限制DirectByteBuffer回收不及时会导致堆没满、机器内存爆排查时要看本地内存。能主动提到这两个面试官就知道你不是只背了概念而是真踩过或者真理解过。下篇预告下一篇聊类加载。JVM 内存结构讲的是东西放哪类加载讲的是东西怎么进来的一个.class文件从磁盘到能被new出来中间要过加载、验证、准备、解析、初始化五道关。还有那个被问烂了的双亲委派模型以及它为什么会被打破——Tomcat 为什么要打破它、JDBC 的 SPI 又为什么要打破它。想提前看的话去翻翻ClassLoader.loadClass()那几行源码核心逻辑其实就几十行。下篇一行一行拆。唠点键盘之外的 · 第 23 篇
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进