
前两天晚上处理了一个线上服务卡顿的问题接口P99从平时50ms直接飙到3秒CPU偶尔冲到100%jstat一看Young GC一分钟几十次Full GC更是每隔几秒就来一次。排到后面发现罪魁祸首是一段用String.intern()缓存业务key的代码配合一个巨大的HashMap直接把老年代撑爆了。排查过程中顺带把class文件打开核对常量池这才意识到很多JVM内存问题根源都藏在常量池三个字里。这篇文章我打算把两件事揉在一起讲透一是JVM调优从监控定位到参数配置的完整实战路径二是从Class文件常量池到运行时常量池再到字符串常量池的逐层解析。两者看着是两个方向实际上是一条线——不懂内存模型你根本不知道参数该往哪个方向调不懂常量池你永远解释不了为什么字符串占用会比想象中多一倍。内容适合正在学JVM的初级开发也适合已经在线上被Full GC折磨过的中高级工程师下面所有命令和参数都是我在实际项目里验证过的可以直接抄作业。1. 从一次线上事故说起先学会定位再谈调优1.1 事故现场Full GC成为常态接口全面超时我先还原一下当时的场景。服务是Spring Boot应用JDK 8默认使用Parallel Scavenge Parallel Old收集器堆内存配置是-Xms4g -Xmx4g。某天发布一个新功能后监控面板上Full GC次数开始直线上升平均每隔3到5秒就触发一次每次Full GC耗时在1到2秒。这意味着整个服务平均每几秒就会冻结一次接口超时自然不可避免。初步排查顺序是这样的先用jstat -gcutil pid 1000连续观察GC状态发现Eden区使用率几乎一直在99%以上Old区则在每次Full GC后才勉强降下去很快又涨回来。接着用jmap -histo:live pid | head -30抓存活对象分布结果让我很意外——排名前几的全是char[]和String而不是业务对象。这时候基本可以断定是字符串相关的问题后来定位到是一个缓存工具类对每个key都调用了intern()而这个key由用户ID加订单号拼接而成基数有上千万。每个intern之后的字符串都会进入字符串常量池并关联到老年代直接导致Old区被持续填满。这里想强调一个原则调优第一步不是调参数而是找到真正占用内存或触发GC的对象来源。盲目把堆调大只会让问题延迟爆发并不会根除。1.2 运行时数据区JVM内存模型必须烂熟于心既然要聊JVM调优运行时数据区是绕不开的地基。JVM规范把内存划分为线程共享和线程私有两大部分。线程私有的包括程序计数器、虚拟机栈、本地方法栈它们随线程生灭线程共享的包括堆、方法区堆里再细分新生代和老年代方法区在JDK 8之后由元空间Metaspace实现。很多调优参数都是围绕这张图展开的。比如-Xms和-Xmx控制堆大小-Xmn控制新生代大小-XX:MaxMetaspaceSize控制元空间上限。如果你连对象分配在新生代、长期存活对象晋升老年代这条基本链路都模糊那看到一堆GC日志时完全不知道哪个指标异常。我个人的经验是把运行时数据区分成三块记。第一块是线程私有的栈区存局部变量、操作数栈栈溢出一般就是递归太深或局部变量过多第二块是堆区几乎所有对象都在这里分配也是调优主战场第三块是元空间存类元信息、常量池、方法字节码JDK 8以后不再使用永久代默认元空间只受本机内存限制这对部署在容器里的应用来说是个隐患后面会专门讲。1.3 对象从哪来到哪去堆、栈与元空间的职责边界先捋一下对象的一生。绝大多数对象在新生代的Eden区分配Eden区满了触发Minor GC存活下来的对象年龄加一进入Survivor区的From区或To区。对象每熬过一次Minor GC年龄加一当年龄达到阈值默认15可通过-XX:MaxTenuringThreshold设置或者Survivor区装不下时就晋升到老年代。老年代满了触发Major GC/Full GC这通常是性能杀手。这里有个特别容易被忽略的点大对象直接在老年代分配。-XX:PretenureSizeThreshold可以设置大对象阈值超过阈值的大对象不会进新生代直接进老年代。如果一个服务频繁创建几MB的byte数组老年代会涨得飞快Full GC次数飙升。所以处理线上Full GC问题时先看一下是不是有大对象在搞事比一上来就调堆参数要靠谱得多。栈与堆的边界线程私有的栈里只存局部变量和引用真正的对象实例还是在堆里。栈上分配JVM的逃逸分析优化可以把某些不会逃逸出方法的小对象直接分配在栈上减少GC压力但这是JIT自动优化的结果人工无法强制控制。元空间存的是类的结构信息如果应用使用CGLIB、ASM动态生成大量代理类元空间膨胀会非常快。2. JVM调优实战监控、定位与参数配置2.1 调优前的准备工作JMX、jstat、jmap的正确用法不少人拿到一个JVM问题就直接改参数重启结果问题复现了还是不知道原因。正确的流程是先监控、再定位、最后调整。我在实际工作中最常用的三件套就是jstat、jmap和jstack再加一个visualvm做可视化辅助。jstat -gcutil pid 1000是最快了解GC状态的命令每秒输出一次Eden、Survivor、Old、Metaspace的使用率和GC次数与耗时。我一般连续采集5到10分钟重点看两个指标Full GC次数是否持续增长、GC耗时是否稳定。如果Full GC次数稳步上升说明有内存泄漏或对象持续堆积如果GC耗时很长但次数少说明单次GC需要处理的对象太多可能堆太大或对象引用结构太复杂。jmap -dump:live,formatb,fileheap.hprof pid可以导出堆快照配合MAT或VisualVM分析大对象和引用链。我之前定位intern问题就是靠堆快照里看到数百万个char[]和String在等待被回收。jstack则用于线程问题比如死锁、线程阻塞、CPU飙高时抓线程栈。注意这三个命令在JDK 8里都用得比较多在JDK 11之后部分命令被jcmd替代但思路一样。2.2 堆内存参数-Xms、-Xmx与比例分配的计算逻辑堆内存参数是最常被调的但大多数人只记住了-Xms和-Xmx忽略了内部比例。这里分享一套我自己总结的配置逻辑。第一步是确定堆的总大小。经验值是先按照服务负载估算活跃数据量。如果服务承载的缓存、会话、业务对象总量大约2GB那堆设4GB左右比较合理留出足够余量应对突发流量。-Xms和-Xmx建议设为相同值避免JVM在运行时动态扩容缩容带来的性能抖动。启动时一次性申请到位虽然占用的物理内存多一点但换来了稳定的GC行为。第二步是新生代大小。-Xmn决定新生代容量新生代太小会导致Minor GC频繁太大则老年代空间不足Full GC更频繁。业界经验是新生代占堆的1/3到1/4具体要看应用的对象分配速率和存活率。如果刚开始不确定可以先保持1/3运行一段时间用jstat观察Minor GC频率和晋升大小再调整。我遇到过一个批量计算服务对象几乎全是临时对象存活率极低把新生代从1/4调大到1/3之后Minor GC次数下降了40%关键是因为Eden空间变大对象在Minor GC前就被回收了不需要频繁进入Survivor和晋升老年代。下表是我常用的一组初始参数可以作为起点参数取值说明-Xms4g初始堆大小与实际运行负载有关-Xmx4g最大堆大小建议与Xms一致-Xmn1.5g新生代大小约占堆37%-XX:MetaspaceSize256m元空间初始大小-XX:MaxMetaspaceSize512m元空间最大大小防止动态生成类耗尽内存第三步是观察与回调节。不要期望一次就能配出完美参数。启动后连续观察一周重点关注GC频率、Full GC次数、内存占用曲线再决定是否微调。2.3 线程与元空间参数容易被忽略的调优点堆之外两个经常被忽略的调优点线程栈和元空间。-Xss控制每个线程的栈大小默认在JDK 8里是1MB。很多服务线程数一两百个这块内存积少成多。如果应用线程逻辑里没有深递归可以适当调小到512KB甚至256KB为堆腾出更多空间。我优化过一个网关服务把-Xss从1MB降到512KB线程数稳定在500个直接省出约250MB内存。元空间在JDK 8之后默认没有上限只受本机物理内存限制。这意味着一个动态生成大量代理类或者频繁重复加载类的应用可能慢慢耗尽容器内存而JVM自己毫无察觉。所以一定要显式设置-XX:MaxMetaspaceSize我一般设置在256MB到512MB之间。设置之后如果元空间真的不够用会抛出java.lang.OutOfMemoryError: Metaspace这反而是一个清晰的信号可以针对性排查类加载器是否泄漏。3. 垃圾回收调优读懂GC日志降低停顿3.1 GC日志分析一眼看懂新生代老年代在干什么GC日志是调优的核心依据前提是得会看。我先给出一个典型GC日志片段来自JDK 8的Parallel收集器[GC (Allocation Failure) [PSYoungGen: 1376256K-174766K(1536000K)] 1924816K-741859K(4046848K), 0.0678138 secs] [Full GC (Ergonomics) [PSYoungGen: 174766K-0K(1536000K)] [ParOldGen: 4105M-3947M(4105M)] 6071151K-3947M(4046848K), 1.7839473 secs]这里面信息量很大。[PSYoungGen: 1376256K-174766K(1536000K)]表示新生代GC前占用1376256KGC后占用174766K区域总容量1536000K。括号后面的1924816K-741859K(4046848K)表示整个堆GC前的占用、GC后的占用和堆总容量。最后的0.0678138 secs是GC耗时这个数值如果超过几百毫秒就要警惕。Full GC那行里的[ParOldGen: 4105M-3947M(4105M)]更直白老年代GC前已经用了4105M总容量也是4105M说明老年代已经满了GC之后只回收了约150MB紧接着又会满。这种满了回收一点再满再回收的循环是典型的空间不足表现单靠调参数很难根治必须先找到谁在填满老年代。我习惯在启动参数里加上-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -Xloggc:/path/gc.log把GC详细信息输出到独立文件方便离线分析。JDK 11以后语法变成了-Xlog:gc*:filegc.log方式不同但目的一样。3.2 新生代大小与对象晋升从分配速率反推参数调新生代大小之前先搞清楚两个概念分配速率Allocation Rate和晋升速率Promotion Rate。分配速率指每秒钟新生代分配对象的总字节数晋升速率指每秒钟晋升到老年代的对象字节数。这两个指标能直接指导参数调整。用jstat的-gc选项可以算出一个粗略结果但我更推荐用GC日志来反推观察两次Minor GC之间Eden区从空到满的时间间隔用Eden容量除以间隔时间就得到分配速率。如果分配速率很高每秒几百MB说明新生代太小或者应用确实在创建大量临时对象这时候可以适当增大新生代。如果晋升速率很高说明Survivor区容不下存活对象对象过早进入老年代你可以检查-XX:SurvivorRatio默认8适当调整Survivor区占比或者调大-XX:MaxTenuringThreshold让对象多扛几轮Minor GC。举个实际例子。有个报表服务每次跑批都会创建大量中间结果对象。最初配置是-Xmn1gSurvivorRatio8意味着Eden约900MB两个Survivor各约100MB。跑批时Eden区平均1分钟就满一次Minor GC频繁而且从日志看每次都有大约30MB对象晋升到老年代。我把-Xmn调到1.5g同时把SurvivorRatio调成6Eden和Survivor空间都变大了Minor GC频率从每分钟4次降到每分钟1到2次晋升量也明显下降。这说明调整生效了原因是Survivor空间变大后年轻对象有更多机会在Survivor区被回收而不是被迫晋升。3.3 垃圾回收器选型Serial、Parallel、CMS与G1的取舍垃圾回收器选择直接影响停顿时间没有一个最好的收集器只有适合你业务场景的。我用一张表列出几个主流选项收集器适用场景特点常用参数Serial / Serial Old单核小机器、客户端程序单线程GC停顿长但实现简单默认在客户端模式Parallel Scavenge / Parallel Old追求吞吐量的服务端多线程GC停顿相对较长但总吞吐量高-XX:UseParallelGCCMS追求低停顿并发标记清除停顿短但碎片化和CPU占用是问题-XX:UseConcMarkSweepGCG1大堆、可预测停顿分区化可以设置最大停顿时间目标-XX:UseG1GC -XX:MaxGCPauseMillis我这几年主力业务用的都是Parallel吞吐量高适合批处理和中等规模的Web服务。CMS在JDK 8时代很流行但CMS的浮动垃圾、内存碎片和并发模式失败都是坑比如CMS退化为Serial Old进行Full GC时停顿会非常夸张。现在新服务我基本直接上G1尤其是堆内存超过6GB、机器核数多的情况下G1可以通过-XX:MaxGCPauseMillis把单次停顿控制在合理范围。G1的调优有一个关键点不要太过依赖-XX:MaxGCPauseMillis强行设置一个极低的目标比如10ms会导致G1频繁调整区域大小并增加GC次数反而降低吞吐量。通常设置到100ms到200ms是一个相对均衡的范围。G1的另一个重要参数是-XX:G1HeapRegionSize默认会根据堆大小自动计算一般不用手调。如果你要观察G1行为打开-XX:PrintAdaptiveSizePolicy可以看到自动调整的细节。4. 常量池详解Class常量池、运行时常量池与字符串常量池4.1 Class文件常量池字节码里的符号世界聊完调优进入常量池这个话题。Java源文件编译成class文件后class文件里有一块非常重要的结构叫常量池Constant Pool。它就像一个符号表记录了类中使用到的所有字面量字符串、数字常量和符号引用类名、方法名、字段名、接口名。JVM在执行字节码时需要靠这些符号引用来定位到具体的类和方法。你可以用javap -v命令查看class文件的常量池这是最直观的学习方式。随便编译一个类然后执行javap -verbose com.example.Demo输出中会有一段Constant pool:里面按索引号列出了一堆条目比如#1 Methodref #12.#33 // java/lang/Object.init:()V #2 Fieldref #34.#35 // com/example/Demo.name:Ljava/lang/String; #3 String #36 // hello #4 Class #37 // com/example/Demo每个常量条目都有一个tag标志用来表示常量类型。JVM规范定义了多种常量类型最常见的包括Utf8字符串、Class类或接口、Fieldref字段引用、Methodref方法引用、InterfaceMethodref、String、Integer、Float、Long、Double、NameAndType等。我第一次看的时候觉得很枯燥但后来发现理解这些tag对排查两类问题很有帮助一是动态代理生成的大量类会不会挤爆元空间二是字符串字面量如何进入字符串常量池。4.2 运行时常量池类加载后的动态入口class文件里的常量池是静态存储的当类被JVM加载到内存后它会被解析到方法区的运行时常量池Runtime Constant Pool中。运行时常量池和Class文件常量池最大的区别是它具有动态性通俗讲就是可以在运行期往里加数据。动态性的经典体现就是String.intern()。这个方法可以把一个运行期创建的字符串对象尝试加入字符串常量池如果池中已经有相同内容的字符串就返回池中的引用如果没有就把当前字符串加入池中并返回引用。在JDK 8中这个字符串常量池String Table在堆里的一个特殊区域类似一个HashMap结构保存的是字符串对象本身或者说引用。运行时常量池里面存的还包括类中所有方法、字段的符号引用这些符号引用在类加载链接阶段会被解析成直接引用。链接阶段没有真正查找到对应类或方法时就会抛出NoClassDefFoundError或NoSuchMethodError。所以运行时常量池不是一个静态的存储容器它参与着类加载、解析、执行的全过程。这也是为什么说常量池直接影响JVM内存和性能。4.3 字符串常量池String对象与内存泄露的真实案例回到事故本身。那段出问题的代码大概长这样public String getCacheKey(String userId, String orderId) { return (userId _ orderId).intern(); }意图是复用字符串对象减少重复key的内存占用。但如果key基数非常大千万级别intern()会把每一个不同的字符串都塞进字符串常量池而且一旦进入池中基本不会被GC回收即使回收条件也非常苛刻。这相当于在你的堆里偷偷养了一只吞内存的怪兽。我那次事故就是因为用户订单的组合key量级实在太大intern()把老年代直接喂满了。正确的做法是只有字符串有限且重复率高的时候才考虑intern()比如固定的枚举值、状态名对于高基数的动态拼接字符串绝对不要intern。更合理的方案是用缓存组件如Redis或者为key设计更紧凑的数据结构比如用long类型拼接ID。关于String.intern()在JDK 6和JDK 8的区别这里也值得多说一句。JDK 6里字符串常量池在永久代永久代空间有限滥用intern很容易OutOfMemoryError: PermGenJDK 7之后字符串常量池被移到堆中虽然空间大了但堆仍然是有限资源内存问题只是从永久代转移到了堆里并不代表可以乱用。很多人面试时背了JDK 7之后字符串常量池在堆中这个结论却不知道实际调优时它依然是个重要的内存风险点。5. 避坑清单与个人体会5.1 我踩过的几个JVM调优坑调优路上踩过的坑比收获的经验更值钱。第一个坑是看到GC频繁就无脑加堆内存。我接手过一个服务堆从4g加到8g后又加到16g结果Full GC反而更频繁了因为堆太大单次GC扫描对象数量暴增停顿时间翻倍。后来通过分析对象分布找到是数据库连接池泄漏导致空闲连接对象堆积修掉泄漏后堆回到4g反而很稳。堆内存不是越大越好要基于活跃数据和分配速率来确定。第二个坑是只改参数不观察。有一次我调整了新生代比例上线后系统CPU飙升一看-Xmn设得太大老年代被压缩到只有堆的1/5大量对象在老年代积累Full GC更严重。后来我养成了习惯任何参数调整都要配合GC日志和监控观测至少一天观察对象晋升速率、GC停顿时间和CPU消耗而不是拍脑袋觉得应该会好。参数调整从来不是一次性的它是一个持续循环的过程。第三个坑和常量池相关用intern()来优化字符串内存结果越优化越糟糕。那次事故之后我对intern产生了强烈的敬畏之心。字符串常量池本质是空间换时间的产物它能帮忙也能反噬。遇到类似场景我会先问清楚三个问题字符串基数大不大重复率高不高生命周期长不长三者中哪怕有一个不满足都不建议用intern。5.2 调优的正确姿势度量、定位、验证、复盘最后分享一套我反复打磨后形成的调优工作流分为四步度量、定位、验证、复盘。度量是指先采集基线数据用jstat、GC日志、监控面板记录当前GC频率、停顿时间、内存占用和接口延迟。定位是指通过堆转储、对象直方图、线程栈分析找到真正的瓶颈验证是指针对瓶颈做一次小范围、可回滚的参数修改然后继续观测复盘是指把问题和解决过程记录下来沉淀成团队的知识库。这套流程最反直觉的一点是大部分JVM调优问题的答案其实不在JVM参数里面而在代码里。我处理过的为数不多的真实线上案例中真正靠调整JVM参数解决的比例不到三成剩下的都是代码层面的问题比如集合无边界增长、字符串滥用、大对象大量创建、缓存设置不合理。JVM参数只是最后一公里的微调手段而不是第一公里的救命稻草。我个人这几年的体会是把运行时数据区、垃圾回收算法、常量池这三个基础吃透比背一百条调优命令有用得多。很多面试官也会问JVM调优实战经验但他们更想听到的是你怎么定位问题、怎么验证方案而不是背参数。希望这篇总结能帮你少走一些弯路。如果在实际排查中遇到奇怪的内存问题建议你按照这个顺序来先看对象是谁再看GC日志怎么走最后才动参数。这条路我走过确实有效。