ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JDK8 JVM调优与GC实战:回收器选型、参数模板及OOM排查

JDK8 JVM调优与GC实战:回收器选型、参数模板及OOM排查 现在团队里只要有人问“线上接口偶尔卡个几百毫秒是什么原因”十有八九最后都会落到 JVM 调优和垃圾回收器这个话题上尤其是还在跑 JDK8 的项目。JDK8 是个很特殊的分水岭它既有成熟的 Parallel Scavenge 和 CMS又带来了 G1还彻底删掉了永久代改用元空间。这意味着同样一段“加大堆内存、调小新生代”的老经验放在 JDK8 上可能反而把系统推向频繁 Full GC。这篇内容我打算按自己的实战路径来写——先把内存模型和对象生命周期讲透再逐个拆解 JDK8 里能用的垃圾回收器然后给出可以直接抄的参数模板、工具链用法、GC 日志判读方法最后落到 OOM 的各种现场。适合正在做服务端开发、需要处理线上性能问题或者准备面试想真正搞懂 JVM 工作原理的人。1. 先把 JVM 内存模型捋直调优才有坐标系我见过太多“调优”是这么做的上来就-Xmx4g然后看 GC 日志里 Full GC 次数少了就完事。这种做法能碰对是运气碰不对就是埋雷。真正动手前必须把内存模型这张地图在脑子里画出来否则你连参数改的是哪块区域都不知道。1.1 运行时数据区到底分几块JDK8 改了什么JVM 在运行时会把自己管的内存切成几块每块职责不同回收策略也完全不同。按线程私有和线程共享来分理解起来最快。线程私有的三块程序计数器记录当前线程执行到哪条字节码指令。唯一不会抛 OOM 的区域占用的内存可以忽略不计。虚拟机栈每个方法调用创建一个栈帧栈帧里放局部变量表、操作数栈、动态链接、方法出口。平时说的“栈溢出”绝大多数就是这里由-Xss控制单个线程栈大小。本地方法栈给 native 方法用的HotSpot 里和虚拟机栈合并实现一般不单独关心。线程共享的两块堆对象实例和数组的主要存放地也是垃圾回收的主战场。调优 90% 的参数都在动这块。方法区存放类元信息、常量、静态变量、即时编译后的代码缓存。JDK7 及以前叫永久代PermGen由 JVM 堆管理JDK8 开始改成元空间Metaspace挪到了本地内存由操作系统管。这个改动的影响被严重低估了。以前永久代容易OutOfMemoryError: PermGen space因为类加载多了会把堆里那块固定区域撑爆JDK8 换成元空间之后元空间默认不设上限能一直吃到操作系统内存耗尽报错变成OutOfMemoryError: Metaspace而且更容易连带触发机器层面的不稳定。所以-XX:MaxMetaspaceSize在 JDK8 里是必须显式设置的参数不能放任不管。除了这几块还有两块容易被忽略但很要命的区域直接内存Direct Memory通过-XX:MaxDirectMemorySize限制NIO 的DirectByteBuffer就住在这它不受堆大小约束以及JVM 自身的开销包括 GC 元数据、JIT 编译线程、线程栈总量、代码缓存等。这两块加起来经常占到堆内存的 30% 以上算容器内存配额时漏掉就会被打死。1.2 对象从分配到回收的完整路径理解对象的一生比背参数有用得多。新建对象优先在Eden 区分配。如果开启 TLAB-XX:UseTLAB默认开启线程会先在 Eden 里划一小块私有区域避免多线程分配时争抢指针。这块优化不显眼但对高并发分配的影响很大。Eden 满了触发Minor GC。存活对象被复制到 Survivor 区From 到 To 来回复制对象年龄加 1。年龄达到阈值后晋升老年代阈值由-XX:MaxTenuringThreshold控制Parallel 和 G1 下默认是 15。这里有个很多人不知道的机制动态年龄判定。不是非得熬到 15 岁才能晋升。如果 Survivor 区中某一批同龄对象的总大小超过了 Survivor 空间的一半那么年龄大于等于这批对象年龄的所有对象会直接晋升老年代。这就是为什么有时候你把MaxTenuringThreshold调到 15实际对象三四岁就进老年代了——参数没生效是动态判定提前触发了。还有两个特殊通道大对象直接进老年代。-XX:PretenureSizeThreshold可以设置这个阈值但它只对 Serial 和 ParNew 生效Parallel Scavenge 不认这个参数G1 里则用 Humongous Region 处理一个对象超过单个 Region 大小的一半就算大对象。长期存活对象。老年代满了触发 Full GC这个代价通常比 Minor GC 高一到两个数量级。反向看回收一个对象要经过两次标记第一次可达性分析从 GC Roots 出发扫描引用链不可达的对象被标记第二次检查对象是否重写了finalize()且未被调用过如果是就放进 F-Queue 等待执行执行完还没被引用才真正回收。finalize()这个方法在实际项目里基本不该用它的执行时机不确定还容易拖慢 GC。1.3 一次 Minor GC 的现场还原假设堆是 4G用 Parallel Scavenge默认新生代和老年代是 1:2那新生代约 1.33GEden 和两个 Survivor 按 8:1:1 分Eden 约 1.06G每个 Survivor 约 133M。当 Eden 被填满触发 Minor GC暂停所有应用线程Stop-The-World。从 GC Roots 出发标记 Eden 和当前 From Survivor 里的存活对象。把存活对象复制到 To Survivor年龄 1。清空 Eden 和 From Survivor然后 From 和 To 角色互换。关键点来了如果 To Survivor 装不下所有存活对象多余的对象会通过“分配担保”机制直接进老年代。这是最隐蔽的问题来源之一很多人看到老年代莫名其妙涨得快其实就是 Survivor 太小导致的提前晋升。提示如果你观察到 Minor GC 之后老年代占用明显上升先别急着调 GC 策略先把-XX:SurvivorRatio和新生代大小重新算一遍。Survivor 太小是提前晋升最常见的原因。2. JDK8 里的垃圾回收器怎么挑JDK8 这个版本的好处是选择多坏处也是选择多。很多人上手就查“哪个回收器最好”这个问题问法本身就错了——没有最好的只有适配当前内存规模、延迟要求和吞吐要求的。2.1 分代收集器的组合关系与适用边界先搞清楚哪些回收器能配对。新生代和老年代各有各的实现必须成对使用。新生代回收器老年代回收器启用方式特点SerialSerial Old-XX:UseSerialGC单线程客户端模式或小内存场景ParNewCMS-XX:UseConcMarkSweepGC低延迟响应优先Parallel ScavengeParallel Old-XX:UseParallelGC吞吐优先JDK8 默认G1自身管理整堆-XX:UseG1GC可预测停顿大堆首选补充一点容易搞混的JDK8 的默认回收器在服务端机器上CPU 大于 2 核、内存大于 2G是 Parallel Scavenge Parallel Old在客户端机器上是 Serial。所以很多项目其实一直跑在 Parallel 上从没动过参数。Serial 系列现在基本只在嵌入式或者内存极小的场景用得上。它的优势是简单、没有线程交互开销、没有额外内存占用单核环境下反而比并行版本快。但如果你的堆有 8G 以上单线程回收一次全堆能卡秒级直接排除。2.2 Parallel、CMS、G1 的取舍逻辑Parallel Scavenge Parallel Old的核心目标是吞吐量也就是用户代码运行时间 / (用户代码时间 GC 时间)。它的设计取向是少停顿次数、多干活适合后台批处理、离线计算、数据同步这类对单次停顿不敏感、但对整体算力敏感的场景。可调参数主要是两个-XX:MaxGCPauseMillis设置最大停顿时间-XX:GCTimeRatio设置吞吐量目标默认 99即 GC 时间不超过 1%。注意MaxGCPauseMillis调小之后JVM 会自动缩小新生代代价是 GC 更频繁、吞吐量下降。这是个跷跷板不能两头都要。CMS是 JDK8 时代低延迟的代表目标是缩短停顿代价是牺牲吞吐量并占用额外 CPU。它把老年代回收拆成四个阶段初始标记STW很短、并发标记、重新标记STW、并发清除。最长的并发标记和并发清除阶段是和应用线程同时跑的所以停顿短。但 CMS 有几个硬伤这也是它后来被 G1 取代的原因浮动垃圾并发清理期间应用还在产生新垃圾这些只能等下一次。所以 CMS 不能等老年代满了才动手默认老年代使用到 92% 就触发-XX:CMSInitiatingOccupancyFraction通常要调到 70 到 80 留出余量。并发模式失败Concurrent Mode Failure如果在并发清理期间老年代就被填满了CMS 会退化成 Serial Old 做一次单线程 Full GC那个停顿就是灾难级的。这是 CMS 线上事故的第一大来源。内存碎片CMS 用的是标记-清除不做压缩跑久了老年代全是碎片大对象分配不下又触发 Full GC。G1是 JDK8 里唯一兼顾吞吐和停顿、并且能处理大堆的通用回收器。JDK9 之后它成了默认但 JDK8 里需要手动开启。它的整体思路是把堆切成很多等大的 Region每个 Region 可以动态扮演 Eden、Survivor、Old 或 Humongous然后基于“哪个 Region 垃圾最多”来优先回收这就是 Garbage First 名字的由来。选型上我一般这么定堆小于 4G、追求吞吐、能接受百毫秒级停顿Parallel。堆 4G 到 8G、延迟敏感、有调优经验CMS 或者 G1。CMS 需要精细调参G1 相对省心。堆大于 8G直接 G1别犹豫。CMS 在这个量级上的碎片和并发失败风险太高。堆超过 16G、停顿要求 10ms 级JDK8 里 G1 也吃力这时候应该考虑升级 JDK 版本用 ZGC。2.3 G1 的 Region 模型与停顿预测原理G1 的参数里有个容易被忽略的-XX:G1HeapRegionSize。如果不设JVM 会自动推算规则大致是把整堆除以 2048 得到一个目标值然后向上取到 2 的幂且限制在 1M 到 32M 之间。比如 4G 堆4G/2048 2M那 Region 就是 2M16G 堆是 8M32G 堆是 16M。这个值为什么重要因为它决定了大对象的标准。超过 Region 一半的对象会被标记为 Humongous直接分配在连续的 Region 里且回收时机比较尴尬——如果它被判定为全垃圾会立刻回收否则要等并发标记周期结束。所以如果应用里有大量 1M 到几 M 的缓存对象Region 设小了会产生一堆 HumongousGC 效率反而下降。我遇到过一个案例16G 堆没设 Region 大小推算出来是 8M但业务里有大量 6M 左右的图片缓存对象每个都触发 Humongous 分配GC 日志里全是Humongous字样调大-XX:G1HeapRegionSize16m之后问题就没了。G1 的停顿预测靠的是-XX:MaxGCPauseMillis默认 200ms。这里必须提醒一句这个值是目标不是承诺。G1 通过历史回收数据估算哪些 Region 值得回收选出能在目标时间内搞定的集合Collection Set。如果你把目标设成 10msG1 会几乎收不动老年代最后堆积到并发模式失败。经验值3 到 8G 堆设 100 到 200ms8G 以上设 200ms 就够了别贪心。3. 参数怎么定从机器规格倒推 JVM 参数参数不是背下来的是算出来的。而且必须从机器规格和容器限制倒推不能拍脑袋设个大值。3.1 堆内存、元空间、线程栈的账要算清楚先说默认值很多人不知道默认堆是多少。JDK8 里-Xms默认是物理内存的 1/64-Xmx默认是 1/4。真跑在生产上8G 的机器默认堆上限只有 2G而且初始堆小会经历一段不断扩堆的过程每次都触发 Full GC。关于-Xms和-Xmx是否要一样我的观点很明确生产环境必须设成一样。堆动态扩缩容本身是有代价的而且初始堆小会在启动阶段频繁 GC。唯一例外是你确定这个应用是低频的、内存占用极小的边缘服务那可以省点内存。算账的顺序是这样的以一台 8G 内存、8 核的机器为例第一步确定 JVM 总的可用内存上限。这台机器如果只跑这一个 Java 进程可以给到 6G留 2G 给操作系统和可能的外部依赖。如果是容器那容器 limit 才是硬边界。第二步从这 6G 里扣掉堆外开销元空间按类加载数量估。一般业务系统 200 到 300M 足够用 Spring 全家桶加上动态代理的大项目给 512M 也是合理的。设-XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m。直接内存-XX:MaxDirectMemorySize256m用 Netty 或者大量 NIO 的项目要按实际连接数和缓冲区大小估算。线程栈-Xss默认 1M如果线程数峰值 500那就是 500M。线程数乘以栈大小是实打实的开销这就是为什么-Xss512k在这种场景下很有必要。代码缓存-XX:ReservedCodeCacheSize默认 240MJDK8JIT 编译的代码放这里一般够用。第三步剩下的才是堆。6G 减掉 512M 元空间、256M 直接内存、512M 线程栈、240M 代码缓存大约 4.5G那-Xmx4g是个稳妥的取值。第四步划分新生代。如果不用 G1新生代大小用-Xmn或-Xmn配合-XX:NewRatio控制。NewRatio是“老年代 : 新生代”的比值默认 2也就是新生代占整堆 1/3。对于短生命周期对象多的 Web 应用可以把比例调到 1:1 甚至新生代更大减少晋升压力。注意-Xmn一旦显式设置-XX:NewRatio就失效了而 G1 里-Xmn会被忽略需要用-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent默认 5% 和 60%来控制新生代占比。这几个参数互相覆盖的关系是调参时最容易翻车的地方。3.2 GC 日志参数在 JDK8 下的正确写法生产上不开 GC 日志就是闭眼开车。JDK8 的日志参数和 JDK9 之后完全不同别混着写。-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStamps -XX:PrintHeapAtGC -XX:PrintTenuringDistribution -XX:PrintGCApplicationStoppedTime -Xloggc:/data/logs/gc-%t.log -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles10 -XX:GCLogFileSize50M逐个说作用PrintGCDetails输出详细日志包含各代容量变化PrintGCDateStamps加绝对时间戳排查问题必须要有PrintGCTimeStamps加相对 JVM 启动的时间PrintHeapAtGC在每次 GC 前后打印堆布局量很大一般排查期开稳定后关掉PrintTenuringDistribution打印对象年龄分布判断提前晋升全靠它PrintGCApplicationStoppedTime打印 STW 总时长这个是判断“接口偶尔卡顿”的直接证据。-Xloggc后面加%t会用启动时间戳命名文件避免重启覆盖。配合轮转参数10 个文件每个 50M一共 500M基本够追一周的问题。JDK8 的日志文件名参数是-XX:GCLogFileSize注意 JDK9 之后这套全被-Xlog:gc*:filexxx:time,uptime,level,tags取代了网上搜到的参数经常混着来对着版本抄。3.3 三档常见规格的参数模板下面这三套是我自己在项目里反复用过的可以直接作为起点再根据 GC 日志微调。4C8G堆 4GWeb 服务延迟敏感G1-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize4m -XX:InitiatingHeapOccupancyPercent45 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize256m -Xss512k -XX:ParallelRefProcEnabled -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/InitiatingHeapOccupancyPercent默认 45意思是老年代占用达到整堆 45% 就启动并发标记周期。如果日志里经常出现to-space exhausted或者Evacuation Failure说明这个值太高标记得太晚要往下调到 35 到 40。8C16G堆 8G数据同步吞吐优先Parallel-Xms8g -Xmx8g -XX:UseParallelGC -XX:ParallelGCThreads8 -XX:MaxGCPauseMillis500 -XX:GCTimeRatio99 -Xmn3g -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -Xss512kParallelGCThreads默认是按 CPU 核数算的约等于核数超过 8 核时按公式缩减显式设置成 8 是为了避免容器里 CPU 配额识别不准导致线程数异常。SurvivorRatio8是 Eden 和一个 Survivor 的比例所以新生代里 Eden 占 80%。16C32G堆 16G核心交易低延迟G1-Xms16g -Xmx16g -XX:UseG1GC -XX:MaxGCPauseMillis150 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent40 -XX:G1NewSizePercent10 -XX:G1MaxNewSizePercent40 -XX:ConcGCThreads4 -XX:ParallelGCThreads12 -XX:UnlockExperimentalVMOptions -XX:UseStringDeduplication -XX:MetaspaceSize512m -XX:MaxMetaspaceSize1g -Xss512kUseStringDeduplication是 G1 独有的字符串去重能省下不少堆但会消耗额外的 CPU 和并发线程只在字符串重复度高的场景比如大量 JSON 解析才值得开。ConcGCThreads是并发标记线程数默认是ParallelGCThreads/4设太大反而抢业务 CPU。4. 工具链命令行四件套加 Arthas 的组合拳工具不在多在于知道什么时候用哪个。我的习惯是现场诊断先用命令行四件套需要动态追踪再上 Arthas需要看趋势图才开 VisualVM。4.1 jps、jstat、jmap、jstack 的准确用法与坑jps用来找进程号最基础的入口。jps -l # 输出主类全名 jps -lv # 加上 JVM 参数确认启动参数是不是你想要的jps -lv这个用法我强烈推荐排查“参数改了没生效”这类问题时一眼就能看出实际生效的参数。jstat用来看 GC 实时数据每秒一次打十次jstat -gcutil 12345 1000 10输出里的S0、S1、E、O、M是各区域使用百分比YGC/YGCT是新生代 GC 次数和总耗时FGC/FGCT是老年代 GC 次数和总耗时GCT是总耗时。判断标准很简单FGC 一直涨就是有问题正常稳定的服务 FGC 应该长期不变FGCT / FGC得到的单次 Full GC 平均耗时超过 1 秒就需要处理。常用变体还有-gcnew只看新生代、-gcold只看老年代、-gccapacity看各区域容量。jmap用来做堆分析但有两个坑必须先说jmap -heap 12345 # 查看堆配置和使用情况 jmap -histo 12345 | head -30 # 对象直方图看谁占内存 jmap -dump:formatb,file/tmp/heap.hprof 12345 # 导出堆快照第一个坑jmap -histo:live带live会先触发一次 Full GC 再统计线上执行就是一次几十秒的停顿除非万不得已别在生产上用。用jmap -histo不带 live 时统计的是所有对象包括待回收的会虚高但没停顿风险先粗看足够了。第二个坑jmap -dump在堆大的时候非常慢而且会 STW。8G 堆导出一次可能要一两分钟甚至更久业务直接受影响。正确的做法是配置-XX:HeapDumpOnOutOfMemoryError让它自动在 OOM 时 dump或者从负载均衡摘掉节点再手动导。jstack用来看线程栈jstack -l 12345 /tmp/stack.log-l会额外打印锁信息Locked ownable synchronizers排查死锁必须加。想找死锁也可以直接用jstack输出后搜Found one Java-level deadlockJVM 会自动检测并打印出来。排查 CPU 飙高的标准流程是先用top -Hp pid找出占 CPU 最高的线程 ID把它转成十六进制再到 jstack 输出里搜nid0x加那个十六进制值定位到具体代码行。top -Hp 12345 printf %x\n 12346 # 假设高 CPU 线程是 12346得到 0x303a jstack 12345 | grep -A 30 nid0x303a4.2 VisualVM、JConsole、Arthas 的上手姿势JConsole现在基本只作为兜底工具界面老、功能弱但胜在自带。看内存曲线、手动触发 GC 这些它能干看看线程数变化也够。VisualVM是 JDK8 时代最顺手的图形化工具jvisualvm命令直接启动。装上 Visual GC 插件之后能实时看到 Eden、Survivor、老年代、元空间的曲线一眼就能看出内存是不是锯齿状健康回收还是在缓慢爬升。判断内存泄漏最快的方式就是看这条曲线正常是锯齿泄漏是一路向上、GC 之后回不到原位置。Arthas是后来居上的利器尤其是不能重启的线上环境。核心命令我常用的就几个dashboard # 总览包含线程、内存、GC 情况 thread -n 3 # 找 CPU 占用最高的 3 个线程 thread -b # 直接找阻塞其他线程的元凶 heapdump /tmp/a.hprof # 导出堆快照 trace com.xxx.Service method # 追踪方法内部调用耗时 watch com.xxx.Service method {params, returnObj} -x 2 # 观察入参和返回值thread -b这个命令救过我好几次它能直接告诉你哪个线程持有锁不放导致别的线程全卡住了比翻 jstack 快得多。但trace和watch在高频方法上有明显性能损耗用完一定要stop掉我见过有人忘了 stop第二天发现接口 P99 涨了三倍。dashboard里还有个细节值得看GC 那一栏会显示各代的使用率曲线和 GC 次数。如果老年代使用率稳定在 90% 以上且 FGC 缓慢增长就是内存不够或者有轻微泄漏的信号。4.3 一次 Full GC 频繁的完整排查记录说个真实场景。一个订单查询服务8G 堆用 Parallel上线两周后监控报 FGC 每小时从 0 涨到 20 多次每次约 1.5 秒高峰期接口超时。排查步骤第一步jstat -gcutil看趋势。确认 YGC 正常每秒几次每次 20ms 左右但老年代占用在 GC 后从 40% 一路爬到 95%然后 Full GC 打回 40%。GC 后老年代能回落到 40%说明没有严重泄漏只是对象产生速度超过了回收能力。第二步加-XX:PrintTenuringDistribution重启一个实例。日志里看到大量对象年龄是 1 和 2 就直接晋升了。结合年龄分布算一下Survivor 里的同龄对象总大小确实超过了 Survivor 一半动态年龄判定在起作用。第三步看新生代参数。原来是-Xmn2g -XX:SurvivorRatio8新生代 2GEden 1.6G每个 Survivor 只有 200M。而请求高峰期 Eden 每秒产生大约 400M 垃圾Survivor 根本装不下存活的跨越对象。第四步调整。把-Xmn从 2G 提到 2.7GSurvivorRatio从 8 改成 6Survivor 变成约 337M同时把MaxTenuringThreshold显式设为 15 防止被过早抬高。老年代从 5.3G 缩小到 4.6G。第五步观察。改完后 FGC 从每小时 20 多次降到每天 1 到 2 次YGC 频率略升但单次耗时没变整体 P99 从 800ms 降到 120ms。这个过程里最关键的一步其实是第二步——加PrintTenuringDistribution看到年龄分布没有这个信息前面所有的调整都是猜。5. OOM 与各类异常场景的排查套路OOM 不是一个错误是一类错误的统称。不同后缀的 OOM 指向完全不同的根因处理方法也完全不同。把报错后缀看懂排查能省一半时间。5.1 八种 OOM 的成因与定位方法报错后缀触发位置常见根因处理方向Java heap space堆大量对象未释放、缓存无上限、查询全表加载看 heap dump 找大对象检查缓存淘汰策略GC overhead limit exceeded堆GC 花了 98% 以上时间却只回收不到 2% 的空间基本等同于内存泄漏先加堆续命再找泄漏点Metaspace元空间动态生成类太多CGLIB、反射、脚本引擎设 MaxMetaspaceSize检查是否每次调用都创建新类加载器Direct buffer memory直接内存NIO 缓冲区未释放、Netty 池配置不当设 MaxDirectMemorySize检查 ByteBuf 是否 releaseunable to create new native thread操作系统线程数超过系统限制、线程栈太大查 ulimit、线程数上限、是否存在线程泄漏Requested array size exceeds VM limit堆申请数组长度超过 int 上限附近通常代码逻辑错误检查数组长度计算Kill process or sacrifice child操作系统进程被 OOM Killer 杀掉容器内存配额不足或堆外内存超限Compressed class space元空间压缩类指针空间耗尽调-XX:CompressedClassSpaceSize这里重点说两个最容易被误判的。GC overhead limit exceeded经常被当成“内存不够”于是加堆结果只是把崩溃时间推迟。它的本质是垃圾产生速率超过了回收速率加堆不能降低产生速率。正确姿势是先用jmap -histo看对象分布如果是某个业务对象数量异常就去查对应的业务逻辑如果是HashMap$Node或者byte[]占了大头八成是某个缓存容器没有上限。unable to create new native thread的诡异之处在于堆内存可能还很空但就是创建不了线程。原因通常是三种一是操作系统ulimit -u限制了单用户进程数二是线程栈 1M 乘以线程数吃掉了大量内存堆又开得大两边一起把物理内存挤爆三是线程池创建了太多核心线程。我遇到过Executors.newCachedThreadPool()在突发流量下创建了上万个线程的情况改成有界队列的ThreadPoolExecutor之后就好了。顺带说一句线程池和 JVM 的关系线程池里每个线程都要占用一份栈空间所以最大线程数乘以-Xss是必须从内存预算里扣掉的。有人算过“最大线程数 JVM 剩余可用线程”这个思路是对的但实际约束往往来自操作系统限制而不是堆内存。合理的做法是把最大线程数控制在几百这个量级配合有界队列和合适的拒绝策略。5.2 CPU 飙高、GC 停顿、内存泄漏怎么查CPU 飙高的三种典型模式第一种业务线程在跑密集计算或者死循环。用前面说的top -Hp加jstack定位到具体方法能看到线程一直停在同一个方法栈上。第二种GC 线程在疯狂跑。表现是top里 GC 线程名字带GC或者G1占用很高jstat里 YGC 或 FGC 频率暴涨。这时候要处理的是内存问题不是 CPU 问题。第三种频繁的上下文切换。pidstat -w -p pid 1能看到cswch/s很高一般是锁竞争严重。用jstack搜BLOCKED状态的线程或者用 Arthas 的thread -b。GC 停顿的排查要区分是 Young GC 还是 Full GC 的停顿。判断方式看PrintGCApplicationStoppedTime的输出里面会列出每次 STW 的持续时间。如果 STW 时间远大于 GC 日志里标注的 GC 时间那说明停顿另有原因——可能是在做偏向锁撤销、类加载、或者 JIT 反优化。这个坑很深见过一次线上停顿 300ms 但 GC 日志显示只停了 20ms 的情况最后查出来是 JVM 在做大量偏向锁批量撤销用-XX:-UseBiasedLocking关掉偏向锁才解决。内存泄漏的定位流程我固定这么走先用jstat -gcutil观察老年代在 Full GC 后能否回落。能回落说明只是压力大不能回落才是泄漏。然后用jmap -histo看对象排名对比两次相隔几分钟的输出看哪个类的实例数在持续增长。这一步不需要 dump 文件开销小适合线上。锁定可疑类之后再找个低峰期导 heap dump用 MAT 或者 JProfiler 打开看这个类的 GC Roots 引用链。MAT 的Dominator Tree视图能直接告诉你谁占内存最多Path to GC Roots能告诉你为什么这个对象回收不掉。常见的原因就那么几个静态 Map 只增不减、ThreadLocal 用完没 remove、监听器注册后没注销、连接池/线程池未关闭。注意ThreadLocal 泄漏是重灾区。线程池里的线程是复用的ThreadLocal 如果不在 finally 里 remove它引用的对象会一直活到线程销毁而线程池的线程基本不会销毁。5.3 开发环境与容器环境的调优注意点IDEA 本身的 JVM 调优也值得说两句因为开发机的卡顿经常被误判成代码问题。IDEA 的 JVM 参数在Help Edit Custom VM Options里改配置文件是idea.vmoptions。默认堆上限通常只有 750M 到 1G对于大型项目多模块、大量索引根本不够。-Xms1g -Xmx4g -XX:ReservedCodeCacheSize512m -XX:UseG1GC -XX:SoftRefLRUPolicyMSPerMB50 -XX:CICompilerCount2ReservedCodeCacheSize调大到 512M 是因为 IDEA 的索引和插件代码量巨大默认 240M 经常触发 JIT 代码缓存满表现就是 CPU 突然飙高、卡顿日志里能看到CodeCache is full的提示。SoftRefLRUPolicyMSPerMB50让软引用更快被回收减少内存压力。注意如果你开了大量插件导致 CPU 高先试试File Invalidate Caches重建索引比调参有用。容器环境的坑更隐蔽。JDK8 在 8u191 之前的版本JVM 根本识别不到容器的 cgroup 限制它会读到宿主机的物理内存然后按 1/4 算堆上限。容器 limit 是 2G宿主机 64G堆就开了 16G一启动就被 OOM Killer 干掉。查版本java -version看构建号。8u191 及以后默认开启-XX:UseContainerSupport能正确读取容器限制。更早的版本要么升级要么老老实实手动写死-Xmx。即使在 8u191 之后容器里也建议显式设堆。因为在容器里堆外内存元空间、直接内存、线程栈、JVM 自身都算在容器配额内堆设成 limit 的 70% 到 75% 是比较稳的比例剩下的留给堆外。见过太多“容器 limit 4G堆设 4G跑一会儿就被 Kill”的案例。6. GC 日志判读与批量压测调优日志是唯一的客观证据。很多人调优调不好不是不懂参数是不会读日志。6.1 三种回收器的日志格式对比Parallel 的 Young GC 日志2024-03-15T10:23:45.1230800: 25.456: [GC (Allocation Failure) [PSYoungGen: 65536K-10752K(76288K)] 65536K-11664K(251392K), 0.0234567 secs] [Times: user0.09 sys0.01, real0.02 secs]拆开看25.456是 JVM 启动后的秒数Allocation Failure是触发原因表示新生代没空间分配了PSYoungGen: 65536K-10752K(76288K)表示新生代从 64M 降到 10.5M总容量 74.5M65536K-11664K(251392K)是整个堆从 64M 降到 11.4M堆总容量 245.5M0.0234567 secs是这次 GC 的耗时Times里的user是所有 GC 线程消耗的 CPU 时间总和real是实际墙钟时间。user 远大于 real 说明并行度好user 接近 real 说明基本是串行在跑。CMS 的日志会多出几个阶段2024-03-15T10:23:45.1230800: 25.456: [GC (CMS Initial Mark) [1 CMS-initial-mark: 131072K(174784K)] 145600K(251392K), 0.0012345 secs] 2024-03-15T10:23:45.2000800: 25.533: [CMS-concurrent-mark-start] 2024-03-15T10:23:45.8000800: 26.133: [CMS-concurrent-mark: 0.567/0.600 secs] 2024-03-15T10:23:45.8100800: 26.143: [CMS-concurrent-preclean-start] 2024-03-15T10:23:46.1000800: 26.433: [CMS-concurrent-preclean: 0.290/0.290 secs] 2024-03-15T10:23:46.1100800: 26.443: [GC (CMS Final Remark) [YG occupancy: 80000 K (131072 K)] 0.0850000 secs] 2024-03-15T10:23:46.2000800: 26.533: [CMS-concurrent-sweep-start] 2024-03-15T10:23:47.5000800: 27.833: [CMS-concurrent-sweep: 1.300/1.300 secs]看 CMS 日志只关心两个有 STW 的阶段CMS Initial Mark和CMS Final Remark。这两个的耗时才是真正影响业务的。中间的concurrent-mark、preclean、sweep都是并发的耗时再长也只影响吞吐。如果 Final Remark 时间很长通常是新生代对象多导致重新标记工作量大可以调-XX:CMSScheduleRemarkEdenPenetration之类的高级参数但更简单的办法是减小新生代。G1 的日志信息量最大2024-03-15T10:23:45.1230800: 25.456: [GC pause (G1 Evacuation Pause) (young), 0.0234567 secs] [Parallel Time: 18.2 ms, GC Workers: 8] [GC Worker Start (ms): Min: 25456.1, Avg: 25456.3, Max: 25456.5, Diff: 0.4] [Ext Root Scanning (ms): Avg: 0.5, Max: 1.1, Sum: 4.0] [Update RS (ms): Avg: 0.8, Max: 1.5, Sum: 6.4] [Scan RS (ms): Avg: 0.3, Max: 0.9, Sum: 2.4] [Object Copy (ms): Avg: 12.1, Max: 14.3, Sum: 96.8] [Eden: 512.0M(512.0M)-0.0B(504.0M) Survivors: 4096.0K-8192.0K Heap: 1024.0M(2048.0M)-530.0M(2048.0M)]重点看Parallel Time和各个子阶段的耗时。如果Ext Root Scanning很高说明 GC Roots比如大量线程、大量类太多了检查线程数是不是失控如果Update RS或Scan RS高说明跨 Region 引用多Remembered Set 维护压力大通常意味着老年代对象和新生代对象交互频繁得考虑对象是不是过度分散了如果Object Copy占大头那就是存活对象太多得减小新生代或者减少对象存活时间。6.2 关键指标与判读阈值我整理了一张自己常用的阈值表长时间越界就要动手了。指标获取方式健康范围越界含义YGC 频率jstat 里 YGC 变化率每秒小于 5 次新生代太小或对象分配速率过高YGC 单次耗时YGCT / YGC小于 50ms存活对象太多Survivor 不够FGC 频率jstat 里 FGC 变化率每天少于 2 次老年代不够或有泄漏FGC 单次耗时FGCT / FGC小于 1s堆太大或回收器选择不当GC 总时间占比GCT / 运行时长小于 5%吞吐量受损需要重新选型老年代 GC 后占用jstat 里 O稳定不持续上升持续上升即有泄漏STW 总时长PrintGCApplicationStoppedTime单次小于 200ms影响接口 P99这张表里我觉得最该盯的是最后一个STW 总时长。因为它包含了 GC 之外的停顿原因是对用户最直接的影响。只看 GC 耗时很容易漏掉偏向锁撤销、类加载、JIT 反优化这些隐性停顿。6.3 压测对比的落地做法调优最后一步必须验证而且不能只在一个参数下跑。我一般的做法是准备一组参数组合在同一台机器、同一份压测流量下逐个跑收集 GC 日志后用脚本对比。先写个提取脚本把日志里的关键指标算出来#!/bin/bash LOG$1 echo 文件: $LOG echo YGC 次数: $(grep -c GC (Allocation Failure) $LOG) echo Full GC 次数: $(grep -c Full GC $LOG) echo 平均 STW: $(grep Total time for which application threads were stopped $LOG | awk {s$10; n} END {printf %.2f ms\n, s/n*1000}) echo 最长 STW: $(grep Total time for which application threads were stopped $LOG | awk {if($10m) m$10} END {printf %.2f ms\n, m*1000})然后准备三到四组参数比如新生代 2G、2.7G、3.2G 三档每组用同样的压测流量跑 30 分钟收集日志之后横向对比。压测工具用什么不重要关键是流量模型要贴近真实——尤其是对象创建速率和对象存活时间这两个特征必须和线上一致否则调出来的参数没有参考价值。我自己踩过的一个坑是在压测环境用简单的接口压对象创建快、存活短调出来的参数是新生代越大越好但线上业务的对象存活时间明显长两个环境的最优参数完全不同。后来我改成从线上拉一份真实的请求采样做流量回放参数才有参考意义。还有个实用技巧参数不要一次改多个。有人喜欢一次改五六项跑出来效果好也不知道是哪项起的作用效果差更不知道是哪项搞坏了。一次改一到两项观察至少半小时记录下变化这才是可积累的调优经验。最后再分享一个我自己的做法把每次调优的参数、机器规格、前后 GC 指标记录在一个表格里。跑的项目多了之后这张表就成了自己的经验库新项目上手时直接找规格相近的那一行做起点比从零开始试快得多。尤其是内存规格在 4G 到 16G 这个区间很多业务形态的参数其实高度相似复用率相当高。
返回列表