Java开发踩坑实录:从内存泄漏到接口性能优化的实战笔记

发布时间:2026/9/7 22:51:34
Java开发踩坑实录:从内存泄漏到接口性能优化的实战笔记 凌晨一点五十七分监控平台的红色报警条像一道闪电刺穿了值班室的宁静。用户反馈系统响应越来越慢紧接着订单服务开始超时再往后整个应用直接卡死连健康检查都过不去了。我登录跳板机敲下jstack命令看到几百个线程全部阻塞在同一个地方——ThreadLocal.get()。那一瞬间后背发凉内存泄漏从来不是突然死亡而是温水煮青蛙等你发现时青蛙已经熟了。一次ThreadLocal引发的血案事故源头是个很普通的业务需求在异步处理订单时需要把当前登录用户的信息透传到下游。为了省事同事把用户信息放进了ThreadLocal却没意识到这个线程属于Tomcat的线程池。一旦请求结束线程回到池子里ThreadLocal里的数据却没有被清理。下一个请求复用这个线程时读到了上一个用户的数据。坏消息是数据串号好消息是还能复现。我在关键路径上加了日志果然看到同一个线程先后处理了不同用户的订单。ThreadLocal的清理必须放在finally里这句话背下来容易做到却需要刻进代码习惯里。止损修复很简单在拦截器的afterCompletion方法中调用remove()。但更值得警惕的是这种泄漏不会立刻报错它会潜伏几周直到高并发时某个线程积累了大量对象然后帮你触发一次Full GC。事后复盘我们在代码审查中加入了一条硬性规则谁往ThreadLocal里放了东西谁就必须负责拿走。使用ThreadLocal时还要考虑线程池场景下同一个线程可能承载多个业务上下文。如果实在需要传递上下文建议用TransmittableThreadLocal配合TtlRunnable而不是裸写ThreadLocal。静态集合成了内存的吞噬者排查那次事故时我还顺手用MAT分析了堆转储文件。不看不知道一张拥有几万个条目的HashMap躺在静态字段里key是用户IDvalue是一整个用户画像对象。代码初衷是为了做本地缓存加快查询但既没设置过期时间也没做容量上限。没有淘汰策略的缓存本质上就是一个合法的内存泄漏泵。那种缓存通常是在类初始化阶段由某个管理者put进去的后来业务逻辑不断往里塞数据却没有任何线程负责清理。当我问开发同事“为什么不加过期时间”时他的回答是“当时没想那么多”。可线上环境不允许“没想那么多”。后来我们换成了Guava的CacheBuilder设置了maximumSize和expireAfterWrite问题才彻底解决。如果你的代码里还有裸用的静态集合当缓存请记住一个铁律静态容器要么不存业务数据要么就必须有上限和过期策略。更进一步用JMH压一下HashMap和Caffeine在高并发下的差异你会明白成熟工具存在的意义。写代码时多问一句“这个容器会无限增长吗”或许就能省掉一次凌晨的崩溃。连接泄漏最隐蔽的性能杀手内存泄漏找到了但接口性能依然没有恢复预期。观察监控曲线发现数据库连接池的活跃连接数持续走高时不时报出Connection is not available, request timed out。检查代码发现某个导出报表的服务在分支逻辑里提前return把conn的关闭代码漏掉了。异常路径上的资源释放永远需要try-with-resources兜底这句话我用一次线上事故才彻底信了。我们扫了一遍全项目搜出二十多处手工管理连接的地方有的用finally关闭有的裸写close()。最危险的还是那种把conn传给工具方法由对方关闭的写法——一旦工具方法抛出异常调用方根本不知道连接状态。定下规矩所有Connection、Statement、ResultSet必须使用try-with-resources或者委托给连接池统一管理。同时开启了druid的removeAbandoned让超过阈值未关闭的连接被强制回收但这只是保险丝不是免死金牌。连接泄漏的症状和内存泄漏很像一开始没动静流量一涨就雪崩。如果你发现接口偶尔慢但重启后恢复十有八九是某个连接、文件句柄或线程资源没有被释放。排错技巧很简单观察本地网络连接数或者用lsof -p PID统计文件描述符一旦数字随请求数单调递增资源泄漏没跑了。性能优化先找到瓶颈再动手处理完两轮泄漏我开始回顾那些性能缓慢的接口。很多开发上来就想加缓存、加消息队列其实大多数性能问题的根源是代码本身的逻辑问题。我接手过一个查询订单列表的接口单次耗时1.8秒。用户只是翻个页却要等这么久体验自然是灾难。用Arthas追踪调用链发现一个循环里每查一次数据库都要重新取一次用户表——这就是教科书级的N1查询。加缓存只能掩盖烂SQL优化SQL才能治本。那条慢查询的SQL走了全表扫描因为在user_id字段上用了函数索引直接失效。我改写成等值条件然后顺手用EXPLAIN看了执行计划发现扫描行数从几十万降到了几十。这大概就是数据库性能优化的第一课先看执行计划再谈优化方案很多人连EXPLAIN都没跑过就急着加索引实在本末倒置。另外一个典型问题是在for循环里执行远程调用。有个批量同步库存的接口同步100个商品就要发起100次HTTP请求每次RTT平均50毫秒总耗时至少5秒。我改成并发调用用CompletableFuture把100个请求压成一批耗时直接降到200毫秒。能用一次查询解决的问题不要循环查数据库能用并发解决的问题不要串行等远程。这里需要警惕的是并发数信号量或线程池的队列一定要有界否则下游一抖动你会把整个应用拖垮。序列化与日志被忽视的巨兽有些接口代码看着很简洁但压测结果就是上不去。我遇到过一个大对象接口把整个业务对象转成JSON后塞进Redis再读出来解析。对象里嵌套了多层子对象序列化后足足有2MB。每次请求要序列化2MB、网络传输2MB、再反序列化2MB响应时间能不爆吗大对象是性能优化的盲区因为它在代码里只是一个字段名你根本感觉不到它的重量。解决之道精简DTO只传输前端需要的字段或者使用压缩算法像snappy或gzip能让2MB缩到200KB。日志也有同样的陷阱。为了排查问题有同事在生产环境打印了完整的请求报文和响应报文碰上大对象每次请求产生几十KB日志。日志同步写入磁盘会导致线程阻塞进而影响接口吞吐。日志虽然不是业务逻辑却在关键时刻替业务背锅。我们用logback的异步appender解决了写入阻塞同时规定生产环境禁止打印请求体的全量内容用截断或者MDC记录关键标识即可。日志级别也得谨慎。某个服务在调试级别下输出超过每秒10万行日志直接把磁盘IO打满应用卡死。那一次让我彻底明白没有监控的日志级别调整等于蒙眼开车。动态调整日志级别是一件危险操作事后一定要记得改回来。线程池参数不是越大越好之前提到的线程阻塞排查到最后还发现线程池配置不当。业务线程池设置了100个线程队列容量却只有10。当流量高峰到来时队列迅速填满新任务直接触发拒绝策略。更麻烦的是线程池里执行的任务很多是sleep或等待远程响应线程被占满但CPU却很闲。线程池的线程数不是越大越好要考虑任务是CPU密集型还是IO密集型。IO密集型可以适当多配但必须设置合理的队列和拒绝策略。我们最终把线程池参数调整为核心线程数20最大线程数50队列容量500拒绝策略设为调用者执行。这样一来当任务过多时没有被线程池消化掉的任务会退回给上游调用方由调用方的线程池继续处理从机制上避免了请求丢失。对核心接口还引入了Resilience4j的隔离机制把慢调用隔离在独立的线程池中防止一个下游接口拖垮整个应用。线程池不是银弹它只是把压力从一处搬到另一处关键看你怎么设计泄压阀。许多团队在压测时只关注平均响应时间却忽略了TP99。实际上平均耗时被少数快请求拉低真正的用户体验取决于尾部延迟。性能优化的核心指标是TP99和错误率不是平均值。我们针对慢接口做了限流降级给每个下游依赖配置了超时时间并用hystrix或sentinel做了熔断。一旦某个依赖的错误率超过阈值自动打开熔断器快速失败而不是让所有线程傻等。可观测性没有数据就没有优化经历了这一系列事故我最大的教训是很多问题其实在监控面板上早有预兆只是当时没人去看。如果你对Java应用只监控CPU和内存是远远不够的。内存泄漏、线程阻塞、连接泄漏都会以各自的曲线形态反映在监控上只看平均值等于什么都看不到。至少应该有JVM堆使用率、GC频率和停顿时间、线程状态分布、连接池活跃数与等待数、接口响应时间分布这些指标。我们还搭了一套使用Arthas在线诊断的流程当接口变慢时先thread -n找出最繁忙的线程再trace定位耗时方法必要时heapdump分析对象引用链。工具只是辅助关键是形成一套条件反射第一看GC第二看线程第三看连接第四看SQL。大多数Java故障都逃不过四个字要么锁要么资源。把问题归类为锁竞争或者资源泄漏排查路径就清晰了一大半。那次线上事故的最后我们清理了所有可疑的缓存重写了有泄漏风险的连接代码给批量接口加了并发控制还补上了TP99与GC监控。系统稳定运行一个月后我收到一条新需求要求给接口增加一个新字段需要访问远程用户服务。我下意识地问了一句“这个远程调用会不会出问题超时设置了吗如果对方挂了我们要不要降级”同事笑着说我想多了。但我的回答是“线上环境的每一毫秒延迟、每一个未关闭的连接都可能是下一起事故的起点。”后来这个新接口上线没两天用户服务真的发生抖动而因为提前加了超时和降级我们只是慢了几百毫秒没有整体雪崩。那一刻我知道所有的坑都没有白踩。如果你正在编写Java代码不妨问自己三个问题我打开的连接关闭了吗我用的缓存会无限膨胀吗我调用的远程服务万一慢到超时我能兜住吗这三个问题每个都能写出一篇踩坑实录。开发者的尊严不是靠代码写得多花哨而是靠线上不出事故挣来的。愿你的堆内存平稳愿你的连接池温润愿你半夜的手机永远安静。