ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

聊聊Java项目中的性能优化实战经验

聊聊Java项目中的性能优化实战经验 凌晨两点监控告警像救火警报一样响起。订单接口的TPS从1200掉到80平均响应时间从45毫秒飙到6秒系统里大量线程卡在同一个锁上。我一边翻线程堆栈一边怀疑是数据库又出问题了但DBA说慢查询日志里什么也没有。后来用Arthas的thread命令一抓才发现有几十个线程全部Blocked在某个Jedis连接池的获取操作上——Redis连接池耗尽而锁的持有者是一个在finally块里忘了归还连接的异常分支。那次事故让我明白性能优化不是在代码里乱加缓存或调参数而是从现象到根因的严谨推理。先定位别瞎猜很多程序员遇到性能问题第一反应是“这里加个缓存”“那里改个并发数”这跟蒙着眼睛修车没有区别。我的经验是先看指标再抓线程栈最后看代码。常用工具有Arthas、async-profiler、JFR它们能告诉你方法级别的耗时和调用频次。有一次我们接口慢团队里有人坚持说SQL慢结果Arthas的trace命令显示70%的时间花在Jackson的writeValueAsString上——一个超大的嵌套对象被反复序列化。优化后接口从1.2秒降到了200毫秒。没有数据支撑的优化都是耍流氓。先定位再动手否则你只是在给系统增加新的复杂度。线程池——最容易埋雷的地方Java项目的性能问题十个里有三个跟线程池有关。新手喜欢用Executors.newFixedThreadPool它背后是一个无界队列任务一多就把内存吃光然后触发Full GC系统彻底卡死。我们曾有一个批处理任务线程池大小设成10每个任务要调一个外部接口超时时间设了30秒。高峰期任务积压队列里堆了几万个任务Old区直接被打满。后来改成自定义线程池有界队列拒绝策略设为CallerRunsPolicy同时按业务拆分成独立的线程池。永远不要用Executors的便捷方法去管理业务线程池。另外线程池的线程名一定要起得有意义否则遇到线上问题你连是哪个业务在跑都不知道。线程池的另一个陷阱是线程泄漏。如果你的线程池是局部变量在每次请求里new一个用完了又没有调用shutdown那么系统会持续创建新线程最终耗尽资源。这类问题很难通过监控发现因为线程数曲线是缓慢上升的直到某一天突然崩溃。我们生产环境就出现过类似事故一个定时任务每次执行都创建一个线程池执行完忘了关闭运行一周后线程数涨到了两万。线程池的生命周期要和业务的生命周期对齐要么交给Spring管理要么在类初始化时创建并确保有统一的关闭入口。数据库——重头戏中的重头戏数据库优化是Java性能优化里最值得投入的部分。一个SQL执行计划的变化可能比你在Java层做的所有优化都有效。实战中索引失效是最常见的问题隐式类型转换会让索引失效比如varchar列与int值比较在索引列上做函数运算也会失效比如WHERE DATE(create_time) 2024-01-01。我见过一个报表查询关联6张表跑了15秒explain一看其中一张表的关联列是varchar但传入的是Integer全表扫描。统一类型并加上复合索引后查询变成了200毫秒。SQL优化的第一步永远是看执行计划而不是改代码。深分页是Java后端最容易踩的坑。LIMIT 100000, 20会让数据库扫描前10万行再丢弃越往后越慢。一个分页接口在前端翻到第5000页时就花了5秒因为每页只有20条数据。优化方案有两种一是延迟关联先查询主键再回表获取完整数据二是基于游标用WHERE id 上一页最大id的方式。深分页是性能杀手用延迟关联或游标替代。另外连接池配置也很关键HikariCP的maximumPoolSize不是越大越好一般建议核心线程数 2 磁盘数过大反而增加上下文切换和数据库负载。内存与GC的博弈Java应用的内存管理是一把双刃剑。自动垃圾回收帮你省了事但也会在某个瞬间把服务卡死。一次线上Full GC频繁我们dump了堆发现一个静态Map里保存了所有用户的会话对象没有任何清理机制。这个Map是业务上线时为了做“在线用户统计”加的结果上线三个月Map里有上千万个对象Old区爆满。后来改成用Caffeine本地缓存设置了过期时间和最大容量。没有失效策略的缓存就是内存泄漏的温床。GC调优的目标不是追求GC次数为零而是让GC停顿时间可接受并且堆使用率保持稳定。另一个内存相关的问题是对象在Young区频繁晋升。如果一个对象一会儿在伊甸园区一会儿被复制到Survivor区最后进入Old区然后被回收说明它的生命周期混乱。比如在循环里创建大对象或者使用StringBuilder时未指定初始容量导致频繁扩容。解决方法是减少无意义的对象创建比如使用StringBuilder、避免不必要的包装类型以及谨慎使用stream中不必要的中间操作。大部分GC问题不是调参能解决的而是代码在制造无意义的对象。缓存不是银弹很多人一谈性能优化就想到加缓存但缓存用不好反而会把系统打垮。缓存穿透是查询不存在的数据每次绕过缓存直接打到数据库缓存击穿是某个热点key过期瞬间大量请求涌入数据库缓存雪崩是大量key同时过期导致数据库压力骤增。我们曾有一个商品详情接口一个爆款商品的缓存key在凌晨过期结果那1分钟内有上千个请求绕过缓存去查数据库把库的连接池打满了。后来采用逻辑过期加互斥锁重建缓存的方式读线程拿到旧的缓存值发现过期后尝试获取锁没拿到锁的线程直接用旧值返回拿到锁的线程去数据库重建缓存。缓存设计的核心是保护数据库而不是提高某个接口的速度。本地缓存和分布式缓存的结合也要讲究。如果一个JVM里放了全局共享的缓存那么每个节点都有一份数据一致性很难保证。实战中我们用Caffeine做多级缓存时会监听数据变更的消息主动清掉本地缓存条目。另外缓存key的设计也很重要别把用户ID和业务参数拼出一个无限增长的key集合那和内存泄漏没有区别。缓存不是用来兜底的它需要有明确的命中率监控和失效策略。IO与网络——被忽略的角落Java性能问题还有一个大隐雷区同步IO和网络通信。日志是最典型的例子。生产环境为了排查问题把日志级别设成DEBUG并且每个方法都打log.info结果日志同步写磁盘的耗时超过了业务本身。我们曾有一个服务在循环里打印一个大对象的JSON结果压测时TPS只有500去掉日志后直接到了3000。生产环境的日志级别和打印量直接决定了系统的吞吐量。解决方法是使用高性能异步日志框架比如Log4j2的AsyncAppender或者干脆在核心链路上不打印业务日志只在出错时打印上下文。网络层面HTTP客户端连接重用是经常被忽略的点。一些老代码每次请求都新建HttpClient没有连接池TCP握手和TLS握手的开销巨大。优化后使用Apache HttpClient或OkHttp的连接池设置合理的keep-alive时间。另外DNS解析也可能成为瓶颈尤其是在容器频繁启停的K8s环境里JVM默认的DNS缓存策略会带来问题。每一次多余的IO都是对性能的精确打击。设计层面上的性能思维性能优化到一定程度你会发现瓶颈不在单个方法而在系统的整体设计。比如一个订单处理流程串行调用库存、优惠券、支付、通知四个服务同步耗时500毫秒你优化每个服务只能省下几十毫秒。但是用CompletableFuture并行调用前三个无依赖的服务总耗时直接砍半。再比如循环里一条条插入数据库改成批量插入性能提升是数量级的。性能问题的根源往往在设计而不是代码。合理利用消息队列削峰填谷避免业务高峰期同步处理所有请求。同时尽量减少分布式事务的使用那玩意儿不仅慢而且容易出问题。设计上还有一个容易被忽略的方面接口的粒度。一个接口返回上千个字段即使数据库很快序列化和网络传输也会拖垮你。实战中我们会对大接口做精简用select指定需要的字段而不是selectJSON只返回需要的数据。如果前端确实需要就拆分成多个小接口并行调用。能用数据量解决的问题不要用计算能力去硬扛。性能优化的“度”说了这么多技巧这里想泼一盆冷水不要过度优化。我见过有人为了省一次字符串拼接写出可读性极差的代码也有人为了减少一次反射引入了一个复杂的字节码增强框架结果调试困难。性能优化的本质是收益和成本的平衡。一个每天调用量只有几次的接口你花三天优化到微秒级这是浪费。过度优化是另一种形式的负债。正确的做法是建立性能基线在压测环境中验证每个优化点的收益并且让优化后的代码保持清晰和可维护。性能优化也不是一次性的事情。上线之后需要持续监控关键指标比如接口的TP99、GC频率、线程池活跃度、数据库慢查询数。我们团队每次发布后都会对比性能跑批数据一旦发现退化立刻回溯最近的代码变更。性能优化没有终局只有不断逼近真相的迭代。回到那个凌晨的告警我们最终定位到连接池问题后改完代码、验证通过系统恢复了。但真正的收获不是修好了一个bug而是建立了一套线程池、连接池巡检和异常警报的机制。下次再遇到类似问题我们能够在第一时间发现而不是等用户投诉了才去救火。这也算是在实战中积累下来的一点经验吧。
返回列表