【JVM】JVM 内存结构为什么长成今天这样:一部被现实逼出来的进化史
JVM 内存结构为什么长成今天这样一部被现实逼出来的进化史本文不讲「五大区域分别是哪五个」就结束——那种清单你背一百遍也记不住。我们换个角度先看没有 JVM 的时候程序员有多痛再看每次痛完之后 JVM 做了什么样的妥协最后你会自然明白为什么今天是这个样子以及每次 JVM 启动参数到底在调什么。一、如果回到没有 JVM 的年代C 语言里申请内存是一笔手工账你调用malloc拿到一块地址用完必须自己free。听起来很自由实际有四个躲不开的坑忘了还内存泄漏程序跑几天就撑爆还早了悬垂指针指针还指着那块地址但内容已经被别人覆盖还重了重复释放直接崩溃换个平台就得重编32 位和 64 位、x86 和 ARM 的指针宽度都不一样这四笔账是把内存管理的责任压在写业务代码的人身上。而业务代码本来只想做一件事——存一个订单对象而已。那我们来看看 Java 里同一件事长什么样publicclassMemorySnapshot{publicstaticvoidmain(String[]args){RuntimertRuntime.getRuntime();// 拿到当前 JVM 的运行时句柄longmb1024*1024;// 换算成 MB 方便看System.out.printf(堆最大可用 : %d MB%n,rt.maxMemory()/mb);System.out.printf(已向系统申请: %d MB%n,rt.totalMemory()/mb);System.out.printf(其中空闲 : %d MB%n,rt.freeMemory()/mb);}}一段典型的输出数值因机器而异堆最大可用 : 4016 MB 已向系统申请: 256 MB 其中空闲 : 251 MB注意这里发生了什么我们只写了一次new但从没写过一行「释放」。而且我们可以直接向 JVM 查账——这块内存有多大、用了多少、还剩多少运行时都一清二楚。为什么能查账因为有个人在背后替你画好了一张内存地图哪些内存归谁管、什么时候可以有效回收、多线程同时访问怎么办全都约定好了。这张地图就是运行时数据区Runtime Data Area——也就是我们平时说的「JVM 内存结构」。本文要回答的核心问题这张地图为什么会画成今天这个样子二、第一次尝试把内存当成一整个大水池最简单粗暴的方案是给 JVM 申请一大块连续内存所有东西都往里面放——方法调用产生的临时变量、新建的对象、类的结构信息全塞一起。这个方案跑得起来但代价很快暴露出来而且三条全是致命的代价一生命周期不同的东西混在一起回收无从下手。一个方法调用产生的临时变量方法一返回就没用了存活时间可能只有几微秒而一个缓存对象可能活一整天。如果它们紧挨着放回收器要判断「这块能不能扔」就必须挨个扫描效率极低。代价二多线程跑到一起就出事。多个线程同时对象如果没有隔离区域就必须对整块水池加锁。加了锁并发性能直接塌掉。代价三new完之后没人知道该往哪放。如果只有一块连续空间分配新对象必须找到足够大的连续空洞这在长期运行的程序里会形成大量内存碎片——就像停车场里剩下的都是夹缝明明总空位不少就是停不进去。所以第一次尝试的结论是一块大水池不行必须按「谁在用」和「用多久」来切分。这个结论直接决定了今天内存地图的第一层划分原则。三、第二次尝试按「谁在用、用多久」切成五个区切分的思路非常朴素先看这个数据的归属者是谁再看它活多久。按归属者分只有两类只归一个线程用→ 可以完全不管并发问题每个线程给自己划一份所有线程共用→ 必须考虑并发安全和回收效率按生存期分也只有两类跟着方法调用一起生、一起死→ 用完就扔要活很久甚至活到程序结束→ 需要定期清理两条维度一交叉五个区域就自然浮现了。我们用一家公司来类比记住这个类比后面讲解每个区域时都会用到区域线程归属类比程序计数器线程私有每个人桌上的便签纸记着「我读到第几行了」虚拟机栈线程私有每个人的工位每进一个方法就摞一个文件盘本地方法栈线程私有同上但专门处理 C/C 那边的交接文件Java 堆线程共享公司公共仓库东西最多最杂专门配了保洁方法区线程共享公司的档案室存放所有规章制度和图纸这个类比的局限公司里工位是实体的、固定的而 JVM 的栈空间是「按需向下生长」的每个方法调用叠一个栈帧返回时弹出。公司不会因为你接了个电话就临时加一张桌子。它和 JVM 内存结构的关系这五个区域就是 JVM 内存结构本身。下面逐个展开。3.1 程序计数器唯一不会 OOM 的地方它是什么一块极小的内存保存当前线程正在执行的字节码行号。字节码是 Java 源码编译后的中间指令集JVM 靠它逐条执行。为什么每个线程都要有一份因为 CPU 时间片轮转时线程会被切走再切回来。切回来时必须知道「我刚才执行到哪了」否则就得从头再来。它和 JVM 内存结构的关系它是内存地图里唯一一个在《Java 虚拟机规范》中明确规定不会抛出OutOfMemoryError的区域——因为它要存的东西实在太小了就是在内存里放一个计数器。顺带说一句这也是为什么 Java 的多线程切换比进程切换便宜——上下文只需要保存一个小计数器。3.2 虚拟机栈方法调用靠它叠起来它是什么每个线程私有的内存区生命周期和线程一样长。每调用一个方法就往里压入一个栈帧Stack Frame方法返回栈帧弹出。一个栈帧里装着四样东西局部变量表方法里的局部变量都放这。最小单位叫「变量槽」Slot一个 32 位类型占 1 个槽long和double占 2 个操作数栈做运算的临时台面。比如算a b先把 a、b 压进来再弹出去做加法动态链接指向方法所属类的运行时常量池用来找到真正要调用的方法方法返回地址方法返回后回到调用方的哪一行继续执行publicclassStackDemo{publicstaticvoidmain(String[]args){try{recurse(0);}catch(StackOverflowErrore){System.out.println(栈溢出了递归太深);}}staticvoidrecurse(intdepth){recurse(depth1);// 无限递归每个调用都压入一个新栈帧}}运行后会抛出StackOverflowError。这是栈空间不足和堆 OOM 是两码事。它和 JVM 内存结构的关系栈深度直接由-Xss参数决定HotSpot 在 64 位 Linux 上通常默认 1MB。这个参数调大能支持更深的递归但每个线程占的内存也更大——线程数 × 单栈大小才是真实的内存开销这是很多人调优时踩的坑。3.3 本地方法栈给 native 方法用的它是什么功能与虚拟机栈完全一致只是服务的对象是native方法——那些用 C/C 实现、通过 JNI 调用的方法比如Object.hashCode()的底层实现、System.currentTimeMillis()。它和 JVM 内存结构的关系它和虚拟机栈是兄弟区域规范上是分开的两个区。不过在主流实现 HotSpot 里两者被合并成一个了——所以你实际调-Xss时调的是合并后的这一个栈。3.4 Java 堆唯一需要配保洁的地方它是什么线程共享的最大一块内存几乎所有的对象实例和数组都分配在这里指 HotSpot 的实现且未开启逃逸分析优化时。为什么它最大、最复杂因为对象的生命周期无法在编译期确定——创建容易判断「什么时候可以扔」极难。这个判断工作就是垃圾回收GC。为了降低回收难度HotSpot 把堆又细分为新生代新对象的出生地又分一个 Eden 区和两个 Survivor 区S0/S1。绝大多数对象在这里「出生即死亡」所以回收快、代价低老年代熬过若干轮回收后仍存活的对象搬到这里回收频率低但单次代价高importjava.util.ArrayList;importjava.util.List;publicclassHeapOomDemo{publicstaticvoidmain(String[]args){Listbyte[]leaknewArrayList();intmb0;try{while(true){leak.add(newbyte[1024*1024]);// 每次塞 1MB且被 list 强引用回收不掉mb;}}catch(OutOfMemoryErrore){System.out.println(已塞入 mb MB 后堆溢出e.getMessage());}}}加-Xmx64m跑这段代码会看到OutOfMemoryError: Java heap space。它和 JVM 内存结构的关系堆是 GC 的主战场也是内存调优的核心——-Xms初始堆大小和-Xmx最大堆大小两个参数几乎决定了 JVM 的全部内存行为。注意一处常见误解堆的「分代」是 HotSpot 的实现细节不是《Java 虚拟机规范》的要求。规范只说堆由所有线程共享、可以不连续。到了 G1分代从物理分区变成了逻辑概念到了 ZGC 和 Shenandoah分代更是基本取消。所以「Java 堆 新生代 老年代」这句话只在特定收集器下成立。3.5 方法区类的档案室它是什么线程共享的区域存放已被 JVM 加载的类的结构信息——类名、父类、方法表、字段描述、运行时常量池等以及即时编译器JIT编译后的代码缓存。简单说堆里放对象方法区放类的定义。publicclassMethodAreaDemo{publicstaticvoidmain(String[]args){// 类的元信息在方法区堆里只有一个对象引用MethodAreaDemodemonewMethodAreaDemo();Class?clazzdemo.getClass();System.out.println(类名 : clazz.getName());System.out.println(方法数量 : clazz.getDeclaredMethods().length);System.out.println(类对象在堆中: clazz.hashCode());}}它和 JVM 内存结构的关系方法区是内存地图里最特殊的一块——《Java 虚拟机规范》只把它当作一个逻辑概念来定义具体怎么落地交给各虚拟机实现自由发挥。正因为「实现自由」它才有了后面那段最曲折的搬迁史。四、今天的答案JVM 运行时数据区到这里可以给出准确定义了。JVM 运行时数据区是指Java 虚拟机在执行 Java 程序的过程中把自己从操作系统申请到的内存按照数据的线程归属和生命周期划分成若干功能独立的区域并规定每个区域存放什么数据、何时创建、何时销毁。按线程归属五大区域的最终形态是区域归属存什么溢出时抛什么程序计数器线程私有当前字节码行号不抛规范规定虚拟机栈线程私有栈帧局部变量表、操作数栈等StackOverflowError/OutOfMemoryError本地方法栈线程私有native 方法调用信息同上Java 堆线程共享对象实例、数组OutOfMemoryError: Java heap space方法区线程共享类信息、常量、JIT 代码缓存JDK 8 起为OutOfMemoryError: Metaspace用一句话概括整张地图的设计哲学能被线程孤立起来的就不要共享能按生命周期拆开的就不要混在一起。这是整篇文章的核心结论也是你理解所有后续调优参数的钥匙。五、被淘汰的方案还有用吗永久代与元空间的搬迁史方法区的「实现自由」埋下了一个大坑也成就了 JVM 内存结构中最著名的一次重构。5.1 第一次落地永久代PermGenJDK 7 及之前HotSpot 用永久代来实现方法区而且把它放在了堆内部是堆的一个逻辑部分。问题在于永久代的大小必须提前固定-XX:MaxPermSizeJVM 启动时就定死。而一个程序到底要加载多少类运行前根本无法准确预估——用了 CGLIB 动态代理、大量反射、热部署框架比如早期 Tomcat 反复重载应用的程序类加载数量会远超预期直接撞上java.lang.OutOfMemoryError: PermGen space这个报错在 2010 年前后的 Tomcat 部署现场几乎是家常便饭。更麻烦的是永久代的 GC 效率极低字符串常量池放在里面时大量重复字符串回收不及时。5.2 中间的过渡字符串常量池搬家JDK 7 迈出了第一步把字符串常量池从永久代挪到了堆里。原因很实际永久代 GC 频率低、效果差而字符串是最容易产生大量重复数据的类型。挪到堆里就能享受新生代快速回收的好处。publicclassInternDemo{publicstaticvoidmain(String[]args){StringanewString(hello);// 堆里新建一个对象Stringbhello;// 字面量指向常量池Stringca.intern();// 手动入池返回池中的引用System.out.println(ab);// falsea 在堆b 在池中System.out.println(bc);// true c 就是池里那个}}这段代码的结果是 JDK 7 之后才稳定成这样的。JDK 6 及之前b c的行为与此不同——因为常量池当时还在永久代里。5.3 最终形态元空间MetaspaceJDK 8 直接移除了永久代改用元空间实现方法区并且做了一个关键改变元空间不再使用堆内存而是使用本地内存Native Memory即操作系统内存。这一改三个问题同时解决不再需要预估大小默认受物理内存限制从根本上消灭了「PermGen space」这个报错和老年代解耦类元数据不再挤占堆空间堆可以更纯粹地服务对象分配合并两个 JVMHotSpot 与 JRockit 的合并是 JDK 8 的重要目标而 JRockit 本来就没有永久代它和 JVM 内存结构的关系这是内存地图上唯一一次区域级的重构——方法区的实现载体从「堆内固定大小」变成「堆外动态扩展」。理解这次重构才能明白为什么调优参数从-XX:MaxPermSize变成了-XX:MaxMetaspaceSize。那永久代还有用吗有用——如果你维护的是 JDK 7 的老系统-XX:MaxPermSize依然是必须调的参数面试里「JDK 8 为什么废弃永久代」也是高频题。历史不是没有价值只是换了个地方发挥作用。六、一路上的副产品由分区衍生出的知识内存一旦分区很多新技术就有了立足点。下面这些概念都是这张地图的下游产物。主战场规则层溢出场景实现载体堆外延伸栈帧结构分配优化分配优化JDK 8 被替代依赖JVM 内存结构垃圾回收 GCJMM Java 内存模型各类 OOM 排查元空间 Metaspace直接内存 Direct Memory方法调用与栈帧TLAB 线程本地分配缓冲逃逸分析与栈上分配永久代 PermGen它和 JVM 内存结构的关系下面每一个概念都能在地图上找到自己的坐标。6.1 垃圾回收GC关系GC 是内存分区的直接产物。正因为有了「堆」这个专门放对象的共享区域才需要一个机制来判断哪些对象可以回收。分区方式新生代 / 老年代本质上就是为 GC 算法服务的。类比GC 就是公司的保洁。新生代像茶水间的纸巾用完就扔、每天清好几遍老年代像档案柜里的文件清理频率低但清一次要慎重。局限保洁不会在你还在用的时候把东西扔掉而 GC 的难点恰恰是判断「还有没有人引用它」——这需要从 GC Roots 出发做可达性分析。6.2 JMMJava 内存模型——最容易被混淆的概念这是必须澄清的一处误解JVM 内存结构和 JMM 是两回事。JVM 内存结构JMM Java 内存模型回答什么问题内存怎么分区、放什么多线程看到的数据是否一致性质具体的内存布局抽象的并发规范典型关键词堆、栈、方法区、程序计数器可见性、有序性、原子性、happens-before类比公司的楼层平面图公司的信息传达制度关系JMM 是建立在 JVM 内存结构之上的规则层。因为有了「线程私有栈 共享堆」这个物理事实才产生了「一个线程改了堆里的数据另一个线程可能看不到」的可见性问题——JMM 就是用来规定这类问题的。面试里把这两个混为一谈是最常见的失分点。6.3 直接内存Direct Memory它是什么一块不属于运行时数据区的内存通过Unsafe类直接向操作系统申请常见于 NIO 场景ByteBuffer.allocateDirect()。importjava.nio.ByteBuffer;publicclassDirectMemoryDemo{publicstaticvoidmain(String[]args){ByteBufferbufferByteBuffer.allocateDirect(64*1024*1024);// 64MB 堆外内存buffer.putInt(1);buffer.flip();System.out.println(读回: buffer.getInt());System.out.println(是否直接缓冲: buffer.isDirect());}}关系它是内存地图的堆外延伸由-XX:MaxDirectMemorySize控制HotSpot 中默认约等于-Xmx。为什么需要它堆内数据要做网络 IO 时必须先复制到堆外再交给内核。直接内存省掉了这次复制所以在 Netty、Kafka 这类高吞吐框架里被大量使用。代价它不受-Xmx限制容易被忽略而撑爆物理内存报错是OutOfMemoryError: Direct buffer memory。6.4 TLAB让对象分配不用抢锁它是什么TLABThread Local Allocation Buffer线程本地分配缓冲。堆是一块共享区域那么多线程同时new对象岂不要频繁加锁HotSpot 的解法是在 Eden 区里给每个线程先划一小块私有区域线程自己的对象优先在自己的 TLAB 里分配用完这一小块再申请新的。关系它是「共享堆」和「线程私有」两个设计原则的妥协产物——用线程私有缓冲解决共享区域的分配竞争问题。6.5 逃逸分析与栈上分配关系这是对内存地图的一次巧妙「违反」。既然栈是线程私有、随方法销毁的那如果一个对象根本没「逃逸」出方法没有被外部引用能不能干脆分配在栈上随栈帧一起销毁HotSpot 通过逃逸分析判断对象是否逃逸配合标量替换把本可以栈上分配的对象拆成若干局部变量。这样对象压根没进堆GC 压力大幅减轻。局限逃逸分析开销不小且只在特定条件生效不要指望它替代 GC 优化。以 HotSpot 为例逃逸分析默认开启但栈上分配的实际收益受代码写法影响很大。七、前置知识读懂上面内容需要的地基如果你对下面三个概念还不熟先补一下再往下看。7.1 进程与线程它是什么进程是操作系统分配资源的最小单位线程是 CPU 调度的最小单位。一个进程内的多个线程共享进程的内存空间。关系它是理解「线程私有 vs 线程共享」的地基。为什么程序计数器和栈要每个线程一份因为线程会被 CPU 随时切走切回来必须能恢复现场。7.2 栈与堆数据结构层它是什么这里的「栈」和「堆」是数据结构概念——栈是后进先出堆是一块可以随意申请释放的池子。关系与警示JVM 里的「虚拟机栈」确实用了栈结构栈帧后进先出但 JVM 的「堆」并不是数据结构意义上的堆不是二叉堆。这个名字只是沿用了传统叫法这点经常让人在初学时困惑。7.3 字节码它是什么.java源码编译后的中间产物存放在.class文件里是 JVM 真正执行的指令。关系它是程序计数器的追踪对象也是类元数据的来源类加载后进入方法区。没有字节码程序计数器就没有「行号」可记。八、常见坑与面试高频坑 / 面试题一句话答案展开要点JVM 内存结构和 JMM 是一回事吗不是。前者是内存布局后者是并发规则一个讲「放在哪」一个讲「看不看得见」程序计数器会 OOM 吗不会规范明确规定五大区域中唯一不抛 OOM 的StackOverflowError 属于 OOM 吗不属于但常与 OOM 一起讨论它是栈深度超限OutOfMemoryError是申请不到内存。栈在扩展时也可能抛 OOM永久代和元空间的区别位置不同堆内 vs 本地内存永久代大小固定且需预估元空间动态扩展受物理内存限制字符串常量池在哪个区域JDK 7 起在堆JDK 8 后运行时常量池在元空间注意区分「字符串常量池」和「运行时常量池」String s new String(x)创建几个对象通常 2 个heap 对象 常量池引用若常量池已有 “x”则只创建 1 个堆对象为什么-Xss不能随便调大线程内存 线程数 × 单栈大小调大-Xss会显著降低可创建的线程数上限元空间会无限增长吗不设上限时受物理内存限制-XX:MaxMetaspaceSize未设置时默认为 -1无上限建议显式设置堆越大越好吗不是堆越大单次 GC 停顿越长且会挤占元空间、直接内存的可用物理内存关于 OOM 报错的识别速查Java heap space→ 堆内存不够查大对象、内存泄漏MetaspaceJDK 8/PermGen spaceJDK 7-→ 类加载过多查动态代理、热部署、反射Direct buffer memory→ 堆外内存泄漏查 NIO 缓冲区是否释放Unable to create new native thread→ 线程数超限通常是-Xss过大或线程未回收GC overhead limit exceeded→ GC 花费大量时间却几乎没回收出空间九、如果你今天要选型参数怎么调理解了地图参数就不再是死记硬背。参数管哪个区域建议-Xms/-XmxJava 堆生产环境建议设成相同值避免运行期反复扩缩容带来的停顿-Xss虚拟机栈默认通常 1MB递归深或线程数多时按需权衡-XX:MetaspaceSize元空间首次触发 Full GC 的阈值设太小会导致启动期频繁 Full GC-XX:MaxMetaspaceSize元空间建议显式设置防止类加载失控吃光物理内存-XX:MaxDirectMemorySize直接内存用了 Netty / NIO 时必须关注默认约等于-Xmx-XX:HeapDumpOnOutOfMemoryError全局强烈建议开启OOM 时自动导出堆快照供事后分析一个实用的排查思路遇到 OOM先看报错信息里有没有明确写出区域heap space/Metaspace/Direct buffer memory再对症去查对应的区域不要一上来就盲目加大-Xmx。十、收尾一条时间线带走时间线手工 malloc/free 时代 ↓ 痛在泄漏、悬垂指针、平台绑定 需要有人替运行时管内存 ↓ 朴素方案一块连续大内存 ↓ 痛在生命周期混杂、并发竞争、内存碎片 按「线程归属 × 生命周期」分区 ↓ 五大区域成型程序计数器 / 虚拟机栈 / 本地方法栈 / 堆 / 方法区 ↓ 痛在方法区用永久代实现大小固定 → OOM: PermGen space 方法区搬迁 ↓ JDK 7字符串常量池 → 堆 ↓ JDK 8永久代 → 元空间本地内存 今天的形态运行时数据区 元空间 直接内存三句话记住全部分区原则能被线程孤立的不共享能按生命周期拆开的不混放——所有区域划分都源自这一条。两条战线线程私有的计数器 两个栈随线程生死线程共享的堆 方法区才是 GC 和并发问题的战场。一次重构方法区从「堆内固定大小的永久代」搬到「堆外动态扩展的元空间」是内存地图上唯一一次区域级重构也是所有 JDK 7 与 JDK 8 参数差异的根源。延伸阅读建议按此顺序学效果最好内存溢出排查实战—— 拿本文第九节的参数配合jmap、jstat、MAT 工具做一次真实的 OOM 分析垃圾回收算法与收集器—— 理解堆分区之后顺理成章往下走JMM 与并发编程—— 澄清本文第六节提到的混淆进入并发领域类加载机制—— 方法区里那些类信息是从哪来的答案在这里JIT 编译与逃逸分析—— 回到本文 6.5 节把优化手段补齐一句话总结JVM 内存结构不是谁拍脑袋设计的而是被「内存泄漏」「并发竞争」「大小无法预估」这三个现实问题一步步逼出来的。理解了每一次妥协的原因你记住的就不再是五个名词而是一套推理方式。