尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
JVM实战指南:内存模型、垃圾回收与OOM排查调优
凌晨两点零三分告警群弹出一条消息订单服务CPU使用率98%接口P99延迟从200ms飙升到3s。我登录线上机器先敲了top发现Java进程几乎吃掉了一整颗核再用jstat看了一眼GC情况Full GC数据每隔几秒就跳一次——内存回收已经跑不过对象分配的速度了。那一刻我意识到JVM从来不只是面试题它是Java程序真实运行的那台“机器”。你平时写下的每一行代码new的每一个对象起的每一个线程最终都要在这台机器上跑。这篇东西我会按自己排查问题的思路来写不按教科书目录走。先讲清楚JVM的职责边界再把运行时数据区、类加载、对象分配、GC回收器、调优工具、线程池这些内容串成一条完整链路。适合准备面试的人也适合线上出问题不知道从哪下手的开发以及想系统梳理一遍JVM但没时间啃大部头的朋友。1. 先搞清楚JVM到底是“什么”和“不是什么”1.1 JVM的职责边界很多人一提到JVM第一反应是垃圾回收。这不算错但太片面了。JVM是一台完整的虚拟机它承担着加载字节码、校验字节码、解释执行或即时编译、内存管理、线程调度、与操作系统交互等一系列工作。垃圾回收只是其中的“内存管理”这一部分。我平时检查一个Java进程习惯把JVM看作四层结构最外层是类加载子系统负责把.class文件搬进来中间是运行时数据区也就是那几块内存区域再往下是执行引擎包括解释器和JIT编译器如C1、C2最后才是垃圾回收系统。你遇到的大多数OOM、Full GC、CPU飙高问题根源往往不在JVM本身而在你写出来的代码和配置。JVM只是把问题暴露出来的那一层。这个认知非常重要。因为如果你把JVM当成一个黑盒出问题时就只能重启、加内存运气好能混过去但根本原因还在。反过来如果你理解了JVM的职责边界你会知道该去看堆、看栈、看GC日志还是看类加载冲突。1.2 JDK、JRE、JVM三者的关系面试里高频出现的问题JRE和JVM之间是什么关系我用一个生活化的类比来记JVM是灶台JRE是厨房JDK是整间餐厅。灶台负责真正把菜做熟也就是运行Java字节码厨房包含灶台和调料、锅碗瓢盆相当于JVM加上核心类库餐厅则在厨房之外还配了菜谱和刀具对应JDK里那些javac、jar、jps、jmap等开发工具。一句话总结JRE JVM 核心类库JDK JRE 开发工具。开发环境装JDK纯生产环境跑Java程序可以只装JRE但实际线上部署多数时候还是用JDK因为需要用诊断工具排查问题。1.3 跨平台原理和“找不到JVM”的尴尬Java的跨平台靠的是字节码和JVM分层。源码.java编译成.class字节码字节码交给不同操作系统上的JVM解释执行或JIT编译成本地机器码。只要每个平台都有对应的JVM实现你的程序就不用改代码。代价是JVM本身是“平台相关”的Windows、Linux、macOS各有各的版本。这里顺便说一个开发中很常见的问题启动Java程序时报“no jvm could be found on your system”。我遇到十有八九是环境变量配错了。排查顺序很简单确认java -version能正常输出不能输出说明JDK没装好或PATH没配。检查JAVA_HOME是否指向JDK根目录不是bin目录。检查PATH里是否包含%JAVA_HOME%\binWindows或$JAVA_HOME/binLinux/macOS。注意32位和64位混用的问题DLL或so文件位版本不对也会报找不到。还有一个坑有些人改了JAVA_HOME之后没有重新开终端当前会话里环境变量还是旧的。改完环境变量一定要重开一个终端窗口这是所有环境变量类问题里最容易忽略的一步。2. 运行时数据区把“内存模型”真正看成一栋楼2.1 五个运行时区域的分工与风险运行时数据区是JVM执行Java程序时使用的内存划分共分五块。先记一个总原则堆和方法区是线程共享的虚拟机栈、本地方法栈、程序计数器是线程私有的。线程共享的区域需要面对并发和GC线程私有的区域随着线程创建和销毁自然分配和释放。下面这张表我建议直接收藏面试和排查问题都经常要回头看区域线程共享存什么典型异常程序计数器私有当前线程执行的字节码行号指示器无唯一不会OOM的区域虚拟机栈私有栈帧局部变量表、操作数栈、动态链接、方法出口StackOverflowError / OutOfMemoryError本地方法栈私有为native方法服务StackOverflowError / OutOfMemoryError堆共享几乎所有对象和数组OutOfMemoryError: Java heap space方法区JDK8后是元空间共享类型信息、常量、静态变量、JIT代码缓存OutOfMemoryError: Metaspace其中程序计数器是最容易被忽略的区域。它的作用就是记录当前线程执行到哪一条字节码指令。线程切换后CPU能通过程序计数器恢复现场。因为它是“线程私有”的所以不需要GC也不会OOM。2.2 堆内存为什么非要分代堆是JVM管理的最大一块内存几乎所有对象都在这里出生。为什么堆要分成新生代和老年代根本原因是“大多数对象朝生夕死”。如果你的代码里创建了大量短命对象它们很快就会“死亡”把这些对象集中放在新生代GC时只需要扫描这一小块区域用复制算法快速清理效率远高于扫描整个堆。新生代内部又分成Eden区和两个Survivor区S0、S1。HotSpot默认比例是Eden:S0:S1 8:1:1。每次Minor GC后存活对象从Eden和S0复制到S1然后清空Eden和S0。也就是说真正浪费掉的只有S1那10%的空间比复制算法“对半划分”的50%浪费要小得多。这个设计直接决定了对象分配的顺序新对象优先分配在Eden区如果能通过逃逸分析可能直接在栈上分配连堆都不进Eden满了触发Minor GCMinor GC后仍然存活的对象进入SurvivorSurvivor里年龄足够大或者Survivor空间放不下时才进入老年代。2.3 虚拟机栈一个栈帧就是一次方法调用虚拟机栈描述的是Java方法执行时的内存模型。每次调用一个方法JVM就创建一个栈帧压入栈中方法执行完栈帧出栈。栈帧里有四样东西比较重要局部变量表、操作数栈、动态链接、方法出口。局部变量表存放基本类型、引用类型和returnAddress类型操作数栈是执行字节码指令时的工作区。给你看一个最直观的例子执行int c a b这行代码字节码层面大概是这样// a、b已经存放在局部变量表下标0和1 iload_0 // 将局部变量表下标0的值压入操作数栈 iload_1 // 将局部变量表下标1的值压入操作数栈 iadd // 弹出栈顶两个int相加结果压回栈 istore_2 // 将栈顶结果存入局部变量表下标2看不懂字节码没关系只要理解操作数栈就像厨房台面局部变量表是储物柜。所有计算都得把食材变量值从储物柜拿到台面操作数栈上操作做完再放回去。理解这个你就知道为什么局部变量表越大、操作数栈越深单个栈帧占的内存越多。栈相关的典型故障是StackOverflowError最常见就是递归没有出口。但还有一个容易被忽略的场景单个线程栈大小-Xss设置过大在高并发下会迅速耗尽系统内存因为每创建一个线程都要分配栈空间。默认-Xss在Linux x64下通常是1MB如果业务确实需要很大栈深度再调大不要盲目改。2.4 方法区到元空间一张改了很久的“签证”方法区存放类信息、常量、静态变量、JIT编译后的代码缓存。在JDK 7及以前方法区的实现叫“永久代”PermGen它在JVM堆之外但大小受-XX:MaxPermSize限制。到了JDK 8HotSpot把永久代彻底移除用**元空间Metaspace**替代并改用本地内存。为什么非要换我总结三个核心原因永久代大小难以准确预估字符串常量池和类元数据稍微多一下就OOM而且触发的是Full GC代价很大。永久代引入了不必要的GC复杂度HotSpot需要专门写一套永久代回收逻辑。为JRockit等虚拟机合并做铺垫让HotSpot更容易实现统一。元空间直接使用本地内存默认情况下只受物理内存限制所以“Metaspace OOM”反而少了但如果你用了CGLIB、Spring等大量生成代理类的框架还是可能因为类元数据过多而OOM。生产环境建议显式设置-XX:MaxMetaspaceSize给它一个上限避免本地内存被类元数据悄悄吃光。2.5 直接内存容易被当成“不存在”的堆外空间直接内存不是JVM运行时数据区的一部分但它常被NIO类使用。DirectByteBuffer通过unsafe.allocateMemory分配堆外内存绕过堆内存的GC管理适合大块数据读写和网络传输场景比如Netty就大量使用直接内存。这块内存由-XX:MaxDirectMemorySize控制默认等于-Xmx。我见过一个比较隐蔽的线上故障代码用Netty框架但连接没释放堆内存看起来正常直接内存却一路涨到机器物理内存上限最后进程被操作系统杀掉。这种问题用jmap看堆是看不出来的需要查看NIO缓冲区数量或监控RSS内存。所以调优时别只盯着堆堆外内存同样要纳入监控。2.6 必须澄清运行时数据区 ≠ Java内存模型这可能是JVM话题里最容易被混淆的概念。平时说的“JVM内存模型”有时候指“运行时数据区”也就是上面讲的内存划分但严格意义的Java内存模型JMMJava Memory Model是另一套东西定义的是多线程程序里共享变量的读写可见性规则核心是volatile语义、synchronized语义、happens-before原则。面试官如果问“介绍一下Java内存模型”你可以先反问一句“您想听运行时数据区还是JMM多线程模型”这不是抬杠而是体现你真懂区分。如果两个都能展开聊那这个问题的分数基本就到手了。JMM典型的落地场景是DCL单例为什么需要volatile以及为什么对long/double的读写不一定有原子性。3. 类加载JVM怎么“认识”你的.class文件3.1 加载、链接、初始化三阶段类加载机制看起来只是“把类文件读进来”但它内部有三个阶段每个阶段都有讲究加载、链接、初始化。链接还可以细分为验证、准备、解析。加载通过全限定名找到.class字节码读入内存并生成一个java.lang.Class对象作为访问入口。加载阶段并不要求类已经初始化甚至可以来自jar包、网络、动态代理生成的字节码。验证检查字节码文件格式、元数据、符号引用等合法性防止恶意或错误的字节码破坏JVM。这步经常被忽略但它很重要ClassFormatError就是这一环节抛出来的。准备为类变量static修饰的字段分配内存并设置默认零值。这里有个高频考点private static int x 10;在准备阶段结束后x的值是0不是10。真正把10写进去发生在初始化阶段。解析把常量池里的符号引用替换为直接引用。可以理解为把“人名”解析成“电话号码”。初始化执行静态代码块和静态变量赋值按照代码顺序执行。我遇到过不少线上问题根因都在“初始化”顺序上。比如静态代码块里抛了异常类初始化失败后续访问该类就会抛ExceptionInInitializerError或NoClassDefFoundError。这种问题看起来神出鬼没实际上就是类加载的初始化阶段没有通过。3.2 双亲委派模型为什么默认不能被打破JVM默认的类加载器有三级启动类加载器Bootstrap ClassLoader负责加载JAVA_HOME/lib下的核心类平台类加载器Platform ClassLoaderJDK9后取代了扩展类加载器应用类加载器Application ClassLoader负责加载classpath下的类。双亲委派的核心规则每个类加载器收到加载请求后先把这个请求委派给父加载器父加载器处理不了才由自己去加载。这个设计的直接好处是保证核心类不被篡改。比如你自己写一个java.lang.String放到classpath里双亲委派机制下启动类加载器会先加载JVM自带的String你的类加载请求根本轮不到应用类加载器天然防止核心类被“同款替换”。那有没有打破双亲委派的场景有。典型的如JDBCDriverManager是由启动类加载器加载的但MySQL驱动Jar包在应用classpath下启动类加载器不可能认识它。解决方案就是线程上下文类加载器由驱动管理器委托当前线程上下文加载器去加载具体驱动实现。Tomcat等Web容器也会打破双亲委派因为要支持同一个JVM里多个Web应用部署同一个类的不同版本。理解双亲委派后很多“ClassCastException类转换异常”的诡异问题就好查了。同一个类如果被两个不同类加载器各加载一次它们的Class对象在JVM看来就是两个完全不同的类型强转必炸。3.3 常见类加载异常怎么定位不靠猜我总结了三种最常见的类加载相关故障每个都对应不同的现象异常/错误含义典型排查方向ClassNotFoundException加载时找不到类定义检查classpath、jar包是否缺失、依赖是否未打包NoClassDefFoundError类编译期存在运行时加载失败或初始化失败检查jar包是否多版本混用、静态初始化是否抛异常NoSuchMethodError编译期有这个方法运行时类里没有依赖版本冲突优先查mvn dependency:tree排查类加载问题我常用的两个工具是启动参数-XX:TraceClassLoading打印类加载过程和Arthas的classloader命令。Arthas能直接查看某个类的类加载器、加载来源jar包还能查看不同类加载器的层级关系比翻日志高效得多。4. 对象的一生从new到垃圾回收4.1 对象的创建过程一次new做了多少事你写new Object()JVM实际干了五件事类加载检查检查这个类是否已经被加载、解析、初始化过。如果没有先走一遍类加载流程。分配内存在堆上划分一块大小确定的空间。分配方式有两种如果堆内存规整用“指针碰撞”移动指针即可如果内存碎片多用“空闲列表”找合适大小的空闲块。GC算法决定了堆是否规整。内存空间置零把分配到的内存初始化为默认零值这样对象的实例字段不赋值也能有默认值。设置对象头写入对象所属类的元数据指针、对象的哈希码懒计算、GC分代年龄、锁标志位等。执行构造方法执行init方法按代码里写的初始化逻辑给字段赋值。其中第2步有一个并发安全问题多个线程同时new对象可能争抢同一块内存。HotSpot的解决方案是CAS加失败重试以及TLABThread Local Allocation Buffer。TLAB就是每个线程在Eden区预分配一小块私有缓冲线程先在自己的缓冲里分配对象缓冲满了才去共享区分配并申请新的TLAB。这样绝大多数对象分配都不需要锁竞争这也是为什么“new对象”在Java里非常廉价的原因之一。4.2 对象的内存布局一个Java对象到底占多少字节一个Java对象在堆里由三部分组成对象头、实例数据、对齐填充。对象头包含Mark Word和类型指针Mark Word存哈希码、GC年龄、锁信息32位下占4字节64位下占8字节类型指针指向类的元数据默认开启指针压缩-XX:UseCompressedOops后从8字节压到4字节。实例数据是字段值对齐填充是为了让对象大小是8字节的整数倍便于CPU寻址。举个例子一个只有int a字段的对象在64位JVM开启压缩指针的情况下对象头约12字节实例数据4字节共16字节正好8字节对齐不需要填充。如果再加一个long b实例数据变成12字节对象头12字节总计24字节依然对齐。这也是为什么小对象数量巨大时对象头带来的内存开销非常可观——一个只有几个字段的小对象可能一大半都是对象头。4.3 对象的访问定位句柄和直接指针当你在代码里持有一个对象引用比如User user new User()这个user变量在栈上它指向堆里的对象但“指向”有两种实现方式句柄访问堆里有个句柄池引用先指向句柄句柄里再存对象实例数据的地址和类型数据的地址。好处是GC移动对象时只需要修改句柄里的指针引用本身不用变。直接指针访问引用直接指向对象地址访问对象只需要一次寻址但GC移动对象后要更新所有引用。HotSpot使用直接指针方式因为Java程序访问对象非常频繁少一次指针定位性能更好。代价就是GC回收时需要更仔细地维护引用关系。4.4 对象是怎么一步步走进老年代的很多面试题问“对象什么时候进入老年代”这其实是一个链条把GC分代设计串起来了正常成长对象在Eden出生经历一次Minor GC后存活年龄加1进入Survivor。默认年龄加到15-XX:MaxTenuringThreshold进入老年代。动态年龄判定JVM不会傻等所有对象都到15岁。如果Survivor里相同年龄的对象大小总和超过Survivor空间的一半年龄大于等于这个阈值的对象会直接进入老年代。这是为了那些“中等寿命”对象能尽早升到老年代避免在Survivor之间反复复制。大对象直接进老年代可以通过-XX:PretenureSizeThreshold设置大于阈值的大对象直接分配到老年代。为什么大对象在Eden和Survivor之间复制成本很高而且很容易撑满Survivor。但注意这个阈值只对Serial和ParNew回收器有效G1下行为不同。Survivor空间不足Minor GC后存活对象太多Survivor装不下就会通过分配担保机制把多余的对象直接放到老年代。理解了这条链路你就能解释“为什么我的老年代一直涨”可能是大对象太多可能是Survivor配置不合理也可能是确实有对象生命周期长。方向就不会错。4.5 如何判断一个对象“已死”GC回收对象前先要判断对象是否已经“不可达”。引用计数法是最直观的方案每引用一次加1失效减1为0就回收。但循环引用问题直接判了它死刑A引用B、B引用A外部没有引用计数却不为0永远回收不了。HotSpot用可达性分析从一组称为GC Roots的根对象出发向下遍历引用链凡是不可达的对象都被判定为可回收。哪些对象可以作为GC Roots主要有虚拟机栈栈帧中局部变量表里引用的对象。方法区里静态属性引用的对象。方法区里常量引用的对象。JNInative方法引用的对象。活跃线程、synchronized锁持有的对象。顺带说一句Java里的引用分四种强度强引用、软引用、弱引用、虚引用。强引用只要存在被引用的对象永远不会被GC回收软引用在内存不足时才回收弱引用只要GC就回收虚引用几乎等于没有引用只是用来接收对象被回收的通知。做缓存的时候“软引用弱引用”是常见思路但现在的Caffeine等缓存框架已经把这些细节封装好了日常开发直接用框架即可。5. GC回收器与回收算法选对才是王道5.1 三种基础算法各有各的代价GC的基础算法只有三种所有回收器都是在它们基础上的工程化组合标记-清除Mark-Sweep先标记所有可回收对象再统一清理。最直观但会产生大量内存碎片后续大对象分配困难可能提前触发Full GC。标记-复制Mark-Copy把内存分成两块只在这两块之间复制存活对象。解决了碎片问题但浪费空间。HotSpot在新生代用Eden和两个Survivor的组合把浪费控制在10%左右。标记-整理Mark-Compact先标记存活对象然后把这些对象向内存一端移动直接清除边界外的空间。没有碎片但移动对象需要更新引用且需要Stop The World停顿时间更长。一个比较反直觉的结论是没有一种算法在所有场景下最优。新生代对象存活率低适合复制老年代对象存活率高适合标记-清除或标记-整理。这就是分代收集的理论基础。5.2 回收器横向对比与选型HotSpot历史上出现过很多回收器我直接给结论回收器模式追求目标适用场景现状Serial单线程简单可靠Client模式、小内存老古董Parallel Scavenge Parallel Old多线程高吞吐后台计算、批处理JDK8默认ParNew CMS多线程低延迟交互式应用JDK9废弃CMSG1多线程并发可预测停顿服务端大堆JDK9默认ZGC并发超低延迟超大堆、低延迟JDK15转正CMSConcurrent Mark Sweep当年是为了“低延迟”而生的名字里就带着“并发”。它的设计思路是用多次短停顿代替一次长停顿减少业务线程冻结时间。但它有三个硬伤对CPU资源敏感并发阶段抢占CPU、无法处理浮动垃圾并发阶段的垃圾只能下次处理、基于标记-清除会产生内存碎片。碎片严重时无法分配大对象被迫Full GC停顿可能比Parallel还严重。JDK 9开始CMS被标记为废弃慢慢退场。G1为什么能成为默认它的核心创新是把整个堆划分成多个大小相等的Region不再要求物理上连续。G1可以跟踪每个Region的回收价值在后台维护一个优先级列表优先回收垃圾最多、成本最低的Region从而“可预测停顿”。你可以通过-XX:MaxGCPauseMillis设置期望的最大停顿时间比如200msG1会尽量向这个目标靠拢。但注意这只是一个“软目标”实际停顿还要看堆大小和对象分配速度。ZGC则是走得更远的低延迟方案通过染色指针和读屏障把GC停顿压到10ms以内甚至更低。但它对堆大小有要求适合超大堆几十GB甚至几TB的低延迟场景。如果你的堆只有4GB盲目上ZGC不一定比G1好还会引入新的排查复杂度。5.3 关键GC参数速查直接给一张我做调优时常用的参数表JDK9注意日志参数已经变了参数作用-Xms / -Xmx堆初始大小 / 堆最大大小生产环境建议设成相同避免动态扩容-Xmn新生代大小-XX:NewRatio老年代与新生代的比例默认2表示老年代是新生代的2倍-XX:SurvivorRatioEden与Survivor的比例默认8表示Eden占8份S0和S1各占1份-XX:MaxMetaspaceSize元空间上限-XX:UseG1GC启用G1JDK9默认-XX:MaxGCPauseMillisG1期望最大停顿时间-XX:HeapDumpOnOutOfMemoryErrorOOM时自动生成堆转储文件-XX:HeapDumpPath堆转储文件输出路径-Xlog:gc*JDK9开启GC日志取代PrintGCDetails-XX:PrintGCDetailsJDK8打印GC详情-Xss线程栈大小这里强调一个经验调JVM参数之前一定要先有监控数据没有GC日志就去“盲调”堆大小和闭着眼睛拧螺丝没什么区别。生产环境至少要做到“OOM能出dump、GC有日志、关键指标有监控”这比什么调优公式都重要。6. JVM调优实战从一次OOM的完整排查链路说起6.1 案例背景一次大促前的压测事故我负责的一个订单查询服务在大促前压测时表现非常诡异接口TPS一上来老年代占用就像坐了火箭Full GC被频繁触发每次Full GC之后老年代只下降一点点然后又迅速涨满。整体表现就是“CPU飙高、接口超时、Zombie进程拖垮服务器”。很多人的第一反应是“把堆调大Full GC的频率就降下来了”。但这个思路在这里是错的因为老年代上涨速度太快就算堆从4GB调到8GB也只是把“涨满”的时间从10分钟拖到20分钟治标不治本。6.2 工具链按顺序出牌别一上来就瞎猜我定位这个问题的顺序很有代表性分享给大家第一步是jps -l列出当前机器上的Java进程拿到目标服务的PID。不要用ps命令去猜哪个是Java进程jps会直接列出JVM进程包括主类名或jar包名。第二步是jstat -gcutil pid 1000每秒打印一次GC统计观察Eden、Survivor、老年代的使用率和Full GC次数。我当时的输出显示full GC次数增长极快而且Full GC之后老年代的占用率只回落了几个百分点说明老年代里堆了大量无法回收的对象。第三步是jmap -heap pid看堆的配置和当前分区使用情况确认不是启动参数配错。确认堆大小合理后进入第四步抓堆转储。第四步是jmap -dump:formatb,fileheap.hprof pid生成堆转储文件。注意如果进程堆很大这个操作本身会暂停应用一会儿生产环境建议在业务低峰期做或者用Arthas的heapdump命令更灵活。第五步用MATMemory Analyzer Tool或VisualVM分析hprof文件。MAT的“Leak Suspects”报告能直接给出可疑对象。我当时看到的是一个ConcurrentHashMap里堆积了海量byte[]每一个都有几百KB。最后再用jstack看一下线程状态确认同时有没有大量线程阻塞在同一个锁上。thread dump的分析要点找WAITING和BLOCKED状态的线程看它们在等什么锁。工具用途典型命令关键输出jps列出Java进程jps -lPID和主类jstatGC统计jstat -gcutil pid 1000GC次数、各区占比jmap堆信息/堆转储jmap -heap pid / jmap -dump堆配置、hprof文件jstack线程快照jstack pid线程状态、锁等待jconsole图形化监控直接启动CPU、堆、线程曲线VisualVM综合图形化直接启动堆、CPU、dump分析Arthas在线诊断dashboard / thread / jad运行时反编译、热点线程6.3 根因与修复问题最后出在代码上MAT分析发现那个ConcurrentHashMap根本问题是缓存没有淘汰策略。业务代码每次查询都会把一个庞大的历史数据集合全量塞进缓存但没有任何基于容量或时间的淘汰机制。数据量一旦涨到某个量级老年代就会被这些缓存对象塞满。修复也很简单直接给缓存加上容量上限和过期策略大字段改成按需加载不再一次性全量缓存。上线后我再用jstat观察Full GC从每分钟几十次直接降到几近于零接口RT恢复正常。这个案例能说明一个道理调优的目的不是改参数而是找到那个真正不合理的代码或设计。GC参数和堆大小只是最后的兜底手段。6.4 用混沌注入验证监控和预案问题修复之后我一般会再做一步验证用混沌工程工具主动制造故障看监控和告警是否真的能第一时间发现。阿里开源的ChaosBlade支持JVM故障注入比如模拟方法延迟、异常抛出甚至直接触发Full GC# 模拟方法调用延时 blade create jvm --delay 300 --classname com.example.OrderService --methodname queryOrder # 模拟CPU满载 blade create jvm cpu --fullload --cpu-count 2 # 在测试环境模拟OOM blade create jvm oom --mode heap --area heap --size 512M这类操作我用的时候有两条铁律第一只能在测试环境或预发环境做第二必须事先和团队约定演练时间段否则容易误判为真实故障。混沌注入的价值在于它能验证“当GC真的出问题、CPU真的飙高时你的监控告警链路、人工排查SOP是否经得起考验”。能模拟故障才能真正把“万一发生”变成“已经演练过”。6.5 开发环境的JVM内存设置别让IDEA成为OOM重灾区热搜词里有一条“设置idea的jvm的运行内存的大小防止开发测试时出现oom”线下开发环境确实也值得说两句。IDEA本身运行在JVM上项目越做越大默认内存不够就会出现卡顿、直接卡死或者编译OOM。在IDEA里通过Help - Edit Custom VM Options打开idea64.exe.vmoptionsWindows或idea.vmoptionsmacOS/Linux我常用的配置-Xms1024m -Xmx4096m -XX:ReservedCodeCacheSize512m -XX:MaxMetaspaceSize1g -XX:UseG1GC如果使用Maven构建时出现java.lang.OutOfMemoryError: Java heap space可以在MAVEN_OPTS环境变量里加大堆export MAVEN_OPTS-Xms512m -Xmx2gGradle用户则是在gradle.properties里配置org.gradle.jvmargs-Xms512m -Xmx4g -XX:MaxMetaspaceSize1g这里要提醒一句开发机内存设置不要贪大。你给IDEA分配8GB自己电脑只有16GB那操作系统、浏览器、数据库客户端都会被挤到交换分区去反而更卡。根据机器物理内存给IDEA分配总内存的四分之一到三分之一比较合理。7. 线程池与JVM最大线程数到底怎么定7.1 线程不是“免费的”每个Java线程默认都有独立栈空间-Xss默认在Linux x64下约1MB。虽然这是虚拟内存但线程多了首先面临的是线程切换和内核管理开销其次才受限于物理内存。一个并发1000线程的应用光线程栈就可能在真实内存中占用几百MB甚至更多。很多人有一个错误直觉线程越多处理越快。实际上当线程数超过CPU核心数多出来的线程就是在排队等CPU。每条线程从运行态切换到等待态再从等待态切换回运行态都有上下文切换开销。线上系统出现“线程数很高、CPU很忙、但业务吞吐很低”时往往不是因为线程不够而是线程太多导致大量时间耗在切换上。7.2 计算线程池大小CPU密集和IO密集分开算线程池核心参数不用背但要会估算。业界有两个常用的经验公式CPU密集型任务线程数 CPU核数 1。加1是为了在某个线程因为缺页中断等偶然因素被卡住时还有一个额外线程保证CPU不闲。IO密集型任务线程数 CPU核数 * (1 等待时间 / 计算时间)。如果“等待时间 / 计算时间”不好估经验值抄2倍到3倍CPU核数通常都不会太离谱。比如你的接口要做一次本地计算然后远程调一次RPCRPC耗时100ms本地计算10ms等待和计算比是10比1那线程数可以按CPU核数 * (1 10)来估。如果机器是8核那大约88个线程。这个数量不是精确值但比“往大了配”要靠谱得多。7.3 “JVM剩余可用线程”这个说法的问题热搜词里有一条“线程池设置最大线程数是JVM剩余可用线程”我特意说一下这个说法很有迷惑性但它忽略了一个本质问题线程池能创建多少线程并不等于“应该创建多少线程”。JVM有足够的资源创建1000个线程不代表你用1000个线程跑业务就是对的。线程池设计要综合考虑四个变量核心线程数、最大线程数、阻塞队列、拒绝策略。corePoolSize是“正常情况下同时工作的线程数”。maximumPoolSize是“队列满了之后最多能扩到的线程数”。workQueue决定了任务积压时怎么排队。RejectedExecutionHandler决定了队列和线程都满时任务被怎么处理。一个常见的生产错误是核心线程设得很大队列也设得很大结果高峰期任务大量堆积在队列里任务的等待时间越来越长接口RT飙升但线程池又一直出于“能处理”的假象而不触发拒绝策略。正确的做法是队列设一个合理上限满了以后尽快触发拒绝策略而不是无限堆积。比如用CallerRunsPolicy让提交任务的线程自己执行被拒绝的任务天然实现背压。有条件的话最好把线程池的指标活跃线程数、队列长度、拒绝次数接入监控配合jstack才能回答“线程池是不是瓶颈”这个问题。8. 面试与常见误区考官想听的是思路不是背诵8.1 高频面试题串讲JVM相关的面试题来来回回就那么几类核心是这几组内存结构相关运行时数据区分几块哪些线程共享哪些会OOM对象在堆里如何布局。类加载相关双亲委派是什么为什么要这样设计怎么打破有哪些常见异常。GC相关GC Roots有哪些对象什么时候进老年代CMS和G1的区别如何选择回收器。OOM实战线上OOM怎么排查堆转储文件怎么分析怎么看GC日志。调优相关你的服务JVM参数怎么配的为什么这样配有没有实际调优案例。面试官通常不会只问一个孤立问题而是顺着你的回答不断追问。比如你答“G1把堆划分成Region”他可能接着问“那G1怎么处理大对象”。这类追问背后考察的是“你是在背还是真懂”。8.2 一个值得学习的回答方式如果你被问“JVM内存模型”可以这么回答“您指的是运行时数据区还是Java内存模型JMM这俩名字常被混用。如果是运行时数据区JVM将内存划分为程序计数器、虚拟机栈、本地方法栈、堆、方法区其中前三块线程私有后两块线程共享。如果是JMM那讲的是多线程下的可见性问题核心是volatile和happens-before规则。”这种回答有结构、有区分、有边界比张口就背“程序计数器是当前线程执行的字节码行号”要强太多。面试官真正想听的是你面对模糊问题时的梳理能力而不是能否复述教科书。8.3 我见过最多的四个误区第一OOM就加内存。这是最大的误区。很多时候OOM的根因是内存泄漏或者数据无限增长不解决代码问题加多少内存都是延迟爆炸时间。第二-Xmx设得越大越好。堆越大Full GC时停顿时间越长经历过一次10GB堆Full GC的人应该都懂。第三生产环境不加GC日志。没有日志等于没有证据出问题只能重启重启完一切线索都没了。第四以为JMM就是内存结构。这个前面说过概念混淆会导致你答非所问。8.4 如果想深入可以看哪些资料如果你想系统深入周志明写的《深入理解Java虚拟机JVM高级特性与最佳实践第3版》是最经典的选择很多面试官自己也看过这本书。但我的建议是把这篇文章里的链路先跑通动手抓一次线上或本地的堆转储用MAT分析一次再看书时会事半功倍。纸上谈兵永远是效率最低的学习方式。我的个人体会是JVM相关知识最怕“背结论”。背下所有默认参数不如理解一个对象从创建到回收的完整旅程背下所有回收器的名字不如亲手抓一次dump分析出问题对象。真正吃透JVM的人不是书背得多而是线上问题遇到得多并且每次都坚持把问题排查到根因。希望这篇文章能帮你在“排查到根因”的路上少走几步弯路。
RELATED

相关推荐

滚动轴承故障诊断实战:参数优化VMD与样本熵完整流程

滚动轴承故障诊断实战:参数优化VMD与样本熵完整流程

简介:面向机械故障诊断研究者和相关专业学生,这份技术文档围绕滚动轴承故障诊断中的信号处理与特征提取难题,系统阐述基于参数优化VMD和样本熵的诊断方法。文档从变分模态分解原理入手,分析惩罚因子与分解个数对分解效果的影响&am…

📅 2026/9/30 13:08:06
Windows 10 与 Windows 11 视频编解码区别:AV1、HEVC、HDR 与 D3D12 编码支持对比

Windows 10 与 Windows 11 视频编解码区别:AV1、HEVC、HDR 与 D3D12 编码支持对比

Windows 10 和 Windows 11 在常见视频播放上的基础能力比较接近。对于 H.264、VP9、MPEG-2、VC-1 等主流格式,两个系统都可以通过系统组件或显卡硬件加速完成解码。真正拉开差距的地方主要集中在 AV1 编解码、D3D12 视频编码框架、WDDM 驱动模型、HDR 视频处理 以及…

📅 2026/9/30 13:08:06
HarmonyOS 7 ArkUI + Navigation:折叠屏动态分栏与状态连续性适配【鸿蒙心迹】

HarmonyOS 7 ArkUI + Navigation:折叠屏动态分栏与状态连续性适配【鸿蒙心迹】

我拿一个很普通的笔记应用 FoldNote 做了折叠屏适配。真正难的地方并不是把一列改成两列,而是设备从折叠态切到展开态时,用户刚刚选中的笔记、列表滚动位置、草稿状态和路由关系都不能被“顺手重置”。折叠屏适配经常从“宽度变大了,多放一列…

📅 2026/9/30 13:08:06
MORE NEWS

更多资讯

📰

晓多客服机器人AI工作流落地实战指南

简介:本资源是一份聚焦AI客服落地实践的专业技术文档,面向客服系统开发者、智能客服产品运营者及人工智能应用研究者,深入解析晓多客服机器人如何通过深度学习与自然语言理解技术,解决家电、电商等行业售前型号对比、售后并发接待…

📰

Manus 2.0 刷屏背后:AI Agent 的“文档处理“能力,底层到底从哪来?

Manus 2.0 刷屏背后:AI Agent 的"文档处理"能力,底层到底从哪来?关键词:Manus 2.0、AI Agent、文档处理 API、WebOffice、AI 抽取、AIPPT、格式转换一、Manus 2.0 刷屏,但我盯上的不是云电脑 9 月 28 日夜里…

📰

面向对象分析与UML类图:五种关系详解与工具实操

1. 类图在面向对象分析里到底站在什么位置刚入行那会儿,我对类图的理解特别肤浅,觉得它就是一堆方框加箭头,画出来给领导看的"技术画"。直到有一次接手一个已经跑了三年的老系统改造,前任留下的文档几乎为零&#xff0c…

📰

系列教程——冰梭个人版完整上手实录(个人套餐篇)

本文是一篇实录型教程:所有截图都来自作者用自己的账号、真实文件一步一步操作得到的真实页面,没有摆拍,也没有任何推销话术。它能回答三个问题:1. 怎么把一个几百 MB 到几十 GB 的大文件发给客户?2. 客户的素材怎么收…

📰

稳定币 Visa 卡退款:别把商户状态当到账凭证

用户拿着海外软件后台写着"Refunded"的截图找客服,质问为什么 App 里的钱包余额没有变。如果你的系统里只有一个 refunded true 字段,客服此刻只能抓瞎:说钱到了,用户截图打脸;说退款失败,商户那…

📰

工业知识蒸馏实战:用DeepSeek提取老师傅经验,构建新人快速培养系统

简介:这份PDF文档面向工业制造领域的技术管理者、工艺工程师及AI落地实践者,围绕老技师操作经验难以沉淀、新人培养周期长等现实痛点,给出基于DeepSeek与知识蒸馏的完整传承方案。全文共295页、56个大章节,从行业痛点与技术挑战剖…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬