1. 性能优化的第一原则让数据说话别让直觉背锅Java性能优化这个题目看起来是个技术活但说到底它是个方法论问题。我见过太多同行一听到接口慢CPU高就立刻打开代码凭着直觉把循环改成并行、把HashMap换成ConcurrentHashMap折腾一整天指标纹丝不动甚至更差了。这种猜测驱动优化是性能优化里最大的坑没有之一。性能优化的正确顺序永远是测量 → 定位 → 优化 → 再测量。不管你是刚接手一个老系统的新人还是已经写了七八年Java的老手只要跳过第一步直接开改就一定会走弯路。这篇文章就是要跟你聊聊我在这条路上踩过的坑、总结出来的实战技巧以及几个值得反复咀嚼的案例。文章会覆盖从代码细节、并发模型、内存管理到线上排查的完整链路适合正在为系统性能发愁的Java工程师也适合刚入门不久、想建立正确优化思路的后端开发者。先记住一句话没有数据的优化都是耍流氓。你想优化接口就得先知道慢在哪里。是想说整个接口平均响应500ms还是P99响应时间到了3秒是数据库查询占了大头还是在等待外部接口返回是线程池打满了在排队还是JVM在做Full GC这些问题的答案决定了你该往哪个方向努力。我个人的习惯是优化前先回答三个问题可观测性做没做好能不能拿到每个环节的耗时数据压测环境跟生产环境像不像如果这三点都没着落那就先别急着优化先把监控补齐再说。实测下来一个接口慢90%的场景靠链路追踪和性能剖析工具就能一眼定位根本不用猜。1.1 性能优化最容易踩的坑凭感觉动手举一个很典型的例子。有一次我给某团队看一段业务代码里面有个循环每次循环都做了字符串拼接。代码大概长这样String result ; for (Order order : orderList) { result result order.getId() ,; }很多人的第一反应是这还不简单换成StringBuilder。然后改完一看接口从1200ms变成了1150ms提升微乎其微。因为在这个场景里orderList可能只有几十条字符串拼接根本就不是瓶颈。真正慢的是循环里调用的那个getOrderInfo()方法它在底层逐个查了数据库。这个例子说明什么性能优化的第一步不是动手改代码而是先搞清楚瓶颈到底在哪个环节。你盯着一段看起来低效的代码去优化很可能是在给系统美容而不是在做性能手术。正确姿势是按照调用链逐段加耗时统计或者用性能剖析工具去看CPU都在忙什么、线程都在等什么。搞清楚了再动手一次改动能看到一次真实的收益这种正反馈才是性能优化能持续做下去的动力。1.2 先测量后优化的工具链JMH、async-profiler、Arthas测量工具我常用的有三类各有所长JMH用于微基准测试也就是测单个方法、单段逻辑的性能。比如你想比较两种序列化方式的吞吐或者想知道HashMap的初始容量设置多大更划算用JMH能得到可信数据。它的关键优势是解决了JVM的预热、死代码消除、黑编译等问题直接写main方法来测是不准的。Benchmark BenchmarkMode(Mode.Throughput) Fork(value 1, warmups 1) public void testConcatWithStringBuilder(Blackhole blackhole) { // 待测逻辑 }async-profiler是采样型性能分析工具最擅长的是生成火焰图。线上CPU飙高的时候用它抓一张火焰图哪个方法占用了最多CPU一目了然不需要埋点开销很小非常适合生产环境。Arthas是我在线上排查问题时的首选。它能够动态查看方法调用耗时、查看线程栈、反编译线上代码甚至能够直接在线执行一些诊断命令。很多偶发超时CPU忽高忽低的问题靠Arthas就能在几分钟内缩小范围。这三件套配合起来测量层的拼图基本就齐了JMH负责实验室数据async-profiler负责CPU热度Arthas负责线上问题诊断。后面讲案例的时候我会再详细展开怎么用。2. 代码层面的性能陷阱高频小套路也要讲方法很多人以为代码层面的优化是雕虫小技不如架构调优来得高级。但我的经验是很多性能问题恰恰是从一行不起眼的代码开始发酵的。尤其在业务系统里那些被反复调用的基础方法、工具类、公共组件一旦写得不讲究量变引发质变整体性能就会很难看。这部分的优化逻辑很简单把高频路径上的代码写干净把无意义的开销消灭掉。具体从字符串处理、集合使用、日志和异常三个高频场景说起。2.1 字符串处理StringBuilder、正则与split的代价字符串是Java里最常用的对象之一也是性能问题的高发区。最常见的问题有三个。第一个是循环里做字符串拼接。Java编译器虽然会把优化为StringBuilder但每次循环都会新建一个StringBuilder对象循环次数多的时候对象分配和垃圾回收的开销就上来了。正确做法是在循环外声明一个StringBuilder循环内复用。第二个是正则表达式。String.matches()每次调用都会编译一次正则这个开销远比你想象的大。如果同一个正则要在循环里匹配1000次那就应该预编译成Pattern对象然后复用Matcher。我在实际项目里见过一个接口光是正则编译就占了CPU的15%。第三个是String.split()。它在内部使用了正则表达式对于简单的分隔符比如逗号、竖线其实有更轻量的实现方式。比如自己遍历字符或者用第三方库里专门优化的split。不过这块收益通常没那么夸张除非确实测出来是瓶颈否则不用太纠结。还有一个容易被忽略的点字符串对象的创建频率。每次调toString()、每次做JSON序列化都会产生大量短命对象给年轻代带来压力。像下面这种写法在高频方法里就要避免// 不推荐每次都创建新对象 String msg user: userId |order: orderId; // 推荐只有在确实需要时才拼接 if (log.isDebugEnabled()) { String msg user: userId |order: orderId; log.debug(msg); }这些小技巧在教科书里都有但真正在工程里能坚持写对的人不多。我们优化的目标不是把每行代码都改到性能最优而是把高频路径上的代码改到足够干净。2.2 集合选型初始容量、遍历方式与数据结构选择集合用得好不好直接决定了内存占用和访问效率。这里不谈基本原理只说几个实战中容易踩的问题。HashMap的初始容量别懒省事。很多人写new HashMap()然后在循环里put几百上千条数据。HashMap默认容量是16扩容阈值是0.75意味着插入12个元素就会引发一次扩容扩容要重新计算hash、搬移数组次数多了开销很明显。如果你知道大概的数据量直接指定初始容量。比如要放1000条数据用new HashMap(1344)1000/0.75向上取整可以避免扩容。一个简单的经验值是目标容量 / 0.75 1。LinkedList不是不好但要分清场景。它的随机访问是O(n)的如果代码里经常调用get(index)那性能会非常难看。反过来如果你是频繁地在头部插入删除那ArrayList反而吃亏。很多人无脑选ArrayList或者无脑选LinkedList都是不对的要按访问模式来定。遍历方式也别轻视。在Map上遍历用entrySet()要比分别用keySet()再get()快因为减少了又一次hash查找。这在数据量大的时候差距很明显。另外能用for-each就用for-each避免手动iterator()时多一次方法调用当然现代JIT会把它优化掉但规范一点总没错。还有一点很多人没意识到不要用keySet()去交换Map的键和值除非你真的需要。因为这意味着把整个Map的内容重新散列到新Map里内存和时间都翻倍。如果只是需要根据value找key考虑维护一个反向映射或者用现成的双向Map组件。2.3 日志与异常业务代码里沉默的吞性能大户日志和异常这两个东西对性能的影响是润物细无声的。它们不会让系统立刻崩掉但会把吞吐拉低一截。日志方面最常见的浪费是字符串拼接发生在日志级别判断之前。比如log.info(order{}, user{}, items{}, order.getId(), user.getName(), getItemsDetail(order));即使日志级别是WARNgetItemsDetail(order)这个方法的调用也会执行如果这个方法里做了数据库查询或者复杂计算那这个写日志的操作实际上变成了一次隐蔽的脏活。正确做法是用参数化日志让日志框架自己判断或者先用isDebugEnabled()判断再拼参数。更大的坑是在高频循环里打日志。哪怕日志框架再快高频IO的消耗也会吃掉性能。我之前排查过一个系统明明业务很简单CPU却居高不下最后发现是一个聚合任务每秒在INFO级别输出几百条日志每条日志还要做JSON序列化。把日志级别调高、把无用的日志删掉之后CPU直接降了40%。这个调优成本几乎是零收益却大得吓人。异常方面创建异常对象是一个非常重的操作因为要抓取完整线程栈。很多代码喜欢用异常来做流程控制比如try { return userMap.get(userId); } catch (NullPointerException e) { return defaultUser; }这既丑陋又低效。正确的做法是先判断containsKey()或者直接用getOrDefault()。还有在循环里抛异常再捕获是灾难级的写法遇到这种情况一定要重构。异常的定位应该是真正的意外情况不是常规逻辑分支。3. 并发、内存与IO三大核心维度的工程调优代码层面的优化是微观层面真正能对系统性能产生质变影响的是并发设计、内存管理和数据访问这三大维度。这些地方的优化往往需要结合业务场景来做判断没有放之四海而皆准的参数但有一些共通的思路和底线。3.1 线程池参数不是抄来的是算出来的线程池是Java并发的基础设施但我发现很多团队线程池参数是抄来的要么抄网上的默认配置要么抄别的项目的。这其实是个隐患。不同业务系统的IO密集度和CPU密集度完全不同硬套模板很容易出问题。线程池设计的核心就一句话线程数应该跟任务的阻塞特性匹配。计算密集型任务线程数可以设为CPU核心数1因为线程基本不等待多了反而增加上下文切换。IO密集型任务线程可以多一点因为大部分时间都在等IO网络、数据库、文件并发度可以提高。经验公式是// CPU密集型 corePoolSize CPU核数 1 // IO密集型 corePoolSize CPU核数 * (1 等待时间 / 计算时间)这个等待时间和计算时间的比例不好精确测量但可以估算。比如一个任务里数据库查询占了80%纯计算只占20%那线程数大概可以给到CPU核数的5倍左右。眼尖的读者会发现IO密集型的线程数可以远大于核数这不是错误因为等待期间的线程不占CPU。更重要的两个参数是队列类型和拒绝策略。很多生产事故都出在这两个参数上。ThreadPoolExecutor的执行流程是先跑核心线程核心线程满了就进队列队列满了才开非核心线程都满了走拒绝策略。如果队列是LinkedBlockingQueue且不设上限那非核心线程永远没有机会启动任务会无限积压。看起来系统很稳实际上延迟越来越高最终把内存也拖垮。我推荐的组合是有界队列 合适的拒绝策略。有界队列的容量根据业务能接受的积压量来定拒绝策略优先用CallerRunsPolicy它不会丢弃任务而是让提交任务的线程自己执行起到背压作用让上游感知到系统已经过载。AbortPolicy直接抛异常DiscardPolicy会静默丢任务都要非常谨慎。线程池大小不是越大越好我的体会是宁可稍小一点让任务排队也不要开太大导致上下文切换和资源争抢。线程池的调整一定要配合压测不能光靠理论推算。3.2 锁竞争与并发数据结构从synchronized到无锁化并发场景下的性能优化本质上是减少线程之间的竞争。锁竞争是性能杀手当多个线程争抢同一个锁时线程会经历阻塞、唤醒、上下文切换每一次都是昂贵的操作。Java的synchronized在JDK 6之后做了大量优化从偏向锁到轻量级锁再到重量级锁很多情况下性能是不差的。但它在高竞争场景下仍然会有瓶颈。替代方案有几种各有适用场景**读写锁ReentrantReadWriteLock**适用于读多写少的场景。比如一个配置缓存大部分请求是读偶尔更新。用读写锁能让多个读线程并行访问只有写线程才互斥。**StampedLock**是更高性能的读写锁变体它支持乐观读读线程完全不加锁只在写线程发生时短暂冲突。对付那种读非常多、写极少的场景非常有效。不过它不可重入使用时要小心容易踩坑。并发容器方面ConcurrentHashMap是我见过用得最多的选择它在读操作上完全不阻塞写操作只锁对应的桶并发度很高。CopyOnWriteArrayList适合读多写极少的场景写时复制读时无锁但每次写都复制整个数组代价不小。ConcurrentLinkedQueue是无锁队列适合生产者消费者模型但不如LinkedBlockingQueue容易控制边界。还有一个经验是尽量避免使用全局锁把锁的粒度降到最小。能用分段锁就不用单一锁能只在关键路径上加锁就不要把整个方法锁住。锁的范围越小线程等待的概率越低性能自然就上去了。如果在业务代码里发现synchronized整个方法大概率是可以优化的。无锁化是个更高级的思路通过CAS原子操作或者基于LongAdder等新工具来替代传统锁。这些方案读多写多的情况下反而可能更高效但实现的复杂度会高不少。我建议先从降低锁竞争做起无锁化留到确有必要的时候再做。3.3 对象分配与GC调优别一上来就动JVM参数提到性能优化很多人第一反应是调JVM堆大小、换GC算法。但说实话GC调优应该是性能优化的最后一步而不是第一步。GC本身只是清理垃圾对象的工具如果业务代码本身创建了大量多余对象再好的GC也扛不住。我见过一个项目并发上不去监控显示Full GC频繁于是团队把堆从4G调到了16G结果GC反而更慢了。为什么堆变大垃圾对象更多每次GC扫描的时间更长停顿也更久。真正的解法是减少对象的创建。减少对象分配是有套路的一是复用对象而不是重复创建。比如日期格式化、加密算法能做成静态常量就做成静态常量。SimpleDateFormat本身线程不安全但可以用ThreadLocal包装后复用比每次new一个快得多。二是避免自动装箱。在高频循环里int转Integer、long转Long都会产生新对象。能用基本类型就不要用包装类型能用原始数组就不要用List。这一点在数据处理量大的场景特别明显。三是善用不可变对象与分段大对象。大对象直接进入老年代一旦积累下来就很难被回收。如果一个订单对象塞进了大量字段很多字段其实用不上那就要考虑精简或者用更扁平的数据结构。顺便说一句堆内存里的对象越多GC的吞吐量就越低这是个数学问题不是调优技巧能解决的。真正需要手动调GC参数的时候也请遵循一个原则每个参数都改一次、测一次观察吞吐量和停顿时间的变化。G1是当前主流的默认垃圾回收器适合大堆、停顿时间可控的场景。CMS已经过时不要去新项目里用。如果有低延迟需求可以考虑ZGC但ZGC在高吞吐场景下不一定占优得实测。从工程角度说先做到对象分配克制、再谈GC调优这个顺序是不能反的。否则你调了半天参数内存压力还是被业务代码源源不断地制造出来治标不治本。3.4 数据访问与IO优化连接池、SQL与网络超时的坑数据访问和IO往往是系统性能的最终瓶颈因为外部依赖的延迟远比本地计算高。数据库一次查询要几毫秒到几十毫秒一次RPC调用要几十毫秒一次磁盘IO还要数毫秒这些时间和CPU纳秒级的操作完全不在一个数量级。先说数据库访问。最常见的问题是N1查询先查出一个列表然后循环里再逐条查详情。这个问题的解法很简单用IN查询一次性取出所有详情或者用LEFT JOIN连表查询。但JOIN也不是万能的如果两边数据量都很大JOIN会非常耗时。我习惯的做法是数据量小用JOIN数据量大且分页分别查询再内存组装。SQL索引失效是另一个高频问题。比如给varchar字段加索引查询时却传了数字类型MySQL会做隐式转换索引直接失效。再比如在索引列上做函数运算、模糊查询前置通配符都会让索引变成摆设。这部分建议用EXPLAIN逐个验证执行计划别指望数据库自己优化。再讲数据库连接池。连接池大小不是越大越好。连接数太多数据库服务端要维护大量连接上下文切换加剧连接数太少请求排队等待连接吞吐下降。一个经验值是连接数 ((核心线程数 * 2) 有效磁盘数) 左右但这跟具体数据库规格强相关要实测。用HikariCP的话官方文档推荐的核心思想是连接池宁可小一点也不要大因为连接空闲是资源的浪费。网络调用的超时设置是被小看的问题。很多接口没有设置超时时间或者设置得非常大比如60秒。下游一旦变慢上游线程全部卡在等待上线程池被打满整个应用失去响应这就是典型的线程耗尽事故。我的建议是外部依赖调用的超时时间要分级内网普通接口设200ms慢查询接口设1秒外部第三方接口最多3秒并且要配好重试策略幂等前提下。这类超时设置的优化往往比加服务器更有效。4. 真实案例复盘三个典型性能攻坚战理论讲得再多不如看一个完整案例来得直观。下面这三个案例是不同维度的性能问题都具有一定的代表性分别是SQL与代码层面的查询优化、线程池参数错误导致的故障、以及GC停顿引发的偶发超时。他们都有一个共同特征先通过数据定位根因再针对性优化最终用压测验证结果。4.1 案例一某订单查询接口从3秒到300毫秒这个接口的功能很简单根据用户ID查询用户的订单列表附带订单的商品明细。接口最初版本响应时间大约在2.8到3.2秒之间业务方已经忍无可忍。接手后的第一件事不是看代码而是看调用链。我用Arthas的trace命令对接口做链路追踪很快发现耗时分布极其不均匀其中约2.4秒花在数据库查询上剩下的400多毫秒花在结果组装上。然后继续看SQL执行情况发现两条问题第一查询订单主表时用的条件列user_id没有索引导致全表扫描。看表结构发现这个表已经有三百万行数据全表扫描几乎要把整个表读一遍。解决方法是给user_id加普通索引这条SQL从800ms降到了30ms。第二查询商品明细时使用了循环内逐条查询也就是经典的N1问题。一次查询10个订单就要执行10次商品明细查询也就是10次数据库往返。解决方法是改成一次IN查询批量取出然后内存中组装。这条改动把明细查询总耗时从1.5秒降到了150ms。接下来看那400ms的结果组装发现代码里做了大量的对象转换每个订单对象要转成DTO再转成VO循环里还调用了一次远程配置服务。我把远程配置结果改成了本地缓存对象转换也减少了中间层400ms降到了100ms左右。最后压测结果接口平均响应时间280msP99在450ms左右整体达到了预期目标。这个案例看起来不难但核心思路值得记住先用数据定位哪个环节慢再针对性地改不要一开始就重写整个接口。每一步改动都有实测数据支撑改完立刻验证基本不会走弯路。4.2 案例二批量任务处理中线程池参数不当导致系统假死某个批处理系统负责每天跑数据同步把外部系统的数据拉回来处理后写入数据库。刚上线的时候一切正常跑了几天后开始出现系统假死的报警CPU不高、内存正常但任务就是不推进。接到排查任务后用Arthas的thread命令查看线程栈发现大量工作线程都阻塞在一个LinkedBlockingQueue.take()调用上说明线程池没有足够的任务可以处理但外部显示数据积压严重。进一步检查线程池配置发现是这样写的ThreadPoolExecutor executor new ThreadPoolExecutor( 10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue() );核心线程10个最大线程10个队列无上限。问题就出在这个无上限队列上。在数据量平峰时10个线程够用任务都进了队列处理速度正常。但在数据量高峰期任务源源不断进入队列线程池永远不会创建更多线程因为队列没满没到触发扩容的条件所有任务都在排队10个线程像蜗牛一样处理。看起来系统假死其实是处理能力被队列容量给卡死了。修复方案是把无界队列改为有界队列容量设为5000线程数提高到30数据同步是典型的IO密集型任务等待时间远大于计算时间拒绝策略使用CallerRunsPolicy。参数调整后用历史数据重放压测处理速度提升了3倍积压的数据在半小时内清空。这个案例给出的教训很直白线程池的队列容量不是越大越好无界队列会掩盖系统过载的事实。有界队列虽然可能触发拒绝策略但配合CallerRunsPolicy反而能形成天然的背压机制让任务提交方减缓节奏避免系统被拖垮。4.3 案例三接口偶发超时与GC停顿的关联排查这是一个线上偶发超时问题很让人头疼。某个核心接口平时响应只要50ms但从监控看每天有几十次请求会超时超时时间集中在几百毫秒到一两秒不等。由于发生频率不高排查难度很大。我先用Arthas的trace分析了接口的调用链发现正常路径上各个环节耗时都很稳定唯独偶尔会有一次异常长的停顿。再配合dashboard命令观察JVM状态发现在停顿时间点附近出现了Full GC的标记停顿时间长达800ms到1.2秒。这下基本定位到了偶发超时不是业务逻辑变慢而是GC停顿把整个应用冻结了。继续分析GC日志发现老年代占用率每次Full GC前都很高而且有不少大对象直接进入老年代。排查代码后发现某次需求中引入的一个缓存组件每次请求都会创建一个大数组约5MB用于临时存储请求量一高老年代很快就满了。解决方式有三步第一把临时大数组改为池化复用不再每次创建第二给缓存组件设置合理的过期策略避免无效对象长期驻留第三步配合调整了G1的MaxGCPauseMillis参数把目标停顿时间从默认的200ms改为100ms并适当增大年轻代。修改后Full GC频率从每十分钟一次降到每小时不到一次偶发超时基本消失。这个案例最有价值的地方在于性能问题可以藏在任何一层。看起来是接口慢实际是GC看起来是GC实际是业务代码无节制地创建大对象。排查过程必须一步一步来从现象往下挖直到找到最根本的源头。5. 常见性能问题排查速查与避坑清单这一节把平时工作中最常遇到的性能问题按现象-排查思路-常用工具-典型原因梳理成表方便遇到问题时快速对照。这部分更像一张地图帮你在面对问题时找到方向然后深入到对应工具和技术细节里。5.1 CPU飙升、内存上涨与线程阻塞排查速查现象常用排查手段典型原因应对方向CPU 使用率居高不下topasync-profiler火焰图死循环、正则高频执行、频繁Full GC、序列化开销大分析火焰图找热点方法优化算法、增加缓存、减少对象创建接口偶发超时Arthastrace GC日志Full GC停顿、外部依赖慢、线程池饱和检查GC日志设置合理超时时间观察线程池积压情况线程阻塞严重Arthasthread jstack锁竞争、数据库连接池耗尽、下游服务不返回检查线程栈优化锁粒度调连接池容量拆分外部调用内存持续上涨jmap/Arthasheap dump内存泄漏、缓存无上限、大对象堆积分析堆转储定位泄漏对象增加缓存淘汰策略吞吐量上不去压测工具观察QPS与TP99线程数过少、锁竞争激烈、数据库瓶颈结合压测调整线程池参数优化SQL与索引拆分热点数据这张表不是万能的但能帮你在陷入泥潭时快速恢复方向感。实际的排查过程往往是多个手段交替使用比如先看CPU再抓线程栈先看GC日志再找代码热点一环扣一环。我还想强调一个实用习惯保存每次压测的基线数据。很多团队优化接口前后没有记录数据改完不知道提升多少下次再优化时又得从头测起。我会把每次优化的环境参数、压测并发数、平均响应时间、TP99、QPS等存成一个简单的表格或文档长此以往就形成了一张性能演进地图比任何优化文档都有说服力。5.2 性能优化避坑清单来自一线的经验沉淀最后把这几年调优路上踩过的坑整理一下有些是技术问题有些是流程问题但它们都实实在在地拖慢过我的进度。别只关注平均值要看P99和P999。平均值很容易被少数慢请求拉高但P99才能反映真实用户体验。一个接口平均100ms但P99是800ms问题依然严重必须精确分析P99里发生了什么。压测环境要尽量贴近生产。数据集大小、数据分布、并发模型都不同压测结果可能完全不可信。最典型的是数据量少了几个数量级什么慢SQL都测不出来一上生产就原形毕露。一次只改一个变量。性能优化的基本原则是控制变量。同时改了SQL和缓存和线程池出了问题你根本不知道是哪一步引入的。哪怕再着急也要一个改动一轮压测地来。优化后必须回归测试。有些优化会改变业务行为比如缓存过期策略调整可能导致数据不一致线程池参数调整可能引发拒绝策略触发。性能与正确性永远是并行的不能为了性能破坏业务逻辑。不是所有代码都值得优化。对于一年只跑一次的任务用不着为它抠性能。性能优化的ROI要算清楚优化高频路径、核心接口、关键链路比优化冷门边缘代码有价值得多。我自己在做性能优化时的体会是真正拉开工程师差距的不是会不会用某个工具而是能不能在正确的时间优化正确的地方。这个判断力来自对业务的熟悉程度也来自对性能原理的深入理解。希望这篇文章能帮你建立一套系统化的优化思路下次再遇到性能问题先测量、再定位、后动手高效且扎实地解决问题。
阅读完成 · 觉得有帮助?