
JVM内存结构是Java开发者的必修课也是面试里最容易被追着问深的知识点之一。每当有人跑过来跟我说“线上内存溢出了”“Full GC频繁”我第一反应不是急着加内存而是先问自己这块内存到底住在JVM的哪块区域区域找到了问题基本就解决了一半。这篇文章我会把JVM内存结构彻底拆开从运行时数据区、堆栈、元空间到对象布局、直接内存再到排查工具和真实踩坑经历尽量按我自己的实战逻辑来写。不管你是准备背jvm相关面试题还是想弄明白jvm调优到底调什么都能从里面找到能直接用到的东西。1. 从运行时数据区看JVM内存全貌1.1 先搞明白“运行时数据区”到底分了几块根据《Java虚拟机规范》JVM在执行Java程序的过程中会把它管理的内存划分成若干个区域。注意这里说的是“逻辑划分”不是物理内存的绝对隔离。同一个物理页可能被堆和栈同时使用就像家里一套房子客厅和卧室有各自职能但水电管道还是共享的。在HotSpot VM也就是Oracle JDK和OpenJDK默认使用的虚拟机实现中我们常说的内存结构大致包含这些区域区域线程共享情况主要存什么异常表现程序计数器线程私有当前线程正在执行的字节码行号无Java虚拟机栈线程私有方法调用过程中的栈帧StackOverflowError / OutOfMemoryError本地方法栈线程私有native方法调用状态StackOverflowError / OutOfMemoryErrorJava堆线程共享几乎所有对象实例OutOfMemoryError: Java heap space方法区/元空间线程共享类元信息、运行时常量池等OutOfMemoryError: Metaspace运行时常量池线程共享字节码常量、符号引用等包含在方法区异常中直接内存所有线程可访问NIO堆外缓冲OutOfMemoryError: Direct buffer memory这里要提醒一句直接内存并不属于《Java虚拟机规范》中定义的运行时数据区它是HotSpot扩展出来的一块“法外之地”。但实际线上出问题几乎都绕不开它所以学内存结构的时候必须把它单独列出来。1.2 线程私有区域为什么天生不需要加锁从线程共享关系能看出一个规律虚拟机栈、本地方法栈、程序计数器跟随线程创建和销毁区域内的数据不可能被其他线程访问自然没有并发问题不需要加锁。线程私有区域的生命周期也特别清晰。比如虚拟机栈它的生命周期和线程完全一致线程结束即释放。而堆和方法区是线程共享的对象可以被多个线程同时访问所以必须有同步机制还要由GC线程统一管理。很多人对“为什么栈内存不用GC回收”感到迷惑其实原因很简单栈帧的生命周期是方法调用级别的从方法进入开始压栈到方法返回弹栈它是确定性的堆里的对象生命周期则是非确定性的对象离开作用域后还可能在别处被引用必须由GC来判断和回收。从这一层开始理解后面各种内存异常出现的原因就会清楚得多。2. 堆、栈、方法区三个最常考也最常出问题的板块2.1 Java堆几乎所有对象的“家”堆是所有线程共享的内存区域在JVM启动时创建。我们平时说的“堆内存OOM”大多发生在这里。从HotSpot的分代结构来看堆分成新生代和老年代。新生代一般又按伊甸区和两块幸存区划分比例由-XX:SurvivorRatio控制默认是8也就是Eden区和每块Survivor区的比例是8:1:1。新建对象优先在Eden区分配Eden区满的时候触发Minor GCMinor GC之后存活对象进入Survivor区经历一定次数的GC之后晋升老年代。默认阈值是15次由-XX:MaxTenuringThreshold控制。有一个容易被忽略的点-Xmn设置新生代大小的时候不是简单按“Eden Xmn”理解。比如这样配置-Xmx2g -Xms2g -Xmn1g -XX:SurvivorRatio81G的新生代按8:1:1拆分后Eden区约800M两个Survivor区各约100M。新生代如果设置过小对象会被频繁晋升到老年代老年代可能被塞满设置过大又把老年代挤没了同样有风险。所以调堆参数之前得先搞清楚你服务里的对象到底是什么生存模式。堆大小设置还有一个原则生产环境最好把初始堆和最大堆设为同一个值也就是-Xms和-Xmx相同避免运行期“堆从小扩大”带来性能抖动。毕竟扩容需要STW虽然时间不长但对大流量服务来说每次停顿都可能被放大成异常。我一般先给服务一个估算值比如并发量每秒几千、单个请求中间对象约几百KB的先给4G起步再通过监控逐步调整。2.2 虚拟机栈方法调用的“记账本”Java虚拟机栈是线程私有的生命周期和线程相同。每执行一个方法虚拟机会同步创建一个栈帧压在当前线程的栈里方法结束栈帧出栈。这个过程先进后出和数据结构里的栈完全一样。一个栈帧包含四部分核心信息局部变量表存放方法参数和局部变量最小单位为槽long和double占两个槽其余类型占一个槽。操作数栈存放中间计算结果字节码指令从操作数栈取数和压数。动态链接指向运行时常量池中当前方法引用用于解析符号引用。方法返回地址记录方法退出后应该回到哪里继续执行。举个例子一个简单的累加方法public int add(int a, int b) { return a b; }对应字节码大致是iload_0、iload_1、iadd、ireturn。前两条指令把局部变量表槽0和槽1的整数压入操作数栈iadd把两个数弹出并相加再压回操作数栈ireturn把结果返回给调用方。可以看到局部变量表和操作数栈是配合使用的栈帧本身不只是“存数据”那么简单。虚拟机栈最常见的问题是递归调用太深导致栈溢出。每次方法调用都会创建一个栈帧递归深度不是无限的而是受栈大小限制。默认栈大小通常在512KB到1MB之间具体看平台和JVM版本。如果方法里再声明一个很大的数组局部变量表占用更大递归深度会更浅。排查这种问题第一反应通常是加-Xss比如-Xss1m但加栈大小是治标不治本。如果递归业务上不可避免应该先考虑改成迭代或者用显式数据结构存中间状态确实要保留递归才把栈调大。而且-Xss影响所有线程假设设置了1M栈服务开了500个线程光是栈内存就可能占500M还没算堆和元空间。线程池线程数多的时候不能只盯着堆调参。2.3 方法区元空间与运行时常量池后来的主角方法区在《Java虚拟机规范》里是个逻辑概念。HotSpot在JDK6、7时代用永久代来实现它JDK8之后彻底改为元空间放到本地内存中不再用堆内存。为什么改因为永久代的大小很难准确预估动态生成类的框架一多比如CGLIB、ASM、热部署、Groovy脚本很容易让永久代爆掉触发频繁Full GC。改成元空间后默认情况下JVM不再限制这块区域的上限而是交给系统物理内存管理只有通过-XX:MaxMetaspaceSize才能设硬上限。注意一个理解误区-XX:MetaspaceSize不是元空间的上限它更像一个水位线。达到这个值之后JVM会比较积极地去进行类元数据回收如果回收效果不好元空间继续增长直到撞上MaxMetaspaceSize。线上如果发现不断Metaspace OOM先别急着调这个值应该回头查是不是类加载器泄漏了。比如每次部署都用新的ClassLoader加载类旧ClassLoader又没有被释放元空间就会不断被“死掉但还挂在引用链里”的类元数据填满。运行时常量池是方法区的一部分用于存放编译期生成的各种字面量和符号引用。JDK7开始字符串常量池被移到了Java堆中这个变化会直接影响intern()的表现String s1 new String(hello) new String(world); s1.intern(); String s2 helloworld; System.out.println(s1 s2); // JDK7及以上通常是 trueJDK7之前intern()会把字符串复制到永久代JDK7之后则会把首次出现的实例引用放入字符串常量池于是new出来的对象和常量池里的引用可能指向同一个对象。这种差异经常被拿来考细节实际开发里要小心的是大量调用intern()会让堆里积累很多字符串实例比不用它更占内存能避免就避免。3. 直接内存堆外的“法外之地”3.1 为什么NIO需要堆外内存Java堆内存虽然方便但它有一个硬伤操作系统对文件、Socket等IO操作通常走本地内存地址如果JVM把数据从堆内拷到堆外再交给内核就多了一次拷贝的开销。于是NIO引入了DirectByteBuffer直接在堆外分配一块内存堆里只放一个很小的对象指向这块内存配合系统调用完成更高效的数据搬运。这类内存不归堆管也不随着Minor GC直接回收它的分配和释放依赖JVM里的Cleaner机制。严格说还是跟GC有关联但没有“对象在Eden区Minor GC就能回收”那么直接。Netty这类高性能框架大量使用堆外内存原因也在这里。直接内存的大小默认等于堆的最大值通过-XX:MaxDirectMemorySize可以修改。如果程序里异常频繁地分配堆外内存又没及时释放就可能抛出java.lang.OutOfMemoryError: Direct buffer memory我见过一个典型场景某个网关服务用了Netty又自己封装了一个临时ByteBuffer缓冲池每来一个请求都从堆外allocate一块却没有用池化的内存复用结果堆内存才用了一半直接内存先爆了。排查时用jstat看堆一切正常最后把native内存信息导出来才定位到。所以看内存不能只盯-Xmx。3.2 什么时候该用堆外内存一句话总结为了性能和数据传输效率可以使用堆外内存但不要为了“不GC”而滥用。堆外内存不受Xmx限制于是很多人误以为它是免死金牌实际上它的回收更不可控。对于大多数业务服务普通堆内byte[]完全够用只有在IO密集、希望减少数据拷贝的场景下才值得引入堆外缓冲。4. 对象在内存中到底长什么样布局与分配细节4.1 对象布局三件套一个Java对象在堆中由对象头、实例数据和对齐填充三部分组成。对象头包含运行时的Mark Word锁状态、哈希、GC分代年龄等和类型指针指向类元数据。如果开启了指针压缩在64位系统上一个普通对象的对象头通常占12字节没压缩时是16字节数组对象还会多一个4字节的数组长度。实例数据对象的字段值包括从父类继承下来的字段。字段排列顺序有规则主要看声明顺序和虚拟机的对齐优化策略。对齐填充HotSpot要求对象起始地址必须是8字节的整数倍所以对象头加实例数据不足8的倍数时会自动填充占位。拿一个简单类来算class User { int id; String name; }假设开启了压缩普通对象指针对象头12字节int id占4字节String name作为引用类型压缩后占4字节合计20字节。20不是8的倍数填充到24字节。够直观吧别看4字节的填充不起眼如果服务里有一亿个这样的对象一个对象多4字节就是400M的额外占用。想亲眼看真实布局可以用OpenJDK的JOL工具依赖就一行dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency然后在代码里输出System.out.println(ClassLayout.parseClass(User.class).toPrintable()); System.out.println(ClassLayout.parseInstance(new int[10]).toPrintable());顺带回答一个面试常见题一个Object占多少字节开启指针压缩时对象头12字节填充到16字节不开启时Mark Word 8字节加类型指针8字节也是16字节。两种情况下一个Object实例都是16字节。4.2 对象分配的路径栈上分配、TLAB还是堆平时说“对象都分配在堆上”其实是在简化。HotSpot运行时还有逃逸分析。如果对象没有逃逸出方法并且可以分解为标量JIT可能直接在栈上分配“对象拆散后的字段”这种优化叫标量替换。试想一个方法内部Point p new Point(x, y)p没有被返回也没被其他线程访问JIT完全可以把它当作普通局部变量处理不触发堆分配。但绝大多数对象的生存期没那么简单还是得走堆分配。为了减少多线程并发向堆申请内存时的锁竞争JVM给每个线程分配了一块TLAB即线程本地分配缓冲。线程优先在TLAB里分配对象TLAB满了再向堆申请新缓冲区。所以看到堆使用率很高不代表真的有很大对象也可能是TLAB分配碎片化导致的。大对象分配要重点注意。像几MB以上的大数组HotSpot倾向于直接进入老年代不会在Eden区和Survivor区之间反复搬移。-XX:PretenureSizeThreshold可以指定直接进入老年代的对象大小阈值但这只对Serial和ParNew这类分代收集器有效给G1配置这个参数往往不生效因为G1已经做了对象区域化分配。5. 线上内存问题排查从现象到结论的工具链5.1 先用jstat看JVM内部计数器拿到一个线上进程第一件事是jps找到PID然后可以用jstat看内存和GC状态jstat -gcutil 12345 1000输出里那几列的含义大概是S0/S1是幸存区使用率E是Eden区使用率O是老年代使用率M是元空间使用率YGC/FGC是年轻代和Full GC次数YGCT/FGCT是各自的累计耗时。看到FGC次数不断增长、FGCT一次比一次长基本可以确定老年代有大量垃圾在反复清理。这时候再去dump堆才有的放矢。5.2 堆dump与MAT分析查看对象实例排行用jmap -histo最快jmap -histo:live 12345 | head -50它会按实例数量和占内存大小排出类名。注意live选项会触发一次Full GC线上操作不要太频繁最好避开业务高峰期。要深入定位引用链就得抓堆快照jmap -dump:live,formatb,file/tmp/heap.hprof 12345JDK9以后也可以用jcmdjcmd 12345 GC.heap_dump /tmp/heap.hprof出来的hprof文件用MAT或者VisualVM打开。MAT里重点看两个视图Histogram看对象类型分布Dominator Tree看谁持有大对象。我一般先看Leak Suspects向导它会把可疑的“一堆对象被一个根引用锚住”的直接原因标出来。这里说一个我处理过的真实案例有个任务调度服务每五分钟全量刷新一次缓存逻辑是往静态Map里put新数据就完事。结果老年代持续上升明明缓存“应该”只有几千条dump出来却是几十万条。原因是每次刷新都构造了新对象旧对象并没有从Map里移除Map的引用链条把它们全“锚”住了。MAT里一看Dominator Tree根是一个静态字段问题当场就清楚了。修复就一句话刷新前先清空Map。5.3 常见内存泄漏模式速查很多线上内存问题不是真的“不够用”而是“用完忘了还”。下面这些模式我基本都遇到过泄漏模式表现定位方法静态集合只加不减老年代持续增长histogram Dominator TreeThreadLocal没remove线程池线程一直持有旧数据对比业务日志时间点看线程上下文数据库连接/IO未关闭直接内存或堆内缓冲区堆积查看打开的文件描述符、连接池监控ClassLoader无法卸载元空间持续增长类加载统计或JFR监听器/回调未解绑对象被生命周期很长的集合持有分析长命类字段引用链排查经验里有一条很重要不要一上来就dump堆。先看jstat判断问题区域再通过日志判断大概操作时间点否则dump文件好几个G分析起来自己先崩溃。6. 常见内存异常速查表与调优心得6.1 异常与解决方向对照异常信息涉及区域典型原因我的处理建议StackOverflowErrorJava虚拟机栈递归或方法嵌套太深优先改迭代实现必要时适当调-XssOutOfMemoryError: Java heap spaceJava堆对象太多、堆太小、内存泄漏调整-Xmx、做heap dump分析OutOfMemoryError: Metaspace元空间动态生成类过多、类加载器泄漏检查ClassLoader必要时设MaxMetaspaceSizeOutOfMemoryError: Direct buffer memory直接内存堆外ByteBuffer未释放使用池化缓冲区检查NIO代码no suitable jvm was found to start the application环境层启动器在当前系统找不到JVM检查JAVA_HOME和PATH不是内存结构问题最后一行单独说一下这个报错经常被误以为是JVM内存调参问题其实它发生在进程还没起来的时候是启动器去本机找JRE/JVM失败。处理方法是检查环境变量把JAVA_HOME指向有效的JDK目录并重启终端。它和运行时内存结构不相关但搜这个词的人确实多所以放进来了。6.2 调优心得先做好度量再动参数jvm调优面试题经常被问但不少人把调优理解成“背几个参数然后乱调”。真正的调优流程应该是压测或线上监控定位瓶颈判断瓶颈在堆、GC还是其他资源针对性加参数再压测验证。实际操作中我通常会遵守下面几个原则生产环境-Xms和-Xmx设成相同减少运行期扩容抖动。不要盲目把堆设成物理内存的80%。机器上还有栈、元空间、直接内存操作系统本身也要留余量。经常见到8G物理内存的机器堆配到6G结果线程多跑几十个就整机无响应因为其他内存区域没算进去。元空间最好设一个-XX:MaxMetaspaceSize比如256M或512M。虽然不设置更容易出的是性能问题而不是崩溃但一个失控的服务迟早让你半夜被叫起来。GC算法的选择和参数调整同样重要。老年代频繁Full GC不一定是堆不够很可能是对象晋升太快或大对象过多。用G1就学会分析停顿日志用ZGC就先确认可观测性数据上来没有不然瞎换GC也白搭。别把GC日志丢掉。加参数把GC日志输出到文件出问题的时候没有日志就跟断了监控一样抓瞎。常见配置-Xlog:gc*:file/logs/gc.log:time,uptime,level:filecount10,filesize20m很多线上无厘头的内存问题最后都是靠GC日志的时间线定位到的并不总需要多么高深的工具。7. 写在后面一点个人体会最后再分享一点实践感受。JVM内存结构这套内容面试能背、文档能抄但真正值钱的是你把它当成一套“诊断地图”来用。哪块区域出问题异常信息对应哪个区域下一步该看什么工具全都在这个结构里画好了路径。我曾经处理过一个Full GC持续两秒的服务第一反应以为是堆不够把-Xmx从4G调到8G结果老年代照样涨Full GC频率还更高了。后来dump一看问题是某个接口把查询结果缓存进了一个静态MapMap的value又是一个大对象列表而触发缓存更新的任务每次都是追加而不是替换典型的“只增不清”。调参根本救不了代码一改立刻平稳。所以内存调优不是搏参数是搏你对“对象在哪里活着、被谁引用着”的理解深浅。下次再遇到服务内存告警别急着加机器按内存结构过一遍对象在哪块区域分配哪块区域在膨胀膨胀的根在哪里。你会发现网上那些jvm面试题、jvm调优面试题其实都在围着这几件事打转。