Java 应用性能瓶颈定位实战——从压测结果到代码级优化的完整路径
Java 应用性能瓶颈定位实战——从压测结果到代码级优化的完整路径
适读人群:Java后端工程师、技术团队负责人 | 阅读时长:约16分钟 | 核心价值:掌握从压测数据出发,逐层排查到代码级问题的完整方法论和工具链
那次让我成长最快的排查经历
2020年双十一前三周,订单服务压测时P99在200并发下跑到了1800ms,远超500ms的目标。
最开始我的思路是:P99高 → 可能是代码慢 → 加缓存。
加了Redis缓存后,测试环境P99确实降了一些,到了1200ms。但还是不达标,而且生产数据加进来后,缓存命中率只有40%,缓存没起到预期的效果。
带我的老同事看了我的操作,摇摇头说:"你连瓶颈在哪都不知道,就开始优化了。这叫瞎优化。"
他带着我做了一次系统的排查:
- 先看JVM监控,GC情况正常,堆内存没问题
- 再看线程Dump,发现大量线程在等待数据库连接
- 看连接池配置,最大连接数是5,远远不够
- 把最大连接数从5改到50,P99直接降到280ms
改了一个配置,P99从1800ms降到280ms。而我之前折腾了一天的缓存优化,只降了600ms。
性能优化的前提是正确定位瓶颈,而不是凭感觉猜。
性能瓶颈定位的层次模型
从外到内,性能问题的层次是:
第一层:压测指标异常(P99高、TPS低、错误率高)
↓
第二层:系统资源层(CPU、内存、网络IO、磁盘IO)
↓
第三层:JVM层(GC、堆内存、线程)
↓
第四层:框架/中间件层(连接池、线程池、RPC超时)
↓
第五层:代码层(慢方法、N+1查询、同步锁竞争)
↓
第六层:存储层(慢SQL、索引、锁等待)排查顺序:从外到内,逐层排除。不要跳层,跳层容易走弯路。
第一步:看系统资源
压测时同时监控服务器的CPU、内存、网络IO:
# CPU使用率(监控整体和每核)
top -H -p $(pgrep -f 'java.*OrderService')
# 更详细的CPU分析
mpstat -P ALL 1 10 # 每秒输出,查10次
# 内存使用
free -m
vmstat 1 10
# 网络IO
sar -n DEV 1 10
# 磁盘IO
iostat -xz 1 10CPU满(>90%):可能是计算密集型操作、或者GC占用CPU CPU低但TPS也低:可能是IO阻塞(等待DB、等待网络) 内存持续增长:内存泄漏 磁盘IO高:数据库频繁读写磁盘,查询走了全表扫描或Buffer Pool太小
第二步:分析 JVM
GC日志分析
JVM启动参数加上GC日志:
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m \
-jar app.jar压测期间用gceasy.io或gcviewer分析GC日志,关注:
- Young GC频率:正常是每秒0-2次,超过10次/秒说明新生代太小
- Full GC:如果发生Full GC,STW时间通常>1秒,直接导致P99飙升
- GC耗时占比:GC耗时/总时间 > 5%就需要关注
线程Dump分析
线程Dump是排查"系统不忙但TPS低"的关键:
# 方式1:jstack
jstack $(pgrep -f 'java.*OrderService') > /tmp/thread_dump.txt
# 方式2:kill -3(发SIGQUIT信号,JVM会打印线程dump到stdout)
kill -3 $(pgrep -f 'java.*OrderService')
# 方式3:jcmd(推荐,信息更完整)
jcmd $(pgrep -f 'java.*OrderService') Thread.print > /tmp/thread_dump.txt重点看线程状态分布:
# 统计各状态线程数
grep -E "java.lang.Thread.State:" thread_dump.txt | sort | uniq -c | sort -rn
# 输出示例:
# 156 java.lang.Thread.State: WAITING (on object monitor)
# 43 java.lang.Thread.State: TIMED_WAITING (sleeping)
# 28 java.lang.Thread.State: BLOCKED (on object monitor)
# 12 java.lang.Thread.State: RUNNABLE大量WAITING (on object monitor):线程在等某个锁,找持锁线程 大量BLOCKED:线程在等进入synchronized块,可能是锁竞争 大量等待DB连接的WAITING:连接池不够
用 Arthas 在线诊断
Arthas是阿里开源的Java诊断工具,可以在不重启服务的情况下分析性能问题:
# 启动Arthas,attach到目标进程
java -jar arthas-boot.jar $(pgrep -f 'java.*OrderService')
# 查看最耗CPU的线程
thread -n 5
# 统计方法调用次数和耗时(持续1000ms,采样率1%)
monitor -c 1000 com.example.service.OrderService createOrder
# 找出方法里每行代码的耗时
trace com.example.service.OrderService createOrder
# 实时输出方法入参和返回值
watch com.example.service.OrderService createOrder '{params, returnObj}' -x 3
# 查找调用栈中的慢调用(>500ms的)
stack com.example.dao.OrderDao queryOrderList '#cost > 500'trace命令的输出非常有价值:
---[2.031ms] com.example.service.OrderService.createOrder()
+---[0.012ms] 参数校验
+---[0.450ms] 查询用户信息
+---[1.823ms] 查询库存 ← 这里耗时最长!
+---[0.045ms] 插入订单记录
+---[0.023ms] 发送MQ消息直接定位到查询库存是瓶颈,进一步分析这个方法。
第三步:排查中间件层
连接池监控
HikariCP提供JMX监控,通过Actuator暴露:
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,hikaricp
metrics:
enable:
hikaricp: true访问/actuator/metrics/hikaricp.connections.active查看活跃连接数。
如果活跃连接数长期维持在最大值,pending等待数>0,就是连接池不够用。
调整:
spring:
datasource:
hikari:
maximum-pool-size: 50 # 根据DB能力调整,不是越大越好
minimum-idle: 10
connection-timeout: 3000 # 等待连接超时3秒(默认30秒!)
idle-timeout: 600000
max-lifetime: 1800000线程池监控
Spring的@Async、各种线程池executor,压测时要监控:
// 暴露线程池指标给Actuator
@Bean
public ThreadPoolExecutor orderExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
20, 100, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// 注册到Micrometer
ExecutorServiceMetrics.monitor(Metrics.globalRegistry, executor, "order-executor");
return executor;
}第四步:代码级分析
用 JMH 做方法级基准测试
当Arthas定位到某个方法有性能问题,用JMH精确测量:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class OrderServiceBenchmark {
@Benchmark
public void createOrder_currentImpl(Blackhole bh) {
// 当前实现
bh.consume(currentOrderService.createOrder(testRequest));
}
@Benchmark
public void createOrder_optimized(Blackhole bh) {
// 优化后实现
bh.consume(optimizedOrderService.createOrder(testRequest));
}
public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(OrderServiceBenchmark.class.getSimpleName())
.build();
new Runner(opt).run();
}
}输出:
Benchmark Mode Cnt Score Error Units
OrderServiceBenchmark.createOrder_currentImpl avgt 20 2847.234 ± 123.4 us/op
OrderServiceBenchmark.createOrder_optimized avgt 20 312.891 ± 15.2 us/op当前实现2847μs(约2.8ms),优化后312μs,提升了9倍。
踩坑实录
坑1:N+1查询在低并发下不明显,高并发时成倍放大
现象: 订单列表接口,基准测试P99是45ms。但100并发时P99直接到了2800ms,比基准高了62倍。
原因: 代码里有N+1查询:先查出20个订单,然后循环对每个订单查询一次商品信息——一共21次SQL查询。低并发时数据库轻松处理,每次10ms,总计210ms,不明显。高并发时数据库连接被大量20条循环SQL占满,等待连接时间暴增。
解法: 用Arthas的trace命令发现了21次数据库调用。改为一次批量查询:先查20个订单,取出所有productId,再用IN子句一次查回所有商品信息,SQL从21次降到2次。P99从2800ms降到180ms。
坑2:synchronized方法在高并发下成为串行瓶颈
现象: 库存扣减接口,单线程基准是12ms。10并发时TPS是80(正常应该是833),P99到了8000ms。
原因: 代码:
public synchronized boolean deductStock(Long productId, int quantity) {
// 查库存 + 更新库存
}整个方法加了synchronized,所有请求串行执行。
解法: 把锁粒度从方法级别细化到商品ID级别,不同商品的库存扣减可以并行:
private final ConcurrentHashMap<Long, Object> productLocks = new ConcurrentHashMap<>();
public boolean deductStock(Long productId, int quantity) {
Object lock = productLocks.computeIfAbsent(productId, k -> new Object());
synchronized (lock) {
// 同一商品的扣减串行,不同商品可以并行
}
}改完后10并发TPS从80提升到860,P99从8000ms降到90ms。
坑3:String拼接在热点路径上产生大量垃圾对象
现象: 日志接口TPS只有2000,CPU 95%以上,GC非常频繁(每秒Young GC 15次)。
原因: 代码里在热点路径上拼接了大量字符串用于日志记录:
// 每次请求都执行这行,即使日志级别不是DEBUG
logger.debug("Request params: " + JSON.toJSONString(request) + ", User: " + userId);JSON.toJSONString(request)和字符串拼接每次都创建大量临时对象,压垮了GC。
解法: 改为SLF4J的延迟计算格式:
logger.debug("Request params: {}, User: {}", request, userId);这行代码只有在DEBUG级别开启时才会实际格式化字符串,非DEBUG时什么都不做。GC压力骤降,TPS从2000提升到8000。
完整排查流程回顾
压测发现P99过高
↓
检查系统资源(CPU/内存/磁盘IO)
↓ CPU和内存正常?
检查JVM(GC频率/Full GC/线程Dump)
↓ GC正常,线程大量WAITING?
检查连接池/线程池(active连接数/pending数)
↓ 连接池正常?
Arthas trace定位慢方法
↓ 找到慢方法?
检查是否有N+1查询/同步锁/字符串拼接
↓
用JMH基准测试验证优化效果
↓
回归压测,验证P99达标总结
性能排查的核心是:从数据出发,逐层排查,用工具说话,不凭感觉猜。
工具链:top/iostat(系统层)→ GC日志/jstack(JVM层)→ HikariCP/Actuator(中间件层)→ Arthas(代码层)→ JMH(方法层)。
每一层的数据都能指向下一层,不要跳层优化。
