很多人一听到“Java性能优化”就觉得是门玄学,以为要么背下一堆JVM参数,要么像做外科手术一样给线上系统打补丁。我在一线写Java十几年,做过从零到一的系统设计,也处理过凌晨三点的Full GC告警,可以负责任地说:性能优化更像一门“工程手艺”,核心不是记住某个参数,而是先搞懂程序运行时到底发生了什么,再用工具找到真实瓶颈,最后用可复现的数据验证每一步改动。这篇文章我尽量用实战的角度讲透,从JVM原理到代码陷阱,从GC调优到并发、数据库、缓存,最后把我平时排查问题用的工具箱和完整流程一起列出来。不管你是刚入门的Java开发,还是写了两三年但总觉得优化无从下手的中级工程师,甚至是准备Java面试的同学,这里面的很多点都能直接用上。
1. 先弄懂运行时在做什么,再谈优化方向
1.1 一次请求背后,JVM到底在忙什么
很多性能问题其实不是代码逻辑有问题,而是我们根本不了解JVM在处理一次请求时做了什么。一个方法调用的背后,JVM要完成类加载、字节码验证、栈帧创建、对象分配、垃圾回收、线程调度等一系列动作。你写的每一行代码都会映射到这些底层机制上,性能问题自然也从这些机制中来。
先说内存区域。线程执行方法时,每个方法对应一个栈帧,里面有局部变量表、操作数栈、动态链接、返回地址。对象本身是分配在堆里的,局部变量只是持有对象引用。也就是说,你new出来的对象不会因为方法结束就立刻消失,它要等GC来回收。方法区在JDK 8之后改成了元空间,存放类元信息,默认只受本机内存限制,但类的总量太大同样会撑爆。
理解这一点后,很多问题就能对上号。比如频繁创建对象的代码,表面上看只有一行new,实际是在不停往堆里塞垃圾,给GC制造压力;再比如递归没有终止条件,会不停压栈帧,最终抛出StackOverflowError。这些都是运行时机制直接导致的性能隐患。所以,做性能优化的第一步不是调参数,而是先建立JVM内存和线程的直观认知,知道自己的代码在运行时占用了哪些资源。
1.2 JIT编译与热点代码:一把双刃剑
JVM里有一段非常容易被人忽略的性能机制:即时编译(JIT)。HotSpot虚拟机默认先以解释器模式运行字节码,同时统计方法调用次数和循环回边次数,某个代码段达到阈值之后,JIT就会把它编译成本地机器码,这就是热点检测。JDK 8之后默认开启分层编译,C1负责快速编译,C2负责深度优化,配合使用可以在启动速度和峰值性能之间取得平衡。
JIT能让一段看似低效的代码在运行一段时间后变得高效,但也会带来一些反向问题。首先是“去优化”现象,如果JIT基于错误的假设做了激进优化,比如认为某个字段永远不会变化,结果字段变了,JVM只能废弃已编译代码退回解释执行,这个过程本身有开销。其次是多态方法调用,接口调用在热点代码中会被JIT做内联优化,但调用点有多个实现类时,内联会变得复杂,严重时会影响性能。
这些机制告诉我们什么?代码写得清晰、单一职责、避免在热点路径上出现频繁的分支切换,其实就是在帮JIT做优化。我见过一个老系统,一个方法里几十个if-else来区分业务类型,压测时TPS一直上不去,后面拆成策略模式后就明显改善,本质就是让JIT更容易内联和优化,而不是每走一次都做很长的分支判断。
1.3 版本选择:换JDK本身就是性能优化
提升Java性能最简单、ROI最高的事情,可能是升级JDK版本。很多人还在线上跑着JDK 8,不是说不行,但JDK 17、21在运行时性能、GC、内存占用、启动时间方面都有明显进步。比如JDK提供的ZGC在低延迟场景下可以用毫秒级暂停回收大堆,而JDK 8里只能靠G1硬扛;再比如虚拟线程在JDK 21正式发布,用同步写法也能扛住高并发,减少了线程池调优的复杂度。
我实际迁移过几个服务从JDK 8到JDK 17,什么都不改的情况下,接口P99延迟有时能下降10%到20%,主要收益来自JIT优化、GC改进以及基础库的性能修正。如果你还在维护老系统,建议至少先在预发环境验证一次,重点看GC停顿和启动时间的变化。不要觉得升级是架构师的事,这其实就是最省事的一次性能优化。
2. 代码层面的性能陷阱:我几乎都踩过
2.1 字符串拼接:从一次200ms的接口事故说起
先讲一个真实案例。有一个报表导出接口,负责把几千条记录拼成一个CSV格式的大字符串,最初代码是这么写的:
String result = ""; for (Record r : records) { result += formatRecord(r); result += "\n"; } return result;线上接口平均耗时200多毫秒,数据量再大一点就超时。原因很简单:String是不可变对象,循环里每执行一次+=,都要创建一个新String对象,还要把旧字符串内容复制一遍。5000条记录就是5000次字符串创建和复制,时间复杂度和空间浪费都很大。改成StringBuilder之后,接口耗时直接降到30毫秒左右:
StringBuilder sb = new StringBuilder(records.size() * 64); for (Record r : records) { sb.append(formatRecord(r)).append('\n'); } return sb.toString();注意我设置了初始容量,因为StringBuilder默认容量只有16,扩容也要复制数组,一次性估算好容量能再省一点。这个案例最典型的启示是:性能问题的根子往往不在框架层,而在最简单的代码习惯上。所有开发者都应该把“循环内字符串拼接用StringBuilder”当成肌肉记忆,同时不要忽略初始容量的设置。String.format、日志中的"xxx" + user这类写法,在高频调用下都会产生同样的隐形成本。
2.2 集合选型与容量设置:别让HashMap频繁扩容
Java集合是日常开发使用最多的类库,但用错集合、不设置初始容量,带来的性能开销非常隐蔽。拿ArrayList和LinkedList来说,ArrayList基于数组,随机访问O(1),尾部追加通常O(1),但中间插入删除要搬移元素;LinkedList基于双向链表,头尾操作O(1),中间插入也是O(n)因为要遍历找位置,再加上每个节点都有额外对象头,内存占用明显更高。所以除非明确需要频繁头尾增删,否则优先ArrayList。
HashMap的默认负载因子是0.75,初始容量16。当你不断put数据,元素数量达到容量×0.75就会触发扩容,扩容要重新计算hash,重排所有元素,这是很大的开销。我见过一段代码:循环往HashMap里放一万条数据,没设初始容量,扩容发生十几轮,GC压力也上来了。解决办法非常简单,估算最大元素数,除以0.75再向上取整,直接写成初始容量:
int expectedSize = 10000; Map<String, Object> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);还有一个高频选型问题是线程安全。HashMap在多线程put时可能造成数据错乱和死循环,不要图方便加个synchronized包一层,在并发场景直接用ConcurrentHashMap。ConcurrentHashMap内部通过CAS加锁和分段思想实现高并发读写,性能远超全局锁。另外,对集合做只读操作时,可以考虑用不可变集合,避免防御性复制带来的额外开销。
2.3 日志、异常与序列化的隐性成本
日志、异常、序列化这三样东西,日常写代码基本都会碰到,但它们的性能成本经常被忽略。
日志如果不加控制,等于在正式代码里埋雷。最典型的写法是log.debug("用户信息:" + user),注意这里的字符串拼接在方法调用发生之前就已经执行了,哪怕日志级别根本不会输出debug,拼接工作也白白做掉了。正确做法有两种:一种是在外面先判断logger.isDebugEnabled(),另一种是使用参数化日志占位符,比如log.debug("用户信息:{}", user),SLF4J在日志级别不满足时不会执行字符串拼接和toString,能省下不少无谓开销。异步日志也是大流量系统里的必备手段,把日志写入动作放进单独线程,避免阻塞业务线程。
异常创建的开销远大于普通对象创建。抛出异常时要填充完整调用栈,StackTraceElement数组能撑到非常大,在高频路径上把异常当业务分支用,比如用try-catch控制流程、用异常判断某个值是否存在,会显著拖慢系统,同时导致大量栈内存分配和GC压力。正确的做法是能用条件判断就用条件判断,异常只留给真正的异常场景。如果确实需要在循环中捕获异常,至少把异常对象复用。
序列化的坑也很深。JDK原生Serializable性能非常差,生成的二进制流还带有大量类信息和类型标记,体积又大又慢。我曾经优化过一个RPC接口,直接换成Kryo之后,同样的对象序列化耗时下降了80%左右。如果你在走HTTP或者JSON协议,Jackson/Gson足够用;追求极致性能可以选Protobuf、Kryo,但要注意兼容性和调试成本。
2.4 IO与文件读写:大数据量下的隐形瓶颈
IO操作是最容易被性能问题盯上的环节。文件读写如果采用逐行或逐字节的处理方式,性能会非常差,因为每个小的读写调用都可能触发用户态到内核态的切换。解决办法是加缓冲,BufferedReader或者BufferedInputStream,原理是一次性把一大块数据搬到内存,再由内存慢慢读,极大减少系统调用次数。我实测一个大文件解析场景,改成缓冲之后耗时从原来的十几秒降到两秒左右。
FileChannel的transferTo和transferFrom方法还能进一步利用操作系统零拷贝能力。它的原理是数据从文件内核缓冲区直接发送到Socket缓冲区,避免经过Java堆内存复制一次,非常适合做文件下载、网关转发之类的场景。另一个经常被忽视的IO问题是NIO下的缓冲区分配。ByteBuffer.allocateDirect分配堆外内存,能减少一次堆内到堆外的拷贝,但分配和释放比堆内缓冲区更昂贵,适合反复复用的大块Buffer场景,不适合小对象频繁创建。IO优化没有一个放之四海而皆准的答案,核心原则是把数据搬运次数降到最低,把每次读写的批量尽量拉大。
3. 深入GC调优:堆该设多大,回收器怎么选
3.1 JVM内存参数的正确打开方式
很多人调JVM参数全凭搜索引擎,抄一段配置就往线上扔,这是很危险的。GC调优的第一步不是去设置各种冷门参数,而是先明确:你到底要给堆多大、给元空间多大、什么时候留下诊断现场。
堆大小设计的第一个原则,是把-Xms和-Xmx设置成同样大小。JDK默认初始堆和最大堆不一样,会导致系统运行过程中堆不断扩容收缩,每一次扩容都是在申请内存,收缩之后下次再申请,整体开销比固定堆更大,GC也会变得更不稳定。我一般会估算应用活跃数据量,加上峰值缓冲,然后设置固定堆。比如容器内存16GB,JVM堆留8GB,元空间上限1GB,线程栈和堆外内存还要占掉不少,不能把容器内存全塞给堆,否则会出现堆外内存溢出。
调试参数也建议提前打开。-XX:+HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath这两个参数让系统在OOM时自动生成堆转储文件,这是事后排查的关键证据。GC日志建议保留并做滚动清理,毕竟没有日志就没有判断依据。
-Xms8g -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/logs -Xlog:gc*:file=/opt/logs/gc-%t.log:time,uptime,level:filecount=10,filesize=100m3.2 回收器选型:CMS已经过时,要看清G1和ZGC的适用边界
早期的CMS在JDK 9之后被标记为废弃,JDK 14就彻底移除了。现在主流的实际选择是G1和ZGC,但很多人并不清楚它们的区别,只知道“新一点的就对”。
GC选型本质是吞吐量、暂停时间、内存占用三者的权衡。G1通过把堆拆成多个Region,用可预测的暂停时间模型来处理回收,适合大部分Web服务场景,也是JDK 9到JDK 21的默认回收器。ZGC的目标则是超大堆、低暂停,它把大部分回收工作和应用线程并发完成,能做到几毫秒甚至更短的暂停时间,适合堆能达到几十GB、对延迟要求极高的系统,比如高频交易后台、在线游戏服务端。但ZGC不是银弹,它的CPU开销不低,在8核以下的机器上收益可能不明显,甚至导致吞吐量下降。
| 垃圾回收器 | 暂停特性 | 适用场景 | 备注 |
|---|---|---|---|
| Serial | 暂停较长 | 小型客户端应用 | 单线程回收 |
| Parallel | 可控制但偏高 | 批处理、吞吐优先 | JDK 8默认 |
| CMS | 并发暂停较短 | 老年代回收 | 已废弃,碎片化严重 |
| G1 | 可预测暂停 | Web服务、大堆通用 | JDK 9+默认 |
| ZGC | 极短暂停 | 超大堆、低延迟服务 | JDK 15+转正 |
我对大多数服务的技术建议很简单:默认G1,只调暂停时间目标,比如-XX:MaxGCPauseMillis=200。如果堆真的冲到几十GB,暂停坚持不下来,再考察ZGC。不要一上来就把回收器换掉,GC调优的工具属性是“让现有代码更稳定”,不是让烂代码起死回生。
3.3 一次Full GC导致线上抖动的完整排查
有个真实案例我印象很深。某个订单服务的后台定时任务每天早上八点跑,正好和用户上班高峰撞上,服务开始频繁超时,监控里看到CPU飙到80%,Young GC间隔从几秒缩短到不到一秒,还会间隔十几分钟出现一次Full GC。
排查思路是这样的:先用jstat看GC频率和耗时,jstat -gcutil pid 1000,连续采集几秒,确认Full GC次数在持续上涨。接着用jmap -dump:format=b,file=/tmp/heap.hprof pid做堆转储,然后下载用MAT打开。分析结果的思路不是看哪个对象占内存最多,而是看持有链:一个对象是谁创建出来的,为什么一直活着。最后定位到代码里有一个静态Map,缓存了最近7天的用户行为记录,value是一个不断膨胀的ArrayList,容量没有上限。这个Map被定时任务在遍历时清理,但清理逻辑只在某个特定条件下触发,导致数据无限累积。
修复方案很直接:给缓存加一个上限,达到1000条就熔断不再写入;把数据落到数据库或Redis,不再堆积在内存里。改完之后Full GC彻底消失,接口延迟也回到正常。这个案例最大的价值是提醒我们:很多GC问题不是JVM参数不对,而是应用层把JVM当成了无限内存的存储,写缓存和队列时没有边界。
3.4 经典OOM类型与dump分析指南
OOM不只有堆内存溢出一种,排查前先分清类型,方向才不会错。堆溢出最常见,具体表现是java.lang.OutOfMemoryError: Java heap space,要重点看堆转储里的对象持有链;元空间溢出通常是类加载器泄漏或者动态生成类太多,报错是Metaspace;栈溢出是StackOverflowError,对应无限递归;堆外内存溢出表现为Direct buffer memory,原因是用了ByteBuffer.allocateDirect没有释放内存,或者用了非堆内存的开源组件。
不同OOM类型对应不同的排查工具。堆溢出用MAT或JProfiler做dump分析,重点找大对象和它的GCRoot引用链;元空间溢出用-XX:MetaspaceSize和-XX:MaxMetaspaceSize观察曲线,容器里还要看类加载器数量;堆外内存溢出比较隐蔽,可以用NMT(Native Memory Tracking)启动-XX:NativeMemoryTracking=summary,观察堆外区域增长趋势。我经常看到有人堆OOM后反复加-Xmx,如果元空间或DirectBuffer泄漏,怎么加堆都没用,反而让GC压力更大。先定位OOM发生在哪块空间,再决定用什么手段止损。
4. 并发场景下的性能杀手:锁、线程池与内存泄漏
4.1 synchronized到底慢在哪,怎么判断该换锁
Java并发开发最常用的是synchronized,很多人对它有个刻板印象:加了synchronized就会变慢。实际不是这样,synchronized在JDK 6之后做了大量优化,无竞争时只是CAS方式置一个标记,开销很小;真正慢的是锁竞争激烈时,锁会膨胀为重量级锁,依赖操作系统互斥量,此时线程可能被挂起和恢复,上下文切换的成本非常高。ReentrantLock基于AQS在用户态自旋和排队,在竞争激烈的场景表现往往更好,还支持超时、中断和Condition,但两者在低竞争下的性能差距很小。
更重要的优化思路是减少锁竞争,而不是换锁。常见的做法是缩小锁粒度,比如分布式库存扣减不锁整个商品的库存对象,而是拆分到多个库存分区;读多写少的场景,用ReentrantReadWriteLock或StampedLock代替排他锁;单纯统计计数,用LongAdder代替AtomicLong,让它把竞争分散到多个累加单元里。我见过一个订单系统,把一把全量锁拆成20把分段锁之后,并发能力涨了近三倍。真正的性能瓶颈通常不是锁本身,而是锁保护的临界区太大、太频繁。
4.2 ThreadLocal:用起来很爽,忘清理就是事故
ThreadLocal很适合做线程内状态传递,但它在配合线程池使用时有个非常经典的坑。ThreadLocal的原理是每个线程里有一个ThreadLocalMap,key是ThreadLocal对象本身,value是你放进去的数据。问题在于ThreadLocal的key是弱引用,如果没有强引用指向这个ThreadLocal,GC回收key之后,value还留在Map里,而且Map的key位置是null。线程池里的线程是长期存活的,这些null value就永远占着内存,越积越多,造成内存泄漏。
更严重的还有数据串号问题。比如一个请求进来,你把用户ID放到ThreadLocal里,但请求结束后忘了remove;线程池下一次处理其他用户的请求时,用的还是同一个线程,ThreadLocal里保留了上一个用户的ID,查询结果就错乱,别人能看到不该看的数据。这种问题特别难排查,因为它不是必现,要有并发量才会触发。我给自己定的规矩是:ThreadLocal的set和remove必须成对出现,并且remove要放在finally里。
ThreadLocal<String> userIdHolder = new ThreadLocal<>(); try { userIdHolder.set(userId); // 业务逻辑 } finally { userIdHolder.remove(); }如果涉及异步线程间传递上下文,可以考虑使用TransmittableThreadLocal这种专门解决线程池上下文传递的组件,它能在fork线程时自动拷贝,任务结束再恢复,比自己在代码里逐个传安全得多。
4.3 线程池参数:无界队列是内存炸弹
使用线程池时踩坑最多的地方,就是任务队列设置成了无界队列。Executors.newFixedThreadPool内部用的是无界LinkedBlockingQueue,当任务提交速度远超处理速度时,队列会无限堆积,占满堆内存,最终OOM。更麻烦的是,由于队列永远没满,线程池不会创建更多线程,也不会触发拒绝策略,明明系统已经很堵了,还是看不到任何异常,只看到内存不断上涨。
线程池的参数要根据业务特性来定。IO密集型任务,线程数量可以大一点,常见的估算是CPU核数乘2左右,因为线程大部分时间在等待IO;纯CPU计算任务,线程数控制在CPU核数加1附近,过多反而增加上下文切换。队列建议使用有界队列,比如ArrayBlockingQueue,容量根据系统能容忍的积压数据量来算。拒绝策略默认的AbortPolicy会直接抛异常,可以根据业务用CallerRunsPolicy,让提交任务的线程自己去执行任务,起到天然限流的作用,但调用方会因此变慢,需要做好心理准备;DiscardPolicy和DiscardOldestPolicy虽然不抛异常,但会静默丢任务,在重要业务里要慎重。先想清楚任务提交峰值、处理速度、允许丢弃程度,再定参数。
4.4 伪共享、LongAdder与并发容器选型
这几个词可能听着陌生,但并发性能优化绕不开。伪共享是指多个线程修改不同变量,但这些变量恰好落在同一个CPU缓存行上,导致一个线程修改自己的变量时,反而让其他线程的缓存行失效,每次都要重新从主内存加载,性能下降非常明显。JDK提供了@Contended注解来给字段加填充,让不同变量进入不同缓存行,需要配合-XX:-RestrictContended参数使用,因为默认不生效。不过日常业务遇到伪共享的机会不多,通常出现在框架源码和自研低延迟组件中。
LongAdder和AtomicLong的对比则是很实际的选型问题。AtomicLong在高并发下只有一个value做CAS操作,竞争越激烈失败重试越多;LongAdder内部维护一组Cell数组,把累加操作分散到多个槽位,最后求和。如果业务只需要最终计数准确,比如接口请求量统计、订单量统计,LongAdder明显更合适;但如果需要基于当前计数值做判断,比如多线程抢购时的库存校验,AtomicLong的强一致性才够用。
并发容器的选型也不能偷懒。ConcurrentHashMap是主力,适合高频并发读写;CopyOnWriteArrayList适合读远多于写、写操作频率极低的场景,它的写操作会复制整个底层数组,所以只要有频繁写就别用;BlockingQueue在生产者消费者场景里通常是标配,但也要注意容量限制,无界队列的问题前面已经说了。总之一句话:并发性能优化的本质是减少共享,做不到减少共享,就要想办法分散竞争。
5. 数据库与缓存:接口慢,大多慢在下游
5.1 一条慢SQL拖垮数据库的经典案例
应用自身优化到一定程度以后,性能瓶颈往往转移到最下游的数据库。让我印象深刻的是一次数据库CPU持续100%的事故,起因是一条统计SQL,在create_time字段上用了date(create_time)函数做条件,写法是where date(create_time) = ?。结果就是这个函数让索引失效,MySQL只能对整张六千万行的表做全表扫描,一次查询就把数据库CPU打满,连带所有业务接口全变慢。
优化方式是把它改成范围查询:where create_time >= ? and create_time < ?,这样就能直接利用create_time列上的索引,扫描行数从几千万降到了几百。这个案例说明,SQL优化的第一步不是加索引,而是先学会看执行计划,用explain确认是否走了索引、扫描行数多少。索引失效的常见原因还有隐式类型转换、对索引列做运算、前导模糊查询等,排查时要留意。数据库CPU突然飙升时,先用慢查询日志或performance_schema找到那条消耗最大的SQL,不要急着重启数据库,重启解决不了根因。
5.2 ORM的N+1查询:循环查库害死人
JPA或者MyBatis开发时,N+1查询是特别容易踩的坑。业务上经常这样写:先查出一批用户ID,然后在循环里调用findById查用户详情,结果是100条数据要执行101次SQL。看起来代码很清晰,一次主查询加100个单条查询,可实际上数据库和网络的开销都被放大了几十倍。我优化过一个用户列表接口,把循环内的查询改成先批量查出ID,再用where id in (...)一次查出所有数据,接口耗时从900多毫秒降到不足80毫秒。
要避免N+1,核心是保持批量意识。在循环里写数据库查询、在循环里调用远程RPC之前,先停下来想一下有没有办法合并成一次。ORM的懒加载也是N+1的重灾区,JPA环境下要主动使用EntityGraph或者fetch join;MyBatis下要注意collection嵌套查询。还有一个容易忽略的点是批量插入,MyBatis可以在一条语句里拼多个value值,而不是每行数据执行一次insert,对大数据量导入的加速非常明显。接口一旦涉及到列表查询,都应该检查这个列表查询会产生多少条SQL,这个数字会告诉你答案。
5.3 缓存的穿透、击穿、雪崩处理
缓存是数据库最好的朋友,但缓存本身也有三个典型问题:穿透、击穿、雪崩。穿透是查询一个不存在的数据,缓存和数据库都没有,请求会直接打到数据库;击穿是某个热点key在缓存过期瞬间,大量请求一起涌向数据库;雪崩是大批量key在同一时间失效,或者缓存服务本身不可用,导致数据库压力瞬间打满。
处理穿透,比较常用的办法是缓存空值,给不存在的key也设置一个短过期时间,或者使用布隆过滤器在缓存前做一层存在性判断,过滤掉那些必然查不到数据的请求。处理击穿,关键是让重建缓存的过程互斥,单机可以用锁,分布式场景用Redis分布式锁或本地锁再加双重检查,只让一个请求去查数据库重建缓存,其他请求等待缓存重新生效。处理雪崩,最实用的是给过期时间加随机值,避免同一时刻成批失效;同时核心数据尽量做多级缓存,本地缓存扛第一波,Redis扛第二波。缓存更新的模式要选对,业务里常见的先删除缓存再更新数据库、先更新数据库再删缓存,都会产生短暂不一致。Cache Aside模式是比较稳妥的做法:读时缓存未命中就读库并回填,写时先更新数据库再删缓存,删缓存失败时要考虑延迟双删或者消息补偿。缓存是加性能的,但缓存本身的稳定性也要当成生产级问题对待。
5.4 深分页问题:offset越大越慢
MySQL分页查询有一个非常隐蔽的性能问题:limit offset, size,当offset非常大的时候,比如limit 1000000, 20,数据库仍然要扫描到第1000020行才开始返回,前面100万行的扫描全部是浪费。我优化过一个订单后台列表,翻到后面几页时接口稳定卡在5秒以上,SQL很简单,就是order by id limit巨大的offset。
解决办法有两种。最常见的是延迟关联:先查出主键,再回表取完整行数据。原来直接select所有字段,改成select * from orders t join (select id from orders order by id limit 1000000, 20) tmp on t.id = tmp.id,让数据库在索引上完成分页扫描,再回表取数据,效率会高很多。另一种是游标分页,也就是说记住上一页最后一条记录的ID,用where id > ? order by id limit 20来查询下一页,这种方案完全避免offset扫描,但要求排序字段稳定且唯一,实际业务中非常适合按时间倒序的Feed流和评论列表。我自己做分页接口的习惯是,能走游标就走游标,深分页加日期范围限制,否则就把延迟关联作为兜底方案。
6. 一套可复用的性能诊断工具箱与流程
6.1 常用诊断命令与Arthas实战
排查Java性能问题,命令行工具是第一道防线。jps可以快速列出Java进程及PID;jstat最适合观察GC,比如jstat -gcutil 1234 1000每秒输出一次GC情况;jmap用于内存分析,可以jmap -histo查看对象统计,也能jmap -dump做堆转储;jstack输出线程栈,是排查死锁和线程卡死的利器。生产环境直接用jmap -dump要小心,进程会暂停一下,最好在低峰期操作。
Arthas是我用得最多的线上诊断利器。它的优势是不用重启服务就能排查问题,很多场景不需要先写代码加埋点。dashboard命令进入后能实时查看线程、内存、GC、类加载信息;thread -n 3直接打印CPU占用最高的前3个线程,快速定位繁忙线程;jad可以反编译线上类,确认运行的代码是不是和本地一致;watch观察方法入参和返回值,排查参数异常;trace统计方法各步骤耗时,比在代码里打日志灵活得多。火焰图工具async-profiler也值得掌握,它可以采样CPU调用栈并生成火焰图,一眼看出CPU时间到底烧在哪个方法的哪条调用链上。工具有很多,关键是形成自己的完整套路:进程状态、线程状态、内存状态、方法耗时,一层一层向下钻。
6.2 JMH基准测试:用数据代替直觉
性能优化最怕的是什么?凭感觉说这段代码慢、那段代码快。JMH就是用来做Java微基准测试的标准工具,它可以消除JIT预热、死代码消除、测量误差等问题。我在优化字符串拼接方案时就是靠JMH说服了同事,代码大概是这样的:
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @Fork(1) public class StringConcatBenchmark { @Benchmark public String concatWithPlus() { String s = ""; for (int i = 0; i < 50; i++) { s = s + i; } return s; } @Benchmark public String concatWithBuilder() { StringBuilder sb = new StringBuilder(128); for (int i = 0; i < 50; i++) { sb.append(i); } return sb.toString(); } }运行JMH方法很简单,引入相关依赖,main方法里用OptionsBuilder指定include方法名和输出文件,跑完会生成一份详细报告,包含每种方法单次调用平均耗时。实测下来,50次循环里字符串拼接用+会比StringBuilder慢一个数量级,这就成为改造的有力证据。需要提醒的是,JMH测的是微基准,结果只能代表当前CPU环境下的相对差异,不能直接照搬到真实业务里,真实系统还要靠压测和监控来验证。
6.3 我一直在用的性能优化工作流
优化工作看起来千头万绪,实际能成体系。我自己的做法是这样的:先看监控建立基线,把现网或者压测环境下的QPS、延迟、CPU、内存、GC、数据库慢查询记录下来;然后根据瓶颈现象缩小范围,CPU高先看线程栈和火焰图,内存高先看GC日志和堆转储,接口慢优先看跨进程调用链路;定位到具体代码或SQL后,做单点修改,每次只改一处,改完立刻回归验证,对比基线和优化后的数据。最忌讳的做法是上来就同时调一堆JVM参数、改一堆代码,出了问题根本不知道是哪一步引起的。
还有两个原则很重要。一个是先做低成本高收益的调整,比如优化循环内字符串拼接、修掉N+1查询、把无界队列改有界队列,这些改动风险小见效快;另一个是JVM参数放最后调。我见过有人一上来就把-Xmx翻倍,结果GC暂停更长,业务没变快反而超时加剧。GC调参是止血手段,代码质量才是根本。整个优化过程中,每次改动都最好能留档,记录改动内容、原因、验证结果,既方便复盘也能避免重复踩坑。
6.4 踩坑多年后的一些“反直觉”体会
做了这么多年性能优化,有一个反直觉的体会想分享出来:性能优化最大的瓶颈,往往不是代码本身,而是数据流和架构设计。很多系统的性能问题表面上是接口太慢,实际上是一层套一层的同步RPC调用,或者一个请求触发了几十次数据库查询,或者反复做大数据量的对象拷贝。在这些问题面前,局部代码再怎么微调也是杯水车薪,真正有效的优化是从整体数据路径上砍掉不必要的开销。
另一个感受是,性能优化里最容易出现的错误不是技术不会,而是改完不看效果、不做回归。一次优化做完,压测数据反而更差了,就要回头审视是不是引入额外开销,比如加了缓存但缓存读写的序列化消耗比直接访问数据库还高。性能优化是持续迭代的过程,没有一劳永逸的终极方案,保持用数据说话的习惯,比记任何参数都管用。
最后再分享一个小建议:所有人都不妨在平时写代码时,刻意练习一种“性能敏感性”,看到循环就想到复杂度,看到集合就想到容量,看到IO就想到批量,看到线程池就想到队列边界,看到缓存就想到失效策略。这些习惯积累起来,比出问题时再临场诊断强十倍。