ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JVM内存区域划分详解:堆、栈、元空间与OOM排查实战

JVM内存区域划分详解:堆、栈、元空间与OOM排查实战 1. 内容整体设计与思路拆解先搞清楚JVM内存区域到底解决什么问题聊JVM内存区域划分得先想明白一件事我们为什么要去了解这块内容。我自己刚工作时也是一头雾水觉得这是考试题目、面试八股跟写业务代码没什么关系。直到有一次写了一个批量导入功能导入到一半直接报java.lang.OutOfMemoryError: Java heap space线上数据卡在中间我才意识到内存这个东西不是“了解个大概”就行了的。把JVM内存区域划分这套东西吃透最直接的价值有三个。第一面试的时候这是高频考点不管你是初中级还是高级开发面试官几乎必问“JVM运行时数据区有哪些”而且往往会追着问“哪个区域会抛OutOfMemoryError”、“哪些区域是线程共享的”。第二定位线上问题的时候报错信息里其实已经把问题指向哪个区域写得很明白了比如Java heap space指向堆、Metaspace指向元空间、unable to create new native thread很可能跟栈和操作系统的线程限制有关你只有知道每个区域是干嘛的才能顺着报错精准施策。第三做JVM调优的时候所有参数比如-Xms、-Xmx、-Xss、-XX:MetaspaceSize本质上就是在对内存区域“做手术”你在调整哪个区域的内存大小这个问题如果心里没有地图调优就是瞎猜。这篇文章我会用实战视角把JVM内存区域掰开揉碎从整体设计思路开始然后逐个区域讲解作用、参数、常见异常最后把对象创建流程和OOM排查串起来。看完之后你至少能做到看到任何一条JVM内存相关的报错都能快速说出它对应哪个区域、大概是什么原因、可以从哪里入手排查。1.1 注意一个高频混淆点内存区域划分不等于JVM内存模型先把最容易搞混的概念说清楚。很多刚学JVM的同学会把“JVM内存区域划分”和“JVM内存模型”当成同一个东西其实完全不是。内存区域划分说的是JVM运行时数据区有哪些板块比如堆、栈、方法区、程序计数器、本地方法栈讨论的是“数据存在哪”。而Java内存模型Java Memory ModelJMM讨论的是多线程场景下变量什么时候能对其他线程可见、原子性怎么保证、指令重排的规则是什么说的是“并发可见性规则”。面试的时候如果被问到这两个概念一定分开回答混在一起基本就凉了一半。这次我们聚焦内存区域划分JMM以后有机会再单独写一篇。2. 核心细节解析与实操要点逐个区域拆解JVM运行时数据区JVM运行时数据区可以粗分成两大阵营线程私有的区域和线程共享的区域。线程私有的包括程序计数器、虚拟机栈、本地方法栈每个线程各有一份线程共享的包括堆和方法区在HotSpot里JDK 8之后是元空间整个进程只有一份。这个设计逻辑其实挺朴素——每个线程的调用状态、执行位置天然是独立的不需要共享但对象实例和类的元信息是整个程序都要访问的放在一起更高效、更好管理。理解了这个大框架后面的细节才不会乱。2.1 程序计数器唯一一个不会OOM的区域程序计数器Program Counter Register在线程私有的三块区域里体积最小作用却不可替代。它的本质是一个行号指示器记录的是当前线程正在执行的字节码指令的地址。如果说Java方法是解释执行的那程序计数器告诉解释器“下一条指令在哪”如果是本地方法程序计数器的值是Undefined。为什么程序计数器是线程私有的因为CPU在多线程之间切换的时候一个线程被切出去再切回来得知道自己刚才执行到了哪一行否则就没法继续执行了。所以JVM规范明确规定每个线程必须有一个独立的程序计数器这是唯一一个不会抛OutOfMemoryError的运行时数据区。这一点可以当常识记下来答面试题的时候是抢分点。这个区域日常开发基本接触不到不需要设置参数也不用监控知道它的存在和定位就行。2.2 虚拟机栈一个方法调用一组栈帧虚拟机栈JVM Stack描述的是Java方法执行的线程内存模型。每次方法调用都会在栈里压入一个栈帧方法执行结束则弹出。一个线程的栈帧由四块组成局部变量表、操作数栈、动态链接、方法出口。局部变量表存的是方法里的基本类型变量、引用类型变量和返回地址。操作数栈可以理解成一个临时计算工作区比如执行a b这条指令就是把a和b从局部变量表压入操作数栈做加法结果再存回局部变量表。动态链接的作用是支持方法调用时的符号引用转直接引用。方法出口则记录方法返回后从哪里接着执行。栈的大小默认值在不同平台上不一样大多数64位系统默认大约是1MB可以通过-Xss参数调整。比如-Xss512k把每个线程的栈大小设为512KB。这里有一个常见误区不是栈越大越好。栈越大能容纳的栈帧越多递归越深但相同的内存总量下能创建的线程数就越少。很多人把栈调大之后发现并发线程数起不来就是因为栈吃掉的内存变多了。调栈大小一定要先想清楚你的场景是深度递归还是高并发线程。这个区域抛的错误主要有两个StackOverflowError和OutOfMemoryError。StackOverflowError最常见的原因是递归没有正确终止比如死循环递归调用栈帧不断压入超过栈深度上限后直接报错。而OutOfMemoryError一般出现在尝试创建新线程但内存不够分配栈空间时配合日志里的unable to create new native thread一起出现。2.3 本地方法栈给native方法用的区域本地方法栈Native Method Stack的作用和虚拟机栈非常像区别在于虚拟栈服务Java方法本地方法栈服务native方法。HotSpot实现里把虚拟机栈和本地方法栈合在一起了所以用-Xss设置栈大小能同时影响两者。在实际开发中常见的native方法比如Object.hashCode()的某些底层实现、文件IO里的FileInputStream.readBytes()以及很多基于JNI的库像加密算法库、音视频处理库它们的C/C实现需要一块独立的内存空间来运行这就是本地方法栈的用途。平时我们不怎么直接控制这块区域但排查OutOfMemoryError: unable to create new native thread的时候就可能牵扯到这块区域和操作系统线程数量的双重限制。我遇到过的场景是系统用户线程数限制被耗尽ulimit -u到了上限报错虽然在栈区域根因却在操作系统层排查的时候要会跳出来看。2.4 堆JVM内存大地主几乎所有对象都在这里堆Heap是JVM内存区域里最大的一块也是GC回收的主要战场。Java世界里绝大多数对象实例和数组都在堆里分配这也是为什么堆是调优的核心对象。从垃圾回收的角度看堆被进一步划分为新生代Young Generation和老年代Old Generation新生代里又分为Eden区、Survivor From区和Survivor To区比例通常是8:1:1。新生代存放“朝生夕灭”的对象老年代存放存活时间比较长的对象。当新生代对象经过多次Minor GC仍然存活就会晋升到老年代。这个设计源于IBM等公司的统计研究大约80%~98%的对象在创建后很快就不再使用。把大部分对象放在新生代用复制算法回收效率很高。堆相关的参数我很确定是每个做Java的人都要掌握的这里列一份速查表参数作用实际经验-Xms初始堆大小建议和生产环境-Xmx设成一致避免运行时扩容抖动-Xmx最大堆大小线上推荐设为物理内存的50%~70%留足系统和其他进程空间-Xmn新生代大小常用的经验值是整堆的1/3左右但超大批量生成临时对象的场景值得单独测试-XX:NewRatio老年代/新生代比例默认2也就是老年代占2份新生代占1份-XX:SurvivorRatioEden/Survivor比例默认8即Eden占8份两个Survivor各占1份-XX:MaxTenuringThreshold对象晋升老年代的年龄阈值默认15并行GC适当调小可以更早回收长期存活阈值偏低的对象堆内存不够用时会抛出java.lang.OutOfMemoryError: Java heap space。这个报错太典型了一旦出现要么是堆太小要么是代码里持有大对象或者集合无限增长没释放。排查思路后面单独写一节这里先说一个特别常见的失误总想着把堆调大就能解决问题实际上堆调大很多时候只是暂时把问题掩盖对象泄漏导致的内存暴涨并不会因为堆变大就消失该OOM还是会OOM只不过时间推迟了。2.5 方法区与元空间存放类的结构信息和常量池方法区Method Area在JVM规范里定义为一个逻辑区域用来存储类信息、常量、静态变量、即时编译器编译后的代码等数据。在JDK 8之前HotSpot用永久代PermGen来实现方法区永久代使用的是JVM堆内存的一部分所以也会受堆大小影响。从JDK 8开始永久代被彻底取消改用元空间Metaspace来实现方法区元空间使用的是本地内存不再受堆上限的约束。这个变动为什么重要因为永久代经常导致java.lang.OutOfMemoryError: PermGen space尤其是在热部署代码、大量生成动态类比如用cglib、反射生成代理类的应用里永久代空间不断被撑爆。改成元空间之后这类问题大大缓解但不能完全不管因为如果元空间被无限撑大比如动态类泄漏会消耗整个服务器内存。元空间相关参数有-XX:MetaspaceSize这是初始的高水位线触发GC的阈值以及-XX:MaxMetaspaceSize限制元空间最大值。还有一个常考的细节JDK 7之前字符串常量池在永久代里所以容易出现PermGen spaceOOM。JDK 7把字符串常量池挪到了堆里这个改动导致String.intern()的行为在前后版本中有差异面试经常被拿来问。JDK 8又把永久代整体移除方法区的实现变成了元空间。可以简单理解为字符串常量池现在存在于堆内存而不是方法区。2.6 直接内存堆外内存不等于方法区直接内存Direct Memory不是JVM运行时数据区的规范组成部分但在使用NIO的时候非常重要。NIO的ByteBuffer.allocateDirect()会直接从操作系统内存中分配一块堆外内存省去了堆内和堆外之间的拷贝在IO密集场景下能显著提升性能。Netty默认就大量使用直接内存。直接用-XX:MaxDirectMemorySize可以设置直接内存上限默认等于堆的最大值。这块内存出现OOM时报错往往不是常见的Java heap space可能直接是操作系统层面内存不足表现为进程被杀进程OOM Killer或者native内存分配失败。排查这个场景比堆OOM麻烦推荐用Native Memory TrackingNMT来跟踪内存使用启动参数加上-XX:NativeMemoryTrackingsummary然后用jcmd查看详情。3. 实操过程与核心环节实现从new一个对象看完整内存分配流程讲完各区域之后接下来把对象创建的全流程串一遍这对理解内存区域划分的实际运转非常有帮助。3.1 对象分配全流程从类加载检查到TLAB入口当我们写下Object obj new Object()JVM内部大致经历以下步骤。第一步类加载检查和解析。JVM检查常量池里能否找到这个类的符号引用找不到就触发类加载。类加载过程还好但如果类本身很大加载时需要大量元空间所以动态生成大量新类时元空间压力会很大。第二步分配内存。对象所需的内存大小在类加载完成后就已经确定。分配的时候JVM根据堆是否规整采用两种方式指针碰撞或空闲列表。简单理解如果堆内存规整用过的内存在一侧没用过的在另一侧中间一个指针作为分界点分配就是把指针往空闲侧移动对象那么大距离这叫指针碰撞。如果堆内存不规整就需要维护一个空闲列表来找合适的内存块。选择什么方式取决于垃圾收集器是否带压缩整理功能CMS那个年代的GC跟现在的G1、ZGC又有差别。第三步处理并发安全。多线程同时分配对象有可能把指针移动错乱。HotSpot的解法是TLABThread Local Allocation Buffer即每个线程从Eden区预先拿一小块内存作为自己的专属分配区。这样大多数情况下分配对象不需要锁竞争只有TLAB不够用了才会被分配一块新的TLAB同时可能采用CAS重试机制来保证分配安全。这也是为什么说对象大概率在新生代Eden区被分配跟进TLAB有关。第四步设置对象头。对象头里存储哈希码、GC分代年龄、锁状态标记等信息。特别说一下HashCode并不是一开始就有的而是首次调用hashCode()时计算并写入对象头的Mark Word。所以如果你没有调用hashCode()对象头里可能没有哈希码。第五步执行构造函数这里很关键——JVM保证代码先给实例变量赋默认值比如int是0引用是null再执行init方法。这个顺序如果不清楚看源码的时候容易蒙圈。你可能会看到某个对象创建后字段值“一开始是0后来变成了设置值”这不是灵异事件是JVM的赋值顺序决定的。3.2 逃逸分析与栈上分配不是所有对象都一定在堆里学了对象分配流程之后如果只知道“所有对象都在堆里”那就落伍了。HotSpot有个强大的优化手段叫逃逸分析Escape Analysis。如果JVM能证明某个对象没有“逃逸”出方法也就是这个对象只在方法内部使用不会被外部访问那么JVM可能不把它分配到堆里而是直接在栈帧上分配空间或者干脆做标量替换把对象的字段拆成独立的局部变量不创建真实对象。方法结束后栈帧弹出对象直接被销毁完全不需要GC介入。这对短生命周期的小对象性能提升非常明显。打开逃逸分析的开关是-XX:DoEscapeAnalysisJDK 8默认开启。配合-XX:PrintEscapeAnalysis需要用debug版本JVM或者观察GC日志中对象分配数量的变化可以验证效果。这里我说个实操经验如果你想验证逃逸分析是否对性能有影响可以写一个方法内部创建大量小对象的循环用默认配置跑一遍再加上-XX:-DoEscapeAnalysis关掉逃逸分析再跑一遍对比吞吐量通常会发现默认配置好不少但具体收益依赖场景不要迷信任何“默认”。从内存区域的角度看逃逸分析其实就是“打破了对象一定分配在堆上的常识”这个思想面试时聊到会加分说明你对JVM优化不只看表面结论。3.3 HotSpot新生代对象的生命周期创建完对象之后生命周期开始在新生代展开。新对象优先在Eden区分配。新生代GC算法用复制算法因为新生代存活对象少复制成本低而且能避免内存碎片。GC时把存活对象复制到Survivor区Eden区整个清空干净利落。这里有个关键的细节两个Survivor区任何时候总有一个是空的用来作为下一次GC的复制目标。对象每经历一次Minor GC并且存活下来年龄就加1当年龄达到-XX:MaxTenuringThreshold设定的阈值默认15就会晋升到老年代。如果Survivor区装不下了也会提前晋升到老年代。大对象比如很长的数组字符串等直接进入老年代这可以通过-XX:PretenureSizeThreshold设置阈值比如超过某个大小的对象不经过新生代直接在老年代分配避免新生代频繁复制大对象。说实话这些规则在实际调优中没有那么神秘理解它们你才能在看GC日志时看懂为什么新生代占用有时高有时低以及为什么老年代会不停增长。4. 常见问题与排查技巧实录把报错和调参变成看得见的动作4.1 典型OOM分类速查遇到OOM先认准它是哪一类再决定怎么处理。我把工作中遇到的常见OOM类型整理成了一张速查表报错内容问题区域常见原因处置思路Java heap space堆堆太小或内存泄漏dump堆快照用MAT分析对象引用链PermGen space永久代JDK 7及以前动态类加载、热部署换JDK 8或者临时调MaxPermSize并定位类加载问题Metaspace元空间动态类生成失控设MaxMetaspaceSize并结合类加载器排查泄漏unable to create new native thread栈/OS线程限制线程数打满或栈空间过大检查ulimit线程数限制评估线程池设置适当调小栈大小Direct buffer memory直接内存NIO/Netty使用过度设置MaxDirectMemorySize并排查ByteBuffer释放是否及时GC overhead limit exceeded堆GC堆过小或者大对象过多GC一直在拼命回收却回收不到足够空间堆dump分析调整堆或优化代码排查OOM的思路我总结四个字先看类型再抓现场。类型就是上面的表现场就是抓堆转储快照、GC日志、线程快照这三样东西。启动时加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/dump.hprofOOM发生时JVM会自动dump堆快照然后你用MATEclipse Memory Analyzer打开里面能自动分析“疑似泄漏点”结合Dominator Tree和引用链就能快速锁到哪个对象占了大头。还有一个很重要的观点可能跟很多人的直觉相反看到OOM不要第一时间调大内存。先做一次堆dump分析确认到底是泄漏还是分配不足。如果是代码问题导致对象无法释放疯狂调大堆只会把机器拖死反而不如小堆时OOM来得更快、更明显。我见过一个事故同事把一个服务的-Xmx从2G调到8G症状消失了一段时间结果一个月后内存泄漏越来越严重OOM再次出现机器直接卡死最终只能重启。那个内存泄漏根源其实是一段缓存Map只增不减用MAT十分钟就能定位却因为调大了堆而延误了一个月。4.2 IDEA和Gradle相关的JVM内存配置这里顺便把开发测试环境最常见的几个配置问题讲掉。热词里有不少人在搜“设置IDEA的JVM运行内存的大小防止开发测试时出现OOM”以及“Expiring daemon because JVM heap space is exhausted”这种报错。这些都是IDE或构建工具的JVM参数问题跟线上调优是同一套逻辑但入口不同。IDEA本身运行在JVM之上如果IDEA卡顿或者导入项目时OOM需要调整的是IDEA安装目录下的idea64.exe.vmoptions文件Windows或idea.vmoptionsMac/Linux通常里面有-Xms、-Xmx、-XX:ReservedCodeCacheSize等参数。常见的推荐配置是把-Xmx设到2G~4G具体看你机器内存。还要注意-XX:ReservedCodeCacheSize这个不够会导致IDEA编译时卡死建议至少512m。改完vmoptions要重启IDEA才能生效。Gradle崩溃报错Expiring daemon because JVM heap space is exhausted说的是Gradle守护进程的堆空间不够了。你可以在gradle.properties里配置org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m这里-Xmx2048m就是给Gradle守护进程的堆上限可以按项目大小调整。同时注意配置org.gradle.daemontrue默认开让守护进程常驻否则每次构建都要起新JVM慢又容易OOM。还有一个冷门但好用的参数org.gradle.paralleltrue可以并行构建模块构建大项目时配合足够的堆内存速度会有明显提升。IDE、构建工具和线上应用的JVM是彼此独立的改IDE不会影响Gradle改Gradle不会影响线上别混在一起。排查开发环境问题先明确“这是谁在跑”再找到对应的配置文件。4.3 工具实操用jstat和jcmd快速观察内存区域排查无法复现的线上问题可能连堆dump都没抓到这时候靠的就是历史GC日志和实时监控。JDK自带的jstat工具足够用来看内存区域的实时变化。常用命令格式如下jstat -gc pid 1000 10这条命令的意思是对进程号为pid的JVM每1000毫秒输出一次GC统计信息一共输出10次。输出里的S0C、S1C代表两个Survivor区容量EC是Eden容量OC是老年代容量MC是元空间容量对应的S0U、EU、OU就是以使用量。如果OU一路飙升且GC后不下降基本就是老年代内存泄漏或者大对象持续进入如果EU频繁满触发Minor GC但OU不涨说明对象死亡速度很快属于正常。如果想知道具体是哪个类占据了大头可以用jmap -histo:live pid输出实例最多的类排行不过这个命令会触发一次Full GC线上操作要谨慎最好在低峰期。更友好的是用jcmd pid GC.class_histogram没有显式触发Full GC的风险严格说某些版本仍可能影响性能推荐优先用jcmd。还有一个小技巧排查内存泄漏时可以先观察堆中某个明显异常的类比如某个自定义的HashMap、ArrayList实例再配合jmap -dump:formatb,fileheap.hprof pid抓快照到MAT里深挖。担心快照太大也可以先用jmap -dump:live参数只保留存活对象速度更快但注意live参数会触发Full GC生产环境谨慎执行。4.4 一把梭调参清单最后给一份实战调参清单照着设置基本能覆盖大多数开发测试环境的JVM内存问题。生产环境需要结合业务和监控数据调整但这份清单可以作为起点。java -Xms4g -Xmx4g \ -Xmn1g \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/logs/heapdump.hprof \ -Xloggc:/opt/logs/gc.log \ -XX:PrintGCDetails \ -XX:PrintGCDateStamps我解释一下每个参数的理由。-Xms和-Xmx都设成4g避免运行期堆扩容带来的性能抖动-Xmn1g给新生代1g-XX:MaxGCPauseMillis100告诉G1尽量把GC停顿控制在100毫秒内这只是目标JVM会尽力。-XX:HeapDumpOnOutOfMemoryError是抢救手段OOM时自动输出dump-Xloggc记录GC日志这两个一定要加真出问题的时候没现场就跟打仗没地图一样。注意JDK 8及以下用-XX:PrintGCDetailsJDK 9之后建议直接用-Xlog:gc*统一日志风格比如-Xlog:gc*:file/opt/logs/gc.log:time,uptime,level,tags。两套参数语法不同网上很多旧教程直接用PrintGCDetails放到JDK 11里反而不一定好使。JDK 17以后默认使用G1所以大部分场景不用特意指定除非你已经确定要用ZGCJDK 15支持或者仍然用ParallelGC。调优的时候记住一个原则不要为了调参而调参先有指标和现象再有参数修改一次只动一个变量改完必须观察。比如你想验证-Xmn新生代大小对Minor GC频率的影响就只改-Xmn跑一轮压测对比GC日志再调整别的不然多个变量一起改出问题都不知道是谁引起的。最后再分享一点体会JVM内存区域划分这块内容我在不同阶段的理解完全不一样。刚学时觉得是概念堆砌工作两三年看是排查工具真正做了几次线上事故处理之后才意识到它是理解JVM一切行为的地图。每次看到OOM或者GC频繁的日志脑子里的第一反应不该是“堆多大”而是“哪个区域产生了问题、为什么会产生、这个区域和周边区域怎么联动”。把基础打牢比背一打调优参数有用得多。希望这篇分享能帮你把JVM内存区域这块知识串成一条线遇到问题的时候能更快摸到门路。
返回列表