1. 从一次线上告警说起为什么要正儿八经做Java优化前阵子值班凌晨两点被电话叫醒线上一个核心服务CPU直接飙到400%接口平均耗时从80ms涨到3秒多网关层超时告警刷了满屏。登上去一看GC日志里Full GC每隔几秒就来一次每次停顿好几秒老年代直接被打满。这种场景做过Java后端的人应该都不陌生。问题来了为什么一个运行了大半年的服务会突然出这种事代码没改流量也没暴涨多少但系统就是扛不住了。答案其实不复杂——Java优化的核心从来不是等出了问题再救火而是提前建立一套可量化的性能认知体系。你得知道系统正常情况下长什么样哪些指标是健康的基线哪些位置是潜在的瓶颈以及当异常出现时用什么工具、按什么顺序、花多长时间定位到根因。这里先给没怎么接触过性能优化的朋友打个底。所谓JAVA优化面其实很宽小到一段循环里字符串拼接的写法大到JVM堆内存分配、GC收集器选型、数据库连接池大小调优再到分布式链路里线程池参数、缓存策略、异步化改造都属于优化的范畴。但万变不离其宗所有优化的起点都是同一个动作——测量。这也是我想在“JAVA优化之路”这个系列第一篇里重点讲清楚的事。这篇不会一上来就丢一堆JVM参数让你抄而是先把优化这件事的底层逻辑理顺怎么定位瓶颈、怎么选择合适的工具、怎么从JVM和代码两个层面做针对性优化。地基打牢了后面每一篇讲具体场景才有意义。这篇文章适合谁一种是刚写了两三年Java、遇到了线上性能问题但不知道怎么下手的同学另一种是写过一些CRUD、想系统建立性能优化知识体系的后端工程师。文章里涉及的全部命令、参数、排查步骤都是我在真实环境里验证过的可以直接照着用。2. 优化思维的底层逻辑先把“测量-定位-验证”闭环跑起来2.1 别猜先测一次失误让我长了教训做优化最容易犯的错就是凭经验猜。我刚工作第二年的时候接手过一个报表导出接口用户反馈导出10万条数据要一分多钟。当时我第一反应是SQL慢花了半天优化SQL、加索引结果导出时间只降了几秒没啥本质变化。后来一个老同事过来什么代码都没看先让我压测。跑了一轮jmeter他发现CPU利用率才20%内存占用也不高但耗时特别长。他让我用async-profiler抓了一下火焰图真相一下就出来了——时间几乎全部花在POI的单元格样式创建上循环里每个单元格都new了一个CellStyle对象这个操作极其昂贵。换成复用样式对象之后导出时间从70秒直接降到12秒。这个案例让我印象特别深也奠定了我做性能优化的第一个原则永远先用数据说话不要用直觉代替测量。那测量到底测什么我的习惯是分三层看第一层看资源CPU、内存、磁盘IO、网络IO这四项是硬指标能快速判断瓶颈在哪个方向。第二层看JVM堆内存使用、GC频率和停顿、线程状态、类加载情况这是Java应用特有的视角。第三层看应用接口RT、TPS、错误率、慢SQL、外部调用耗时这是从业务视角看到的真实体验。这三层信息合在一起基本能拼出一张完整的性能地图。就像医生看病先量体温血压做血常规再决定要不要拍CT而不是直接开刀。2.2 二八原则优化做在刀刃上把所有代码都优化一遍既不现实也没必要。实际线上系统的性能瓶颈往往非常集中通常80%的资源消耗来自20%的热点代码。优化的核心能力其实是——快速找到那20%。我会用几个简单标准来判断哪些值得优化调用频率高不高。一个每秒被调用上千次的方法哪怕单次只要省0.1ms总收益也远大于一个每天只调用几次但每次省100ms的方法。耗时占比大不大。看接口RT的构成哪一段占的时间最长就先动哪一段而不是挑软柿子捏。影响面广不广。核心交易链路上的问题优先级永远高于非核心功能。这里也想多说一句很多人一提到优化就盯着代码层面比如把for循环改成stream、把StringBuffer换成StringBuilder。说实话这些属于锦上添花绝大多数场景收益微乎其微。真正值得投入精力的是IO、GC、锁竞争、线程池配置、数据库访问这些重资源操作。2.3 优化必须能量化改完要能说出数字变化做了优化怎么知道有没有效果凭感觉“好像快了一点”是不行的。我给自己定的规矩是每次优化改动前后必须用同一套压测方案或线上监控数据做对比。具体来说我会记录以下指标做前后对照接口RT的P99、P999而不是只看平均值。TPS每秒事务数反映吞吐能力。GC频率和单次GC停顿时间特别是Full GC的次数。CPU、内存的占用情况。外部依赖数据库、Redis、第三方接口的耗时变化。如果你改了代码但拿不出这些数据的对比那这个优化是否真的有效其实是存疑的。尤其是JVM参数调整很多时候你以为生效了实际上参数可能写错了位置、被覆盖了或者干脆没生效。这些坑后面会单独讲到。3. Java性能分析工具箱这些工具你必须会用3.1 JDK自带命令行工具不装任何插件也能排查很多新手一上来就装各种性能分析软件实际上JDK自带的那几个命令行工具用好它们已经能解决70%的问题。我给团队培训的时候反复强调先把这些命令练熟比装一桌子工具强。jps查看Java进程最基础的入口命令。找到目标进程的PID后面所有操作都围绕这个PID进行。常用参数-l能显示完整主类名-v能显示启动参数排查环境差异时特别有用。jstat监控JVM各项统计信息。我最常用的几个用法# 查看gc情况每1秒打印一次连续打印10次 jstat -gc pid 1000 10 # 查看类加载情况 jstat -class pidjstat -gc输出的S0C、S1C、EC、OC、MC分别对应新生代两个Survivor区、Eden区、老年代、元数据区的容量下方的S0U、S1U、EU、OU是各自的已使用量还有YGC、FGC是Young GC和Full GC的次数YGCT、FGCT是各自的累计耗时。这组数据能让你快速判断内存分布是否合理、GC是否频繁。jmap导出堆内存快照。当怀疑内存泄漏或堆占用异常时使用# 导出堆dump文件 jmap -dump:formatb,file/data/dump/heap.hprof pid # 查看堆内存概要可以快速看到各个分区的使用情况 jmap -heap pidjstack打印线程快照。一看线程状态、二看锁等待、三看死锁CPU飙高定位线程问题就靠它# 打印线程栈 jstack pid thread_dump.txt出现CPU飙高时配合top命令先找到占用CPU最高的线程用printf %x\n 线程id转成十六进制再去thread_dump.txt里搜索就能定位到具体代码行。这几条命令组合起来已经能应对大多数线上问题的初步排查。而且它们最大的好处是——生产环境基本都自带不需要额外安装。3.2 可视化工具与Arthas把排查效率拉满命令工具虽好但交互体验确实差点意思。如果你想更直观地看堆内存、线程、GC情况我推荐两个工具二选一VisualVM和JMCJava Mission Control。VisualVM更轻量打开dump文件、看CPU采样都挺好用JMC自带飞行记录器功能适合采集长时间运行的数据。但要说线上排查神器还得是Arthas。这个阿里巴巴开源的Java诊断工具我几乎每个项目都会装上真·救过命。常用的几个命令# 查看方法调用耗时尤其是慢调用排查 trace com.example.service.OrderService createOrder # 查看方法入参、返回值、异常 watch com.example.service.OrderService createOrder {params, returnObj, throwExp} -x 3 # 反编译线上class确认运行的到底是不是最新代码 jad com.example.service.OrderServiceImpl # 查看某个类的加载来源 sc -d com.example.service.OrderServiceArthas最爽的一点是不需要重启应用就能做动态诊断。面对“本地跑得好好的线上就是不行”这种经典问题用它attach上去就能看清线上真实状态。3.3 火焰图定位CPU瓶颈最快的路径如果你用Arthas的profiler命令或者独立的async-profiler就能生成火焰图。火焰图这东西一旦用上就回不去了。它把CPU采样到的函数调用栈按比例铺开横向越宽的函数占用CPU时间越多瓶颈一目了然。生成火焰图的步骤其实很简单# 在Arthas里启动CPU采样60秒 profiler start # 等60秒后 profiler stop --format html生成的HTML文件用浏览器打开重点看顶部那些横向特别宽的“平顶”那就是热点。我曾用这个方法看到一个本该走缓存的服务每次请求都打了MySQL而调用栈里清晰显示是缓存查询key拼接错误导致永远命中不了问题一下就定位了。我的经验是当你不确定瓶颈在哪、又需要快速定位时火焰图永远是最佳选择。4. JVM层面的核心优化实践把GC和内存这块硬骨头啃下来4.1 堆内存分配不是越大越好先泼一盆冷水把JVM堆内存调大不一定能让应用变快反而可能带来更长的GC停顿。堆内存这块核心原则是够用就行留出余量别贪大。举个例子。一个服务原本-Xmx4gGC还算正常Full GC偶尔一次每次停顿几百毫秒。后来流量涨了有人直接把堆调到8g结果Full GC停顿时间翻倍到一秒多接口超时反而变多了。原因很简单堆越大GC时要处理的对象越多单次停顿时间就越长。那堆内存到底设多少合适我的做法分几步先看服务器的物理内存总量给操作系统和其他进程留足空间Java进程最多占总内存的50%-70%。观察正常流量下老年代的使用水位。如果老年代使用率长期稳定在50%左右堆大小就是合适的如果一直顶着上限走说明堆不够或存在对象无法回收的问题。设置-Xms和-Xmx为相同值避免运行期堆动态扩容带来的性能损耗。这个细节很多人忽略但影响是实打实的。4.2 GC收集器选型别在JDK 8上死守Parallel很多团队JDK 8用了好几年GC默认是Parallel Scavenge Parallel Old。这个组合的优点是吞吐量高缺点是STWStop The World停顿时间不可控堆越大、停顿越明显。如果你的服务对延迟敏感接口RT要求P99在200ms以内我建议优先考虑G1。JDK 9之后G1成为默认收集器JDK 8上也能用配置起来很简单-XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent45MaxGCPauseMillis是G1的目标停顿时间设成100ms不代表一定能保证但G1会向这个目标努力。G1HeapRegionSize建议根据堆大小调整堆在4g-8g时用8m比较合适。InitiatingHeapOccupancyPercent是触发并发标记的老年代占比阈值默认45如果老年代增长快可以适当调低让G1提前开始并发标记避免并发标记跟不上对象分配速度而导致Full GC。这里说个真实对比。我之前调过一个报表服务堆6gJDK8默认GC高峰期Full GC一天十几次单次停顿800ms到1.5秒。切换G1之后Full GC变成几乎为零取而代之的是并发标记和Mixed GC停顿控制在100ms以内接口P99从500ms降到了150ms。这个优化只改了JVM参数一行代码没动。4.3 内存泄漏的排查思路从堆Dump中找到真凶JVM优化躲不开的一个问题是内存泄漏——内存占用只涨不回最终把堆打满然后频繁Full GC然后应用假死。典型的症状是服务运行几天后老年代使用率不断上升GC后基本不下降Full GC越来越频繁最终OOM或者响应越来越慢。排查这类问题我的固定套路是从jstat -gc确认老年代趋势连续观察几次确认是不是只增不减。用jmap -dump导出堆快照注意在OOM之前导出别等OOM了才想起来。用MATMemory Analyzer Tool打开dump文件先看Leak Suspects报告MAT会直接告诉你哪个对象占了大头、被谁引用着。顺着引用链往业务代码方向追基本能找到泄漏点。分享一个我踩过的坑。有一次排查一个定时任务服务的内存泄漏MAT显示有一个HashMap占了60%的堆里面全是历史任务的数据。代码检查后发现这个Map是类的静态字段只往里put、从不remove。定时任务每5分钟执行一次数据越积越多。修复方式就是在处理完任务后从Map中移除。这种问题如果不看堆dump靠肉眼Review代码确实很难发现。4.4 线程池参数为什么“默认配置”是最大的坑线程池是Java并发编程里最有技术含量、也最容易被用错的组件。很多人写代码时直接Executors.newFixedThreadPool(10)图省事但线上环境这么干埋了不少雷。先记住一个原则永远不要用Executors提供的快捷方法创建线程池。原因很简单newFixedThreadPool和newSingleThreadExecutor的阻塞队列是LinkedBlockingQueue默认容量Integer.MAX_VALUE任务堆积时内存会被打爆。newCachedThreadPool的最大线程数是Integer.MAX_VALUE高并发下会创建大量线程直接拖垮系统。正确的打开方式是手动new ThreadPoolExecutor参数按业务场景计算。我一般这样估算ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), // 有界队列 new NamedThreadFactory(order-async), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数怎么定如果任务是CPU密集型设置为CPU核数1如果是IO密集型设置为CPU核数*2。但这只是起点实际值要结合压测结果调整。队列大小考虑任务积压容忍度队列太大任务延误时间长太小触发拒绝策略的概率高。拒绝策略里CallerRunsPolicy是我用得最多的它让提交任务的线程自己执行任务不会丢任务还能天然提供背压。5. 代码层面的优化实战从字符串到SQL处处有学问5.1 字符串拼接别再把“”当成万能药先回答一个经典问题Java里字符串用拼接到底好不好答案分场景。如果是少量、固定次数的拼接比如日志信息、常量拼接完全没问题编译器会自动优化成StringBuilder。但如果在循环里拼接就完全是另一回事了// 这个写法每次循环都new一个StringBuilder非常浪费 String result ; for (int i 0; i 10000; i) { result item ,; }10万次循环这种写法会创建10万个StringBuilder对象和10万个String对象GC压力飙升。换成显式StringBuilderStringBuilder sb new StringBuilder(100000); for (int i 0; i 10000; i) { sb.append(item).append(,); }预分配容量也很重要避免StringBuilder内部数组频繁扩容复制。这个优化虽然看起来不起眼但在高频调用路径上收益是可以被放大的。5.2 HashMap使用细节初始化容量和避免哈希冲突HashMap是Java里用得最多的集合类但很多人的用法是有问题的。第一个问题是不设初始容量。默认容量16负载因子0.75也就是说存到第13个元素就会扩容。扩容不只是数组复制还要重新计算所有元素的hash是个代价不低的操作。如果你能预估容量直接给定// 预估存100个元素除以0.75加上余量设置容量为256可以避免扩容 MapString, Object map new HashMap(256);第二个问题是key的hashCode实现不佳导致哈希冲突严重。HashMap在链表长度超过8时会转成红黑树JDK 8但链表很长时get的复杂度就从O(1)退化到O(n)了。我排查过一个性能问题一个Map里存了上万条数据每次get耗时几十毫秒。查下来发现key是一个自定义对象hashCode写得极其粗糙大量对象落在同一个桶里。重写hashCode之后耗时降到微秒级。5.3 并发编程锁的粒度和类型选择并发编程里锁用得好不好直接决定系统的吞吐上限。我见过太多人一上来就synchronized锁整个方法简单粗暴但性能往往不理想。一个典型的例子一个缓存服务读多写少每次读都要加锁。优化方式很简单// 不锁整个方法只锁写入逻辑 private final ReadWriteLock lock new ReentrantReadWriteLock(); public Object get(String key) { lock.readLock().lock(); try { return cache.get(key); } finally { lock.readLock().unlock(); } }读锁是共享的多个线程可以同时进入读方法吞吐量一下就上来了。如果追求更高的并发性能还可以考虑ConcurrentHashMap的原子操作或者用LongAdder替代AtomicLong高并发写场景下LongAdder的竞争更小。这些细节展开能写一整篇这里先提一点不要过早引入复杂并发结构先确认锁确实是瓶颈再说。5.4 SQL与索引优化Java应用性能的最大变量说实话排查过这么多性能问题真正影响最大、出现频率最高的瓶颈是数据库访问。Java代码层面的优化经常只能带来百分之几十的提升而一条SQL从全表扫描变成走索引性能可能是几十倍甚至上百倍的差距。我的SQL优化顺序是这样的先看执行计划MySQL里EXPLAIN重点看type字段从好到差依次是system、const、eq_ref、ref、range、index、ALL。看到ALL全表扫描就要注意了。检查索引是否真正被用到。有时候你以为建了索引但SQL写法导致索引失效。比如在索引列上做函数运算、隐式类型转换、前导模糊匹配都会让索引失效。避免SELECT *只查需要的字段。这不只是减少传输量还能让覆盖索引生效。分页深翻页优化LIMIT 1000000, 20这种写法性能极差可以用延迟关联或者基于游标的方式优化。关于N1查询也多说一句这在ORM框架下非常常见。查了10条订单又循环查每条订单的明细产生了10次额外查询。优化方式就是一次join查出来或者用IN批量查询。5.5 常见性能反模式速查表反模式问题表现优化方向循环内查数据库或RPC接口RT随数据量线性上升批量查询、缓存、预加载大对象频繁创建GC压力大Young GC频繁对象复用、池化深拷贝使用不当CPU消耗高内存占用大按需浅拷贝、避免不必要拷贝日志打印过多IO成为瓶颈磁盘被打满控制日志级别、避免循环打日志同步等待外部服务线程池被占满请求排队异步化、超时设置、熔断降级使用System.currentTimeMillis()计算耗时高并发下该调用本身也有开销压测后再评估通常影响不大6. 线上问题排查实录从CPU飙高到接口超时的完整复盘6.1 排查方法论一套可以反复套用的流程先整理一套通用排查流程我称之为“四板斧”不管什么性能问题先按这个顺序跑一遍看全局用top看CPU、内存、负载用vmstat看上下文切换和等待队列用iostat看磁盘IO。看JVM用jstat -gc看GC情况用jmap -heap看堆分区使用用jstack看线程状态。看应用查监控系统里的RT、TPS、错误率指标看是不是某个接口突然恶化。看依赖检查数据库慢查询日志、Redis耗时、外部接口调用情况确认瓶颈在下游还是在自身。这套流程能覆盖绝大多数场景。下面用一个真实案例把每一步串起来。6.2 真实案例一次CPU飙高400%的排查全过程某天下午监控告警订单服务CPU使用率从20%飙到400%接口P99从80ms涨到3秒。我按照上面的流程开始排查。第一步登录服务器跑top -Hp pid发现进程里有两个线程的CPU占用超过100%一个约180%一个约150%。这两个线程肯定是元凶。第二步把线程ID转成十六进制printf %x\n 12345假设线程ID是12345得到0x3039然后用jstack pid | grep -A 30 0x3039看线程栈到底在干什么。结果线程栈显示两个线程都卡在同一个地方——一个Redis客户端的lpop命令上而且是通过一个自定义的延时队列在循环拉取消息。代码大致长这样while (true) { String message redisTemplate.opsForList().leftPop(queueKey, 5, TimeUnit.SECONDS); if (message null) continue; process(message); }表面上看逻辑没问题但问题出在leftPop设置的阻塞时间是5秒可实际的Redis连接池和超时配置有问题导致这个调用在极端情况下会进入忙等循环不断重试CPU被吃满。加上这个服务部署了两个实例两个实例都开了同一个队列的消费者竞争加剧问题被放大。第三步结合GC日志确认不是GC问题后基本锁定是Redis客户端使用姿势的问题。修复方案有两步一是调整Redis连接池参数和超时配置二是把这种阻塞式拉取改成监听模式或者加上退避策略避免空转。第四步修改完代码重新压测。同一套压测脚本CPU从400%降到30%P99从3秒回到85ms。整个排查加修复大约花了一个半小时。这个案例想说明什么性能问题的表象可能是CPU、内存、耗时但根因往往在框架使用姿势、外部依赖配置这些代码之外的地方。如果一开始就只盯着代码优化很可能白忙一场。6.3 接口超时排查一层一层往下拆还有一个高频问题接口突然变慢怎么定位是哪一层的锅我的办法是先看链路追踪比如SkyWalking、Zipkin或者公司自建的调用链系统。如果调用链显示时间主要花在“自身”阶段那就是应用内部的问题回到第6.2的JVM和代码排查如果时间花在“数据库”调用上就去查慢SQL如果花在“HTTP/RPC调用”上就是下游服务慢要么优化下游要么对当前服务做异步化、超时控制、熔断降级。如果公司没有链路追踪系统也可以用Arthas的trace命令手动抓一下看方法内部的耗时分布trace com.example.service.OrderService createOrder #cost 200这条命令把超过200ms的调用路径打出来子调用分别耗时多少一目了然。这个方法在临时排查时非常好用。6.4 常见问题速查表建议收藏症状可能原因优先排查方向CPU飙高死循环、频繁GC、正则回溯、序列化top jstack火焰图内存持续增长Full GC频繁内存泄漏、大对象过多jstat jmap MAT接口RT突增慢SQL、下游变慢、线程池打满链路追踪、trace命令应用假死请求无响应线程池耗尽、死锁、GC停顿过长jstack看线程状态Young GC频繁堆太小、对象创建过多jstat确认调堆或优化代码老年代涨得快大对象直接进老年代、内存泄漏jmap看对象大小MAT分析7. 我给新人的几条优化建议这一篇写了挺多内容最后想以个人体会收个尾也算给刚入坑的同学一些指引。第一不要为了优化而优化。在业务正常运行、各项指标健康的情况下花大量时间去做微优化投入产出比很低。优先级的判断标准始终是有没有明确的问题有没有量化的数据改了之后能不能验证。第二先把一个工具用精再学下一个。我见过很多同学每个工具都装了一遍但遇到问题时一个都用不利索。我的建议是先把jstat、jstack、jmap这几个JDK自带命令用熟再把Arthas学透这套组合已经能覆盖绝大多数线上问题的诊断。第三养成看监控的习惯。不要等告警了才登录服务器看指标。平时多观察业务的流量走势、RT波动、GC情况建立自己对系统正常状态的感知异常出现时才能第一时间发现“哪里不对”。第四优化完一定要沉淀。我把每一次优化都记成文档包含问题现象、排查过程、根因分析、解决方案、前后数据对比。这不仅是自己的技术积累下次遇到类似问题时检索起来比重新排查省太多时间。这算是“JAVA优化之路”的开篇重点在线路图的搭建和几个核心场景的实战举例。后续我打算按这个系列继续往下写包括JVM参数调优的详细案例、GC日志的深度分析方法、Arthas的高阶用法、MySQL慢SQL优化实战等等。你如果对某一篇特别感兴趣可以在评论区告诉我我按需求来安排优先级。最后再说一个每次优化后必做的小动作把改动的配置、代码、参数都记到变更记录里标注清楚改前改后的监控数据。别小看这一步它能在后续出问题时帮你快速回滚定位也能让你在复盘时看到每一步的真实价值。