有朋友来问“Java 性能优化到底该从哪里上手”我第一反应不是甩给他一堆工具清单而是反问他你现在的系统究竟慢在哪里这个问题很基础但能劝退一大半人。因为多数性能优化做不下去不是缺招数而是根本没找到瓶颈。没见过谁靠猜能稳定地把接口从2秒优化到200毫秒。真正靠谱的做法是先度量、再定位、然后改动、最后压测确认每一步都带数据。这篇文章我就按自己平时排查问题的习惯展开一条Java性能优化路线覆盖代码热点、JVM参数、并发锁和线上问题定位适合已经能写Java、但面对性能问题还没有系统思路的开发者。1. 先度量再优化没有数据一切调优都是耍流氓1.1 为什么那么多性能优化在“白忙”我见过太多团队做性能优化的方式业务方反馈接口慢马上有人说“加个缓存”有人说“把机器配置升高”还有人直接换框架。一顿操作后效果可能有一点点但谁也说不清是哪个动作起的作用。这种“碰运气优化”的问题在于不同瓶颈的表现非常相似。比如接口偶尔超时可能是Full GC停顿可能是数据库慢查询也可能只是某个第三方接口抖动。这三种原因对应的解法完全不同不看数据就动手基本等于蒙眼开车。做Java性能优化第一步永远是搞清楚“时间花在了哪里”。是CPU忙着算还是在等待锁又或者是内存不够导致频繁垃圾回收。这些信息不会自动出现在日志里必须主动去采集。等数据出来后优化方向往往自己就浮出来了。很多时候根因比想象中简单一条被忽略的SQL、一个看着很正常的循环、一个没有及时清理的缓存。把时间花在找根因上比盲目改代码有效得多。1.2 常用工具怎么配合使用指标与场景对照工具不在多而在于能回答当前的问题。以我的习惯拿到一个性能问题会先开JFR或Arthas采样几分钟同时看GC日志和线程状态。如果CPU高立刻抓火焰图如果内存高堆转储跑不掉。下面是日常用得最多的组合现象工具能看出什么CPU使用率飙高async-profiler / Arthas热点方法、火焰图、线程运行状态频繁Full GCjstat GC日志各代晋升、停顿时间、导致GC的对象来源线程卡死或锁竞争jstack线程状态、持有锁的对象、阻塞位置内存缓慢上涨jmap MAT对象分布、泄漏嫌疑、引用链接口时快时慢JFR 应用日志GC停顿、IO等待、方法耗时分布这套组合不需要一次性全上。比如遇到CPU高先上火焰图找到最热的调用链再沿着调用链去看代码、看SQL遇到内存上涨重点看全GC的频率和堆对象的增长曲线。记住一个原则先区分CPU忙、内存紧、IO慢、锁等待四种大类别再决定用什么工具深入。方向错了工具越强越容易看花眼。1.3 建立基线别让优化变成“自我感觉良好”有段时间我帮同事调一个接口他改完代码后和我说“感觉快多了”我让他拿压测数据出来结果比原来还慢。这类事很常见生产环境流量波动大人工点几下页面的“快慢”根本不可信。所以我在动手优化前一定会先做一轮压测或者采集一段监控数据记下关键指标作为基线。优化一个变量后在同环境、同负载下再跑一遍用数据确认有没有变好。压测工具不需要复杂。接口层面用wrk或ab就够方法级别要精确对比可以用JMH。关键是控制变量同一台机器、同样的参数、同样的请求量一次只改一项。否则出了问题都不知道是哪个改动引起的优化成果也难以沉淀成经验。我甚至会在优化列表里写一行“改动前TPS、改动后TPS”别嫌麻烦这些数字就是后续所有判断的底气。2. 代码层面的热点优化在成本爆发之前把它摁住2.1 数据结构选型复杂度不是唯一的答案代码层最值得花时间的是数据和算法选型。这个优化改的是“源头成本”比后面调几个JVM参数更直接。比如要判断一个元素在不在集合里HashSet的查找是O(1)而ArrayList的contains是O(n)。数据量小的时候差别不大但一旦到了几十万、上百万条性能差距就是天壤之别。我见过有人用ArrayList存所有用户ID每次请求都扫一遍集合接口慢到超时换成HashSet后问题立刻消失。但要泼一盆冷水别为了“优雅”去过度优化。如果只是处理十几个元素的列表花大量时间改成复杂哈希结构反而得不偿失。数据结构选择不是“越高级越好”而是“按真实数据量预估后够用”。复杂度分析的常数项同样重要HashMap的哈希计算有开销TreeMap每次插入要维护红黑树。真实的取舍应该基于业务数据规模做一个估算再决定要不要换结构。最好的证据就是压测拿真实数据跑一下谁快用谁。2.2 字符串与集合操作里容易被忽略的细节字符串处理是Java代码里最常见的隐藏开销。一个典型场景是循环拼接字符串。虽然编译器会把若干次“”优化成StringBuilder但在循环里拼接还是会产生大量中间对象。我曾经优化过一个导出报表的接口原本在循环里不断执行str value几万行数据拼得GC频繁改成单独创建StringBuilder并预置容量后GC次数立刻降下来。另一个容易忽略的是String.format它在日志和报文拼接里很常见但每次调用都有格式化开销如果处在高频路径用简单拼接或参数化日志代替更好。集合的初始容量也值得一提。HashMap默认容量16扩容阈值是0.75也就是说插入到12个键值对就可能触发扩容扩容需要重新计算哈希并复制数组。如果提前知道数据量构造时直接指定初始容量可以省掉多次resize。ArrayList同理频繁add会不断触发扩容拷贝。这类优化不看代码看不出效果但它确确实实减少了分配和拷贝高频场景下收益会被放大。不要嫌麻烦这属于成本最低、收益最直接的一类优化。2.3 减少对象分配让GC压力从源头降下来在Java里对象分配本身很快但分配得太多会推高GC压力。所以“减少分配”不是抠门而是给垃圾回收器减负。最典型的优化点有三个一是循环内部避免new对象尽量把对象提到循环外复用但要注意复用对象导致状态残留二是能使用原始类型的地方就用原始类型比如计数器、累加值包装类型的自动装箱会增加分配三是避免把大字符串或大数组频繁创建为临时对象。这些都是能让GC“慢下来”的办法。我自己的体会是关注“分配率”比关注“单次分配大小”更重要。线上可以看GC日志里各个区域分配和晋升的速度如果增长曲线很陡多半是有代码在疯狂new对象。定位到热点后再考虑复用或者缓存。但必须提醒一句对象池并不是银弹。频繁创建小对象时现代JVM的逃逸分析可能直接把它分配在栈上完全没有GC压力硬加对象池反而增加复杂度。先用JMH做个小实验再决定要不要上。3. JVM参数调优先把原理弄明白再去动旋钮3.1 为什么不要一上来就堆参数JVM调优是Java性能优化里最容易“背锅”的一环。很多人遇到性能问题就想着把-Xmx调大或者换一个GC收集器。但JVM参数的设计目标本来就是在绝大多数场景下自适应地工作默认值通常已经很合理。你盲目改参数往往是拆东墙补西墙堆设得太大单次GC时间变长堆设得太小GC频率变高。调优的前提是先明确当前压力来源是分配过快、晋升过多还是元空间不足这些问题在GC日志里都有迹可循不看日志直接调参和抓药不把脉没什么区别。我比较反感一种做法“网上说这个参数能提升性能就全加上”。每个参数都影响一组行为组合起来可能互相矛盾。比如有人既想减少Full GC又把Metaspace调成无上限结果老的Full GC没解决新的元空间膨胀又来了。JVM调优更像是对症治疗先看症状再查日志最后只动跟症状相关的参数并且每次只改一个压测验证。3.2 读懂GC日志所有调优的证据都在这里我刚入门调优时最怕读GC日志后来发现只需要关心三件事频率、停顿、内存变化。拿一段典型的日志举例2025-05-10T10:15:12.1230800: 18.345: [GC (Allocation Failure) [PSYoungGen: 2048K-512K(6144K)] 4096K-2048K(20480K), 0.0123456 secs]PSYoungGen: 2048K-512K(6144K)表示新生代回收前占2048K回收后占512K总容量6144K后面的4096K-2048K(20480K)表示整个堆从4096K降到2048K总容量20480K最后的0.0123456 secs是这次GC的暂停时间。如果一段时间内日志频繁出现这种记录说明分配压力大。如果老年代一直涨甚至触发Full GC说明有对象晋升过多或者泄漏。现代JDK一般用-Xlog:gc*:filegc.log:time,uptime,level输出格式更丰富但核心还是看这几项。注意线上查看GC日志时别只看停顿时间还要看对象在各代之间的移动趋势。频率比单次停顿更影响吞吐量两者要一起看。3.3 堆内存和元空间的规划方式从数据反推参数堆参数不能拍脑袋定要从业务数据反推。我一般先在不限内存压力的情况下压测观察堆占用曲线的峰值再把这个峰值放大一定余量作为-Xmx同时让-Xms等于-Xmx避免运行期频繁扩容收缩。新生代的大小影响Minor GC频率原则是别太小否则对象一出生就要被回收频繁GC浪费时间。具体比例没有绝对标准可以先给老年代留出足够空间再看日志里晋升速率慢慢微调。Metaspace存类元数据对绝大多数服务端应用来说先给512MB到1GB观察就够了除非你有特别多的动态类加载。设太大反而是给类加载器泄漏留空间。还有人经常问-Xmn设多少我习惯让新生代占到堆的三分之一到二分之一再根据GC日志微调。不要迷信某个固定比例因为应用的对象生命周期差异很大数据会告诉你答案。3.4 垃圾收集器选型G1够用的场景就别折腾JDK 8的ParallelGC、JDK 11的G1以及更晚的ZGC各自适合不同场景。G1是目前很多版本默认的收集器特点是能控制停顿时间适合大多数服务端应用。ZGC主打低延迟能让GC停顿降到几毫秒甚至更少但它的内存开销和CPU占用并不低。所以不要看着新收集器就叫好先在G1上把参数和日志调清楚确认真的是停顿时间不可接受再考虑迁移。选型决策要基于数据现在的Major GC停顿是几十毫秒还是几百毫秒业务能不能接受直接换收集器不一定解决根因还带来了新的不确定性。我实际踩过的坑为了阉割停顿从G1换成ZGC结果吞吐量下降了一截最后发现瓶颈根本不在GC而在外部IO。换回来的过程更折腾。常规做法是先确认G1的-XX:MaxGCPauseMillis是不是真的失效再结合-XX:G1NewSizePercent等参数做调整实在不行再考虑ZGC。4. 并发与锁优化性能瓶颈经常藏在同步里4.1 锁粒度把“门禁”从整栋楼缩到一间房并发问题的本质是共享资源的竞争。Java里看到synchronized并不等于有问题真正的问题是锁的范围过大。比如给整个方法加锁所有线程都得排队哪怕你只是读了一个共享变量。一个经典的优化是把大方法拆开只在修改共享状态的那几行加锁。锁粒度越小并发度越高。但粒度也不能无限拆因为拆得太细可能一个逻辑需要多次加锁反而增加开销和出错概率。我写并发代码时会先问自己这个状态是不是真的需要所有访问者都互斥能不能改成只保护临界区除了粒度还得选锁类型。synchronized在低竞争下开销不大ReentrantLock提供更灵活的公平性和超时设置读多写少的场景用ReadWriteLock极少数极高并发读可以了解StampedLock。但锁选择不是越复杂越好能用synchronized就别堆新特性简单代码出问题概率更低。真正要紧的是“保护边界”对不对而不是锁的API好不好看。4.2 并发容器与原子类比锁更好的并发策略很多并发场景其实不需要显式加锁用对了并发容器就赢了。ConcurrentHashMap是常客写操作分桶处理读操作基本无锁高并发场景下统计数量时用LongAdder比AtomicLong更好因为它把竞争分散到多个缓存行减少CAS碰撞。读多写少的配置列表可以用CopyOnWriteArrayList它用空间换一致性适合极少变更、频繁遍历的场景。这些容器选择的关键依据是读写比例和数据大小而不是“看起来很高端”。举一个我亲手优化过的例子一个高频埋点接口用HashMap加锁记录计数每次上报都全表锁改成ConcurrentHashMap后因为读多写少并发能力直接上了一个台阶。需要额外提醒一点ConcurrentHashMap的size()并不一定非常精确如果业务严格要求强一致得自己加同步这也是“并发容器不是万能药”的地方。并发优化要理解容器的取舍而不是盲目替换。4.3 线程池参数算清楚再配置别用默认值糊弄线程池配置是最容易被“抄作业”带偏的地方。网上很多文章喜欢给一个固定公式但我更建议先分清楚任务是CPU密集型还是IO密集型。CPU密集型任务线程数建议控制在CPU核数上下过多线程只会增加上下文切换IO密集型任务线程数可以更丰富因为等待IO时线程不占CPU常用的估算是“核数 *1 等待时间/计算时间”。实际项目中做不到那么精细我会先按经验值起步然后用压测微调。队列和拒绝策略同样重要。无界队列会让线程数永远到不了maximumPoolSize任务堆积到内存溢出而不设队列长度、直接丢弃任务又会把请求丢掉。我们线上常用有界队列加CallerRunsPolicy让处理不过来的任务退回调用线程执行给系统一个自然的背压。示例配置ExecutorService pool new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() );这里核心线程8、最大线程16、队列容量1000既保留弹性又防止无限堆积。注意线程数的最终值还是要靠压测确认公式只是起点。4.4 ThreadLocal便利背后的坑要盯着ThreadLocal在Java并发里处理线程隔离很有用但它最大的坑藏在对象生命周期里。线程池中的线程会复用ThreadLocal里的值也跟着线程活下来。如果业务每次使用时往里面写入数据却忘记在finally里调用remove下一次同一个线程被分配其他任务时可能读到上一次的脏数据甚至长期引用一个不该存活的大对象造成内存泄漏。我见过一个调度器因此把对象一直挂在工作线程上导致老年代迟迟无法回收最终影响了整个JVM。正确模式是在try/finally里使用用完立即remove确保线程回到池子里时是干净的ThreadLocalContext local new ThreadLocal(); try { local.set(buildContext()); doTask(); } finally { local.remove(); }另外ThreadLocal变量本身用弱引用那只是防止ThreadLocal对象泄漏并不会帮你清理Value别被网上只言片语误导。所有基于线程池的场景都要把“清理”当成代码的一部分而不是可选项。5. 线上问题排查实录所有性能问题都可以被定位5.1 CPU飙高一束手电照向线程栈CPU突然飙到接近100%多数人第一反应是“看哪个进程把机器打满了”其实第二步才是关键找到进程内哪个线程在忙。Linux下先top -Hp pid找到占用CPU高的线程号转成十六进制后再用jstack看线程栈。这个栈会直接告诉你代码正在哪里执行最常见的凶手有三类死循环或原生循环等待、正则表达式回溯、频繁的GC。如果是死循环栈顶通常是一个你自己写的while如果是正则问题栈顶会出现在Pattern匹配相关代码如果是GC线程栈上能看到大量分配行为。下面是一组常见命令top -Hp 12345 printf 0x%x\n 12346 jstack 12345 | grep -A 20 0x303a定位到具体线程后再回到业务代码里看逻辑。不要拿着“CPU高”这个结论去猜线程栈会把你领到真正出问题的行。5.2 内存持续上涨与OOM让堆转储把凶手指出来内存问题比CPU问题隐蔽但手段更明确让JVM把堆打出来分析对象分布。先用jmap -dump:formatb,fileheap.bin pid导堆再用MAT或VisualVM打开。常见泄漏模式有三种静态集合不断塞对象线程池任务里的ThreadLocal未清理本地缓存只写不淘汰。另外如果堆里发现大量相同业务对象去看谁在引用它们。我记得有个案例是错误日志收集器把异常对象存到一个全局List里每天千万级请求三天后OOM。改法特别简单限制条数或者只记录摘要。排查内存问题切忌只看老年代涨不涨而要看“到底哪些对象占着空间”。堆转储是回答这个问题的唯一可靠办法。拿到堆转储后优先看直方图按retained size排序你会发现藏得再深的对象也会露出来。线上导堆要选好时机最好在内存上涨但没有完全OOM的时候操作配合一次Full GC生成live对象堆信息更干净。5.3 接口时快时慢把GC停顿和数据库慢查询一起看接口快慢不稳定十有八九不是代码本身的逻辑问题而是周期性抖动。我通常同时看两个方向JVM是不是在周期性Full GC数据库是不是有固定某个SQL时不时慢。Full GC会让所有线程STW表现为接口整体卡顿几百毫秒数据库慢查询则是某个请求长时间等待连接或执行SQL表现为部分请求突然超时。把GC日志和慢查询日志拉出来对齐时间点通常能一眼找到关系。如果某次接口变慢正好是Full GC时间点那么优先优化堆和GC如果GC日志很干净就去查SQL执行计划、索引使用和连接池状态而不是继续怀疑代码。我见过一个团队查了两周接口抖动最后发现是定时任务在同一时间扫表把连接池占满了。方向对了问题往往就不是问题。5.4 排查时最容易犯的几个错误都是我踩过的坑第一个错误是“重启就完事”。重启能清掉当前内存和线程问题但根因还在过段时间会复发。第二个是“同时改多个变量”。今天既调了堆参数又换了集合还改了一条SQL就算性能变好了你也不知道是哪一步起效。第三个是“只凭监控曲线做判断”。曲线只能给方向最后还得落到代码和日志上。第四个是“忽略JDK小版本差异”。同一套参数在旧版本和新版本上的行为可能完全不同排查时先确认运行环境。这些坑的背后是同一个原则Java性能优化必须像做实验一样一次一个假设一次只改一个变量用数据验证。排查过程本身也要留痕哪一步做了什么、发现了什么都记下来。回头再看这些记录比任何经验帖都有用。最后说说我现在的习惯吧。优化前的第一件事永远是让监控把数据留够优化中每次只动一处跑完一轮压测记录前后差异优化后的代码至少在线上观察一个小周期确认没有新的内存或线程异常。Java性能优化不是某一周的热血冲刺更像是每次发布前的小体检。踩坑多了你会发现真正有效的招数就那么几条大部分时间都在和数据打交道。希望这篇里的一些判断方法能帮你少走点弯路遇到问题时先想起那句话先拿数据再谈优化。
阅读完成 · 觉得有帮助?