这个标题看着简单,背后其实是一整套工程方法论。我干Java这块快十年,从单体到微服务,从小公司到日活千万的平台,性能问题遇到过太多。很多时候所谓“性能优化”,不是上来就改代码,而是先搞清楚问题到底在哪一层、卡在哪一个环节、为什么慢。这篇文章我想用几条真实的线上案例做主线,把JVM内存、GC调优、代码层面的常见坑、数据库和缓存配合,以及最后怎么压测和排查,从头到尾完整串一遍。无论你是刚开始接触JVM调优的新人,还是正在做性能排查的进阶选手,都可以按章节取用。
1. 性能优化的整体思路与排查方法论
1.1 先把“性能问题”这件事想清楚
性能问题的本质,是有限的资源遇到了不合理的竞争或消耗。CPU、内存、磁盘IO、网络带宽,任何一个成为瓶颈,都会体现在延迟上升和吞吐下降上。Java应用尤其特殊:它跑在JVM上,有自动内存管理,但也因此引入了一个别的语言不太需要操心的维度,就是GC。
我见过太多人一说性能优化就盯着代码抠细节,结果优化了半天,一压测发现瓶颈在数据库连接池。也见过另一类人,压根不看指标,凭感觉把堆内存从4G调到8G,然后该卡还是卡。这两种做法都不可取。
我自己的方法论是四步走:先量化、再定位、然后优化、最后回归验证。听起来简单,但80%的线上性能事故,都栽在第一步没做扎实。量化不是说看一个平均数,而是要看它的分布。比如一个接口平均耗时200ms,听起来还好,但如果TP99到了1200ms,那就说明有大量长尾请求在拖后腿,这种情况你光看平均值根本发现不了。
1.2 量化指标:没有数据的优化都是耍流氓
常规需要关注的指标,我建议至少包含下面这几类:
| 指标 | 含义 | 我常用的采集工具 |
|---|---|---|
| QPS / TPS | 每秒请求数 / 每秒事务数 | LoadRunner、JMeter、wrk |
| RT(平均/TP99/TP999) | 请求处理时间,尤其关注TP99 | Cat、SkyWalking、Micrometer |
| GC频率与停顿 | Young GC / Full GC次数与耗时 | jstat、GC日志、Arthas |
| 线程状态 | BLOCKED、WAITING、RUNNABLE分布 | jstack、Arthas |
| 系统资源 | CPU、内存、磁盘IO、网络IO | top、vmstat、dstat |
这些指标之间的因果关系要特别注意。比如CPU高不一定是计算密集,也有可能是频繁GC导致CPU空转、线程上下文切换过多导致的。如果你只看到CPU高就去找CPU慢的代码,很可能白忙一场。
定优化目标也是一门学问。不要拍脑袋写“我要优化到100ms”,而是要先摸清现状,再看瓶颈在哪里、投入产出比划不划算。举个例子,一个下单接口RT稳定在800ms,里面600ms花在调用两个第三方服务上,由于第三方服务是强依赖,你就算把本地代码优化得再优秀,最多也就省下200ms。这时候你要么引入并行调用,要么和对方约定更快的协议,而不是傻傻地在本地抠代码。目标一定是在整个链路里找“最值钱的杠杆点”。
1.3 排查工具矩阵:用什么工具处理什么场景
很多Java新人看到jstack、jmap这些命令就发怵,其实掌握顺序就很简单。我自己平时排查问题,会按照“系统层->JVM层->应用层”的顺序来取证。
系统层看top、vmstat、free、iostat。top看CPU和负载,vmstat看上下文切换和等待队列,free看内存是否吃紧,iostat看磁盘是否打满。系统层看完,再到JVM层用jps找到Java进程,jstat看GC和类加载,jmap导堆快照,jstack看线程栈。应用层的分析和链路追踪用Arthas、SkyWalking或者CAT。
这里我特别想说Arthas,这个阿里开源的诊断工具基本就是Java排查神器。你不需要临时改代码加日志,线上直接就能看方法调用参数、返回值、异常堆栈,还能直接反编译线上代码。Arthas dashboard一条命令就能看到线程、内存、GC的实时面板,比一个个命令打快多了。
2. JVM原理与内存调优实战
2.1 内存模型说起来玄乎,其实就那么几块
JVM的内存可以粗分成三个池子:堆内存、非堆(元空间、栈、直接内存)、以及JVM自身管理需要的一些结构。平时我们最关心的还是堆。
堆内存内部又分年轻代和老年代。年轻代里还有Eden区和两个Survivor区(S0、S1)。绝大部分对象先在Eden区创建,年轻代GC(Minor GC)之后存活的对象会往Survivor区复制,每经历一次GC年龄加1,达到阈值(默认15,可调)就晋升到老年代;大对象也可以直接进老年代。
这套设计背后是“弱代假设”:绝大部分对象朝生夕灭,活不过一次GC。所以把短命对象放在一块小区域里高频清理,把长命对象放老年代低频清理,整体GC成本就能压得很低。理解这个,你就能理解为什么Survivor区太小会导致对象频繁晋升老年代、进而导致Full GC。
元空间存放类的元数据,和Java堆分开,默认无限(受系统内存限制),一般不用太操心。线程栈默认1M,如果疯狂开启线程要注意,上千个线程上千M,堆还没满机器先满了。
直接内存(DirectMemory)很多人忽略,但像Netty这类框架用得非常凶。它绕过了堆,走系统内存,不受堆大小控制。如果流量高,而且直接内存被耗尽,会报OutOfMemoryError: Direct buffer memory,这种问题光调堆是不够的。
2.2 垃圾收集器不是越新越好
垃圾收集器的选型经历过好几个阶段。从Serial、Parallel,到CMS,再到G1,再到ZGC,每一代都在解决不同场景的痛点。
| 收集器 | 适用场景 | 特点 |
|---|---|---|
| Serial | 单核小内存客户端 | 单线程,停顿明显 |
| Parallel | 追求高吞吐,对停顿不敏感 | 多线程并行回收 |
| CMS | 老年代并发回收 | 停顿较低,但碎片化严重,JDK9起废弃 |
| G1 | 大堆多核,可控停顿 | 区域化分代,主流默认 |
| ZGC | 超低延迟,TB级堆 | 停顿通常在10ms以内 |
我现在的生产环境,如果是JDK8且没有特别需求,就用G1;JDK17以上默认就是G1,真要追求极低停顿才考虑ZGC。G1的最大特色是把堆划分为大小不等的Region,每次回收一部分Region,而不是整堆清扫,因此可以做到“可预测的停顿时间”。你给我一个目标,比如“每次GC停顿不超过50ms”,G1可以往这个方向调整,但Parallel做不到。
很多老项目还在用CMS,我得说一句:如果已经上JDK11以上,尽快迁G1。CMS有两处硬伤,一是空间碎片化容易导致Full GC,二是并发阶段CPU竞争明显。迁移之后一般都能看到Full GC次数明显下降。
2.3 一个真实的内存参数调整案例
有个业务服务,8核16G的机器,原先用的是默认参数,平时还算稳定,但一到业务高峰就频繁Full GC,每次停顿将近1秒,接口RT飙到两三秒。
我先看GC日志,发现年轻代回收很频繁,而且每次回收后晋升老年代的对象特别多。再看代码,发现有批量导入功能,一次性创建了几十万个中间对象,大部分应该朝生夕灭,但因为年轻代太小,Survivor区放不下,全跑到老年代了。
当时给的调整方案是这样的:
java -Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:InitiatingHeapOccupancyPercent=45 -XX:G1NewSizePercent=10 -XX:G1MaxNewSizePercent=50 -XX:G1ReservePercent=10 -XX:MaxTenuringThreshold=6 -Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps机器16G,堆给了8G,留了一半给系统缓存和其他进程。这个比例不是随便拍的,长期观察内存占用之后,8G足够承载正常峰值,再多也只是堆着不用,GC反而会有更大压力。G1的Region数量和暂停目标直接相关,MaxGCPauseMillis设100ms,是业务可接受的延迟上限;InitiatingHeapOccupancyPercent调低到45,意思是老年代用了45%就开始酝酿并发标记,避免真到满了才手忙脚乱触发Full GC。
MaxTenuringThreshold从15调到6,是因为从日志看绝大多数对象其实在第一轮GC就死了,没必要让对象来回倒腾这么久。调整后最有价值的变化是:Full GC次数从高峰期的每分钟十几分钟一次,直接降到几乎为零。接口RT的TP99从2.3秒降到380ms。这个案例的核心不是某个参数对不对,而是每次调参都有日志和现象做依据。
2.4 从GC日志里读懂JVM在干什么
GC日志是调优的第一手证据。我贴一条典型的G1日志片段:
[GC pause (G1 Evacuation Pause) (young), 0.0285348 secs] [Parallel Time: 25.3 ms, GC Workers: 8] [Eden: 2128.0M(2128.0M)->0.0B(1984.0M)] [Survivors: 96.0M->96.0M] [Heap: 3000.2M(8192.0M)->872.2M(8192.0M)] [Times: user=0.12 sys=0.03, real=0.03 secs]第一行告诉我们:这是一次年轻代暂停,耗时28ms。Pause后面括号里的Eden从2128M变成0,说明这次回收把Eden里的对象基本都处理了,Survivors维持96M,说明没有发生明显的“晋升风暴”。
要重点盯的是Full GC和并发标记周期。比如这种:
[Full GC (Allocation Failure) 4096M->3800M(8192M), 1.5230342 secs]Full GC卡了1.5秒,堆整体回收效果还那么差,3896M只回收了不到300M,这就说明老年代里堆了大量生命周期长或者无法回收的对象。这种日志一旦出现,基本可以直接怀疑内存泄漏或者对象长期被引用。
解读GC日志要形成一套自己的判断模板:年轻代GC频率高不一定是事;年轻代GC每次耗时在几十毫秒内正常;Survivor区是否打满;晋升对象的量级。如果Survivor持续打满,就需要扩大Survivor,或者检查是不是把大对象堵在年轻代。如果Full GC频繁,则大概率是内存泄漏、堆过小、或者有大对象滥用。
3. 代码层性能优化:从原理到动手改造一个慢接口
3.1 最常见的坑:循环里的N+1查询
前阵子公司内部做了一次代码走查,我拿一个列表页接口举例,用户打开一个100条订单的列表页,后端第一版代码大约是:
public List<OrderVO> listOrders(int userId) { List<Order> orders = orderMapper.selectByUserId(userId); List<OrderVO> result = new ArrayList<>(); for (Order order : orders) { User user = userMapper.selectById(order.getUserId()); OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); vo.setUserName(user.getName()); result.add(vo); } return result; }这个接口如果订单量只有二三十条,线上也跑得挺欢。但一旦某个用户拉出八百条订单,映射关系和订单数量级一起来,一次页面请求就会触发成百上千条SQL,数据库压力瞬间上来。每次循环只算一次查询,用户体感顶多慢一两秒,但并发一上来数据库连接池首先被拖死。
优化方案一句话总结:把循环内的多次查询,改成一次性批量查询。
public List<OrderVO> listOrders(int userId) { List<Order> orders = orderMapper.selectByUserId(userId); if (orders == null || orders.isEmpty()) { return Collections.emptyList(); } Set<Long> userIds = orders.stream() .map(Order::getUserId) .collect(Collectors.toSet()); Map<Long, User> userMap = userMapper.selectBatchByIds(userIds) .stream() .collect(Collectors.toMap(User::getId, Function.identity())); List<OrderVO> result = new ArrayList<>(orders.size()); for (Order order : orders) { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); User user = userMap.get(order.getUserId()); if (user != null) { vo.setUserName(user.getName()); } result.add(vo); } return result; }从1000次查询变成2次查询,视野开阔很多。类似的N+1问题在MyBatis里还有个典型的坑,就是在for循环里调用xml里配置的嵌套查询select,同样等于把SQL循环执行,尽量用join或者嵌套结果映射替代。
3.2 锁粒度与对象创建:两个隐藏杀手
并发编程里最经典的问题之一,就是锁粒度太粗。我有一个真实案例:一个库存扣减方法,为了省事,给整个方法加了synchronized。这个方法的内部逻辑其实很快,但被锁保护起来之后,所有线程排队执行,QPS直接定格在几十。
优化思路是把全局锁改为分段锁,或者用乐观锁机制,比如在数据库层面用版本号做乐观锁定:
update stock set count = count - ? where product_id = ? and count >= ?或者用Redis的Lua脚本做原子扣减。很多场景下锁并不是完全不能有,而是要让锁保护的代码块尽可能小。比如同一把锁里既要执行数据库查询又要处理网络IO,那查询等待的时间会阻塞所有请求。
另一个隐藏杀手是循环里大量创建对象。Java里有String、包装类、集合对象,这些对象创建成本倒不高,但会给年轻代GC带来压力。循环里来一个字符串拼接用+,比如:
String sql = "select * from user where id in ("; for (Long id : ids) { sql += id + ","; } sql += ")";这个代码在几百个ID的场景下没问题,但上万次循环且每次循环都拼接一次字符串,会产生大量中间String对象,浪费内存和触发GC。改成StringBuilder之后,性能差距非常明显。虽然JVM会对字符串拼接近乎作弊地优化,但在循环体内部它也无能为力。
3.3 数据结构选型:ArrayList还是LinkedList
Java里的集合选型,很多开发都在用直觉,而不是数据规模。我见过有人在一个需要频繁按下标访问元素的场景里用LinkedList,理由是“反正就几百个元素,无所谓”。几百个确实无所谓,但如果是几百万呢?LinkedList在按下标访问时是O(n)复杂度,而ArrayList是O(1),这个差距在数据量大时是秒级和毫秒级的差距。
同理,HashMap的初始容量设置也常被忽略。如果预知Map里会放10000个元素,而不设置初始容量,默认初始容量是16,那么它会在达到阈值的时候扩容好几次;每次扩容都要重新计算hash并把所有元素搬到新数组里,代价不小。正确做法是new HashMap<>(10000 / 0.75f + 1),减少扩容次数。
选数据结构的核心原则是:先想清楚数据规模和访问模式,再决定用哪个容器。顺序访问选ArrayList,频繁头部插入选LinkedList但更推荐ArrayDeque,按键值查找用HashMap,保持插入顺序用LinkedHashMap,去重且保持唯一用HashSet。极端性能要求下,甚至可以用数组加手动哈希替代HashMap。高性能网络框架很多就是放弃泛型容器,直接手写对象池和数组结构。
3.4 反直觉部分:不要过度优化
做性能优化最容易走火入魔的地方,就是没事找事。一个接口RT本身也就5ms,你非要去把for循环里的条件顺序调整一下,或者把i++改成++i,这都是在浪费精力。
性能优化的优先级,永远是大结构优先于小技巧。也就是说,先看外部依赖能不能减少、网络调用能不能合并、缓存策略对不对、数据结构算法复杂度能不能降一档,最后才轮得到微操。真正决定系统性能的往往是架构层面的事情,比如缓存命中率、异步化和削峰,而不是某个方法里少创建了一个对象。
4. 数据层性能优化:SQL、索引与连接池配合
4.1 SQL慢不慢,看执行计划而非直觉
数据层优化里最常用的就是慢SQL治理。很多请求慢,慢在数据库查询上。数据库查询慢,90%的问题又出在索引设计和SQL写法。
最常用的分析工具是EXPLAIN。比如:
EXPLAIN SELECT * FROM order_info WHERE user_id = 123 AND status = 1 ORDER BY create_time DESC;看执行计划里的type字段,如果出现ALL,说明全表扫描,基本就是索引没走上的结果。这时候第一反应是看看where条件里涉及到的字段有没有建联合索引,以及索引顺序是否合理。经验法则是:等值条件放前面,范围条件放后面,排序字段尽量并入索引。
索引失效的场景,我这里整理几个特别常见的:
| 场景 | 原因 |
|---|---|
WHERE name LIKE '%abc%' | 前模糊,无法走索引 |
| 对索引字段做函数运算 | 如DATE(create_time) = '2024-01-01',索引失效 |
| 隐式类型转换 | 字符串字段当数字查,索引可能失效 |
| OR连接非索引列 | 全表扫描 |
| 联合索引不符合最左前缀 | 跳过了第一列直接查第二列 |
我见过最典型的败笔是:给create_time建了索引,但查询条件写成DATE(create_time) = '2024-01-01',索引完全用不上。改成create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'后,查询从秒级直接降到毫秒级。
4.2 连接池大小不是越大越好
连接池的配置是最容易被过度设计的参数之一。很多开发以为连接池配得越大,性能就越好,结果数据库反而先扛不住。连接池大小跟系统处理能力、数据库性能和每个连接占用资源都有关系。一个经验公式大致是:
连接数 = (线程数 × (1 + 阻塞系数)) / 平均每个连接服务时间占比
后端服务如果是纯IO密集且大部分时间在等数据库返回,连接数可以相对大一些;如果CPU密集,连接数就小,否则线程切换和数据库并发压力都会失控。一个简单的经验参考值是:单机4核8G,数据库连接池一般给20到50个连接足够;超过100个还慢,问题多数不在连接池,而在SQL逻辑本身。
连接池还有几个参数容易被忽略。maxWait太大,请求池满时线程会无限等;minIdle太小,流量波动时频繁建连;connectionTimeout设太短,高峰期会误杀正常请求。我的建议是这些超时参数宁可给得宽裕一点,把问题交给监控,不要为了调优而调优。
4.3 一个完整的数据层优化案例
之前接手过一个订单查询接口,压测到300QPS就扛不住了,数据库CPU飙到100%,SQL日志里大量慢查询。
第一步,看慢SQL,发现Top1是一条对订单表做ORDER BY create_time DESC LIMIT 0,20的查询,加了status = 1和user_id = 123两个条件,但只建了user_id这个单字段索引。由于是扫描大量记录再排序,效率非常低。
第二步,建联合索引(user_id, status, create_time)。这里把create_time放进联合索引,就是为了避免排序阶段额外操作,MySQL可以顺着索引顺序直接拿数据。
第三步,重新压测,同样条件下QPS从300涨到1500,慢SQL消失。
这个案例凸显了一个常见误区:光看SQL好不好,不看索引是否匹配查询模式。加索引看起来是一行代码的事,实际上背后是对查询模式和数据分布的重新理解。
5. 线上问题排查实战实录
5.1 案例一:接口响应突然变慢,CPU飙到90%
一天下午突然收到告警,应用集群某几台机器CPU飙到90%,接口RT从60ms涨到3秒。我先用top确认进程PID,发现是Java进程,然后执行了:
top -Hp <pid>-H参数可以显示进程内的线程,我发现若干线程CPU占用特别高。再把这些线程ID转成十六进制:
printf "%x\n" <tid>然后用jstack输出线程栈,搜索对应十六进制线程号:
jstack <pid> | grep -A 50 "nid=0x..."打开一看,线程死死卡在一个SQL查询上。定位到代码,发现最近版本把一个本来应该走缓存的代码删了,所有请求都直接打到数据库。数据库连接已经耗尽,应用线程全部阻塞在拿连接上,CPU被线程切换和等待消耗殆尽。
这次排查让我确定了一个习惯:线程栈里如果看到大量线程都指向同一个业务代码,那几乎肯定是一条同步调用链上的问题。不要怀疑是偶发,99%是某个公共资源出事了。
5.2 案例二:内存缓慢上涨,最终Full GC频繁
另一个案例是长时间运行的批处理任务,每次跑完内存都涨一点,三四天后系统开始频繁Full GC。
先用jstat观察GC情况:
jstat -gcutil <pid> 1000发现老年代占用持续上升,Young GC后老年代不降。再用jmap导出堆快照:
jmap -dump:live,format=b,file=heap.bin <pid>导入MAT工具分析,发现大量对象被一个静态全局Map持有,而这个Map是“缓存”用的,但每次写入都忘了删除。听着蛮低级,但这种问题在生产上非常普遍。解决办法加上合理的缓存淘汰策略,比如用Caffeine或者Guava Cache,而不是自己写一个永不淘汰的Map。
这里也建议多一步:用MAT查看Dominator Tree前几个节点,通常一眼就能看出是哪个类的问题。
5.3 常见问题速查表
| 症状 | 优先排查项 | 常用命令 |
|---|---|---|
| CPU飙高 | 死循环/频繁GC/线程阻塞 | top -Hp、jstack、jstat |
| 内存溢出 | 堆溢出/直接内存溢出 | jmap dump、MAT |
| Full GC频繁 | 老年代对象多/内存泄漏 | jstat -gcutil、GC日志 |
| 接口RT长 | SQL慢/外部依赖慢/本地代码慢 | Arthas trace、日志、链路追踪 |
| 连接池满 | SQL长事务/连接泄漏 | 连接池监控、show processlist |
| 线程阻塞 | 死锁/锁竞争/资源等待 | jstack -l、Arthas |
这张表没有覆盖所有场景,但能覆盖我实际工作中80%的线上性能问题排查路径。核心思路永远是:先系统层确认瓶颈,再JVM和线程层确认对象和行为,最后到业务代码里定位根因。
6. 压测与回归:优化结果到底算不算数
6.1 压测要压真实流量,不要只压理想情况
很多团队做性能验证,喜欢用JMeter开一堆线程怼一个接口,压出来的数据非常好看,但一上生产就原形毕露。原因是测试预案没有考虑:接口之间相互依赖、缓存命中率和生产环境不同、网络往来有损耗。
我的建议是压测至少分三层:单接口压测,验证这个接口自身的极限;全链路压测,模拟真实业务场景,比如从网关到业务再到数据库;排名压下最好在灰度环境或容灾验证环境做,这样压完还能看到对真实系统的反向冲击。
压测指标也不是越猛越好。如果是给客户承诺QPS 1000,那压测就按1200来压,留20%余量;如果优化目标是RT的TP99低于300ms,就直接看TP99指标而不是平均值。平均值会骗人,TP99不会。
6.2 一套可以照着用的回归流程
优化做完,别急着合代码。我最常用的回归清单是这样的:
- [ ] 优化前记录基线的QPS、RT、TP99、GC频率等指标
- [ ] 在相同环境下重复压测,至少跑10分钟,观察波动
- [ ] 验证业务功能不受影响,特别是缓存、事务边界、幂等性
- [ ] 观察GC日志、线程状态,确认不是靠牺牲其他资源换来的性能
- [ ] 小流量灰度上线,对比监控曲线,确认没有劣化
这套流程也许看起来繁琐,但它能避免一个特别常见的问题:局部性能提升了,整体系统却变差了。比如为了降低数据库压力,把很多东西塞进Redis,结果Redis成了新的热点;为了提升接口响应,加了一层层缓存,结果缓存一致性问题把数据搞乱了。性能优化是系统工程,永远要放在全局去看。
7. 从一个案例说开去:性能优化是持续的工程习惯
最后说一个前前后后耗费两周的调优过程作为收尾。
一个报表查询接口,数据量是千万级,原本每天跑一次,耗时三分钟没人觉得有问题。后来业务改成用户可自助查询,速度慢了体感很糟,用户直接在群里开喷。我拿到需求之后第一反应不是优化SQL,而是先问:用户真的需要实时全量数据吗?
最终方案是:每天晚上用离线任务把报表结果预计算好存到结果表和Redis里,查询时直接读缓存。数据库只承担预计算任务的写入,用户查询走缓存,接口RT从180秒降到30ms,成本几乎没有增加。
这个思路的本质是读写分离、空间换时间,也就是把计算前移。性能优化不是每次都在跑道上加速,而是尽可能不要每次都在赛道上跑。做方案时,先想清楚扇出逻辑、时间窗口和一致性要求,再动手。
回过头看,Java性能优化这件事,三分靠技术,七分靠思维。技术层面的工具、参数、框架知识是有限的,真正拉开差距的,是遇到问题时能否快速建立假设、用数据验证假设、用最小的成本解决问题。踩过这么多坑之后,我个人的体会是:保持怀疑精神,让每一个优化动作都有数据和日志做支撑,比收藏一百篇调优文章都管用。
如果你也想从零开始把自己项目的性能摸一遍底,我建议先别急着改代码,先把GC日志打开、把慢SQL日志打开、把监控面板拉出来,连续观察一周。数据会告诉你它需要什么。