前两天晚上处理了一个线上服务卡顿的问题,接口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,format=b,file=heap.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和晋升老年代。
下表是我常用的一组初始参数,可以作为起点:
| 参数 | 取值 | 说明 |
|---|---|---|
| -Xms | 4g | 初始堆大小,与实际运行负载有关 |
| -Xmx | 4g | 最大堆大小,建议与Xms一致 |
| -Xmn | 1.5g | 新生代大小,约占堆37% |
| -XX:MetaspaceSize | 256m | 元空间初始大小 |
| -XX:MaxMetaspaceSize | 512m | 元空间最大大小,防止动态生成类耗尽内存 |
第三步是观察与回调节。不要期望一次就能配出完美参数。启动后连续观察一周,重点关注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前占用1376256K,GC后占用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*:file=gc.log,方式不同但目的一样。
3.2 新生代大小与对象晋升:从分配速率反推参数
调新生代大小之前,先搞清楚两个概念:分配速率(Allocation Rate)和晋升速率(Promotion Rate)。分配速率指每秒钟新生代分配对象的总字节数,晋升速率指每秒钟晋升到老年代的对象字节数。这两个指标能直接指导参数调整。
用jstat的-gc选项可以算出一个粗略结果,但我更推荐用GC日志来反推:观察两次Minor GC之间Eden区从空到满的时间间隔,用Eden容量除以间隔时间就得到分配速率。如果分配速率很高(每秒几百MB),说明新生代太小或者应用确实在创建大量临时对象,这时候可以适当增大新生代。如果晋升速率很高,说明Survivor区容不下存活对象,对象过早进入老年代,你可以检查-XX:SurvivorRatio(默认8),适当调整Survivor区占比,或者调大-XX:MaxTenuringThreshold让对象多扛几轮Minor GC。
举个实际例子。有个报表服务,每次跑批都会创建大量中间结果对象。最初配置是-Xmn1g,SurvivorRatio=8,意味着Eden约900MB,两个Survivor各约100MB。跑批时Eden区平均1分钟就满一次,Minor GC频繁,而且从日志看每次都有大约30MB对象晋升到老年代。我把-Xmn调到1.5g,同时把SurvivorRatio调成6,Eden和Survivor空间都变大了,Minor GC频率从每分钟4次降到每分钟1到2次,晋升量也明显下降。这说明调整生效了,原因是Survivor空间变大后,年轻对象有更多机会在Survivor区被回收,而不是被迫晋升。
3.3 垃圾回收器选型:Serial、Parallel、CMS与G1的取舍
垃圾回收器选择直接影响停顿时间,没有一个"最好"的收集器,只有适合你业务场景的。我用一张表列出几个主流选项:
| 收集器 | 适用场景 | 特点 | 常用参数 |
|---|---|---|---|
| Serial / Serial Old | 单核小机器、客户端程序 | 单线程GC,停顿长,但实现简单 | 默认在客户端模式 |
| Parallel Scavenge / Parallel Old | 追求吞吐量的服务端 | 多线程GC,停顿相对较长,但总吞吐量高 | -XX:UseParallelGC |
| CMS | 追求低停顿 | 并发标记清除,停顿短,但碎片化和CPU占用是问题 | -XX:UseConcMarkSweepGC |
| G1 | 大堆、可预测停顿 | 分区化,可以设置最大停顿时间目标 | -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: PermGen;JDK 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日志怎么走,最后才动参数。这条路我走过,确实有效。