做Java性能优化这件事,我干了快十年,有个体会越来越深:性能问题很少是单一原因造成的,也几乎不可能靠“加内存”或者“调两个JVM参数”就彻底解决。它往往是代码写法、JVM配置、数据库设计、甚至操作系统层面一起“合谋”出来的。Java性能优化,说白了就是在跟资源的浪费作斗争,在有限的内存、CPU、IO里,把每一分都用在刀刃上。
这篇文章会分享一条从理论到实践的完整路径:怎么建立指标基线、用什么工具做诊断、JVM内存和GC的核心原理、代码与数据库层面怎么做优化,以及一个真实接口从1500ms优化到300ms以内的完整复盘。适合刚接触性能优化的初级开发者,也适合已经在线上被各种卡顿、慢查询、Full GC折磨得焦头烂额的开发同学。我会尽量把每一步的“为什么”讲透,而不是只丢给你一堆参数。
1. 性能优化方法论与指标解读
1.1 为什么“先测量、后优化、再验证”是铁律
很多刚入行的同学拿到一个“系统很慢”的反馈,第一反应就是改代码、调参数、换中间件。我见过最夸张的一次,某团队为了优化一个接口,直接把Redis引入了项目,结果问题不但没解决,反而多了缓存一致性的新麻烦。事后定位才发现,真正的瓶颈是数据库连接池配得太小,连Redis都没必要。
这个案例给我们的教训就是:性能优化必须先测量,再动手。
测量的意义在于把模糊的“慢”变成具体的数字。你至少要搞清楚这几个指标:
- 响应时间RT:单个请求从发起到返回的耗时。要注意的是,平均值并没有太大参考价值,因为被极端值拉高的情况很常见。我们真正要关注的是P99、P95这类百分位延迟,意思是99%或95%的请求都在多少毫秒以内。
- 吞吐量:一般用QPS(每秒查询数)或TPS(每秒事务数)来衡量。系统到底能扛多少并发,这个数字最直接。
- 资源占用率:CPU使用率、内存占用、IO等待、网络带宽。这一步是为了搞清楚瓶颈到底卡在哪个资源上。CPU打满和磁盘IO打满,优化方向是完全不同的。
- GC指标:Young GC频率、Full GC频率、单次停顿时间、堆内存分配速率。Java应用如果这一块有问题,再好的业务代码也会被拖垮。
优化本身还要讲究顺序。我习惯用“三层模型”来界定问题范围:代码层(业务逻辑、算法、数据结构)、JVM层(内存分配、GC、类加载)、架构系统层(线程池、连接池、缓存、数据库)。每一层的优化手段和成本完全不一样。我的经验是,永远先做代码层排查,再动JVM参数,最后才考虑架构级改造。因为代码层的改动通常成本最低,收益却可能最大。
1.2 如何在系统上线前建立性能基线
没有基线,就没有对比,也就谈不上优化。你连“现在到底有多慢”都不知道,那“优化后有多快”就变成一个没法回答的问题。
建立基线的方法很朴素:选择一个和生产配置接近的环境,用压测工具打流量,记录一组稳定的数据。我推荐用wrk或者JMeter,稍微熟练一点的话,wrk更加轻量,适合单接口压测;JMeter适合复杂场景编排。
压测过程中有几个细节很容易被忽视:
- 环境隔离:压测环境一定要和生产环境完全隔离,包括数据库、Redis、MQ等外部依赖。我见过有人压测时把测试环境的库和生产环境共用一个Redis,结果压完发现,测试流量把生产缓存全部打失效了,场面相当狼狈。
- 压测时长要够:不要只压30秒,至少要持续5到10分钟,才能观察到JVM内存增长趋势和GC频率的变化。很多内存泄漏问题,短时间压测根本看不出来。
- 记录基线数据:至少在压测脚本里,把QPS、RT、P99、CPU使用率、堆内存使用率、GC日志这些数据完整记录下来,作为后续所有优化的对照基准。
这里多说一句:性能优化是一个循序渐进的过程,核心是每次改动只改变一个变量。如果你一口气改了SQL、JVM参数、连接池和代码结构,结果变快了,你根本不知道是哪个改动起了作用。下次出问题,你又得从头排查。
2. 性能分析工具链选型与使用技巧
2.1 JDK自带工具:jstat、jmap、jstack、jcmd
JDK自带了一整套诊断工具,很多老开发可能觉得它们过时了,但我认为它们依然是线上问题排查的第一道防线,因为这些工具在所有JDK版本里都能用,不需要额外安装。
jstat是查看JVM统计信息的神器,尤其是GC和堆内存使用情况。我最常用的命令是:
jstat -gcutil <pid> 1000 20这个命令的意思是,每秒输出一次GC相关信息,连续输出20次。你会看到类似这样的输出:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 93.72 28.86 57.02 95.33 92.75 1823 12.341 4 1.352 13.693重点看YGC(年轻代GC次数)、YGCT(年轻代GC总耗时)、FGC(Full GC次数)和FGCT(Full GC总耗时)。如果FGC持续上涨,那堆内存里大概率有什么东西一直没能回收,必须赶紧处理。
jmap用来生成堆转储快照。当怀疑内存泄漏时,可以先看堆内存摘要:
jmap -heap <pid>如果确认堆里有个大对象或者异常的对象集合,可以导出完整堆转储文件,再拿到MAT(Memory Analyzer Tool)里分析:
jmap -dump:live,format=b,file=heap.hprof <pid>这里要特别提个醒:线上环境执行 jmap 之前,一定要评估对业务的影响。尤其是 -dump 会把整个堆导出,哪怕加了live参数,也可能会触发一次Full GC,接口就会突然变慢。如果是核心业务系统,建议先降一小部分流量再执行。
jstack用来输出线程快照。CPU飙高、线程死锁这类问题,第一反应就是执行:
jstack <pid> > thread_dump.txt拿到线程快照后,重点搜索这些状态:RUNNABLE、BLOCKED、WAITING,看哪个线程长时间卡在某种状态。遇到死循环或者锁竞争,线程栈能直接告诉你罪魁祸首在哪个类的哪一行代码。
jcmd是JDK 8之后开始提供的一个综合诊断命令,功能更全面,比如查看系统属性、VM参数、堆信息、GC信息等。在较新的JDK上,我一般优先用 jcmd 替代部分 jstack/jmap 的职责。
2.2 更优雅的选择:JFR、Async Profiler、Arthas
JDK自带工具虽然基础好用,但很多场景下还不够。这里推荐三个我日常工作包里常备的工具,它们各有侧重。
JFR(Java Flight Recorder)是JDK 11及以上版本自带的低开销采样分析工具,开销通常可以控制在2%以内,所以可以放心开在生产上。启动时开启:
java -XX:StartFlightRecording=duration=10m,filename=recording.jfr -jar yourapp.jar分析时直接用JDK自带的JMC(Java Mission Control)打开 .jfr 文件,里面有非常详细的分配速率、GC停顿、锁竞争、IO耗时等数据。我最常用的场景就是:压测的同时开启JFR,跑完以后直接看“内存分配热点”,哪里创建了大量对象一目了然。
Async Profiler是一个采样性能分析器,最擅长生成CPU火焰图。它基于异步采样的原理,不需要对代码做任何侵入式的埋点。
./profiler.sh -d 60 -f flamegraph.html <pid>运行60秒后,会生成一个HTML格式的火焰图,在浏览器里打开。火焰图的好处是,最宽的那个“火焰”一眼就能看出来,哪段代码消耗的CPU最高。我在下面要讲的实战案例里,定位大字符串处理耗时,靠的就是火焰图。
Arthas是阿里的开源Java诊断工具,在线上排查时尤其好用,不需要重启应用,不需要改代码。常用命令比如:
dashboard:实时查看线程、内存、GC、CPU使用状态。watch:观察方法的入参和返回值。trace:查看方法内部每行代码的调用耗时。thread -n 3:定位CPU占用最高的前三个线程,自动输出线程栈。
Arthas 还有一个特别好用的点:动态反编译。当你怀疑线上代码和实际运行版本不一致时,可以用jad命令反编译class文件,对照一下行号和逻辑,很快就知道是不是发布出问题了。
3. JVM内存与GC核心优化手段
3.1 理解堆内存结构和GC工作原理,避免瞎调参数
JVM性能优化绕不开的一个话题就是GC(垃圾回收)。我见过很多人一上来就问“我的JVM参数应该怎么调”,这其实是个没前提的问题。调参之前,你必须先理解JVM堆内存的布局和GC的基本运作方式。
Java堆内存分成两大块:
- 年轻代(Young Generation):包括Eden区和两个Survivor区(S0和S1)。新建的对象基本都分配在Eden区,经过几次Minor GC后仍然存活的对象会被提升到年老代。年轻代的GC叫Minor GC,频率高但速度快,因为大部分对象都活不过第一轮就被回收了。
- 年老代(Old Generation):存放长期存活的大对象和多次GC后仍然存活的对象。当年老代满了,就会触发Major GC或Full GC,停顿时间长,对业务影响大。
GC收集器的选择也要看场景。现在JDK 8默认的Parallel GC,适合追求高吞吐量的场景;CMS适合低停顿场景,但因为碎片和CPU开销问题,JDK 9之后已经逐步淘汰;G1是JDK 11及以后的主流默认选择,在可控停顿时间和吞吐量之间取得了平衡;ZGC和Shenandoah可以在超大堆和极低停顿场景下发挥作用,但内存开销更大。
读GC日志是每天的必修课。开启GC日志的方式:
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar yourapp.jar日志中你会看到类似这样的信息:
[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs] [Eden: 1024.0M(1024.0M)->0.0B(1024.0M) Survivors: 2048.0K->2048.0K Heap: 1536.0M(4096.0M)->512.0M(4096.0M)]如果单次停顿时间经常超过200ms甚至500ms,就必须引起重视了。线上业务如果对延迟敏感,每个GC停顿都是用户可感知的卡顿。
3.2 一套可复用的GC调优步骤
GC调参的原则是:先用代码和堆分析定位问题,再调整参数验证,而不是一堆参数堆上去看运气。
第一步,先看GC日志,确认瓶颈类型:
- Young GC太频繁:说明Eden区太小,或者对象分配速率过高。
- Full GC频繁:说明年老代持续增长,通常有两种情况——真正有大量存活对象,或者内存泄漏;再有可能是对象过早晋升。
- GC停顿时间过长:说明堆太大,或者GC收集器配置不当。
第二步,决定调整方向。
-Xms和-Xmx建议设置为相同的值,避免扩容时触发堆重新分配带来的抖动和Full GC。比如说生产环境给4G堆,那就-Xms4g -Xmx4g。- Eden区太小导致的频繁Young GC,可以适当增大年轻代,比如通过
-Xmn设置年轻代大小,或者调整-XX:NewRatio(默认是2,即年老代占堆的2/3。如果希望年轻代更宽,可以调成1)。 - G1收集器引入了一个更直观的参数:
-XX:MaxGCPauseMillis。比如你设置-XX:MaxGCPauseMillis=100,G1会尽量控制GC停顿在100ms以内,它会动态调整年轻代大小来平衡吞吐量和停顿时间。
第三步,也是很多文章不会告诉你的一点:每次调整后,重新做一次和基线完全相同的压测,对比所有指标。如果GC停顿降了,但吞吐量掉了20%,这个优化就不一定是成功的。GC参数永远是为业务目标服务的,业务目标一般是“在满足RT要求的前提下,最大化吞吐量”。
调参过程中还有个小技巧,我实测很管用:用-XX:PrintGCApplicationStoppedTime把应用暂停时间单独打印出来,你会看到很多你以为“GC停顿不多”的系统,实际上在安全点(Safe Point)上卡了很久。比如某个应用开启了GC日志的高频采集,导致每次RLTVM safe point同步都很慢,这个坑我曾经查了一整天才定位到。
4. 代码级优化与数据库性能实战
4.1 最容易踩坑的代码级性能陷阱
性能优化做到最后你会发现,大部分问题并不是JVM参数没调好,而是代码写得不够好。这里梳理几个我经手项目中反复出现的代码级问题。
字符串拼接。很多人图方便,在循环里直接写str += value。Java在字符串拼接上有一个隐性代价:每次+=都会创建一个新的StringBuilder对象,然后做数组拷贝。循环数量一多,大量短时间内释放的对象就会导致Young GC飙升。正确做法是循环外创建StringBuilder,循环内append,最后toString。
StringBuilder sb = new StringBuilder(1024); for (String item : list) { sb.append(item).append(","); } String result = sb.toString();如果提前能估算拼接长度,记得给new StringBuilder(1024)一个初始容量,减少扩容时的数组拷贝。这一点常常被忽略。
正则表达式滥用。正则表达式的编译成本比大多数人想象的高得多。一个Pattern.compile("[a-z]+\\d{4}")如果放在方法内部每次循环执行,性能损耗会非常明显。一定要把Pattern定义成静态常量或缓存起来,只做matcher.matches()操作。
集合初始容量。HashMap默认初始容量16,超过阈值会扩容重hash,这一步在大数据量场景下极其消耗CPU。如果能够估算元素数量,尽量在初始化时指定容量,比如new HashMap<>(1024)。ArrayList同理。
循环内做远程调用或者数据库查询。这个问题在业务代码里最常见。一个循环里逐个查询数据库,100条数据就有100次网络往返。我一直推荐的方案是:循环外批量取回数据,内存中建立映射关系。举一个最典型的例子,订单列表接口需要关联查询用户信息,如果每条订单都去查一次用户表,这接口不可能快;把用户ID集合收集起来,一次WHERE id IN (...)批量查回来,再放到Map里做关联,性能能提升几个数量级。
锁粒度过大。锁是Java并发里性能损耗的一大来源。有人为了解决并发问题,直接在方法上加synchronized,结果所有调用都变成串行了。优化的方向是缩小锁范围、使用读写锁或乐观锁、甚至用LongAdder替代AtomicLong来减少CAS竞争。我调过最夸张的一例,只是把synchronized从方法级别缩小到代码块级别,接口QPS直接翻了三倍。
4.2 数据库慢查询与连接池优化实战
数据库层面更是性能问题的高发区,而且常常和Java本身的问题互相叠加。先说慢查询。
MySQL的慢查询日志要开启起来。定位到慢SQL时,第一件事不是去改SQL,而是看执行计划:
EXPLAIN SELECT * FROM order_table WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 20;执行计划里重点看几个字段:type(全表扫描是ALL,索引查询是range或ref)、key(实际用到的索引)、rows(预估扫描行数)。如果rows是几十万上百万,那这SQL怎么优化都白搭,大概率是索引没建对或者没走索引。
一个非常典型的问题:深分页。前端慢慢往后翻页,LIMIT 100000, 20这样的SQL,MySQL需要先读取100020行,再丢掉前10万行,越往后越慢。换成游标分页或者基于上次查询的最大ID来翻页,性能会好很多:
SELECT * FROM order_table WHERE id > 100000 ORDER BY id LIMIT 20;这个改写思路我强烈建议用在所有分页查询上,尤其是数据量达到几十万上百万之后,效果立竿见影。
连接池方面,HikariCP现在是Spring Boot默认连接池,它本身的性能已经非常好,但配置不对还是会有问题。我推荐一套起步配置:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 max-lifetime: 1800000 idle-timeout: 600000maximum-pool-size不是越大越好。连接数一多,数据库端需要维护的线程和内存也变多,反而拖垮数据库。更关键的是,连接池大小必须跟你的线程池大小、以及数据库的CPU核数相匹配。一个粗略的经验公式:连接数 = ((核心线程数 * (单请求耗时ms + 连接获取耗时ms)) / 1000)。这个公式告诉我们,单次查询耗时越长,需要的连接数越多;但如果查询很快,连接数反而可以少一些。
我踩过这样一个坑:某服务线程池核心线程数是200,连接池最大连接数却设置成了10,结果压测时大量线程都在排队等连接,最终吞吐量上不去,CPU只用了20%。那时候怎么都没想到是连接池不够,以为是服务线程池没调好,白白浪费了一整天。后来想通了,线程池和连接池要针对同一个压测场景去梳理排队关系,整个链路才能畅通。
5. 实战案例:一次订单列表接口从1500ms到300ms的完整优化
5.1 问题背景与压测数据
前面说了那么多理论和方法,这里用一个实际的案例串起来,你就知道一条完整的优化路径长什么样了。
当时的情况是:一个电商项目的订单列表接口,每次翻页都很慢,用户在高峰期经常要转好一会儿圈。线上监控显示P99大约1500ms,P50大约是400ms。这意味着每100个请求里,有1个要等1.5秒以上,对用户体验是致命的。
我接手后的第一步,不是直接改代码,而是先拉了一组测试环境的数据。压测场景设置为:200个并发线程,持续压测5分钟,目标是把P99优化到300ms以内,同时QPS不能低于压测前的1500。
压测前的基线数据如下:
| 指标 | 优化前数值 |
|---|---|
| P50 | 400ms |
| P99 | 1500ms |
| QPS | 1502 |
| Young GC频率 | 每秒6次 |
| Full GC频率 | 每小时2次 |
| CPU平均使用率 | 45% |
当时看到这个数据,我基本可以判断:RT的波动绝对不是GC停顿造成的,Full GC一小时才两次,不至于把P99拉到1500ms。根源大概率在业务代码或者SQL。但GC每秒6次这个频率也确实偏高,值得关注。
5.2 定位瓶颈:火焰图、GC日志和SQL分析的联合诊断
我先用Async Profiler采了60秒CPU火焰图。火焰图打开一看,热点非常集中:接近40%的CPU消耗在订单状态的字符串格式化和日期格式化上。代码里写了一大串SimpleDateFormat的反复创建和字符串拼接,这在订单列表这种高频调用下,就是一台无形的CPU杀手。
接着看GC日志。每秒6次Young GC,说明堆内存对象分配速率极高。用JFR查看分配热点后发现,问题仍然指向那个字符串格式化方法——每次请求都会创建大量短生命周期对象。这就是我常说的“GC和代码层的关联”:并不是GC参数错了,而是代码本身在疯狂制造垃圾。
最后看SQL层面。订单列表接口本来做了一次分页查询,但对每一条订单,又循环去查了一次订单明细表。更糟的是,那个分页用了LIMIT 100000, 20这种深分页写法,每次翻页都要扫描几十万行。N+1查询加上深分页,两个问题叠加,数据库层面自然就慢了。
5.3 优化动作与最终结果
定位清楚后,优化动作就很明确了:
- 代码层:把循环内的
SimpleDateFormat改成DateTimeFormatter(线程安全),并把格式化操作从循环体内移到批量处理中;字符串拼接统一使用StringBuilder,并预设容量。这两步直接把CPU火焰图里最宽的“火焰”消掉了一大截。 - SQL层:把循环查明细的N+1逻辑改写为一次批量查询,再在内存中做分组关联;深分页改成基于上次最大ID的游标分页写法。
- JVM参数层:因为对象分配速率降下来了,Young GC频率自然下降。再配合将
-Xms和-Xmx统一设置为4G,G1的目标停顿时间设置为100ms,堆内存这块就不会再拖后腿。
优化完成后,重新跑了一遍完全相同的压测。结果如下:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| P50 | 400ms | 180ms | 55% |
| P99 | 1500ms | 280ms | 81% |
| QPS | 1502 | 3644 | 142% |
| Young GC频率 | 每秒6次 | 每秒1次 | 83% |
| Full GC频率 | 每小时2次 | 每6小时1次 | 明显改善 |
这个案例有两点值得复盘。第一,整个优化过程里,我没有动任何架构,只是把代码层面和SQL层面的“垃圾”清理干净,收益就远超预期。第二,JVM参数调整其实并不是这次优化的主角,真正起作用的是定位到问题源头并解决它。加权内存的效果永远是放大器,不是发动机。如果代码本身就在疯狂浪费资源,给再多内存也只是放大浪费。
6. 常见性能问题排查与避坑清单
6.1 典型问题速查表
为了方便日常排查,我把这些年遇到的典型性能问题整理成了一张速查表。遇到问题时,先对照这张表缩小范围,会节省大量时间。
| 症状 | 可能的根因 | 首选举措 |
|---|---|---|
| CPU 99%以上,接口大面积超时 | 死循环、正则回溯、频繁GC、线程池打满 | top -Hp定位线程,jstack看线程栈 |
| 内存持续上涨,最终OOM | 内存泄漏、大对象列表、静态集合持有引用 | jmap -dump导出堆,MAT分析支配树 |
| Full GC频繁,吞吐量骤降 | 年老代增长过快、对象过早晋升、连接缓存过大 | 排查Eden分配速率、检查缓存是否有界 |
| 接口偶尔卡顿,RT抖动严重 | GC停顿、安全点同步、磁盘IO抖动 | 开启GC日志,查看停顿时间,检查磁盘告警 |
| 高并发下线程阻塞严重 | 锁竞争、连接池不足、线程池队列过满 | jstack找BLOCKED线程,检查连接池和线程池配置 |
| 数据库CPU高但SQL简单 | 索引失效、隐式类型转换、深分页 | EXPLAIN查看执行计划,改写SQL |
| 一个请求处理特别慢 | 远程调用超时、第三方依赖阻塞 | 用Arthastrace定位耗时方法 |
这张表只能帮你缩小范围,真正的根因还是要靠测量工具逐一验证。我建议每个团队把这张表扩展成自己的线上排查SOP,每次处理完问题补充一行“当时是怎么排查的”,比任何内部培训都管用。
6.2 我踩过的三个坑和复盘心得
第一个坑:压测环境跟生产环境配置不一致。有一次我压测环境用了8核16G的机器调优,参数全部调好了,一上生产就废了——生产只有4核8G。GC行为完全不同,CPU核数对线程池并发度的影响也很大。所以后来我养成了一个习惯:压测环境至少CPU核数和内存大小要跟生产对齐,否则结论完全不成立。
第二个坑:一上来就调GC参数。早期做优化,我总觉得JVM调参是最“高级”的事情,结果参数调了一大堆,收益微乎其微。后来才明白,GC参数只是事后兜底的工具。代码里每秒钟创建几万个临时对象,你再怎么调堆也是白搭。现在我的原则是:优先扫代码,其次看SQL,最后才是JVM。顺序反了,就是在绕远路。
第三个坑:只看平均RT,忽略P99和GC日志。平均RT这个指标太有迷惑性了,被大多数人忽视的“长尾延迟”才是体验杀手。有一次平均RT存从300ms降到了200ms,我以为优化完了,结果一查P99反而涨了两倍。原因是我优化了业务代码,但GC停顿变得更加集中,极端情况反而恶化了。所以,每个优化动作之后,必须同时看均值、百分位和GC日志,缺一个都容易误判。
最后分享一个我一直在用的复盘习惯:每次性能优化都开一个独立的文档,记录以下内容——问题描述、压测数据、火焰图/GC日志截图、优化动作、最终结果。坚持半年下来,你会形成一张独属于自己的“性能优化地图”,下次再遇到同类问题,可能连工具都不用重开,一眼就能锁定方向。这大概就是做性能优化最有意思的地方:它不是在跟技术较劲,而是在跟自己的惯性思维较劲,每一次突破都意味着你对系统的理解又深了一层。