上周帮A同学做模拟面试,前面聊项目聊了差不多半小时,都还挺顺,结果我一抛出“JVM内存模型有哪些区域”,他明显松了口气,背得很流利:程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池、直接内存。我又补了一句:“你们线上服务如果老抛Metaspace OutOfMemoryError,你会先查哪里?”他愣了好一会儿没说话。这个场景其实特别典型——很多人把JVM内存模型当成一道需要背诵的面试题,却没有把它当成一张可以用来排查线上故障的地图。
内存模型这东西,说白了就是JVM运行时把数据放哪、怎么放、怎么回收的一套规则。它不仅是面试题的素材库,更是OOM排障、GC调优、线程问题定位的底层坐标系。这篇文章我会把运行时数据区分区域拆开讲,再把面试里最高频的几个点从原理层面过一遍,包括对象创建、String和常量池、逃逸分析、OOM定位、GC回收器原理,最后落到几个我平时真的在用的JVM参数和堆大小估算方法上。准备跳槽面试的Java工程师,或者想系统补一遍JVM内存机制的开发者,都能在这篇里找到可以直接拿走用的内容。
1. 运行时数据区:一张容易背熟却很难用熟的内存地图
很多教程喜欢把运行时数据区画成几个方框,标上“线程共享”或“线程私有”,然后让你背下来。这种方式能应付最基础的选择题,但一旦遇到“为什么这个区域会抛这种异常”的追问,就很容易露馅。我建议你把这张图当成一张地图来记:程序跑起来以后,每个线程、每个对象、每个类到底安排在哪个位置,出问题了该往哪个区域排查。
1.1 程序计数器、虚拟机栈、本地方法栈:线程私有区域里到底发生了什么
先说程序计数器。每个线程都有一个独立的程序计数器,用来记录当前线程正在执行的字节码指令地址。因为JVM的多线程是靠时间片轮转实现的,一个线程被切出去再切回来,必须知道刚才执行到哪一行字节码,所以它不需要额外内存空间去“共享”什么。这个区域是《Java虚拟机规范》里唯一没有定义OOM情况的区域,生命周期跟着线程走,线程没了它也就没了。
虚拟机栈对应的是Java方法执行的整个过程。每次调用一个方法,JVM就会创建一个栈帧,压入虚拟机栈;方法执行完毕,栈帧弹出。栈帧里有四个关键部分:局部变量表、操作数栈、动态链接、方法返回地址。你可以用下面这段代码感受一下:
public int calc(int a, int b) { int c = a + b; return c; }在字节码层面,a和b会先进入局部变量表,然后通过iload_1、iload_2把它们压进操作数栈,iadd把栈顶的两个数弹出并相加,结果再压回栈,接着istore_3把结果存到局部变量表的c槽位,最后iload_3再压栈,ireturn返回。操作数栈就像是计算器的临时显示区,局部变量表就像贴了标签的抽屉,每个槽位放什么类型、占多大空间,在类编译期就基本确定了。
本地方法栈则是为native方法服务的。HotSpot虚拟机在实现时把虚拟机栈和本地方法栈合并成了一个,但规范层面它俩是分开的。本地方法栈出问题通常也是两种:栈深度超过限制抛StackOverflowError,或者栈扩展时申请不到内存抛OOM。
1.2 堆:几乎所有对象实例的分配主场
堆是JVM管理最大的一块内存,也是GC的主战场。几乎所有对象实例都在这里分配,注意“几乎”这个词,后面讲逃逸分析时会提到少数优化情况。堆是所有线程共享的,所以多线程同时new对象会有分配竞争,这也是后面讲TLAB的出发点。
从分代收集的角度看,堆被划分为新生代和老年代。新生代又细分为Eden区和两个Survivor区,很多人不理解为什么要两个Survivor,你只要记住复制算法的前提就明白了:复制算法需要一个干净的空区域来放存活对象,两块Survivor的作用就是交替扮演这个“空区域”。默认配置下,Eden、From Survivor、To Survivor的比例是8:1:1,这个比例可以通过参数调整,但通常不动。大对象还有一条特殊通道,超过-XX:PretenureSizeThreshold设定的阈值后,会直接进入老年代,避免在新生代反复复制。
堆大小一般通过两个参数控制:-Xms指定堆初始大小,-Xmx指定堆最大大小。生产环境我习惯把两者设为相同值,比如-Xms4g -Xmx4g,避免JVM在运行期因为扩容或缩容触发额外的内存操作,导致停顿不稳定。
-Xms512m -Xmx512m -XX:NewRatio=2 -XX:SurvivorRatio=81.3 方法区、运行时常量池、直接内存:最容易混的三个概念
方法区在《Java虚拟机规范》里是一个抽象概念,HotSpot在JDK8之后用元空间(Metaspace)来实现它。很多人误以为元空间就是永久代换个名字,其实有本质区别:永久代用的堆内存,受-Xmx影响;元空间用的是本地内存,默认只受机器物理内存限制。这个区别直接影响线上排障——Metaspace OOM往往不是因为堆不够,而是动态类加载太多了。
运行时常量池是方法区的一部分。类文件里有一张常量池表,里面的字面量和符号引用在类加载后进入运行时常量池。面试中被问烂的String.intern()就是在跟这个区域打交道,JDK7以后字符串常量池被移到了堆中,很多人忽略了这个版本差异,我后面单独展开。
直接内存(Direct Memory)不在运行时数据区的规范定义里,但它照样能让你拿到OOM。NIO的DirectByteBuffer就是分配在堆外的,如果不停申请而不释放,会抛出Direct buffer memory异常。它受机器内存限制,也受-XX:MaxDirectMemorySize控制,默认等于堆最大值。
| 区域 | 线程共享 | 是否可能OOM | 典型异常 |
|---|---|---|---|
| 程序计数器 | 否 | 不会 | 无 |
| 虚拟机栈 | 否 | 会 | StackOverflowError / OOM |
| 本地方法栈 | 否 | 会 | StackOverflowError / OOM |
| Java堆 | 是 | 会 | Java heap space |
| 方法区/元空间 | 是 | 会 | Metaspace |
| 运行时常量池 | 是 | 会 | 属于方法区,异常同上 |
| 直接内存 | 是 | 会 | Direct buffer memory |
2. 高频面试题背后,面试官真正想听到的推导过程
当你能把上面这些区域在脑子里串成一张地图,下面这些面试题就会变得不那么吓人。它们看上去考的是记忆,实际考的是你有没有深入到原理一层。这一章我挑了几道最常被问的题,直接按“面试官问出来的时候,心里期望你怎么答”来展开。
2.1 JMM和JVM内存模型的概念岔路口
先说一个很多人掉过的坑:面试官说“谈谈JVM内存模型”,你可能条件反射地开始背运行时数据区,但对方很可能问的是Java内存模型(JMM),这两个东西完全不是一回事。
JMM关注的是并发场景下的可见性和有序性问题,它定义了主内存与工作内存之间的抽象关系,以及happens-before规则,解决的问题是“多线程之间共享变量的改动什么时候能被其他线程看到”。JVM内存模型则是运行时数据区,解决的是“程序运行时的数据放在哪、如何分配、如何回收”。一个是并发规范,一个是内存布局。
我建议的回答策略是先确认:“您是指JMM,还是指JVM运行时数据区?”如果面试官问的是后者,你再把前面的地图讲出来;如果问的是前者,就重点讲volatile、synchronized、happens-before这些。你主动做这个区分,本身就会让面试官觉得你对概念边界是清楚的。可以用一句话总结区别:JMM像多人协作时定下的“谁改完了必须告知其他人”的沟通规则,JVM内存模型则是每个人自己的办公桌和公共仓库的储物分布图。
2.2 new一个对象,JVM到底经历了哪几步
这道题几乎每次面试都会出现,而且追问空间很大。完整的流程是:类加载检查、分配内存、初始化零值、设置对象头、执行init方法。
第一步,当JVM执行到new指令时,会先去常量池里找这个类的符号引用,检查这个类是否已经被加载、解析、初始化过。如果没有,必须先走类加载流程。第二步,根据对象大小分配内存。分配方式有两种:指针碰撞和空闲列表。堆内存如果规整,用指针碰撞;如果不规整,用空闲列表。堆是否规整又取决于GC算法是否带压缩整理功能,所以这个点还能延伸到CMS、G1的区别上。第三步,把分配到的内存区域清零,这一步保证了对象的实例字段在构造方法执行前就已经有默认值。第四步,JVM在对象头里写入必要信息,包括哈希码、GC分代年龄、锁状态等。第五步,执行构造函数,按代码逻辑初始化字段。
Object obj = new Object();并发环境下,多线程同时在堆上申请内存,必须有同步机制。HotSpot的解决方案是TLAB,全称Thread Local Allocation Buffer,即每个线程在Eden区预先申请一块私有内存。线程在TLAB里分配对象时不需要锁竞争,TLAB用完了再向Eden区申请新块。记住关键点:TLAB仍然在堆上,它只是分配层面的优化,不是堆外的什么东西。
2.3 经典的String对象创建与intern陷阱
我给你还原一道高频题,很多人第一次看到答案都会愣一下:
String s1 = new String("abc"); String s2 = new String("abc"); System.out.println(s1 == s2); // false String s3 = "abc"; System.out.println(s1 == s3); // false String s4 = s3.intern(); System.out.println(s3 == s4); // truenew String("abc")会在堆上创建一个新的String对象,所以即使内容相同,两个堆对象的引用也不相等。而String s3 = "abc"走的是字符串常量池,直接指向常量池中的对象。intern()的作用是把这个字符串内容加入常量池,如果常量池里已经有相同内容的字符串,则返回常量池中的引用;如果没有,则在JDK7以后会把堆中该字符串对象的引用放入常量池,返回这个引用。
那“new String("abc")到底创建了几个对象”呢?答案是1个或2个:如果常量池中已经存在“abc”字面量,则只在堆里创建了1个String对象;如果常量池里还没有“abc”,则类加载阶段会先把这个字面量放进常量池,堆里再创建一个,一共2个。
这道题更深一层的考点是版本差异。JDK6及以前,字符串常量池在永久代,intern()会把字符串实例复制到永久代;JDK7以后,字符串常量池移到了堆中,intern()可能直接返回堆中已有对象的引用。所以很多老答案放到现在已经不准确了,你能主动讲出这个差异,面试官基本就会认定你是真的理解而不是背答案。
2.4 逃逸分析、栈上分配与TLAB
上一节我已经提了TLAB,这里必须把另一个概念也拎清楚:逃逸分析和栈上分配。很多候选人会把“栈上分配”和TLAB混为一谈,这是面试时很常见的扣分点。
逃逸分析是JIT编译器在运行时做的优化分析,核心是判断一个对象会不会逃逸出当前方法。如果对象只在方法内部被使用,不返回、不传给外部,那么JVM就有可能对它做标量替换,把对象的字段拆成一个或多个局部变量,直接分配到栈帧里或者寄存器里,而不是真正在堆上创建一个完整对象。从这个意义上看,栈上分配是逃逸分析的结果,而TLAB只是堆内分配内存时避免锁竞争的一种手段。TLAB里分配的对象仍然在堆上,后续照样参与GC;逃逸分析成功的对象则可能根本不进堆,GC完全感知不到它。
面试官如果追问“HotSpot里是不是真的把对象分配在栈上”,你可以更严谨地补一句:HotSpot目前主要通过标量替换来模拟栈上分配效果,而不是完整地创建并维护一个栈对象。这个细节很加分,因为它说明你看过虚拟机源码层面的优化路径,而不仅仅是背书。
3. 线上OOM排查实录:内存模型在故障现场的用法
我不建议把OOM当成一个纯理论概念去学,因为只有真的在线上遇过一次,你才会理解堆、元空间、直接内存之间的关系。这一章我会按不同区域拆开讲,每个场景都给出我当时看到的现象、用的命令和判断思路。
3.1 堆内存溢出:从现象到定位代码的完整链路
有一回我们负责的定时任务服务,每天凌晨跑完批量后老年代占用率一直降不下来,过了几天开始间歇性抛出java.lang.OutOfMemoryError: Java heap space。当时的排查链路其实很套路,但很有效。
第一步,用jps -l找到进程ID。第二步,用jstat -gcutil观察GC曲线:
jps -l jstat -gcutil 12345 1000 20如果Old区使用率持续走高并且Full GC之后也掉不下来,基本就是老年代有大量长期存活对象;如果Young GC极其频繁,说明新生代不断有大对象进来,也很可能是分配速率过高。第三步,给进程加上自动导出堆转储的参数,让下次OOM直接把现场留下来:
-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.hprof最后,用MAT打开hprof文件,重点看Dominator Tree里的Retained Heap。那次我定位到的就是一个缓存Map:定时任务把全量用户数据载入内存,Map没有淘汰策略,只增不减。你会在大对象路径里看到它被某个线程持有,顺着引用链找到代码入口,问题就落地了。堆OOM的排查方向就两个,一个是“漏”,处于生命周期最末端的对象没有及时释放引用;一个是“大”,单次操作一次性加载了过多数据,前者修缓存,后者改分批处理。
3.2 StackOverflowError与OOM的边界
栈相关的错误在面试里也很高频。典型的StackOverflowError长这样:一个方法无限递归,每次递归都会创建新的栈帧压入虚拟机栈,栈深度终究会到达上限,于是抛出StackOverflowError。
public void recursive() { recursive(); }但这里有个容易被忽略的点:StackOverflowError和OOM的触发条件不同。StackOverflowError是“当前栈深度已经超过栈容量”,说明栈大小不足以支撑递归深度;而栈相关OOM一般是“栈扩展时申请不到内存”,或者“创建线程数太多,导致本机内存不足以分配给新线程的栈”。面试里经常把两者混着问,你最好能直接说清楚。
线程栈大小用-Xss设置,比如-Xss512k。很多团队习惯把栈调大,但我不建议无脑调大:每个线程都会按这个值分配栈空间,线程数越多,总内存占用越夸张。普通业务方法栈深度需求没那么大,256k到512k足够支撑很深的递归了,除非你确实压测过发现栈深不够。
3.3 方法区/元空间溢出的常见根因
Metaspace OOM比堆OOM少见,但一旦出现,定位思路完全不同。最典型的场景是热部署:一个管理后台频繁重新加载插件,每次部署都会创建新的类加载器,而旧的类加载器如果没被GC回收,它加载过的所有类信息都堆积在元空间里,慢慢就爆了。
排查时不要只看-XX:MaxMetaspaceSize设置得多大,设置上限只是止血,真正的问题是类加载器泄漏。你可以用jmap -clstats PID查看类加载器统计,如果看到很多自定义类加载器,且它们加载的类数量在持续增长,基本可以判断是动态生成类的地方没做清理。常见的元空间泄漏源头包括:频繁使用CGLIB生成代理类、反射调用后没有卸载、Groovy等动态语言脚本编译、JSP文件每次改动重新编译。最稳妥的修复方案是尽量复用类加载器,避免每次热部署都新建一个隔离环境。
3.4 直接内存OOM和本机内存耗尽
直接内存OOM有一个很容易骗过人的特点:堆占用率很低,机器可用内存却一直在下降。NIO的DirectByteBuffer分配在堆外,它的回收依赖Cleaner机制,在GC时被处理。如果什么代码没有显式释放Buffer,且Full GC一直没有触发,堆外用完就会抛OutOfMemoryError: Direct buffer memory。
我之前遇到过一次,是某个报表导出功能大量使用ByteBuffer做数据拼接,用了之后没释放,也不够触发GC,最后直接把机器内存打满了。后来除了加-XX:MaxDirectMemorySize限制外,还把申请逻辑改成了用完立刻回收,并主动在代码里通过引用队列跟踪Buffer状态。直接内存虽然不在运行时数据区规范里,但在线上排障里它的维度绝不能丢。
3.5 一套可复用的OOM排查SOP
总结经验以后,我把OOM排查固定成了下面几步,你也可以直接拿去用:
- 看监控:确认OOM第一次出现的时间点,和发布、定时任务、请求量峰值有没有对应关系。
- 抓GC日志:启动参数里加上GC日志开关,观察Young GC和Full GC的频率、耗时、回收前后的堆占用。
- 留dump:提前加上
-XX:+HeapDumpOnOutOfMemoryError,让JVM在OOM时自动导出堆转储。 - 分析对象:用MAT分析Dominator Tree、可疑引用链、类加载器数量和线程栈。
- 修复后观察:不要修完参数就跑,至少观察一两周,确认GC频率、堆曲线和线上指标都稳定了再收尾。
4. GC视角下的内存管理:回收器工作原理与面试深挖
内存模型和垃圾回收本来就是一体两面。你光知道对象分配在堆里还不够,还得清楚这些对象什么时候被判定为可回收,以及不同的回收器是怎么协作的。这一章的四个问题,基本覆盖了面试里GC方向的深水区。
4.1 引用计数为什么被主流JVM抛弃
引用计数的思路很简单:对象每被引用一次计数器加一,引用失效减一,计数器归零就回收。但它有一个绕不开的死结——循环引用。A引用B,B引用A,外部已经没有入口指向它们了,但计数器始终不为0,永远无法回收。所以主流JVM都不采用引用计数,而是用可达性分析。
可达性分析的思路是:从一组称为GC Roots的根对象出发,沿着引用链遍历,能被遍历到的对象说明仍然存活,遍历不到的对象判定为可回收。GC Roots通常包括这些位置:
- 虚拟机栈中局部变量表引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- 被同步锁持有的对象
面试里“哪些对象可以作为GC Roots”几乎是必问题,但理解它的意义比背诵更有价值:GC Roots的选择决定了GC扫描的边界和效率,所以这个列表必须精心设计,既不能漏掉仍然存活的对象,也不能扫到无关对象。
4.2 三色标记与并发清理的漏标问题
CMS、G1这类回收器在并发标记阶段都涉及三色标记法。白色代表没有被GC访问的对象,灰色代表当前对象已被访问但它的引用字段还没有全部扫描完,黑色代表该对象和它的字段都已经被扫描过。标记完成后,所有白色对象都会被判定为可回收。
问题出在并发标记阶段,GC线程标记的同时应用线程还在改引用关系。如果出现下面这种情况:一个黑色对象持有了某个白色对象的引用,同时另一个灰色对象指向这个白色对象的路径被应用线程切断了,那么白色对象既不会被标记为存活,又失去了从灰色对象到达它的路径,就变成了“漏标对象”,有可能被错误回收。
为了堵住这个漏洞,CMS使用增量更新,G1使用SATB(Snapshot At The Beginning,起始快照),核心思路都是通过写屏障在引用关系变化时记录现场,再在后续步骤补齐标记。你能在讲G1时主动提到SATB,面试官通常就会眼前一亮,因为这属于并发GC优化里比较靠后的知识点了。
4.3 对象晋升老年代的条件与动态年龄判定
对象不是简简单单“在新生代活过几次GC就进老年代”。晋升规则至少有四条:年龄计数器达到MaxTenuringThreshold默认值15;超大对象直接进老年代;Survivor空间容纳不下时,多余对象直接进老年代;还有一种动态年龄判定,Survivor中同年龄对象的总大小超过Survivor空间一半,从该年龄往上的对象直接晋升,不需要等到15。
为什么默认是15,不是20也不是100?因为对象头里的分代年龄字段只占4位,最大能表示15。这个点能答出来,说明你连对象头布局都看过。动态年龄判定的本质是防止Survivor空间被存活对象挤爆:复制算法需要一块干净区域,如果Survivor空间快满了,就不必机械地等年龄达到15,提前晋升更合理。
4.4 G1的Region、RSet与停顿预测
JDK9之后G1成了默认垃圾回收器,面试里问到GC方向时G1几乎是必选话题。理解G1,你先忘掉连续的大堆和严格的新生代老年代分割。G1把堆划分成许多大小相等的Region,每个Region可以动态扮演Eden、Survivor、Old,甚至还有一个Humongous区域专门放超大对象。
RSet(Remembered Set)是G1的一个关键结构,它记录了其他Region中的对象对本Region的引用。回收某个Region时,GC只需要扫描它的RSet就能知道外部引用,避免了全堆扫描。这个概念回答好了,能顺带解答“G1为什么比CMS更适合大堆”的追问。
G1还有一个比较独特的停顿预测模型。它基于历史回收数据估算每个Region的回收成本和收益,再根据你设置的-XX:MaxGCPauseMillis选择回收收益最大的Region集合,尽量把GC停顿控制在期望值内。但这不代表停顿一定不超过200毫秒——预测模型依赖历史数据,如果对象分配速率剧烈波动,预测也会失效。G1在Mixed GC阶段会回收一部分老年代Region,目标是尽量均匀地消化老年代垃圾,而不是像传统Full GC那样一次性压缩整堆。
5. 从参数到实战:JVM内存调优的可落地路径
聊完原理,最后还是得回到“能用起来”这个层面。很多时候大家不是缺理论,是不知道参数怎么配,配完怎么看效果。这一章我先把常用参数列全,再讲一个我来回验证过很多次的堆大小估算方法,最后说说我踩过的调优误区。
5.1 参数家族:标准参数、-X参数、-XX参数怎么分
JVM参数大致分三类:标准参数(以-开头,所有JVM实现都要支持)、-X参数(非标准,但HotSpot基本都支持)、-XX参数(不稳定,后续版本可能变化,但控制的核心行为最多)。面试里需要能口述清楚的高频参数,我整理成了一个表:
| 参数 | 作用 | 常见示例 |
|---|---|---|
| -Xms / -Xmx | 堆初始大小 / 堆最大大小 | -Xms512m -Xmx512m |
| -Xss | 线程栈大小 | -Xss512k |
| -XX:NewRatio | 老年代与新生代比例 | 默认2 |
| -XX:SurvivorRatio | Eden区与Survivor区比例 | 默认8 |
| -XX:MaxTenuringThreshold | 晋升老年代的年龄阈值 | 默认15 |
| -XX:PretenureSizeThreshold | 大对象直接进老年代的阈值 | 1m |
| -XX:MaxMetaspaceSize | 元空间上限 | -XX:MaxMetaspaceSize=512m |
| -XX:MaxDirectMemorySize | 直接内存上限 | 默认等于堆最大值 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM时自动导出堆转储 | 生产环境强烈建议开启 |
| -XX:+UseG1GC | 启用G1回收器 | JDK9+默认开启 |
| -XX:MaxGCPauseMillis | G1期望的最大GC停顿 | 200 |
想知道当前JVM实际生效的参数,两个命令很有用:java -XX:+PrintFlagsFinal -version可以打印所有默认参数;jinfo -flags PID可以看一个运行中进程的启动参数。面试官问到你具体某个参数默认值时,不要只凭记忆,主动说“可以用PrintFlagsFinal确认”反而更专业。
5.2 怎么估算堆大小:别再把-Xmx拍脑袋
很多团队调堆大小全靠感觉:机器8G就设-Xmx4g,再大就设6g,问为什么,答不出来。我提供一个非常粗但足够定量的估算思路。先估算新生代:假设你的服务平均每秒产生约20MB新对象,你希望大约30秒触发一次Young GC,那么新生代至少要有20MB乘以30秒等于600MB的分配空间。考虑到Eden区占新生代的80%左右,新生代设到768MB左右比较合理。老年代则看常驻数据量:如果业务里有一个常驻内存的配置缓存约500MB,老年代可以设到1GB左右。两者相加,总堆设置-Xmx1g到-Xmx2g就是合理的起点。
当然这只是一个粗略估算,真正落地必须结合压测。我习惯用jstat -gc观察GC后的O区值,连续记几天,看老年代有没有稳定上涨趋势。如果Old区每次Full GC后都能回到一个相对平稳的值,说明内存模型是健康的;如果每次都比上一次更高,基本可以认定有泄漏。参数只能在内存分配模型正常时兜底,无法救治泄漏。
5.3 先救代码,再调参数
这是我很想强调的一点:堆参数调得再漂亮,也补不了代码层面的内存病。最常见的几种病,一是集合类没有上限,数据只增不减;二是把大对象放缓存,一次查询拉全表;三是日志对象太大,一条消息几百K;四是循环里反复创建大数组,把Young区瞬间打满。
堆参数更像保险丝,它在代码写得不够好时兜住系统不立刻崩掉,但如果你把-Xmx从2g调到4g,只是把问题暴露的时间延后了。真正健康的调优,一定是先通过dump定位到具体的异常对象,修完代码,再根据观察到的分配速率去匹配堆大小。我见过最典型的反面案例,是团队把老年代调大后,Minor GC频率降低了,但每次Full GC因为老年代太大反而更久,接口超时更严重。GC调优看的不是某一个指标,而是GC频率、单次停顿、吞吐量三个角度的平衡。
5.4 面试官喜欢追问的运维细节
最后补充几个面试官特别喜欢追问的运维细节。第一个是-XX:+HeapDumpOnOutOfMemoryError,它只是开启开关,你还要指定-XX:HeapDumpPath,否则Dump文件可能写到默认目录导致空间不足。第二个是jstack,它很多时候能帮你确认锁竞争和死锁。内存高的服务有时不是对象多,而是线程卡在某个锁上排成一排,每个线程的栈空间都占着一块内存,jstack导出的线程栈一眼就能看到阻塞点。第三个是jmap -clstats PID,它能看到类加载器历史,排查类加载器泄漏时离不开它。
还有一个容易被忽略的问题:GC日志开关。JDK8常用-verbose:gc -XX:+PrintGCDetails,JDK9以后推荐用-Xlog:gc*。很多环境只有OOM dump没有GC日志,分析起来非常被动。宁可日志多写一点,也不要临到故障时两手空空。这些细节在面试里不一定单独成题,但一旦聊到线上案例,你主动提起它们,会很自然地和只背过理论的人拉开差距。
最后分享一个我自己的小习惯吧。每次我改完一段可能会产生大量对象的代码,都会在本机跑一小段压测,同时开着GC日志,盯几分钟内的Young GC次数和堆曲线,确认没有异常了再合代码。不是说非要等线上OOM了才去补dump,这种提前验证的方式省了我很多次半夜排查。内存模型这种知识,单靠背不长久,你真正去解一次OOM,把GC日志和堆dump对着一看,很多概念自己就通了。我的建议是读完这篇后,找一个测试环境故意触发一次堆OOM,把jmap和MAT用一遍,这套动作比任何面试题都有用。