
大家好我是晚安code。订单导出任务挂了日志第一行是java.lang.OutOfMemoryError: Java heap space。有人扫了一眼说「栈溢出了」反手把-Xss调到 4M 重启第二天同一时间照挂。这类乌龙在排查现场特别常见。根子在于 JVM 内存结构没理清——哪块区域装什么、各自会抛什么错、对应哪条命令是后面所有调优和排查的地基。这篇是 JVM 系列的入门篇「初识」只讲最基础的四块程序计数器、虚拟机栈、本地方法栈、堆。每块都给出定义、会出什么问题示例代码我在本机跑过报错和诊断命令的输出都是真的截出来的。一、先把地图摊开JVM 内存结构分成哪几块JVM 内存结构JVM Runtime Data Area运行时数据区JVM 启动之后向操作系统要来、专门用来跑 Java 程序的那几块内存。你可以当成一套合租房——有三个房间是每人一间客厅和厨房是大家共用。按 JVM 规范运行时数据区分五块但归属只有两类每个线程各留一份的程序计数器、虚拟机栈、本地方法栈全进程共用一份的堆、方法区「线程私有」的意思是你起一个线程JVM 就单独给它配一套线程结束跟着销毁。所以线程开得越多私有这部分占的内存就越多——记住这句话第三章讲-Xss的代价时要用到。先给一张表把五块区域的职责和「会不会出事」摆在一起区域装什么谁持有会不会抛 OOM程序计数器当前字节码指令的地址每线程一份不会虚拟机栈Java 方法的栈帧每线程一份会实际更常见的是栈溢出本地方法栈native 方法的栈帧每线程一份会堆对象实例、数组全进程一份会最常遇到方法区类元信息、常量全进程一份会方法区JDK 8 之后叫元空间是下一篇的主角这篇只在必要的时候带一句。二、程序计数器唯一不会抛 OOM 的一块内存程序计数器Program Counter Register一块很小、线程私有的内存存的是当前线程正在执行的那条字节码指令的地址。你理解成看书时夹在页缝里的手指头就行——合上书再打开手指还按在原来那一行。字节码解释器干活的方式是循环取一条指令、执行、改一下计数器的值、再取下一条。分支、循环、跳转、异常处理、线程切换后恢复现场全都得知道「刚才做到哪了」靠的就是它。为什么必须是线程私有CPU 时间片是轮着给的A 线程跑到一半被切走B 上来跑一会儿再切回 A。如果大家共用一个计数器A 回来时就不知道该从哪继续了。所以每个线程一份各记各的。有个细节值得单独拎出来线程正在执行 native 方法的时候程序计数器的值是undefined。因为 native 方法的代码在 C/C 里不受 JVM 字节码那一套管计数器这时候指无可指。JVMS 第 2.5.1 节写得很直白原文就是 undefined。那为什么它不会 OOM因为这块内存压根不需要扩容——它只需要放得下一个指令地址宽度在平台确定时就定死了。规范里给五块区域都写了异常情形唯独程序计数器一个 OutOfMemoryError 都没规定这是全 JVM 独一份。顺带说个容易搞混的点你没法用任何工具直接观测程序计数器。异常栈里那些CategoryTreeDemo.java:21的行号是 JVM 查 class 文件里的 LineNumberTable 得到的跟运行时的 pc 寄存器不是同一个东西。两者反映的是同一个位置但别在面试里说成栈帧里能查到程序计数器的值。三、虚拟机栈一个方法一个栈帧栈溢出从这来虚拟机栈Java Virtual Machine Stack线程私有的内存描述的是 Java 方法执行的内存模型。每调用一个方法JVM 就压一个栈帧进去方法返回栈帧弹出。跟食堂里那摞餐盘一个道理——后放的压在上面拿也从最上面拿。栈帧Stack Frame一次方法调用对应的一块内存里面装四样东西局部变量表、操作数栈、动态链接、返回地址。这四样里新手最容易理解错的是局部变量表基本类型int、long、float 这些直接存值对象类型存的是引用对象本体在堆里所以栈上分配对象这句流传很广的话是有问题的。栈上放的是指向对象的引用不是对象本身。唯一的例外是 JIT 的逃逸分析把没跑出方法外的对象拆成标量、直接在栈上放字段——那是编译器的优化不是你写代码时能指望的东西。再记一句结论虚拟机栈这片内存方法进进出出就是压栈弹栈它的容量在 HotSpot 上由-Xss定死不会自己长大。装不下的结果就是StackOverflowError。规范里其实规定了两种异常别记混栈不允许动态扩展时线程请求的栈深度超过最大深度 →StackOverflowError栈允许动态扩展但扩不出来 →OutOfMemoryErrorHotSpot 属于第一种栈大小拿-Xss一锤定音。所以真实项目里你撞到的几乎都是 StackOverflowError不是栈的 OOM。实测一个配错环的类目树怎么撑爆栈光说递归太深会溢出没意思我拿一个业务里真会碰上的场景来试类目树向上找根节点而运营后台把父子关系配成了环。先看数据CategoryTreeDemo里的类目表// 类目表类目 id - 父类目 idprivatestaticfinalMapLong,LongPARENT_OFnewHashMapLong,Long();static{PARENT_OF.put(1001L,1002L);PARENT_OF.put(1002L,1003L);PARENT_OF.put(1003L,1001L);// 手滑把父级配反了1003 又指回 1001成环}1001 的父级是 10021002 的父级是 10031003 的父级又指回 1001。找根节点的递归出口是父级为 null可这个环里永远走不到 null/** 一路向上找根类目父级为 null 说明自己就是顶层 */staticLongfindRoot(LongcategoryId){LongparentIdPARENT_OF.get(categoryId);if(parentIdnull){returncategoryId;}returnfindRoot(parentId);}拿-Xss512k跑一下java-Xss512k-cp.CategoryTreeDemo输出真实截取中间重复帧略Exception in thread main java.lang.StackOverflowError at java.util.HashMap.getNode(HashMap.java:573) at java.util.HashMap.get(HashMap.java:558) at CategoryTreeDemo.findRoot(CategoryTreeDemo.java:21) at CategoryTreeDemo.findRoot(CategoryTreeDemo.java:25) at CategoryTreeDemo.findRoot(CategoryTreeDemo.java:25) ...同一帧重复到被 JVM 截断这坨输出里怎么找元凶有个很实用的套路从最上面往下扫找第一处你自己写的类。前两帧是 HashMap 在查表那是findRoot内部的普通调用属于被牵连的从第三帧开始CategoryTreeDemo.findRoot开始一遍遍重复第 25 行正是那句return findRoot(parentId)——递归没出口实锤。对比一下第 21 行和第 25 行21 行只出现一次25 行重复到刷屏。这就是逐层往下走和原地打转的区别看重复帧的起止位置比读报错文案快得多。有个坑要说JVM 默认只打印 1024 帧多的会被截掉。所以日志里那些重复帧是被砍过的别以为就循环了几十次。要看得更深可以调-XX:MaxJavaStackTraceDepth不过实际排查里没必要——重复帧只要出现问题就已经定性了。最后说-Xss本身。栈是每个线程一份你把它从默认的 1M 调到 4M线程池里 200 个线程就多占 600M。调之前先想清楚是单线程递归真的太深了还是线程数本来就多。前者可以考虑调后者应该改算法别把参数当创可贴。四、本地方法栈和虚拟机栈长得像服务对象不一样本地方法栈Native Method Stack作用和虚拟机栈几乎一模一样的内存区域区别只在于它服务的是 native 方法——那些用 C/C 写好、通过 JNI 调进来的方法而虚拟机栈服务的是 Java 方法。规范对本地方法栈的要求很松没规定具体实现。HotSpot 干脆把它和虚拟机栈合成了一块所以你在 HotSpot 上找不到单独给本地方法栈配大小的参数-Xss管的是合起来的那整块。这带来一个很实际的结果在 HotSpot 上你基本看不到本地方法栈溢出这种独立报错。native 调用压的帧和 Java 方法压的帧挤在同一摞餐盘里撑爆了抛的还是StackOverflowError。那它什么时候会真的被踩到主要是大量 JNI 调用的场景加解密、压缩、图像处理的底层库不少是 native 实现的每次调用都要在这块栈上占位置。如果你线上有个类库底层是 JNI又赶上递归调用栈溢出的速度会比纯 Java 代码快——因为每层占的栈空间可能更大。这个区域本身没什么可调的理解到HotSpot 上和虚拟机栈是一块就够了。五、堆线程共享的最大一块也是 OOM 重灾区堆Java HeapJVM 管理的内存里最大的一块所有线程共享专门用来放对象实例和数组。把它当成仓库——代码里每new一个对象就往仓库里搬一件货搬进去之后什么时候被清走由垃圾收集器说了算。仓库里都堆着什么列一下比较清楚所有new出来的对象和数组本体都在这从 JDK 7 开始字符串常量池也从永久代挪进了堆里堆是 GC 的主战场所以它还有个名字叫「GC 堆」传统分代的划分是这样的新生代放刚出炉的对象绝大多数朝生夕死熬过几轮 GC 还活着的晋升到老年代。从 JDK 9 起默认 GC 换成 G1内存是按 Region 切的但逻辑上还是分代那套。这块展开又是好几千字我之前单独写过一篇《JVM 垃圾回收全流程拆解》这里就不重复了。参数就两个要紧的-Xms512m# 堆初始大小-Xmx512m# 堆最大大小生产环境通常把这两个设成一样大为的是避免堆反复扩容收缩带来的额外 GC 和性能抖动。可能有人会问-Xms和-Xmx都设成一样不就浪费内存了吗浪费的是预留但不一定用得上的部分。设成一样大JVM 启动时就把这块地址占住运行时不用再跟操作系统来回要——省下的是伸缩带来的停顿代价是这部分内存别的进程借不走。对线上服务来说这笔账通常划算。实测一个分批查询的导出任务怎么把堆撑爆这个 bug 特别典型因为它看上去完全不像 bug——代码本意是分批查库省内存。OrderExportDemo的主循环publicstaticvoidmain(String[]args){Listbyte[]loadedPagesnewArrayListbyte[]();intpageNo0;while(true){loadedPages.add(queryPage(pageNo));// 查一页攒一页if(pageNo%200){System.out.println(已查 pageNo 页全留在内存里没写出去);}}}每一页查回来之后都add进loadedPages这个 List 里从头到尾没释放过。分批查是分了但查出来的东西全攒在内存里分批就白分了。queryPage每次造一块内存代表一页订单数据/** 模拟一次分页查询用一块内存代表这一页的订单数据 */privatestaticbyte[]queryPage(intpageNo){byte[]pagenewbyte[PAGE_SIZE*512];// 每页约 1MBpage[0](byte)pageNo;returnpage;}给 64M 堆跑一下顺便打开堆快照java-Xmx64m-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath./export.hprof\-cp.OrderExportDemo真实输出已查 20 页全留在内存里没写出去 已查 40 页全留在内存里没写出去 java.lang.OutOfMemoryError: Java heap space Dumping heap to ./export.hprof ... Heap dump file created [61762951 bytes in 0.025 secs] Exception in thread main java.lang.OutOfMemoryError: Java heap space at OrderExportDemo.queryPage(OrderExportDemo.java:27) at OrderExportDemo.main(OrderExportDemo.java:18)几个数字值得盯一下。堆上限 64M堆快照却写出了 61762951 字节差不多 59M——也就是说 OOM 那一刻堆里几乎全是这些页数据别的对象连零头都算不上。堆已经到顶了回收器也没辙因为每一页都还被loadedPages强引用着一个都回收不掉。修法很直接查一页写一页byte[]page;while((pagequeryPage(pageNo))!null){writeToFile(page);// 查一页立刻落盘写完就不留引用pagenull;loadedPages.clear();// 如果还要分批统计攒够一批就清}我自己也干过类似的事看到 OOM 第一反应是把-Xmx调大从 2G 加到 8G结果第二天同一时间照样挂。堆溢出要先怀疑有东西该释放没释放加内存只是把爆炸时间往后推。六、栈溢出和堆溢出别搞混区分方法与诊断工具线上出内存问题第一步不是改参数是先把报错看清楚——是栈的事还是堆的事。这一步分错了后面选命令、改参数全是白干。先用一张表把两者的差异钉死栈溢出 StackOverflowError堆溢出 OOM: Java heap space所在区域线程私有每线程一份全进程共享就一块典型成因递归没有出口、递归太深、调用链太长对象造得比回收得快缓存没失效、集合攒着不放、一次加载太多数据主要排查手段翻异常栈里的重复帧线程还活着就jstackjmap -histo看谁占得多jmap -dump抓快照常用参数-Xss-Xms/-Xmx/-XX:HeapDumpOnOutOfMemoryError加内存管用吗治标且有代价治标不治本先找泄漏1先看进程jps -l一切从找到那个 Java 进程开始jps-l24764 sun.tools.jps.Jps 27900 JvmTroubleHolder-l会打出主类全名第一行是jps自己忽略。2栈的问题jstack假设你的服务里有个线程卡在很深的调用链上没溢出但已经走不出来了想知道它卡在哪jstack27900抓到的那个线程真实截取order-sync-worker #19 prio5 os_prio0 tid0x0000021459afd800 nid0x75f8 waiting on condition java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep(Native Method) at JvmTroubleHolder.deepCall(JvmTroubleHolder.java:14) at JvmTroubleHolder.deepCall(JvmTroubleHolder.java:17) at JvmTroubleHolder.deepCall(JvmTroubleHolder.java:17) at JvmTroubleHolder.deepCall(JvmTroubleHolder.java:17) ...同一帧重复到被截断读法和前面那坨 StackOverflowError 一模一样找重复帧里第一个你自己的类。这里第 17 行重复了 1023 次就是那个没有出口的递归调用。可能有人会问jstack抓出来满屏重复帧怎么知道是哪一个方法的问题看重复帧的第一帧——它一定是你自己代码里那个发起下一层调用的行。重复帧上面那几帧比如Thread.sleep、HashMap.get是被牵连的叶子节点它们上面只有一层说明不了问题。反过来如果一个方法在栈里只出现一次那它大概率是无辜的。注意jstack要看的是还活着的进程。如果线程已经因为栈溢出去世了栈早就退干净了这时候只能靠异常日志——好在那份日志本身就带重复帧够用了。3堆的问题jcmd、jmap、jstat堆的诊断工具多几条我按从轻到重的顺序排。先看整体水位jcmd是新版本 JDK 上最稳的选择jcmd27900GC.heap_infoPSYoungGen total 75264K, used 16425K eden space 64512K, 25% used from space 10752K, 0% used to space 10752K, 0% used ParOldGen total 172032K, used 0K object space 172032K, 0% used Metaspace used 2944K, capacity 4486K, committed 4864K新版 JDK 上jmap -heap在部分 GC 组合下已经不太好使jcmd的GC.heap_info是更稳的替代。想知道谁在占地方看对象直方图jmap-histo:live27900|head-12num #instances #bytes class name ---------------------------------------------- 1: 27 13662232 [B 2: 2264 297072 [C 3: 516 63376 java.lang.Class 4: 2120 50880 java.lang.String 5: 791 31640 java.util.TreeMap$Entry第一行[B是byte[]27 个实例占了 13MB把第二名[C也就是 char[]甩开四十多倍。这种一类独大的形状基本就是泄漏的签名。-histo:live里的live会先触发一次 GC 再统计所以数出来的是活着逃过回收的对象更有参考价值。要看具体的引用链就得抓堆快照jmap -dump:live,formatb,filetrouble.hprof27900Dumping heap to C:\...\trouble.hprof ... Heap dump file createdls-lhtrouble.hprof112M trouble.hprof这个 hprof 文件用 Eclipse MAT 或者 JProfiler 打开能顺着谁引用了谁一路找到那根把对象钉死在内存里的引用。-histo只能告诉你凶手是哪一类快照才能告诉你凶手被谁扣着。想看 GC 是不是在空转用jstat采样jstat-gcutil2790010003S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 43.63 43.61 60.57 60.33 2 0.006 2 0.017 0.023 0.00 0.00 51.56 43.61 60.57 60.33 2 0.006 2 0.017 0.023 0.00 0.00 57.91 43.61 60.57 60.33 2 0.006 2 0.017 0.023EEden在涨O老年代纹丝不动——这是正常的对象分配节奏。如果反过来看到 FGC 次数一路飙升、每次回收后老年代水位都不降那就是典型的回收不动了离 OOM 不远。最后是事前预防比事后抓现场省事得多。在启动参数里加一行-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/data/dump这样下次真 OOM 的时候JVM 会自己把快照写下来你人不在现场也拿得到证据。上面那条导出任务的输出里那句Heap dump file created [61762951 bytes]就是它干的。这个参数对堆内存溢出有效但对 StackOverflowError 没用——栈溢出压根没有堆快照可打。这也解释了为什么有人加了它之后栈溢出时什么 dump 都没等到。4两个版本上的坑JDK 9 之后jvisualvm和jhat不再随 JDK 一起发了。你在 JDK 8 里用惯的那两个东西换到新版本会发现找不到命令得自己单独下jhat 则是彻底退役了别再找了。本文所有实测输出来自 Windows x64 上的 Corretto 1.8.0_432。不同 JDK 版本、不同 GC 组合下工具的可用性和输出格式会有出入跑之前先确认下自己环境的版本。写在最后说白了内存区域这块你不需要背得滚瓜烂熟但得能在看到报错的第一秒判断出这是栈的事还是堆的事。是栈就去翻异常里重复的那几帧找第一个你自己写的类是堆就抓快照看谁占着地方、谁把它扣着不放。这一步分对了后面选命令、调参数才不至于瞎折腾——就像开头那个把堆溢出当栈溢出、反手去调-Xss的哥们方向错了参数调得再准也没用。下一篇讲方法区和元空间为什么 JDK 8 要把永久代换掉以及从堆溢出到元空间溢出之间到底差了什么。感兴趣的可以先收藏。参考链接The Java Virtual Machine Specification, Java SE 8 Edition — 2.5 Run-Time Data Areas搜JVMS 2.5 run-time data areasJDK 8 工具文档jstack搜oracle jdk8 jstackJDK 8 工具文档jmap搜oracle jdk8 jmapJDK 8 工具文档jcmd搜oracle jdk8 jcmd我是晚安code持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊你排查内存问题时踩过最久的坑是栈的还是堆的