
很多人一听到 jvm 调优脑子里要么是面试八股文要么是生产环境告警响了才临时抱佛脚查参数。实际上jvm 调优这件事离我们并不远甚至可以说任何跑在 JVM 上的应用——后台服务、数据处理任务、桌面工具甚至最近《我的世界》Java 版卡顿、模组一多就 GC 掉帧——本质上都是同一个问题JVM 的默认配置和你应用的“内存使用习性”不匹配。这也是为什么网上关于 JVM 参数、内存模型、垃圾回收器的文章多如牛毛但很多人看完还是不会排查问题关键就在于缺少一套从“观察现象 → 定位根因 → 调整参数”的完整思路。这篇文章写给谁写给那些遇到线上接口频繁超时、老年代涨个不停、CPU 飙高却不知道从哪查起的 Java 开发者和运维同学也写给准备面试想系统整理 jvm 调优知识的人。我会从 JVM 内存模型和核心参数讲起再把 GC 选型、内存泄漏排查、CPU 飙高定位、Full GC 频发这些高频故障一网打尽最后完整复盘一次真实调优过程。里面所有命令、参数、思考路径都是我在实际项目中反复用过、踩过坑之后沉淀下来的你可以直接照着用。1. JVM调优的正确打开方式先测量再调优1.1 先搞清楚什么情况才需要调优先说个反直觉的结论绝大多数 Java 应用在绝大多数时候是不需要调优的。JVM 的默认参数经过大量实际负载测试对通用场景有相当不错的兼顾。很多应届生简历上写着“熟悉 JVM 调优”结果一上来就把 -Xms、-Xmx 改得乱七八糟反而把原本稳定的服务调到频繁 Full GC这种事我见过不止一次。所以什么时候才需要调优我总结成四个明确信号接口响应时间出现周期性毛刺而且跟 GC 日志里的停顿时间能对上。jstat 里看到 Full GC 频率明显上升或者每次 Full GC 耗时几秒甚至几十秒。老年代Old 区内存持续增长GC 之后也降不回来典型的泄漏或容量不足特征。CPU 使用率异常飚高且线程栈里出现大量 GC 线程或业务线程在疯狂执行某段代码。只要没出现这些信号就老老实实维持现状。而且调优核心是“权衡”低停顿响应式服务友好、高吞吐批处理友好、内存占用成本敏感三者天然矛盾。你要先明确业务到底更在意哪个维度再决定参数方向。比如交易支付链路更在意低停顿半夜跑的报表任务更在意吞吐量没有银弹。换个角度说调优不是让系统变快而是让系统不再“周期性抽风”。你的一切操作都应该围绕“消除不合理的 GC 行为和内存压力”展开而不是追求某个参数看起来很美。1.2 调优前的四个检查维度真正动手改配置前必须先收集数据。我见过太多同学一上来就打开搜索引擎找“JVM 调优参数大全”然后照着抄一遍——这属于盲人摸象。你得先回答四个问题维度一CPU。用top看进程 CPU 占用再用vmstat看用户态和系统态的占比。如果用户态高多半是业务线程在计算如果系统态高可能是频繁的上下文切换或 GC 线程在跑。除了传统的 top、vmstat我还会顺手开一个 atop 或 btop这类实时监控工具能把 CPU、内存、负载的走势看得更清楚对定位“是不是周期性 GC 导致的 CPU 飙高”特别有用。维度二内存。free -g看物理内存的余量jmap -heap pid看堆内各分区使用率。但注意jmap -heap 在某些版本会触发安全点停顿线上务必谨慎。更稳妥的办法是直接看 GC 日志或者用jstat -gcutil pid 1000每秒刷新一次观察各区使用率和 GC 次数的时间趋势。维度三GC。jstat -gcutil是我最常用的命令没有之一。它能告诉你 YGC新生代 GC和 FGCFull GC各发生了多少次、累计耗时多少、当前 Eden、Survivor、Old、Metaspace 的使用率。只要盯住这几个数字就能快速判断问题出在“分配速率太高”还是“老年代容量不足”还是“元空间膨胀”。维度四线程。jstack pid导出线程快照重点找BLOCKED、WAITING、RUNNABLE的线程都在干什么。很多时候接口慢并不是 GC 的问题而是线程池里的线程全堵在锁上或者都在做无意义的空转这时候调 JVM 参数一点用都没有。把这四个维度的数据都拿到手形成一个“基线快照”再去改参数。每改一个变量观察一段时间对比基线的变化。这个流程比我见过的大部分所谓“调优方案”都靠谱。2. 内存模型与关键参数把地基打牢2.1 运行数据区到底管什么先明确一个很多人搞混的概念JVM 内存模型Java Memory Model, JMM和 JVM 运行数据区Runtime Data Area是两件事。排查调优时我们操作的是后者而 JMM 是线程安全的理论基础面试爱考但线上调优基本用不到别把精力放错地方。JVM 运行数据区可以简单分成五块堆Heap、虚拟机栈VM Stack、本地方法栈Native Method Stack、元空间Metaspace、程序计数器PC Register另外还有一块容易被遗忘的堆外直接内存Direct Memory。堆是所有线程共享的内存区域也是对象的主要栖身之所所以它天然是调优的主战场。栈是线程私有的每个线程都有一个栈方法调用的过程可以理解为栈帧入栈、出栈栈帧里存了局部变量、操作数栈、返回地址等。程序计数器记录当前线程执行的字节码指令地址。元空间存的是类元数据、常量池这些JDK8 之后从永久代搬到了本地内存这也是很多人 JDK8 升级后经常遇到 “Metaspace OOM” 的原因。顺带说一个基础但重要的点JRE 和 JVM 是包含关系。JRE 是一个完整的 Java 运行环境包含 JVM、核心类库rt.jar、charsets 等和启动命令而 JVM 只是 JRE 里负责“解释执行字节码”的那一部分。所以你调优时改的参数本质上是告诉 JVM 这个运行环境里“内存怎么切分、垃圾什么时候回收”而不是在改 Java 代码。2.2 堆内存参数怎么给才合理堆内存的参数90% 的情况下绕不开这几个-Xms初始堆大小、-Xmx最大堆大小、-Xmn新生代大小、-XX:SurvivorRatioEden 区与单个 Survivor 区的比例、-XX:MaxTenuringThreshold对象晋升老年代的年龄阈值。先说 -Xms 和 -Xmx。生产环境强烈建议把这两个值设成一样比如-Xms4g -Xmx4g。原因很简单如果初始堆设小、最大堆设大JVM 在运行过程中为了扩容会触发堆重新分配这个过程中有较长的停顿风险而且实际效果是内存反正都要用那么多不如一开始就分配好省去来回伸缩的开销。很多容器环境里出现 OOMKilled就是因为 -Xmx 设得比容器内存上限还大或者 -Xms 太小导致频繁扩容时容器被杀。新生代大小 -Xmn 怎么给这取决于业务对象的生命周期。绝大多数业务对象都是“朝生夕死”比如一次 HTTP 请求产生的临时对象。新生代给得越大Minor GC 频率越低但老年代能用的空间就越小对象晋升空间不足时反而容易触发老年代 GC。我实践下来新生代占堆总大小的 1/3 到 1/2 是个比较稳的区间。特别重视短生命周期对象、请求量极大的场景可以往 1/2 靠如果是长生命周期对象如缓存、连接池对象偏多的应用新生代给 1/3 就差不多了。举一个具体例子。假设一个服务分配到的物理内存是 8G堆设 4G。按新生代占 1/3 来算-Xmn 给 1.5G 左右。默认 -XX:SurvivorRatio8意味着 Eden 区与单个 Survivor 区的比例是 8:1:1那么 Eden 大约是 1.2G两个 Survivor 各 150M。如果 Minor GC 后存活对象超过 Survivor 容量就会直接进入老年代——这个“幸存者溢出”正是老年代快速增长的主要原因之一。至于 -XX:MaxTenuringThreshold默认 15就是说对象在新生代熬过 15 次 GC 还没死就晋升老年代。但注意这只是一个上限JVM 还有动态年龄判定机制后面讲 GC 行为时再展开。这里必须提醒一件事现在 Java 应用大量跑在 Docker 容器里。如果 JVM 版本低于 JDK8u131它无法自动感知容器的 CPU 和内存限制会把宿主机整机内存当成可用内存导致 -Xmx 设得极大最终被 cgroup OOM 杀掉。JDK8u191 之后容器感知默认开启更推荐配合-XX:MaxRAMPercentage75这类比例参数来设置堆大小让容器内存变化时 JVM 能跟着调整而不是写死 -Xmx。2.3 容易踩坑的隐藏参数堆参数大家都会看但有三块区域经常被忽略我直接说结论第一元空间。如果你不显式设置-XX:MaxMetaspaceSize元空间的“上限”就只是本地内存大小理论上可以被无限撑大。有些应用用 CGLIB、动态代理、反射频繁生成类类加载器又不能正常回收元空间就会一路疯涨直到系统内存被打满。所以只要你的应用用到了 Spring AOP、MyBatis 这类带动态代理的框架强烈建议显式设一个上限比如-XX:MaxMetaspaceSize256m至少给它设个底线让它触发 Full GC 而不是直接吃掉整台机器。第二线程栈。-Xss控制单个线程栈大小默认在 x86 平台上大概是 1M。一个服务线程池开到 500 线程光线程栈就是 500M。如果你的应用线程很多而实际方法调用栈并不深可以考虑把 -Xss 调到 256K 到 512K内存压力会小很多。但注意递归深度大的逻辑比如复杂树遍历、深度优先搜索千万不能盲目调小否则轻松 StackOverflowError。第三直接内存。-XX:MaxDirectMemorySize默认等于堆大小。Netty、Kafka 客户端、RocketMQ 这类高性能组件大量使用堆外内存做零拷贝。你只调了堆忘了直接内存一旦堆外分配超过上限就是 OOM而且这种 OOM 日志往往很诡异甚至不会出现在 heap dump 里。凡是用到 NIO 框架的应用我都会把-XX:MaxDirectMemorySize显式设出来通常先给 1G 再根据压测调整。3. 垃圾回收器选型与核心参数解析3.1 先理解垃圾回收的基础机制垃圾回收器的中文名“垃圾回收”太轻描淡写了它其实是 JVM 生命周期里最“重量级”的活动。要理解回收器选型先理解两件事怎么判断对象是垃圾怎么高效回收垃圾判断“垃圾”用的是可达性分析不是引用计数。JVM 从一组根部对象比如栈帧里的局部变量、静态变量、JNI 引用出发沿着引用链往下找能到达的对象就是存活对象其余全部视为可回收。这也是后面排查内存泄漏时看“GC Roots 引用链”的原理基础。回收算法分几大类新生代一般用复制算法把存活对象从一块区域复制到另一块代价跟存活对象数量成正比非常适合“朝生夕死”的场景老年代用标记-清除或标记-整理标记-清除有碎片问题标记-整理会移动对象但停顿更久。不同的回收器只是在不同区域上组合这些算法的调度器而已。选型表如下这是我个人实践中比较刻板的建议回收器适用场景优势注意点Serial单核小内存、客户端应用简单、无额外开销停顿时间长ParallelPS批处理、离线任务追求吞吐量多线程并行回收吞吐率高停顿时间长不适用低延迟在线服务CMS遗留系统、低停顿需求JDK8时代并发标记清除停顿较低已废弃有碎片问题JDK14后移除G1大堆、兼顾低停顿与高吞吐分区式回收可预测停顿小停顿目标下可能回收集估算不准ZGC超大堆、要求极低停顿停顿时间基本不随堆增大需要 JDK11需评估兼容性3.2 新生代与老年代的GC行为如何配合理解了基础再看 GC 行为就顺理成章了。我们把 GC 分成三类新生代 GCMinor GC / YGC、老年代 GCMajor GC / Full GC、G1 特有的 Mixed GC。正常对象的流动路径是优先分配在 Eden 区Minor GC 时存活对象复制到 Survivor 区每熬过一次 GC 年龄加 1年龄达到阈值后晋升老年代。这个路径里有两个特别容易出问题的点。第一个是“大对象直接进老年代”。JVM 有一个-XX:PretenureSizeThreshold参数对象大小超过这个阈值会直接分配到老年代避免在新生代反复复制。但很多人不知道这个参数默认是 0也就是不启用只在部分场景下由 JVM 自行决定要不要直接分配老年代。如果应用经常创建超大数组、超大 List又没有设置阈值这些大对象会在 Eden 区创建、Minor GC 时复制不动迅速把 Survivor 挤爆然后晋升老年代导致老年代空间被快速消耗。第二个是“动态年龄判定”。JVM 并不机械地等对象年龄到 15 才晋升。如果 Survivor 区中相同年龄对象的总大小超过 Survivor 区的一半年龄大于等于该值的对象就会直接进入老年代。这个机制本意是让长期存活对象尽早让位但如果 Survivor 配置太小就会出现“对象明明还年轻却被提前晋升”的尴尬局面。这也解释了为什么很多 Full GC 问题的根子其实在新生代你以为是老年代容量不够疯狂调大老年代实际上是因为新生代特别是 Survivor 区太小导致大量短生命周期对象被过早推入老年代老年代里塞满了本不该出现的“短命鬼”Full GC 自然频发。3.3 G1核心参数与配置示例G1 是 JDK9 之后默认的垃圾回收器也是我现在给绝大多数在线服务首选的方案。它的核心思路是把堆拆成一个个大小相同的 Region回收时不再区分物理上的新生代和老年代而是通过 Remembered Set 来追踪跨区引用每次回收能精准选择收益最高的 Region 集合从而把停顿控制在一个目标范围内。G1 的配置示例以一台 4C8G 的 Spring Boot 微服务为例我的常用启动参数长这样java -Xms4g -Xmx4g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:MaxMetaspaceSize256m \ -XX:MaxDirectMemorySize1g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs \ -Xloggc:/data/logs/gc.log \ -XX:PrintGCDetails \ -XX:PrintGCDateStamps几个参数的作用拆开说。-XX:MaxGCPauseMillis100告诉 G1 尽量把 GC 停顿控制在 100ms 以内这是 G1 最大的卖点也是你调优时最需要根据业务容忍度去调整的参数但注意它只是软目标不是硬保证如果堆分配速率极高G1 会宁可牺牲停顿目标来完成回收。-XX:G1HeapRegionSize可以手动指定 Region 大小默认是堆大小的 1/2048一般不需要动除非你有大量超大对象可以适当调大到 16M 或 32M。-XX:InitiatingHeapOccupancyPercent简称 IHOP默认 45意思是当整个堆使用率达到 45% 时G1 就开始准备并发标记周期为 Mixed GC 做准备。如果你发现 GC 日志中并发标记很频繁但老年代实际并不紧张可以把这个值调大到 60 左右试一下。顺带说一个不是参数但强制建议的配置GC 日志。-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps加上之后每次 GC 的时间、原因、各区域变化都会写进文件。很多时候线上问题没法现场抓事后靠这份 GC 日志就能还原整个过程这比任何在线监控工具都可靠。JDK11 之后日志语法变成了-Xlog:gc*:filegc.log:time,uptime,level语法不同别用错了。4. 高频故障排查实录内存泄漏与CPU飙高4.1 内存泄漏的定位思路与工具链“内存泄漏”四个字吓退不少人但定位方法其实非常固定。先看现象老年代使用率只涨不降Full GC 越来越频繁每次 GC 后老年代几乎纹丝不动最终 OOM。这时候你已经可以基本判定存在内存泄漏或对象被不合理持有。我常用的排查工具链分四步第一步用jstat -gcutil pid 1000确认趋势。盯住 O 区的使用率如果它稳步上升且 FGC 次数同步增加进入下一步。第二步用jmap -histo:live pid | head -50看一眼对象分布排行。这一步能快速告诉我们“什么类型的对象占了大头”。但注意-histo:live会触发一次 Full GC生产环境要挑低峰期做或者干脆不用:live直接用jmap -histo pid。第三步dump 堆快照。命令是jmap -dump:formatb,fileheap.hprof pid堆文件可能有好几个 G导出过程会让 JVM 停顿这是最大的坑。所以更推荐的姿势是提前在启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs让 JVM 在 OOM 的那一刻自动留下现场。如果没有预留那就只能挑低峰期手动导出。第四步把堆快照交给 MATEclipse Memory Analyzer。打开后先看 Dominator Tree按 Retained Size 排序找到占用最大的对象然后右键查看 “Path to GC Roots”。这里会列出完整引用链一眼就能看出是哪个类的静态集合、哪个缓存、哪个 ThreadLocal 把对象牢牢拴住了。分享一个我踩过的典型场景某个接口每次请求都会往一个 ConcurrentHashMap 缓存里写数据key 是业务单号value 是查询结果对象。粗看没问题但问题是这个 Map 只往里写、从不清理单号又每天都在增长几天下来这个 Map 就把老年代塞满了。用 MAT 找 GC Roots 时引用链就是ConcurrentHashMap → Node → key/value非常直观。修复方式也简单要么给缓存加过期清理要么换 Caffeine 这类自带淘汰策略的库。再提醒一个容易误判的点线程池里的 ThreadLocal。业务代码往 ThreadLocal 里塞了数据但线程池里的线程是复用的如果不主动 remove这个数据会一直挂在线程上连带它引用的对象也永远不会被回收。这类泄漏排查时特别隐蔽因为 GC Roots 直接指向 ThreadLocal 对象而你在业务代码里可能早就忘了在哪 set 过。4.2 CPU飙升的线程定位方法CPU 飙高的问题十有八九不是 JVM 参数的问题而是业务代码里的死循环、大对象序列化、正则回溯、频繁 GC 等原因。定位思路是“从进程到线程再到代码行”命令我直接给完整的。先找到 CPU 占用最高的 Java 进程top -c假设 PID 是 1234再看这个进程下哪个线程在烧 CPUtop -Hp 1234找到占用最高的线程 TID比如是 5678把它转成十六进制printf %x\n 5678输出可能是 0x162e。然后导出线程快照按线程 ID 检索jstack 1234 | grep -A 30 nid0x162e这时候你会看到这个线程当前正在执行的代码栈问题定位到了类和方法级别。如果是 GC 线程名字一般是GC task thread#0或者 G1 相关的线程在烧 CPU那说明 GC 本身太频繁问题回到内存配置上如果是业务线程就直接去查对应代码看是不是死循环、是不是在疯狂加锁、是不是在循环里做正则匹配。这个排查流程对本地开发环境同样适用。之前有同学问“IDEA 占用 CPU 过高怎么调优”我让他用同样的方法看线程栈结果发现是某个插件在后台频繁编译扫描根本不是 JVM 参数的问题关掉插件、加大 IDE 的堆内存-Xmx就缓解了。所以遇到 CPU 问题先定位线程再决定是改代码还是改参数千万别一上来就调 GC。4.3 Full GC频繁排查速查表与故障演练Full GC 频发是最常见的线上故障之一原因可能五花八门。我整理了一张速查表贴出来供大家直接对照现象可能原因排查命令/手段处理建议Full GC 后老年代占用依然高内存泄漏或对象被长期持有jmap -histo、MAT 查 GC Roots修复引用链清理缓存用完 ThreadLocal 要 removeFull GC 后占用降了但很快又涨分配速率过高短生命周期对象太多jstat -gcutil 观察 YGC 频率调大新生代、检查代码中是否存在大量不必要的对象创建元空间使用率持续上涨动态生成类太多或类加载器泄漏jstat -gcmetacapacity显式设置 MaxMetaspaceSize排查类加载器引用老年代频繁 GC但回收量很少提前晋升导致老年代塞满短命对象看 GC 日志中的晋升日志调大 Survivor 区、检查大对象分配Full GC 和 CPU 飙高同时出现堆太小或回收器配置不当配合 GC 日志和 jstat增加堆内存、评估切换回收器最后聊一个容易忽视但非常有效的习惯故障演练。不要等线上真的 Full GC 了才去验证监控告警和应急方案。混沌工程工具 Blad 里有一个命令blade create jvm --fullgc可以主动给 JVM 制造 Full GC 压力pre 环境里隔三差五注入一次看你的监控能不能及时告警、线程 dump 能不能快速抓到、应急预案是否真的有效。这套演练跑熟了线上出问题的时候你会比平时冷静得多因为整个过程你已经在预发环境走过无数遍了。5. 一次真实的JVM调优全过程5.1 问题背景与初始状态为了让上面的内容落地我复盘一个典型的调优案例。背景是一个订单查询服务部署在 4C8G 的容器里JDK8默认使用 Parallel GC启动参数只有简单的-Xms2g -Xmx2g。服务高峰期接口 RT 出现周期性毛刺一到整点附近就大量超时运维侧的 GC 告警频繁触发。拿到现场后我先看了 GC 日志好在之前留了-Xloggc发现 YGC 频率已经高到每秒好几次FGC 高峰期每 3 分钟一次每次停顿 2 到 3 秒。这两个数据一出来RT 毛刺的原因就基本清楚了2G 的堆对这台服务的负载来说太小新生代不够导致 YGC 频繁老年代空间也被短生命周期对象提前晋升撑满FGC 每 3 分钟一次停顿 3 秒接口自然跟着抖。再用 jstat 确认各区使用率Eden 区每次 Minor GC 后都能回到低位但 Old 区使用率持续在 90% 以上GC 后只能降到 75% 左右随后半小时内又涨回去。这说明老年代已经积累了大量升得上来、又死活降不下去的对象。配合 jmap -histo 看了一下占比最高的是一堆业务查询结果对象初步判断有部分对象被缓存持有。5.2 调整过程与参数对比基于以上排查我没有一步到位而是分三步做了调整每步观察一天第一步把堆内存从 2G 提到 4G同时把新生代从默认值调到 1.5G让短生命周期对象有更充足的空间。容器是 8G堆 4G 还有 4G 留给系统和其他进程没有顶破 cgroup 限制。第二步从 Parallel GC 切换到 G1设置-XX:MaxGCPauseMillis100。这一步是因为该服务对延迟敏感需要把停顿时间控制在一个稳定阈值内。同时显式设置-XX:MaxMetaspaceSize256m和-XX:MaxDirectMemorySize1g避免隐藏区域失控。第三步处理业务侧代码。jmap -histo:live出来的一堆查询结果对象追到代码后发现是一个本地缓存 Map 只增不减。这个不是靠 JVM 参数能解决的必须从源头修。我让开发改成 Caffeine 带过期策略同时把线程池里用到的 ThreadLocal 统一加了 remove 清理。前后参数对比如下参数项调整前调整后初始/最大堆-Xms2g -Xmx2g-Xms4g -Xmx4g新生代默认1.5g-Xmn1.5g垃圾回收器Parallel GCG1-XX:UseG1GCGC 停顿目标无100ms-XX:MaxGCPauseMillis100元空间上限无256m直接内存上限无1gOOM 现场保留无HeapDumpOnOutOfMemoryError 日志路径GC 日志已开启保留并统一时间戳格式5.3 调优结果与后续观察调整后的效果在第三天的高峰期体现得特别明显FGC 从高峰期每 3 分钟一次降到一天只有个位数YGC 频率虽然还是每天上万次但单次耗时从几十毫秒降到了毫秒级。RT 的 99 分位从 3 秒降到了 300 毫秒以内接口超时告警基本消失。这里必须多说一句这个结果并不是“换了 G1 所以变快了”。换 G1 解决的只是停顿时间的可预测性真正把老年代压力打下来的是三件事叠加——堆空间更合理、缓存不再无限膨胀、Survivor 配置减少了提前晋升。如果不修代码只调参数顶多是把 Full GC 从 3 分钟一次拖到 5 分钟一次治标不治本。另外所有参数调整我都遵循了一个原则一次只改一个变量。调完堆内存跑一天观察稳定了再切 G1。如果一次性把十几个参数全改了出了问题你根本分不清是哪一步引入的。每次改动在启动脚本里写清楚注释和日期方便回滚。这个习惯希望大家务必养成。6. 常见问题速查与个人心得6.1 高频面试题快速梳理jvm 调优是面试高频区这里整理几个最常被问的问题和我的回答思路方便你做最后的知识点厘清。JDK8 默认的垃圾回收器是什么是 Parallel Scavenge新生代 Parallel Old老年代也就是所谓“吞吐优先”的回收器组合。JDK9 之后默认换成了 G1。这个点很多人记反面试时容易翻车。什么时候对象会直接进入老年代三种情况一是大对象直接分配二是年龄达到 MaxTenuringThreshold三是动态年龄判定——Survivor 中同年龄对象总大小超过 Survivor 一半时大于等于该年龄的对象晋升。G1 和 CMS 的核心区别是什么CMS 基于标记-清除会产生碎片且无法处理浮动垃圾G1 基于分区把堆分成 Region可以指定停顿时间目标回收时通过估算各 Region 的回收收益优先回收价值最高的区域还能做 Mixed GC 同时兼顾新生代和老年代。内存泄漏和内存溢出有什么区别内存泄漏是对象已经用不到了但被无用引用挡住GC 回收不掉导致可用内存越来越少内存溢出是堆内存真的不够用了继续申请就会抛 OutOfMemoryError。泄漏是溢出的常见诱因之一但有对象持续创建不释放也能导致溢出。JRE 和 JVM 是什么关系JRE 是 Java 运行时环境JVM 是 JRE 的核心组件负责加载字节码并执行。JRE 还包括类库和其他运行所需的文件而 JDK 则在 JRE 基础上增加了编译器等开发工具。JVM 调优一般看哪些指标我会先看 GC 频率和停顿时间、各区内存使用率、线程状态、CPU 占用以及应用自身的 RT、TPS 和错误率。指标不是孤立看的要串成链路一起判断。6.2 几个容易被忽略的细节与我的习惯最后分享几个我踩坑之后形成的习惯都属于“文档上不会写、但实战很值钱”的细节第一调优前一定要留 GC 日志。哪怕是临时加的-Xloggc:/tmp/gc.log -XX:PrintGCDetails也比没有强。很多人线上出问题现场又抓不到事后什么数据都没有只能靠猜这是最被动的局面。有日志在手至少能还原当时每个时刻的 GC 行为。第二参数不是越多越好。网上一搜“JVM 调优参数大全”能翻出来几十个但真正值得每个人用到的就是堆大小、新生代大小、元空间上限、回收器、停顿目标、GC 日志和 OOM dump。剩下的参数绝大多数场景用了要么没效果要么反而干扰默认行为。参数能少则少多了反而难维护。第三容器环境一定要关注 Java 版本和参数兼容性。JDK8 的老版本没有容器感知JDK11 之后 GC 日志参数语法变了CMS 在高版本已经移除这些如果不知道照抄旧文章的配置会直接启动失败。第四调优不是一次性工作。业务增长、访问模式变化、代码重构都会改变应用的内存画像。我习惯每次大版本上线前做一次 gc.log 的对比分析看是否出现了新的异常趋势。没问题的系统不要为了“调优”而调优动了参数就要负责观察到底。第五说一说工具链沉淀。jstack、jstat、jmap 这几个 JDK 自带命令很多时候比那些花哨的监控平台更直观。监控平台告诉你“有问题”但定位到具体代码行还是要靠原生工具。在预发环境提前把命令练熟线上才能手不抖。我个人最喜欢的方式是写一个包含常用排查命令的脚本一键输出进程的 GC 状态、堆使用率、CPU 排名线程和线程栈快照出问题先跑一遍很多故障能在一分钟内有初步结论。六个月前我帮一个兄弟团队排查过一次诡异的老年代持续增长他们那会儿还不习惯留 GC 日志只给了我一堆监控曲线的截图。我第一反应就是让他们先在启动参数里加日志和 OOM dump然后等下一次故障自动留现场。第二周复现的时候堆快照拿到了MAT 里引用链清清楚楚指向一个静态 Map。那一刻我更加确定JVM 调优工具和方法论永远比技巧更重要。只要你先测量、再定位、最后才动参数再多看似莫测的线上 JVM 故障也不过是一张待填满的速查表而已。