ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java性能优化实战:从JVM调优到SQL优化全流程指南

Java性能优化实战:从JVM调优到SQL优化全流程指南 这个标题看着简单背后其实是一整套工程方法论。我干Java这块快十年从单体到微服务从小公司到日活千万的平台性能问题遇到过太多。很多时候所谓“性能优化”不是上来就改代码而是先搞清楚问题到底在哪一层、卡在哪一个环节、为什么慢。这篇文章我想用几条真实的线上案例做主线把JVM内存、GC调优、代码层面的常见坑、数据库和缓存配合以及最后怎么压测和排查从头到尾完整串一遍。无论你是刚开始接触JVM调优的新人还是正在做性能排查的进阶选手都可以按章节取用。1. 性能优化的整体思路与排查方法论1.1 先把“性能问题”这件事想清楚性能问题的本质是有限的资源遇到了不合理的竞争或消耗。CPU、内存、磁盘IO、网络带宽任何一个成为瓶颈都会体现在延迟上升和吞吐下降上。Java应用尤其特殊它跑在JVM上有自动内存管理但也因此引入了一个别的语言不太需要操心的维度就是GC。我见过太多人一说性能优化就盯着代码抠细节结果优化了半天一压测发现瓶颈在数据库连接池。也见过另一类人压根不看指标凭感觉把堆内存从4G调到8G然后该卡还是卡。这两种做法都不可取。我自己的方法论是四步走先量化、再定位、然后优化、最后回归验证。听起来简单但80%的线上性能事故都栽在第一步没做扎实。量化不是说看一个平均数而是要看它的分布。比如一个接口平均耗时200ms听起来还好但如果TP99到了1200ms那就说明有大量长尾请求在拖后腿这种情况你光看平均值根本发现不了。1.2 量化指标没有数据的优化都是耍流氓常规需要关注的指标我建议至少包含下面这几类指标含义我常用的采集工具QPS / TPS每秒请求数 / 每秒事务数LoadRunner、JMeter、wrkRT平均/TP99/TP999请求处理时间尤其关注TP99Cat、SkyWalking、MicrometerGC频率与停顿Young GC / Full GC次数与耗时jstat、GC日志、Arthas线程状态BLOCKED、WAITING、RUNNABLE分布jstack、Arthas系统资源CPU、内存、磁盘IO、网络IOtop、vmstat、dstat这些指标之间的因果关系要特别注意。比如CPU高不一定是计算密集也有可能是频繁GC导致CPU空转、线程上下文切换过多导致的。如果你只看到CPU高就去找CPU慢的代码很可能白忙一场。定优化目标也是一门学问。不要拍脑袋写“我要优化到100ms”而是要先摸清现状再看瓶颈在哪里、投入产出比划不划算。举个例子一个下单接口RT稳定在800ms里面600ms花在调用两个第三方服务上由于第三方服务是强依赖你就算把本地代码优化得再优秀最多也就省下200ms。这时候你要么引入并行调用要么和对方约定更快的协议而不是傻傻地在本地抠代码。目标一定是在整个链路里找“最值钱的杠杆点”。1.3 排查工具矩阵用什么工具处理什么场景很多Java新人看到jstack、jmap这些命令就发怵其实掌握顺序就很简单。我自己平时排查问题会按照“系统层-JVM层-应用层”的顺序来取证。系统层看top、vmstat、free、iostat。top看CPU和负载vmstat看上下文切换和等待队列free看内存是否吃紧iostat看磁盘是否打满。系统层看完再到JVM层用jps找到Java进程jstat看GC和类加载jmap导堆快照jstack看线程栈。应用层的分析和链路追踪用Arthas、SkyWalking或者CAT。这里我特别想说Arthas这个阿里开源的诊断工具基本就是Java排查神器。你不需要临时改代码加日志线上直接就能看方法调用参数、返回值、异常堆栈还能直接反编译线上代码。Arthas dashboard一条命令就能看到线程、内存、GC的实时面板比一个个命令打快多了。2. JVM原理与内存调优实战2.1 内存模型说起来玄乎其实就那么几块JVM的内存可以粗分成三个池子堆内存、非堆元空间、栈、直接内存、以及JVM自身管理需要的一些结构。平时我们最关心的还是堆。堆内存内部又分年轻代和老年代。年轻代里还有Eden区和两个Survivor区S0、S1。绝大部分对象先在Eden区创建年轻代GCMinor GC之后存活的对象会往Survivor区复制每经历一次GC年龄加1达到阈值默认15可调就晋升到老年代大对象也可以直接进老年代。这套设计背后是“弱代假设”绝大部分对象朝生夕灭活不过一次GC。所以把短命对象放在一块小区域里高频清理把长命对象放老年代低频清理整体GC成本就能压得很低。理解这个你就能理解为什么Survivor区太小会导致对象频繁晋升老年代、进而导致Full GC。元空间存放类的元数据和Java堆分开默认无限受系统内存限制一般不用太操心。线程栈默认1M如果疯狂开启线程要注意上千个线程上千M堆还没满机器先满了。直接内存DirectMemory很多人忽略但像Netty这类框架用得非常凶。它绕过了堆走系统内存不受堆大小控制。如果流量高而且直接内存被耗尽会报OutOfMemoryError: Direct buffer memory这种问题光调堆是不够的。2.2 垃圾收集器不是越新越好垃圾收集器的选型经历过好几个阶段。从Serial、Parallel到CMS再到G1再到ZGC每一代都在解决不同场景的痛点。收集器适用场景特点Serial单核小内存客户端单线程停顿明显Parallel追求高吞吐对停顿不敏感多线程并行回收CMS老年代并发回收停顿较低但碎片化严重JDK9起废弃G1大堆多核可控停顿区域化分代主流默认ZGC超低延迟TB级堆停顿通常在10ms以内我现在的生产环境如果是JDK8且没有特别需求就用G1JDK17以上默认就是G1真要追求极低停顿才考虑ZGC。G1的最大特色是把堆划分为大小不等的Region每次回收一部分Region而不是整堆清扫因此可以做到“可预测的停顿时间”。你给我一个目标比如“每次GC停顿不超过50ms”G1可以往这个方向调整但Parallel做不到。很多老项目还在用CMS我得说一句如果已经上JDK11以上尽快迁G1。CMS有两处硬伤一是空间碎片化容易导致Full GC二是并发阶段CPU竞争明显。迁移之后一般都能看到Full GC次数明显下降。2.3 一个真实的内存参数调整案例有个业务服务8核16G的机器原先用的是默认参数平时还算稳定但一到业务高峰就频繁Full GC每次停顿将近1秒接口RT飙到两三秒。我先看GC日志发现年轻代回收很频繁而且每次回收后晋升老年代的对象特别多。再看代码发现有批量导入功能一次性创建了几十万个中间对象大部分应该朝生夕灭但因为年轻代太小Survivor区放不下全跑到老年代了。当时给的调整方案是这样的java -Xms8g -Xmx8g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:InitiatingHeapOccupancyPercent45 -XX:G1NewSizePercent10 -XX:G1MaxNewSizePercent50 -XX:G1ReservePercent10 -XX:MaxTenuringThreshold6 -Xloggc:/data/logs/gc-%t.log -XX:PrintGCDetails -XX:PrintGCDateStamps机器16G堆给了8G留了一半给系统缓存和其他进程。这个比例不是随便拍的长期观察内存占用之后8G足够承载正常峰值再多也只是堆着不用GC反而会有更大压力。G1的Region数量和暂停目标直接相关MaxGCPauseMillis设100ms是业务可接受的延迟上限InitiatingHeapOccupancyPercent调低到45意思是老年代用了45%就开始酝酿并发标记避免真到满了才手忙脚乱触发Full GC。MaxTenuringThreshold从15调到6是因为从日志看绝大多数对象其实在第一轮GC就死了没必要让对象来回倒腾这么久。调整后最有价值的变化是Full GC次数从高峰期的每分钟十几分钟一次直接降到几乎为零。接口RT的TP99从2.3秒降到380ms。这个案例的核心不是某个参数对不对而是每次调参都有日志和现象做依据。2.4 从GC日志里读懂JVM在干什么GC日志是调优的第一手证据。我贴一条典型的G1日志片段[GC pause (G1 Evacuation Pause) (young), 0.0285348 secs] [Parallel Time: 25.3 ms, GC Workers: 8] [Eden: 2128.0M(2128.0M)-0.0B(1984.0M)] [Survivors: 96.0M-96.0M] [Heap: 3000.2M(8192.0M)-872.2M(8192.0M)] [Times: user0.12 sys0.03, real0.03 secs]第一行告诉我们这是一次年轻代暂停耗时28ms。Pause后面括号里的Eden从2128M变成0说明这次回收把Eden里的对象基本都处理了Survivors维持96M说明没有发生明显的“晋升风暴”。要重点盯的是Full GC和并发标记周期。比如这种[Full GC (Allocation Failure) 4096M-3800M(8192M), 1.5230342 secs]Full GC卡了1.5秒堆整体回收效果还那么差3896M只回收了不到300M这就说明老年代里堆了大量生命周期长或者无法回收的对象。这种日志一旦出现基本可以直接怀疑内存泄漏或者对象长期被引用。解读GC日志要形成一套自己的判断模板年轻代GC频率高不一定是事年轻代GC每次耗时在几十毫秒内正常Survivor区是否打满晋升对象的量级。如果Survivor持续打满就需要扩大Survivor或者检查是不是把大对象堵在年轻代。如果Full GC频繁则大概率是内存泄漏、堆过小、或者有大对象滥用。3. 代码层性能优化从原理到动手改造一个慢接口3.1 最常见的坑循环里的N1查询前阵子公司内部做了一次代码走查我拿一个列表页接口举例用户打开一个100条订单的列表页后端第一版代码大约是public ListOrderVO listOrders(int userId) { ListOrder orders orderMapper.selectByUserId(userId); ListOrderVO result new ArrayList(); for (Order order : orders) { User user userMapper.selectById(order.getUserId()); OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); vo.setUserName(user.getName()); result.add(vo); } return result; }这个接口如果订单量只有二三十条线上也跑得挺欢。但一旦某个用户拉出八百条订单映射关系和订单数量级一起来一次页面请求就会触发成百上千条SQL数据库压力瞬间上来。每次循环只算一次查询用户体感顶多慢一两秒但并发一上来数据库连接池首先被拖死。优化方案一句话总结把循环内的多次查询改成一次性批量查询。public ListOrderVO listOrders(int userId) { ListOrder orders orderMapper.selectByUserId(userId); if (orders null || orders.isEmpty()) { return Collections.emptyList(); } SetLong userIds orders.stream() .map(Order::getUserId) .collect(Collectors.toSet()); MapLong, User userMap userMapper.selectBatchByIds(userIds) .stream() .collect(Collectors.toMap(User::getId, Function.identity())); ListOrderVO result new ArrayList(orders.size()); for (Order order : orders) { OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); User user userMap.get(order.getUserId()); if (user ! null) { vo.setUserName(user.getName()); } result.add(vo); } return result; }从1000次查询变成2次查询视野开阔很多。类似的N1问题在MyBatis里还有个典型的坑就是在for循环里调用xml里配置的嵌套查询select同样等于把SQL循环执行尽量用join或者嵌套结果映射替代。3.2 锁粒度与对象创建两个隐藏杀手并发编程里最经典的问题之一就是锁粒度太粗。我有一个真实案例一个库存扣减方法为了省事给整个方法加了synchronized。这个方法的内部逻辑其实很快但被锁保护起来之后所有线程排队执行QPS直接定格在几十。优化思路是把全局锁改为分段锁或者用乐观锁机制比如在数据库层面用版本号做乐观锁定update stock set count count - ? where product_id ? and count ?或者用Redis的Lua脚本做原子扣减。很多场景下锁并不是完全不能有而是要让锁保护的代码块尽可能小。比如同一把锁里既要执行数据库查询又要处理网络IO那查询等待的时间会阻塞所有请求。另一个隐藏杀手是循环里大量创建对象。Java里有String、包装类、集合对象这些对象创建成本倒不高但会给年轻代GC带来压力。循环里来一个字符串拼接用比如String sql select * from user where id in (; for (Long id : ids) { sql id ,; } sql );这个代码在几百个ID的场景下没问题但上万次循环且每次循环都拼接一次字符串会产生大量中间String对象浪费内存和触发GC。改成StringBuilder之后性能差距非常明显。虽然JVM会对字符串拼接近乎作弊地优化但在循环体内部它也无能为力。3.3 数据结构选型ArrayList还是LinkedListJava里的集合选型很多开发都在用直觉而不是数据规模。我见过有人在一个需要频繁按下标访问元素的场景里用LinkedList理由是“反正就几百个元素无所谓”。几百个确实无所谓但如果是几百万呢LinkedList在按下标访问时是O(n)复杂度而ArrayList是O(1)这个差距在数据量大时是秒级和毫秒级的差距。同理HashMap的初始容量设置也常被忽略。如果预知Map里会放10000个元素而不设置初始容量默认初始容量是16那么它会在达到阈值的时候扩容好几次每次扩容都要重新计算hash并把所有元素搬到新数组里代价不小。正确做法是new HashMap(10000 / 0.75f 1)减少扩容次数。选数据结构的核心原则是先想清楚数据规模和访问模式再决定用哪个容器。顺序访问选ArrayList频繁头部插入选LinkedList但更推荐ArrayDeque按键值查找用HashMap保持插入顺序用LinkedHashMap去重且保持唯一用HashSet。极端性能要求下甚至可以用数组加手动哈希替代HashMap。高性能网络框架很多就是放弃泛型容器直接手写对象池和数组结构。3.4 反直觉部分不要过度优化做性能优化最容易走火入魔的地方就是没事找事。一个接口RT本身也就5ms你非要去把for循环里的条件顺序调整一下或者把i改成i这都是在浪费精力。性能优化的优先级永远是大结构优先于小技巧。也就是说先看外部依赖能不能减少、网络调用能不能合并、缓存策略对不对、数据结构算法复杂度能不能降一档最后才轮得到微操。真正决定系统性能的往往是架构层面的事情比如缓存命中率、异步化和削峰而不是某个方法里少创建了一个对象。4. 数据层性能优化SQL、索引与连接池配合4.1 SQL慢不慢看执行计划而非直觉数据层优化里最常用的就是慢SQL治理。很多请求慢慢在数据库查询上。数据库查询慢90%的问题又出在索引设计和SQL写法。最常用的分析工具是EXPLAIN。比如EXPLAIN SELECT * FROM order_info WHERE user_id 123 AND status 1 ORDER BY create_time DESC;看执行计划里的type字段如果出现ALL说明全表扫描基本就是索引没走上的结果。这时候第一反应是看看where条件里涉及到的字段有没有建联合索引以及索引顺序是否合理。经验法则是等值条件放前面范围条件放后面排序字段尽量并入索引。索引失效的场景我这里整理几个特别常见的场景原因WHERE name LIKE %abc%前模糊无法走索引对索引字段做函数运算如DATE(create_time) 2024-01-01索引失效隐式类型转换字符串字段当数字查索引可能失效OR连接非索引列全表扫描联合索引不符合最左前缀跳过了第一列直接查第二列我见过最典型的败笔是给create_time建了索引但查询条件写成DATE(create_time) 2024-01-01索引完全用不上。改成create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00后查询从秒级直接降到毫秒级。4.2 连接池大小不是越大越好连接池的配置是最容易被过度设计的参数之一。很多开发以为连接池配得越大性能就越好结果数据库反而先扛不住。连接池大小跟系统处理能力、数据库性能和每个连接占用资源都有关系。一个经验公式大致是连接数 (线程数 × (1 阻塞系数)) / 平均每个连接服务时间占比后端服务如果是纯IO密集且大部分时间在等数据库返回连接数可以相对大一些如果CPU密集连接数就小否则线程切换和数据库并发压力都会失控。一个简单的经验参考值是单机4核8G数据库连接池一般给20到50个连接足够超过100个还慢问题多数不在连接池而在SQL逻辑本身。连接池还有几个参数容易被忽略。maxWait太大请求池满时线程会无限等minIdle太小流量波动时频繁建连connectionTimeout设太短高峰期会误杀正常请求。我的建议是这些超时参数宁可给得宽裕一点把问题交给监控不要为了调优而调优。4.3 一个完整的数据层优化案例之前接手过一个订单查询接口压测到300QPS就扛不住了数据库CPU飙到100%SQL日志里大量慢查询。第一步看慢SQL发现Top1是一条对订单表做ORDER BY create_time DESC LIMIT 0,20的查询加了status 1和user_id 123两个条件但只建了user_id这个单字段索引。由于是扫描大量记录再排序效率非常低。第二步建联合索引(user_id, status, create_time)。这里把create_time放进联合索引就是为了避免排序阶段额外操作MySQL可以顺着索引顺序直接拿数据。第三步重新压测同样条件下QPS从300涨到1500慢SQL消失。这个案例凸显了一个常见误区光看SQL好不好不看索引是否匹配查询模式。加索引看起来是一行代码的事实际上背后是对查询模式和数据分布的重新理解。5. 线上问题排查实战实录5.1 案例一接口响应突然变慢CPU飙到90%一天下午突然收到告警应用集群某几台机器CPU飙到90%接口RT从60ms涨到3秒。我先用top确认进程PID发现是Java进程然后执行了top -Hp pid-H参数可以显示进程内的线程我发现若干线程CPU占用特别高。再把这些线程ID转成十六进制printf %x\n tid然后用jstack输出线程栈搜索对应十六进制线程号jstack pid | grep -A 50 nid0x...打开一看线程死死卡在一个SQL查询上。定位到代码发现最近版本把一个本来应该走缓存的代码删了所有请求都直接打到数据库。数据库连接已经耗尽应用线程全部阻塞在拿连接上CPU被线程切换和等待消耗殆尽。这次排查让我确定了一个习惯线程栈里如果看到大量线程都指向同一个业务代码那几乎肯定是一条同步调用链上的问题。不要怀疑是偶发99%是某个公共资源出事了。5.2 案例二内存缓慢上涨最终Full GC频繁另一个案例是长时间运行的批处理任务每次跑完内存都涨一点三四天后系统开始频繁Full GC。先用jstat观察GC情况jstat -gcutil pid 1000发现老年代占用持续上升Young GC后老年代不降。再用jmap导出堆快照jmap -dump:live,formatb,fileheap.bin pid导入MAT工具分析发现大量对象被一个静态全局Map持有而这个Map是“缓存”用的但每次写入都忘了删除。听着蛮低级但这种问题在生产上非常普遍。解决办法加上合理的缓存淘汰策略比如用Caffeine或者Guava Cache而不是自己写一个永不淘汰的Map。这里也建议多一步用MAT查看Dominator Tree前几个节点通常一眼就能看出是哪个类的问题。5.3 常见问题速查表症状优先排查项常用命令CPU飙高死循环/频繁GC/线程阻塞top -Hp、jstack、jstat内存溢出堆溢出/直接内存溢出jmap dump、MATFull GC频繁老年代对象多/内存泄漏jstat -gcutil、GC日志接口RT长SQL慢/外部依赖慢/本地代码慢Arthas trace、日志、链路追踪连接池满SQL长事务/连接泄漏连接池监控、show processlist线程阻塞死锁/锁竞争/资源等待jstack -l、Arthas这张表没有覆盖所有场景但能覆盖我实际工作中80%的线上性能问题排查路径。核心思路永远是先系统层确认瓶颈再JVM和线程层确认对象和行为最后到业务代码里定位根因。6. 压测与回归优化结果到底算不算数6.1 压测要压真实流量不要只压理想情况很多团队做性能验证喜欢用JMeter开一堆线程怼一个接口压出来的数据非常好看但一上生产就原形毕露。原因是测试预案没有考虑接口之间相互依赖、缓存命中率和生产环境不同、网络往来有损耗。我的建议是压测至少分三层单接口压测验证这个接口自身的极限全链路压测模拟真实业务场景比如从网关到业务再到数据库排名压下最好在灰度环境或容灾验证环境做这样压完还能看到对真实系统的反向冲击。压测指标也不是越猛越好。如果是给客户承诺QPS 1000那压测就按1200来压留20%余量如果优化目标是RT的TP99低于300ms就直接看TP99指标而不是平均值。平均值会骗人TP99不会。6.2 一套可以照着用的回归流程优化做完别急着合代码。我最常用的回归清单是这样的[ ] 优化前记录基线的QPS、RT、TP99、GC频率等指标[ ] 在相同环境下重复压测至少跑10分钟观察波动[ ] 验证业务功能不受影响特别是缓存、事务边界、幂等性[ ] 观察GC日志、线程状态确认不是靠牺牲其他资源换来的性能[ ] 小流量灰度上线对比监控曲线确认没有劣化这套流程也许看起来繁琐但它能避免一个特别常见的问题局部性能提升了整体系统却变差了。比如为了降低数据库压力把很多东西塞进Redis结果Redis成了新的热点为了提升接口响应加了一层层缓存结果缓存一致性问题把数据搞乱了。性能优化是系统工程永远要放在全局去看。7. 从一个案例说开去性能优化是持续的工程习惯最后说一个前前后后耗费两周的调优过程作为收尾。一个报表查询接口数据量是千万级原本每天跑一次耗时三分钟没人觉得有问题。后来业务改成用户可自助查询速度慢了体感很糟用户直接在群里开喷。我拿到需求之后第一反应不是优化SQL而是先问用户真的需要实时全量数据吗最终方案是每天晚上用离线任务把报表结果预计算好存到结果表和Redis里查询时直接读缓存。数据库只承担预计算任务的写入用户查询走缓存接口RT从180秒降到30ms成本几乎没有增加。这个思路的本质是读写分离、空间换时间也就是把计算前移。性能优化不是每次都在跑道上加速而是尽可能不要每次都在赛道上跑。做方案时先想清楚扇出逻辑、时间窗口和一致性要求再动手。回过头看Java性能优化这件事三分靠技术七分靠思维。技术层面的工具、参数、框架知识是有限的真正拉开差距的是遇到问题时能否快速建立假设、用数据验证假设、用最小的成本解决问题。踩过这么多坑之后我个人的体会是保持怀疑精神让每一个优化动作都有数据和日志做支撑比收藏一百篇调优文章都管用。如果你也想从零开始把自己项目的性能摸一遍底我建议先别急着改代码先把GC日志打开、把慢SQL日志打开、把监控面板拉出来连续观察一周。数据会告诉你它需要什么。
返回列表