内存泄漏这东西,我一直觉得它是服务器领域最容易被低估的敌人。你看,程序崩溃好歹还有个报错,能让你第一时间警觉;内存泄漏则是钝刀子割肉,表面上一切正常,实际上内存水位一天比一天高,等它终于把服务器内存吃干净的时候,往往已经造成了不可逆的影响——服务雪崩、接口超时、整机宕机,全都来了。这个系列写到现在已经到第四篇,前面聊了内存模型、内存分配、GC机制,这篇专门把"你写的代码怎么悄悄吃掉服务器内存"这件事彻底讲透。
这篇文章适合谁看?后端开发、运维、SRE、还有那些写脚本越写越复杂的Python/Node.js玩家都算。不管你是写Java还是写C++,只要你的代码跑在服务器上,并且需要长时间运行,内存泄漏这个话题就绕不过去。我会从原理讲到实战,从代码模式讲到排查工具,最后再聊聊怎么在团队层面提前把它拦住。
1. 内存泄漏为什么是服务器最阴险的敌人
1.1 崩溃是显性的,泄漏是隐性的
服务器出问题分两种,一种是"哐当"一声直接挂了,另一种是"慢慢变慢、慢慢变卡、慢慢变抽风"。前者容易定位,后者让人抓狂,内存泄漏正是后者的典型。
我印象很深的一次线上事故,服务的性能监控曲线长得跟锯齿一样:每天上午十点半左右开始变慢,GC频率上升,接口RT从50毫秒慢慢爬到800毫秒,到下午两三点达到峰值,然后半夜流量低了又稍微恢复一点。第二天继续循环。刚开始大家都以为是流量波动,后来发现流量其实相当稳定,才意识到事情没这么简单。
这不是个例。内存泄漏的可怕之处在于它的滞后性——代码里埋下的问题可能要到几周甚至几个月后才会暴露,而且暴露的那一刻往往伴随着服务不可用,时间点完全不可控。
1.2 泄漏的三层危害,层层递进
内存泄漏带来的危害不是单点的,而是像多米诺骨牌一样层层推进:
第一层:应用层变慢。堆内存里堆满了"死而不僵"的对象,JVM的GC不得不频繁执行。GC线程跑得越多,业务线程被暂停的时间就越长。你可能注意到了,内存泄漏初期最明显的症状不是OOM,而是服务莫名变慢、线程阻塞、请求超时。
第二层:整机物理内存告急。应用不释放内存,操作系统就得不断把内存页交换到磁盘上去。一旦开始swap,IO就被拖垮,整台服务器的性能断崖式下跌。最终Linux的OOM Killer会出场,随机挑一个进程杀掉——注意,它挑的不一定是你那个出问题的进程,可能是同一台机器上的任何其他服务。这就是为什么线上经常出现"莫名其妙别的服务挂了"的情况,查到最后发现是邻居进程内存爆炸。
第三层:影响放大。现代应用基本都是分布式部署,一个节点内存泄漏,负载均衡会把更多流量打到其他节点,其他节点也跟着告警。如果做了自动扩容,新起的实例也会慢慢被同款问题感染,整个集群一起走向崩溃。所以内存泄漏不是一个进程的事,它是整个系统的事。
1.3 先别急着写代码反思,分清三种内存占用
这里插一句,排查"内存高"的时候,首先要确认它是泄漏还是正常占用。很多新手一看到服务器内存占用率高就惊呼"泄漏了",其实有几种情况非常容易混淆:
- 正常的缓存和缓冲占用。操作系统会把空闲内存用来做文件缓存,JVM的堆内和堆外也可能缓存大量业务数据。这类内存在系统需要时可以释放,不算泄漏。
- 应用设计上的"伪泄漏"。比如某程序启动时一次性加载了大量配置到内存里,之后一直占着不动。它不增长,但也永远不会释放。严格来说不算泄漏,但效果和泄漏一样糟糕。
- 真正的内存泄漏。对象已经没有任何用处了,但因为某些引用关系没断,GC就是回收不了它,导致内存只增不减。
我之前见过一个团队排查了半天业务代码,最后发现是服务器的物理内存条坏了,ECC报错刷屏,系统不断隔离内存页导致可用内存越来越少。所以排查的第一步不是怀疑自己的代码,而是先看系统日志、看进程占用分布,把硬件故障排除掉。
2. 内存泄漏的根因:对象"假死"与GC的无力
2.1 JVM内存模型里,泄漏的对象藏在哪里
标题热词里出现了"jvm内存模型",正好说明这个问题绕不开JVM。简单过一下:Java程序的内存主要分为堆(Heap)、元空间(Metaspace)和堆外内存(Off-heap)。
堆内存是主战场,新生代里对象的生命周期短,Eden区满了就触发Minor GC,绝大多数对象活不过第一轮;能扛过多次Minor GC的对象会被晋升到老年代,老年代满了则触发Full GC。理论上,一个正常的系统在Full GC之后,老年代占用率会明显下降,内存曲线应该是锯齿状——涨上去、GC掉、跌下来,然后再涨。
内存泄漏的典型特征恰恰在于打破了这种节奏。泄漏出来的对象因为一直被引用着,GC无法回收它们,于是每次Full GC之后老年代占用率几乎不降。曲线不再是锯齿状,而是一条从容不迫但永不回头的上升线。这是判断内存泄漏的第一个也是最直观的信号。
2.2 可达性分析:什么样的对象会被GC"判死刑"
JVM判断一个对象能不能回收,靠的不是引用计数,而是可达性分析。从一组称之为GC Roots的根节点出发,沿着引用链遍历,凡是能到达的对象都算"活着";不能被到达的,就是可回收对象。GC Roots包括很多类东西:栈帧里的局部变量、静态字段引用的对象、JNI引用、活跃线程等。
拿仓库来打个比方:GC Roots是仓库出货口,凡是能从出货口够到的东西都是还出着货的;够不到的那些,就是彻底被遗弃的存货,清扫员随时把它们清走。
问题来了。内存泄漏里的对象并不是那种"完全够不到"的对象,而是被一个不该持有它的对象拿着。比如说,一个静态集合保存了某个业务对象,这个业务对象已经没有意义了,但静态集合的生命周期和进程一样长,所以它一直"够得到"那个业务对象。GC觉得它活着,实际上它已经是一具僵尸。这就是我所说"假死"的含义——对业务来说已经死了,对GC来说还活着。
2.3 为什么GC拿泄漏对象没办法
理解了可达性分析,你就会发现一个扎心的事实:GC设计上就管不了内存泄漏。
GC的职责是回收"不可达对象",而泄漏的对象恰恰都是"可达的"。你没办法指望垃圾回收器去判断"这个对象虽然被引用着,但它其实没用了"——那需要业务语义层面的理解,GC做不到。所以内存泄漏在JVM体系里不是一个GC能力问题,而是一个代码设计问题。
C/C++程序员听到这里可能会说:我们手动管理内存,new出来的必须delete,是不是就没这个问题了?实际上更严重。C++里忘记调delete、裸指针被复制多份互相踩踏,照样泄漏,而且没有GC托底,泄漏之后连回收的机会都没有,进程想不死都难。Python倒是有引用计数和循环垃圾回收,但全局缓存、类属性里保存的引用一样可以闷声增长,只是表现形式和JVM不同罢了。
2.4 Metaspace和堆外内存的泄漏更隐蔽
除了常规的堆内存泄漏,还有两类泄漏经常被大家忽略:
Metaspace泄漏常见于类加载器使用不当的场景。每加载一个新类,类元数据就占用Metaspace空间;如果Web应用容器反复部署、SPI动态加载类、反射生成大量代理类,类加载器又得不到释放,Metaspace就会缓慢增长。这类问题比堆泄漏更隐蔽,因为常规的Heap memory OOM报错不会出现,只会在Metaspace区域爆掉。
堆外内存泄漏则是另一大坑。NIO里用DirectByteBuffer分配的直接内存不占堆空间,但同样占用物理内存;如果分配了不释放,最终报错是"Direct buffer memory"。还有一些JNI调用、底层native库也可能静默吞掉内存。堆外内存没法用常规的Heap Dump分析,排查难度直接翻倍。后面讲排查的时候我会再提到这类特殊情况。
3. 最常见的泄漏代码模式,附避坑写法
这一节全是干货。我把日常代码评审里最常见的六种泄漏模式全列出来,每一种都配上典型代码和修改建议。大家可以对号入座,看看有没有中招。
3.1 静态集合只增不减
这是最经典的泄漏方式,没有之一。来看一段我经常在业务代码里见到的写法:
public class MetricsRegistry { private static final Map<String, Metric> METRICS = new HashMap<>(); public static void register(String name, Metric metric) { METRICS.put(name, metric); } }这段代码从逻辑上看很简单,就是注册一些指标。但热点问题是:register方法被调用后,Metric对象进了静态Map,永远没有任何机制把它们清掉。假设这是一个低峰期通过后台线程不断注册任务的系统,每注册一个任务就多一条记录,运行一周、一月、一年之后,这个Map里会攒下多少东西?
这个案例的本质是生命周期不匹配:Map是进程级的,存活时间相当于整个应用的生命周期;但里面的业务对象只需要短暂存活。只要把对象放进了这种超长生命周期的容器里,又没有配套的删除逻辑,它就会被永久持有,形成泄漏。
正确做法是改成带过期时间和最大容量限制的缓存:
public class MetricsRegistry { private static final Cache<String, Metric> METRICS = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public static void register(String name, Metric metric) { METRICS.put(name, metric); } }不要迷信自己的记忆力,觉得"我记得哪里会清理"。任何一个只增不减且生命周期超过业务需求的集合,都应视为潜在泄漏源。
3.2 ThreadLocal用完不清,线程池里埋雷
ThreadLocal的泄漏属于那种"官方文档警告过,但几乎没人当回事"的类型。看下面的写法:
public class RequestContext { private static final ThreadLocal<UserInfo> CURRENT_USER = new ThreadLocal<>(); public static void set(UserInfo user) { CURRENT_USER.set(user); } }问题出在配套的remove上。大量框架代码只做了set,没做remove。如果应用跑在Tomcat这类线程池上,线程在处理完一个请求后不会销毁,而是回到池里等待下一个任务。ThreadLocalMap里的value(UserInfo对象)依然被线程持有,下一个请求进来时如果没覆盖set,旧数据就会一直驻留在内存里。
更麻烦的是,ThreadLocalMap的key是对ThreadLocal对象的弱引用,而value是强引用。一旦ThreadLocal对象的外部强引用消失,key会被回收,但value不会,形成了value永远可达却永远用不上的局面。线程池里的线程数量乘以每个线程残留的value大小,就是泄漏的规模——积累到一定程度,老年代直接报警。
标准解法是用完必须在finally块里显式remove:
try { RequestContext.set(userInfo); // 业务处理 } finally { RequestContext.remove(); }把ThreadLocal当隐式参数传递确实方便,但请记住:所有ThreadLocal都应该配套try/finally清理。你要是敢在代码里写"ThreadLocal只set不remove",线上迟早给你上一课。
3.3 IO、数据库连接不关闭或关闭姿势不对
这个模式乍看好像不算泄漏——毕竟对象本身还是有引用的,不会有GC扫描不到的问题。但连接类资源泄漏的特殊之处在于,它既占堆内存又占非堆资源,比如文件描述符、数据库连接池里的连接槽。
常见的错误有两种。第一种是完全没有关闭流程:
public void readFile(String path) throws IOException { FileInputStream in = new FileInputStream(path); byte[] buffer = new byte[1024]; while (in.read(buffer) != -1) { // 处理数据 } // 忘了调用 in.close() }这段代码在Java 7之前每调用一次就泄漏一个文件描述符。文件描述符属于操作系统的稀缺资源,进程有上限。一旦达到上限,后续所有新开文件、新开Socket都会直接抛"Too many open files",那个报错比OOM还莫名其妙。修改方式是使用try-with-resources:
try (FileInputStream in = new FileInputStream(path)) { byte[] buffer = new byte[1024]; while (in.read(buffer) != -1) { // 处理数据 } }第二种错误姿势是在finally块里手动close,但关闭顺序写错了、或者关闭代码里又抛了异常、或者把close写在if分支里漏掉了某些路径。无论Java还是C#还是Python,资源类对象都应该走语言内置的自动关闭机制:Java的try-with-resources、Python的with、C#的using。这样既保证泄漏不出现,代码也干净很多。
3.4 监听器、回调、观察者忘记反注册
事件驱动架构在现代后端里无处不在。Spring的事件监听器、Netty的ChannelHandler、RxJava的订阅关系,以及各种自定义的Observer,都是同一个套路:addListener的时候很痛快,removeListener的时候谁都不记得。
public class OrderService { private final List<OrderEventListener> listeners = new CopyOnWriteArrayList<>(); public void addListener(OrderEventListener listener) { listeners.add(listener); } // 没有 removeListener! }假设用户聊天模块每分钟创建一组WebSocket连接并注册监听器,一旦连接断开,如果能忘记调removeListener,那些本应销毁的连接对象就被listeners集合牢牢抓住。连接堆越多,内存涨得越快。
修法其实很简单——加removeListener方法,并且把它放进组件销毁的生命周期钩子里。Spring组件就放在@PreDestroy;Netty handler就在channelInactive里反注册;自研框架就在finally里清掉。但现实是很多人嫌麻烦不做,结果就是服务每运行一段时间就变卡,重启能续命,过几天又不行了,这种"周期律"往往就是监听器泄漏在捣鬼。
3.5 缓存只设置过期时间但不设容量上限
这是容易被归为"设计问题"而不是"泄漏"的场景,但危害完全一致。很多开发者喜欢用ConcurrentHashMap做本地缓存:
public class LocalCache { private final Map<String, Object> cache = new ConcurrentHashMap<>(); public Object get(String key) { return cache.computeIfAbsent(key, this::loadFromDB); } }没有任何淘汰策略。上线初期一切正常,因为数据量不大。随着业务推进,用户量增长,缓存条目数可能从几千涨到几百万。每个条目的key和value都强引用着,一点一点吃掉堆空间。
这种"伪泄漏"之所以普遍,是因为单看每次写入的代码都"很正常",没有明显错误。但缓存的本义是以空间换时间,必须外加边界约束。真正的生产级缓存应该交给Caffeine、Guava Cache这类专门组件,设好maximumSize和expireAfterWrite,或者至少用LinkedHashMap实现LRU淘汰:
public class LocalCache { private final Map<String, Object> cache = new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) { return size() > 10_000; // 超过容量就淘汰最旧条目 } }; // 记得加锁,或者用Collections.synchronizedMap包装 }3.6 类加载器泄漏:隐藏最深的重灾区
类加载器泄漏是各类泄漏里最隐蔽的一种。它的触发链是:某个类加载器加载了业务类,业务类内部又持有外部对象引用,但类加载器本身没人帮它"卸货";于是这个类加载器以及它加载的成千上万个类永远留在元空间和堆里。
最常见的现场是应用服务器里反复热部署应用、容器化环境下动态加载插件、Java SPI机制导致Driver和ClassLoader互相引用。根因往往是一个很微小的持有关系:比如静态变量里保存了ClassLoader的引用,或者DriverManager里的Driver注册列表一直引用着某个插件类。
排查这类泄漏靠常规的堆Dump往往不够,还需要检查Metaspace的占用曲线,用工具比如jcmd GC.class_histogram或者更底层的JFR事件看类加载信息。如果发现Metaspace只涨不降,类数量不断增加,就要往这个方向查。
4. 一次真实泄漏事故:从监控告警到定位修复的完整链路
前面讲了原理和模式,接下来把一套完整的排查链路走一遍。我找一个比较有代表性的真实场景来讲——就是开头提到的那个"每天上午十点半准时变慢"的服务。整个过程就是我们日常排障的标准操作。
4.1 第一现场:先收集监控数据,不要瞎猜
告警触发后,我的第一反应不是打开代码编辑器,而是先去监控面板看三样东西:GC频率、老年代内存曲线、CPU使用率。
这个服务用的是Java 11,堆设置4GB。从监控上看,Full GC的频率从每40分钟一次渐渐变成每10分钟一次,随后更密;老年代占用率在每次Full GC之后下降不到10%,然后又快速涨回去。单独看一天的曲线可能不明显,把两周的曲线拉平放在一起,老年代的基线是肉眼可见地往上走的。
同时我确认了CPU不算特别高,网络和数据库的耗时也正常。这就基本排除了流量突增、慢SQL、连接池打满这些因素。我心里已经有了初步判断:大概率是应用内存缓慢增长,方向锁定在JVM堆内部。
4.2 用jstat先看GC细节,缩小范围
接下来用jstat采集一段实时的GC数据:
jstat -gcutil <pid> 5000 10输出里重点看Full GC之后的老年代占用(O这一列)。如果每次Full GC都从80%降到75%,然后很快又回到80%,这个"降不下去"的信号比任何指标都真实。健康的服务,Full GC之后老年代占用率应该有明显回落,比如从80%掉到40%。
同时我注意到新生代每次Minor GC之后,晋升到老年代的对象数量也在增加,说明有对象在新生代里熬过了多次回收,逐步被晋升到老年代,而老年代又清理不掉它们。到这里,范围已经从"可能有很多原因"缩小到了"堆内存里存在长期存活的巨大对象集合"。
4.3 用jmap抓堆快照,MAT分析谁在持有什么
范围缩小后就可以上堆Dump了。生产环境执行jmap要谨慎——它会触发一次完整的GC,并且Dump期间应用会被暂停一段时间。我一般选择低峰期操作,或者用Arthas的heapdump命令,效果更温和一些。
jmap -dump:live,format=b,file=heap.bin <pid>拿到heap.bin之后,我用Eclipse MAT打开,重点看两个地方:Leak Suspects报告和Dominator Tree(支配树)。
Leak Suspects会直接给出疑似泄漏点的列表。我那次看到的是:一个java.util.HashMap实例持有超过80%的堆内存,它被一个静态字段引用。这个信息已经非常致命了——不是某个临时对象占大块内存,而是一个静态容器类把几乎整个堆都装了进去。
然后我去Dominator Tree里找这颗树的根节点,沿着引用链往上翻:一个静态的Map → Map里的每个Entry → 每个Entry的value对象 → 这些对象又引用了大量业务数据。撑起80%堆的那个HashMap,源头指向的是我们框架里一个叫做"字典数据缓存"的工具类。
4.4 定位到具体代码,理解为什么涨得这么凶
拿到对象名已经成功一大半了。回到代码里一搜,果然是这个工具类:
public class DictCache { private static final Map<String, Map<String, Object>> CACHE = new ConcurrentHashMap<>(); private static final ScheduledExecutorService REFRESH = Executors.newSingleThreadScheduledExecutor(); static { REFRESH.scheduleAtFixedRate(() -> { for (String dictType : ALL_DICT_TYPES) { Map<String, Object> data = loadDictFromDB(dictType); CACHE.put(dictType, data); // 每次刷新都覆盖旧数据 } }, 0, 30, TimeUnit.MINUTES); } }看完代码我哭笑不得。这个缓存的key是字典类型,value是字典项数据。问题在于数据库里不断产生新的字典项,而且老字典项从来没被清理。每次刷新任务执行时,value集合变得比上一次更大,旧数据又不会消失。表面上看缓存有"刷新"动作,实际上它每次都是全量更新,且容量不断膨胀,最终吃掉了全部堆空间。
缓存没有设置maximumSize、没有设置expireAfterAccess、没有定期清理过期项——三个漏洞叠在一起。而因为它是静态变量,生命周期和进程一样长,所以GC永远救不了它。逻辑上这其实很像3.5里的"伪泄漏",但又叠加了动态增长的特性,比单纯的缓存无上限更危险。
修复方案不复杂,我用Caffeine替换了原始的ConcurrentHashMap:
public class DictCache { private static final Cache<String, Map<String, Object>> CACHE = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(Duration.ofHours(2)) .build(); public static Map<String, Object> get(String dictType) { return CACHE.get(dictType, key -> loadDictFromDB(key)); } }maximumSize限制了缓存条目的总数,expireAfterWrite保证每条数据每两小时重新加载一次。如果字典数据需要实时性更高,expireAfterWrite可以调短,但必须让旧条目有被淘汰的出口。
4.5 观察验证:GC曲线回归锯齿形
上线修复后的观察才是一切的前提。我紧盯着GC曲线,前三个小时老年代占用还在缓慢下降,那是历史残留对象在等待GC慢慢处理;大约半天之后,老年代曲线终于回到真正的锯齿形——每次Full GC后明显回落,基线不再爬升。两天后我下了结论:泄漏点修复,内存水位恢复健康。
事后总结教训:这个工具类写了三年,一直"挺正常",因为它早期数据量小,问题被掩盖了。等到业务量暴涨,缓存增长速度和对象容量形成了双重放大,问题才显现。经验就是,任何静态容器类都天生自带泄漏嫌疑,尤其是带"刷新"语义的全量覆盖,代码评审时必须重点盯防。
5. 把内存泄漏挡在上线前:评审、压测与监控三板斧
排查是很消耗精力的事情,动不动就几周。与其靠事后的十八般武艺,不如在交付前就设几道门槛,把常见的泄漏模式挡在门外。
5.1 代码评审里的"死亡五问"
我在团队里推动过一个规则:凡是涉及集合、缓存、资源对象、线程的改动,评审人必须逐条确认下面五个问题:
这个对象被谁持有?特别是有没有被静态字段、Spring单例Bean、类加载器链路的持有。
持有者的生命周期是多长?如果持有者是进程级的,那被它持有的对象也必须按进程级的标准来约束,该清理清理,该设上限设上限。
谁负责释放?代码里有没有对应的remove、close、dispose逻辑?没有释放放行的代码,几乎默认是在制造泄漏。
异常路径上释放逻辑还在吗?很多泄漏不是正常流程泄漏的,而是异常分支先return了、先throw了,结果后面的释放代码被跳过。try-finally和try-with-resources能解决大部分问题。
并发场景会不会加剧?比如线程池+ThreadLocal的组合,或者高并发下某个缓存快速膨胀,都要特别标注风险。
不用搞复杂的工具,就这五个问题口头过一遍,其实已经能拦住大半问题。很多泄漏代码在写的时候就知道自己没处理释放,只是没人问,就默认没事。
5.2 压测时盯着几组曲线,不要只看QPS和RT
很多时候压测只关注吞吐量和响应时间,内存指标反而被忽视。我在压测验收时会额外盯三个曲线:
老年代内存曲线是否基线平稳。如果压测从100 QPS升到1000 QPS时老年代上涨是合理的,但降回100 QPS后老年代如果降不下来,就有问题。
Full GC频率是否随压测结束而回落。健康的系统,压力降下来之后GC负担也应该跟着降。如果GC频率下来了但老年代占用保持高位,说明有对象赖在里面不走了。
Metaspace曲线是否随迭代次数增长。特别是对框架类、插件类的压测,如果类加载数量不断增加,就说明有类加载器泄漏的可能。
压测阶段发现内存异常的成本,远远低于线上事故。这个钱千万不能省。
5.3 21世纪排障必备:JVM启动参数和线上工具
我要求所有Java服务启动参数里必须带上这两类:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs -XX:+PrintGCDetails -Xloggc:/data/logs/gc.log第一类是让JVM在OOM的时候自动生成堆快照,免得OOM发生之后没有现场可查;第二类是保留GC日志,事后可以复盘整个内存变化周期。这两个参数本身没有性能开销,纯粹是保险。
线上诊断时,常用工具优先级我会这样排:能不用jmap就不用jmap(生产环境慎用),优先用Arthas的dashboard、heapdump命令,或者用JFR(Java Flight Recorder)录制一段时间的profile,低侵入地定位问题。jstack看线程状态、jstat看GC趋势,这两个命令都是只读的,相对安全,可以放心用。
5.4 从JDK版本和GC选型的角度降低泄漏影响
这里还想多说一句,内存泄漏其实是很难根除的,尤其是大型遗留系统,线上代码几百个模块,你不可能把一个一个的静态变量全翻一遍。所以除了聚焦代码,还要利用更抗造的运行参数兜底。
JDK 11之后,G1逐渐成为主流,JDK 17的ZGC、JDK 21的Generational ZGC又把大堆场景的暂停时间压得很低。选对GC算法本身不是解决泄漏,而是在泄漏存在的情况下尽可能拖延它造成的影响。比如ZGC能把Full GC的停顿降到毫秒级,即使在老年代内存持续增长的情况下,应用也可能扛很久不出现雪崩,给你争取排查窗口。
堆大小设置也同样重要。JVM堆设得太大,GC一次要扫很久;设得太小,内存水位涨得快,还没等到排查就已经OOM。一个相对稳妥的做法是先给JVM设2到4倍于"正常业务峰值时老年代占用"的堆大小,留出冗余,但也别盲目堆到32GB以上。
5.5 团队落地时的一些小约定
最后分享一些我在团队里执行了很久的小规矩,觉得很有用:
第一,全局状态和静态容器的改动必须过审。凡是往静态Map、全局缓存、Spring单例Bean里塞东西的代码,评审时必须说明:这个东西什么时候被移除、由谁移除、如果没有移除会怎样。回答不上来的,直接打回。
第二,周期性任务必须幂等且可清理。定时任务如果往缓存或集合里写东西,必须设计一个对应的清理策略,要么淘汰过期项,要么限定最大条目,要么用带容量上限的缓存组件。只增不减的定时任务就是定时炸弹。
第三,重大版本发布前做一次内存压测和GC日志体检。不需要非常精细,只要确认老年代曲线在压测结束之后能回落,就算合格。跑一次48小时的低峰期监控,成本不高,能避免很多半夜被叫起来的痛苦。
第四,把"内存曲线"纳入常规运维巡检项。不只是看CPU和磁盘,每周看一次老年代基线和Metaspace曲线,很多微小的内存泄漏在早期就能被发现,处理成本极低。
我在实际带团队的过程中发现,真正让内存泄漏变严重的不是某一个高深的底层机制,而是一个个看似毫无问题的常规代码叠加在一起。今天这里漏一个监听器没反注册,明天那里一个缓存没设上限,后天ThreadLocal忘了remove,每一个单独的坑都显得人畜无害,等它们在长时间运行的服务器上汇合起来,就像水管悄悄破了个口,漏水漏到地下室全淹了才发现。
排查技巧和工具说再多,也不如培养团队每个成员的条件反射:凡是生命周期长的容器、凡是线程池里的共享状态、凡是需要释放的资源,都要多想一步"它会不会只进不出"。多想想这一句,比什么都管用。