
先说一个我印象特别深的场景。去年某天晚上七点多我刚准备下班监控群突然炸了一个积分结算系统接口的P99从50毫秒直接飙到6秒超时率冲上30%。第一反应是加机器但扩容之后情况一点没好转CPU使用率也只有20%出头内存看着也不紧张。一群人围在屏幕前看GC日志、看线程栈折腾了一个多小时才定位到根因——不是代码写得多烂而是JVM参数和线程池配置被默认值坑了五年。后来我复盘这类问题发现大部分Java性能事故都有一个共同特点系统不是被某个复杂算法拖垮的而是被那些看起来“没啥问题”的基础配置、调用习惯和监控盲区一点点积累出来的。这篇文章不打算写成教科书式的“性能调优大全”而是把我这些年实际遇到过的场景、踩过的坑、用过的排查手段以及最后生效的优化动作按条理整理成一份可以照着做的实战笔记。涉及内存、GC、线程池、数据库、缓存、工具链也会给你一些能直接拿去用的参数和命令。1. 先搞清楚目标性能调优到底在调什么1.1 那个CPU很低但系统很慢的夜晚前面说的那个积分系统最让人迷惑的地方就是CPU一点都不高平均只有20%左右但接口就是慢。很多人遇到这种情况第一反应是“线程数不够了”于是把Tomcat线程池从200调到500结果没有变好反而更糟。原因不难理解线程都堵在数据库连接或远程调用上加线程只是让更多请求挤在同一个等待队列里上下文切换开销反而上去了。这个案例给我最大的教训是性能调优第一步不是优化而是定位。CPU低但响应慢通常意味着线程处于等待状态——等数据库、等网络、等锁、等磁盘IO。此时CPU是闲着的瓶颈在外部资源或者线程调度上盯着CPU使用率看基本没有用。反过来CPU跑满但吞吐上不去那才是关注代码计算效率的时候。1.2 响应时间与吞吐量一对需要先权衡的指标做性能优化之前得先定义清楚“快”的标准。我习惯把指标分成两类响应时间和吞吐量。响应时间通常看平均值、P95、P99。P99代表99%的请求都落在某个耗时以内它比平均值更能暴露长尾问题。吞吐量单位时间内能处理的请求数常见的就是QPS、TPS。这两者并不总是一起变好。比如批量处理场景为了把吞吐量拉起来可能单次请求耗时反而会变长。处理这种矛盾要先看业务SLA——用户能接受多慢的响应系统要求每秒处理多少笔然后再决定优先优化哪一头。我用一个餐厅例子来理解响应时间相当于“客人从点菜到上菜的时间”吞吐量相当于“餐厅一晚上能接待多少桌客人”。如果只顾着提高翻台率每桌吃太快客人体验会变差如果每桌都精雕细琢翻台率就下来了。线上系统也一样调优本质是寻找当前业务约束下的平衡点。我建议每个服务上线前就建立一张指标记录表至少涵盖这些维度指标项含义常见观测值响应时间均值请求平均耗时结合业务一般小于500msP95/P99响应时间长尾延迟电商类接口一般P991sQPS/TPS吞吐量压测得出GC暂停时间垃圾回收停顿尽量控制在100ms内CPU使用率计算资源占用长期80%需要关注线程数及状态是否有大量阻塞线程核心线程池不被打满慢SQL次数数据库执行效率越少越好有了这张表再看“系统慢”才不会各说各话。1.3 别把优化做成八股文背诵现场现在网上Java面试题很多关于HashMap原理、Synchronized锁升级、GC算法选择大家都能说几句。但说句实在话线上排查性能问题的时候面试八股文能帮上的忙非常有限。我见过一个团队给Spring Boot服务加了这样一组参数-XX:UseG1GC -XX:MaxGCPauseMillis10他们的想法很简单G1不是号称能控制停顿吗那把最大停顿压到10毫秒系统不就不卡了结果上线后GC频率暴增吞吐量反而跌了。原因在于MaxGCPauseMillis设得太小G1会不断调整年轻代大小来满足停顿目标同时回收线程要频繁扫描最终大量CPU时间都花在GC上业务线程反而跑不动了。这种问题的根源就是只背了“结论”没理解参数背后的权衡逻辑。遇到性能问题正确的姿势是先看监控数据再做假设用工具验证最后才动手改配置。没有数据支撑的优化就是碰运气。2. JVM内存与GC最容易被参数误导的地方2.1 从一次OutOfMemoryError事故说起有一个定时结算任务每天凌晨3点跑一次平时都很安静。某天早上运维告诉我任务挂了日志里有一行红字java.lang.OutOfMemoryError: unable to create new native thread。我看到这个错误的第一反应是“内存爆了”但仔细想了下不对。unable to create new native thread翻译过来是不能创建新的操作系统线程通常和堆内存大小没有直接关系而是线程数达到系统上限或者进程虚拟内存不足导致线程栈分配失败。排查过程是这样的# 查看系统对单个进程的最大线程数限制 ulimit -u # 统计当前进程创建了多少线程 ps -eLf | grep pid | wc -l # 查看进程内线程数 cat /proc/pid/status | grep Threads一查发现这个任务的线程数已经接近系统上限。再看代码原来是任务里使用了一个无界的线程池每次处理一批数据就提交一个新的任务线程只增不减。正常数据量下没问题但那天数据量翻了几倍积压的任务越堆越多线程自然就失控了。这里补充一个经验Java的OOM按错误类型分好几种排查路径完全不同。我整理了一个简单对照表错误信息常见根因首选排查方向Java heap space堆内存不足堆对象占用、大对象分配GC overhead limit exceededGC频繁但回收太少对象晋升、内存泄漏unable to create new native thread线程数超限线程池、系统线程限制Direct buffer memory直接内存不足Netty等NIO场景的ByteBuffer分配insufficient memory容器或系统内存不足容器limits、native内存开销包括热搜词里提到的java.lang.OutOfMemoryError: insufficient memory虽然看起来也是“内存不够”但很多情况下是容器限制或Linux物理内存不足而不是Java堆不够用。判断依据很简单先看JVM监控里堆内存水位再看宿主机/容器的内存使用。堆内存还有余量但进程被杀了那多半是容器超限或者操作系统OOM Killer动手了。2.2 G1参数为什么不能照抄别人的网上关于G1调优的文章很多但真正理解G1设计目标的没几个。G1的核心思路是在满足停顿时间约束的前提下尽量提高吞吐量。它把堆分成多个Region通过维护一个可预测的回收集合来控制每次GC的停顿。这里就有一个关键点-XX:MaxGCPauseMillis这个参数不是“我希望GC停顿多久”而是“G1会努力把GC停顿控制在这个范围内”。如果这个值设置得太小比如10毫秒G1就会为了让停顿达标频繁触发年轻代回收导致CPU大量消耗在GC上应用吞吐量明显下降。我一般给通用业务服务这样配置以8GB堆内存为例-Xms8g -Xmx8g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1NewSizePercent5 -XX:G1MaxNewSizePercent60 -XX:G1HeapRegionSize16m -XX:ParallelRefProcEnabled解释一下几个关键参数-Xms和-Xmx设为一致避免运行时动态扩容带来的抖动。这在容器化环境里尤其重要因为动态扩缩容会导致性能不稳定。MaxGCPauseMillis100是一个比较平衡的默认值不要盲目追求降到几十毫秒。G1NewSizePercent和G1MaxNewSizePercent控制年轻代的最小和最大占比。适当调大年轻代可以减少Young GC频率但会牺牲一部分老年代空间要根据对象分配速率调整。G1HeapRegionSize一般不需要手改默认会自动计算。如果大对象比例高可以适当调大到16m或32m减少大对象跨Region分配的开销。调G1参数有个原则先跑两天看数据再动参数一次只动一个。我见过太多人一口气改四五个参数出问题后根本不知道是哪个引起的。2.3 一次Full GC排查的完整还原另一个典型的案例是单机服务每到下午某个时段就出现接口卡顿时长大概持续二十分钟。我看了下监控发现那个时段Old区占用率从40%一路涨到90%然后触发Full GC止损后回落数据越好出现周期性“锯齿”。排查步骤我来还原一下第一步用jstat看GC情况jstat -gcutil pid 1000输出里能明显看到FGCFull GC次数在持续增加每次Full GC耗时要好几秒这已经是很危险的信号了。第二步用jmap或jcmd看堆里的对象分布jcmd pid GC.class_histogram | head -30这里我特别强调一下生产环境尽量用jcmd而不是jmap -histo:live因为后者会触发一次Full GC线上操作风险很高。我一般直接在测试环境复现或者用Arthas的heapdump命令在低峰期执行。结果出来后排名靠前的居然是一个业务DTO对象和日志对象。反查代码发现某段循环逻辑里每处理一条数据都打了一条info日志日志框架的异步队列里堆积了大量待写入对象。数据量一大队列不断膨胀就成了老年代里的“钉子户”。修复并不复杂把关键日志从info降到debug或者加一个采样开关只打印前N条和异常数据。日志队列长度也做了限制避免无界队列继续吞内存。改完之后再观察那个时段的曲线锯齿明显平缓了Full GC基本消失。这个案例给我的经验是GC问题往往不是GC本身的问题而是代码在不停地制造垃圾。调GC参数只能缓解症状真正解决问题要找到谁在疯狂创建对象。把这个问题想明白性能调优就算入门了。3. 线程与并发高并发不是“多开几个线程”这么简单3.1 线程池参数不是拍脑袋定的很多刚接触并发的同学喜欢直接用Executors提供的快捷方法比如Executors.newFixedThreadPool(50)。这个写法在低并发、低流量的时候看不出问题但一旦业务量上来就会成为定时炸弹。我说一个真实的批量导出场景。系统里有一个导出任务内部用newFixedThreadPool(50)并发处理数据队列是默认的无界LinkedBlockingQueue。某个大促日数据量突增50个线程全部阻塞在数据库查询上新任务不断提交全部堆积在无界队列里。结果内存上涨服务假死接口大面积超时。问题根源有两个无界队列导致任务无限堆积线程数固定但线程都在等IO没在真正干活。Java并发编程里线程池的核心参数是这几个corePoolSize常驻线程数maximumPoolSize最大线程数workQueue等待队列handler拒绝策略它们的关系是任务提交时先尝试交给核心线程核心线程满了放到队列队列满了才创建新线程直到maximumPoolSize再满就执行拒绝策略。很多人误以为maximumPoolSize是“只要线程不够就扩展”其实只要队列没满线程数就不会往maximumPoolSize走。线程数的经验估算我一般分两种场景CPU密集型线程数大约等于CPU核数1避免过多线程导致上下文切换。IO密集型线程数可以远大于核数因为线程大量时间在等待IO。一个常用公式是线程数 CPU核数 × (1 等待时间/计算时间)但这个等待时间需要压测采样实际项目中可以先用CPU核数 × 2起步再逐步调整。我建议所有线上服务都手动创建ThreadPoolExecutor明确四个核心参数和队列长度不要用Executors的快捷方法。拒绝策略上如果是重要任务CallerRunsPolicy是一个稳妥选择——不会丢任务只是让提交线程自己跑起到天然限流的作用。3.2 从jstack转储看一次“假死”有一次线上服务进程还活着但所有请求都在超时健康检查也快挂了。这时候第一件事就是抓线程转储jstack -l pid /tmp/thread_dump.txt打开转储文件能看到大量线程处于WAITING (parking)状态但继续往下刷发现有一组线程卡在BLOCKED状态等待同一把锁。在jstack输出里waiting to lock 0x00000000xxxx (a java.util.HashMap)这种信息就是关键线索。定位到锁之后再结合业务代码找持有锁的线程——谁持有了这把锁并且长时间不释放基本就是元凶。那次问题的根因是代码里用了Collections.synchronizedMap来缓存数据某个高峰期频繁读写持锁线程又调了一个远程接口导致锁被占住十几秒。后面一堆请求全堵在锁上CPU不高但系统就是不响应。如果用Arthas排查会更方便thread -n 3这条命令直接列出CPU占用最高的几个线程并打印堆栈。也可以用thread --state BLOCKED看有多少线程处于阻塞状态比手动翻jstack快很多。这类问题教给我一个判断方法CPU高优先看火焰图和线程执行热点CPU低优先找阻塞和等待。两个方向不对应死活都定位不到问题。3.3 类加载问题为什么总在运行时才暴露说个跟热搜词有关的细节java.lang.NoClassDefFoundError: java/applet/applet。这不是一个高频问题可一旦出现很多人会懵——因为代码里根本没有引用过Applet相关的类。我遇到过类似场景一个老项目从JDK 8升级到JDK 11启动时一切正常但运行到某个功能时突然报错。原因就是旧代码或某个第三方依赖在运行时通过反射或间接方式引用了java.applet.Applet——这个API在JDK 9之后已经被模块化移除。ClassNotFoundException和NoClassDefFoundError的区别要分清ClassNotFoundException通常是在代码里显式用Class.forName加载类时找不到属于“主动发现”。NoClassDefFoundError通常是类在编译期存在运行期第一次加载时失败属于“被动暴露”。可能是类初始化失败也可能是依赖的类缺失。排查方法很简单用JVM参数打印类加载过程java -XX:TraceClassLoading -jar app.jar或者用Arthas的sc命令查询某个类是否已经被加载sc java.applet.Applet这种问题比内存问题隐蔽得多因为它在编译期根本不会报错。升级JDK版本时一定要做依赖扫描重点检查已经移除或不再推荐的API。搜索引擎里能搜到大量这类报错本质上都是同样的原因。4. 数据库与缓存瓶颈往往不在Java层4.1 索引失效成本最低的优化性能调优有一类“便宜”的优化不需要改架构不需要加机器改一行SQL就能快上几十倍——那就是索引优化。我见过一个查询表数据量500万语句长这样SELECT * FROM trade_log WHERE DATE(create_time) 2025-01-01;这条SQL跑了2秒加索引也没用因为在索引列上用了DATE()函数导致索引失效全表扫描。改成范围查询之后SELECT * FROM trade_log WHERE create_time 2025-01-01 00:00:00 AND create_time 2025-01-02 00:00:00;同样条件下查询耗时降到50毫秒以内。这种优化收益巨大几乎零成本关键是“查问题”的思路要对。我总结了几个最常见的索引失效情况在索引列上使用函数或表达式比如WHERE DATE(create_time) ...。隐式类型转换比如字符串列用了数字去匹配导致索引失效甚至结果集错乱。前导模糊查询比如LIKE %keyword索引无法命中。联合索引不满足最左前缀原则。遇到慢SQL第一步永远是EXPLAIN看执行计划重点看type、key、rows、Extra。type是ALL意味着全表扫描key是NULL说明索引没用到Extra里出现Using filesort要关注排序性能。4.2 RedisTemplate.increment()报错引发的思考有一个具体报错一直挺经典用RedisTemplate.opsForValue().increment(counter)时Redis返回ERR value is not an integer or out of range。看到这个错误很多人第一反应是“Redis坏了”或者“并发写进去脏数据”其实都不是。排查过程是这样的先用redis-cli进到对应的Redis实例看这个key的类型和值type counter get counter结果type返回的是string但get出来一串类似\xAC\xED\x00\x05t\x00...的乱码。看到这个前缀基本可以确定这个key是用了JDK序列化器写入的——也就是JdkSerializationRedisSerializer默认序列化的结果。根因通常是应用里同时存在StringRedisTemplate和RedisTemplate两个Bean。前者默认用String序列化后者默认用JDK序列化。如果两个组件往同一个key上写值Redis里存的内容格式就会不一致执行INCRBY时Redis要求value必须是数字字符串碰到二进制序列化的内容自然就报错了。解决方案很简单统一序列化器。项目中如果有多个RedisTemplate统一设置成StringRedisSerializer或JSON序列化。计数器场景强制使用StringRedisTemplate。它写入的值是纯字符串不会出现序列化前缀。如果这个key已经脏了先在低峰期删除重建再继续累加。这个案例还带出一个更广泛的判断缓存出问题先看数据长什么样再看代码怎么写的。很多时候不是Redis性能不行是客户端用法不规范。4.3 连接池与本地缓存别让Redis背锅有一次服务压测QPS到1000左右就开始报连接池超时。很多人第一反应是“Redis不够快”其实是因为应用里每个请求都去读同一个热点key连接池被打满了。Redis连接池的参数和线程池一样不是越大越好。HikariCP官网给过一个粗略估算思路连接池大小主要看“单条查询延迟”和“QPS”的乘积。举个例子假设接口QPS500单次查询平均耗时2ms 可估算连接数 500 × 0.002 1算出来只有1个连接就够用这当然极端了但逻辑是对的如果单次查询足够快少量连接就可以支撑很高的QPS。反而把连接池配到200、300数据库连接资源被大量占用其他服务也跟着遭殃。我另一个常用优化是本地缓存。热点数据如果允许秒级延迟可以加一层Caffeine本地缓存TTL设5秒到30秒。本地内存读取是纳秒级Redis读取是毫秒级两者差了三个数量级。加一层本地缓存之后Redis的QPS能降一个数量级接口延迟也更稳定。但本地缓存不是银弹它有一个一致性问题数据更新时各个实例的本地缓存可能短暂不一致。所以只适合“容忍秒级延迟”的业务场景比如配置项、字典表、热门商品信息。要强一致性的数据还是走Redis或者直查数据库更稳妥。5. 工具链与复盘把“感觉”变成“数据”5.1 一次定位问题的工具组合拳Java性能排查的工具链我一直放在手边遇到问题基本按这个顺序出牌。第一步确认进程jps -l第二步看GC表现jstat -gcutil pid 1000第三步看线程状态jstack -l pid /tmp/thread_dump.txt第四步需要看堆对象分布时jcmd pid GC.class_histogram | head -30如果是线上环境我会优先用Arthas。它有几个命令特别好用# 查看方法调用耗时定位慢方法 trace com.example.OrderService createOrder # 实时查看方法返回值 watch com.example.OrderService createOrder returnObj # 查看当前热点线程 thread -n 3trace命令能打印方法链路里每一步的耗时特别适合排查“接口慢但不知道慢在哪”。watch可以不打断线上请求就能看到入参和返回值排查诡异数据非常好用。如果CPU飙到打满就用async-profiler抓火焰图。火焰图能一秒看出CPU时间花在哪个方法上比一行行看日志直观得多。给一个从报警到定位的快速路径参考CPU高 → 抓火焰图找热点方法。CPU低但响应慢 → 看线程转储找BLOCKED/WAITING线程同时看数据库慢查询和Redis慢命令。内存持续上涨 → 看GC曲线抓堆转储再用MAT或VisualVM分析对象占用。接口间歇性卡顿 → 优先怀疑GC停顿或锁竞争配合监控时间点交叉验证。5.2 用JMH给可疑方法做体检团队里经常会发生“这个写法性能更好”之类的争论谁都说服不了谁。我的处理方式是不争论用基准测试说话。JMH是JDK官方出品的微基准测试框架专门用来测单个方法的性能。举个例子有人觉得String.format慢有人说用字符串拼接就行还有人说StringBuilder最快。写一个最简基准import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; BenchmarkMode(Mode.Throughput) Warmup(iterations 3, time 1) Measurement(iterations 5, time 2) Fork(1) Threads(4) public class StringBenchmark { private static final String NAME user; private static final int ID 12345; Benchmark public String format() { return String.format(user:%s:%d, NAME, ID); } Benchmark public String concat() { return user: NAME : ID; } Benchmark public String builder() { return new StringBuilder() .append(user:).append(NAME) .append(:).append(ID) .toString(); } }跑完之后看吞吐量和平均耗时数据一目了然。这类微基准测试要控制变量别把IO操作放进去否则结果会被外部因素干扰。用JMH这件事本身也说明一个工作习惯优化之前先度量优化之后复测。没有前后对比的优化很难评估到底有没有效果。5.3 优化前后的量化对比与日常守护做完整轮优化后最好把结果用一张表记录下来既是给团队的交代也是给自己积累经验。我这里拿之前那个积分系统举例同一套压测场景下优化前后的数据大概是这样的指标优化前优化后接口P99耗时6.2秒180毫秒接口平均耗时1.8秒90毫秒Full GC次数/小时12次0次CPU使用率20%45%QPS支撑能力8003200慢SQL次数/小时40次2次Redis调用量/请求52看到CPU从20%涨到45%不要慌这说明系统真正在干活了。之前线程都在空等资源利用率低吞吐也上不去。优化后CPU被更有效地利用整体吞吐反而翻了好几倍。关于日常守护我自己有几个固定动作。每个月会挑一个低峰时段做一次压测保留历史压测报告做对比每次发布前重点检查数据库查询、缓存操作、线程池参数有没有变化监控告警阈值按P99设置而不是平均值否则长尾问题很容易被平均数据掩盖。这些动作不需要花太多时间但能避免性能问题在发布后才集中爆发。最后分享一个小习惯每到大促前我都会把三件事重新做一遍压测脚本重新跑一遍监控告警阈值重新确认把所有定时批量任务在测试环境预演一遍。性能调优不是阶段性工作更像是一种持续习惯——出了问题不急着改代码先看数据看到数据不急着下结论先定位定到位再去想最优解。这套流程帮我在很多项目里绕开了大坑也希望这篇笔记能给你一些可对照、可复用的参考。