一个Spring Boot服务,内存曲线像温水煮青蛙。不是暴涨,是每天涨一点。垃圾回收跑完,基线比昨天高一截。CPU正常,数据库正常,用户没投诉——这种bug最难抓。
作者Kavya的团队花了整整一周,才定位到罪魁祸首:一个看似无害的日志优化,用了ThreadLocal(线程本地存储)来存请求上下文。代码没报错,测试环境压测也扛得住。但生产环境跑了7天后,堆内存使用率从35%爬到82%,请求延迟从100ms飙到3秒。
这是Java工程师最熟悉的噩梦:内存泄漏从不敲门,它只在你放松警惕时,把服务慢慢勒死。
第一周:我们以为只是流量涨了
故事开始于一张"看起来正常"的监控图。Kavya在Medium上回忆,团队最初注意到内存缓慢上升时,第一反应是自我安慰——最近流量确实在涨,多占点内存不奇怪?
这个判断错得离谱。他们连续观察了三天,发现一个反常识的现象:凌晨流量低谷期,内存曲线依然向上。没有用户请求的时候,堆内存的基线(baseline)却在悄悄抬高。垃圾回收像在做无用功,每次扫完,留下的"干净空间"比上次更少。
到第5天,服务开始出现"亚健康"症状。不是宕机,是偶尔的卡顿。平时100ms的接口,冷不丁变成2秒、3秒。Kavya描述这种体验:"像开车时方向盘突然变沉,松手又好了,但你不敢再开快。"
团队终于认真起来。他们排除了数据库慢查询、第三方API超时、甚至怀疑过JVM(Java虚拟机)的GC算法参数。所有指标都正常,除了那条固执上扬的内存线。
ThreadLocal:被误用的"线程保险箱"
问题代码最终定位到一个日志工具类。同事为了简化代码,把用户ID、请求ID等上下文信息塞进了ThreadLocal。每个线程处理请求时存进去,打完日志再取出来——理论上很干净。
ThreadLocal的设计初衷是给每个线程独立的数据副本,避免锁竞争。但它在Java里有个致命搭档:线程池。
Spring Boot的Tomcat默认用线程池处理请求。一个线程不会用完即抛,而是被回收复用。这意味着:如果ThreadLocal里的数据没手动清理,它会跟着线程进入下一个请求,再下一个。Kavya的团队打了日志,却忘了在请求结束时调用remove()。
更隐蔽的是,他们存的不是简单字符串,是一个包含用户全量信息的上下文对象。每个泄漏的对象引用,都拖着一整棵树的数据。线程池越大,泄漏速度越慢——但也越难被发现。这就像往水库里滴墨水,池子大的时候,颜色变化根本看不出来。
作者没有透露具体数字,但描述了一个典型场景:服务重启后内存正常,7天后涨到危险水位。按Spring Boot默认的200线程池配置估算,每个泄漏对象若持有1MB数据,一周累积可达数百MB的"僵尸内存"。
为什么测试环境永远发现不了?
Kavya的文章戳中了一个行业痛点:这类bug在生产环境才显形。他们的CI/CD流水线跑了全套测试,包括压力测试。但压测通常持续几分钟到几小时,线程池来不及"养肥",内存泄漏的量级被淹没在正常波动里。
开发环境更惨。开发者本地重启服务的频率,可能比上厕所还高。ThreadLocal的问题需要"时间+线程复用"两个条件同时满足,而频繁重启直接把复用概率清零。
团队尝试过用Heap Dump(堆转储)分析,但初期抓到的快照里,泄漏对象被Tomcat的线程引用链埋得很深。Kavya写道:"你看到一堆org.apache.tomcat.util.threads.TaskThread,每个都挂着几MB数据,第一反应是Tomcat有问题,而不是自己的日志代码。"
最终破局的是一条Git提交记录。有人回忆起三个月前的一次"代码简化"重构,把原本显式传递的上下文参数,改成了ThreadLocal隐式存储。提交信息写着"减少方法签名冗余"——典型的技术债伪装成优化。
修复方案:比想象中更麻烦
找到根因只是开始。Kavya的团队面临一个尴尬选择:全局替换ThreadLocal方案,还是加remove()清理?
他们先试了后者。在Spring的拦截器里加finally块,确保请求结束调用ThreadLocal.remove()。测试通过,上线后内存曲线终于平了。但代码审查时,高级工程师提出质疑:如果某个异步线程忘了清理呢?如果未来有人新增入口点漏了拦截器呢?
ThreadLocal的清理是"人治",而人总会犯错。
团队最终回滚到显式传参方案。方法签名变长了,但数据流向一目了然。Kavya的反思很直接:"我们为了省几行代码,引入了隐式状态。这在并发编程里是最危险的trade-off(权衡)。"
文章结尾,作者提了一个未被回答的问题:如果当初选择用Java的ScopedValue(JDK 20引入的ThreadLocal替代方案),能否避免这场事故?ScopedValue设计为自动绑定到特定执行作用域,理论上无需手动清理。但Kavya的团队当时运行在JDK 17,升级成本又是另一个故事。
你的团队还在用ThreadLocal存请求上下文吗?最近一次代码审查,有人检查过remove()的调用位置吗——还是说,你们的服务也在某个角落,悄悄养着一池子泄漏的线程?
热门跟贴