今天不聊太枯燥的底层源码,分享一个写业务代码时容易踩到的“小坑”。
我们经常会用到 subList 来截取集合,这本身没问题。但在某些特定场景下,如果用法随意,很容易引发隐蔽的内存泄漏,等线上问题暴露出来时就晚了。
举个最典型的真实场景:
你需要对接一个不受控制的第三方老接口,它不支持分页,每次调用都会把几千条全量数据全部返回。而你自己的业务只需要取前10条数据做首页展示,并且为了提升性能,会把这10条数据放到本地缓存里。
代码可能很自然地就这么写了:
// 从第三方老接口拉取全量数据,假设有 5000 条
List<String> allData = thirdPartyApi.getAllData();
// 截取前 10 条
List<String> top10 = allData.subList(0, 10);
// 放入本地长生命周期的缓存中
LocalCache.put("home_top_10", top10);
这段代码在本地运行一切正常,拿到的确实只有10条数据。但如果这个逻辑每隔几分钟就执行一次,线上服务器的内存占用就会像温水煮青蛙一样慢慢涨上去,最终可能触发 OOM。
为什么仅仅10条数据会引起内存泄漏?
问题的根源藏在 subList 的底层实现里。我们看一眼 ArrayList 的源码就明白了:
public List<E> subList(int fromIndex, int toIndex) {
// ... 省略越界检查
// 注意看这里,返回的是一个内部类 SubList,并且把 this (原集合) 传了进去
return new SubList<>(this, fromIndex, toIndex);
}
// ArrayList 的内部类
private class SubList<E> extends AbstractList<E> implements RandomAccess {
private final ArrayList<E> parent; // 👈 致命的引用在这里
SubList(ArrayList<E> parent, int offset, int fromIndex, int toIndex) {
// 这个内部类保留了原大集合的引用
this.parent = parent;
// ...
}
}
看出来了吗?ArrayList 的 subList 方法,并没有真正创建一个只包含截取数据的新集合。它返回的是一个内部“视图”类(SubList)。这个视图类通过 this.parent = parent; 这行代码,牢牢持有了对原集合 allData 的强引用。
这意味着:当你把截取出来的 top10 放进长生命周期的缓存后,这10条数据会一直存活。而它的底层(SubList 对象)又死死拽着那5000条数据的原集合 allData,导致垃圾回收器(GC)根本无法回收那个庞大的集合。表面上你只缓存了10条数据,实际上内存里被迫保留了整整5000条。这种设计在处理大数据集和缓存时,尤其需要警惕。
正确的解决办法是什么?
最简单的方案,就是在截取之后,用一个新的 ArrayList 包装一下,创建一个全新的对象,彻底切断它与原集合的引用关系:
// 推荐写法:新建一个ArrayList,彻底切断底层引用
List<String> top10 = new ArrayList<>(allData.subList(0, 10));
如果你习惯使用 Java 8 的 Stream API(或者需要顺便做一些过滤、映射操作),也可以这样写:
List<String> top10 = allData.stream()
.limit(10)
.collect(Collectors.toList());
subList 引发的这种内存泄漏,在编写代码和跑单元测试时基本发现不了。它通常是在系统平稳运行一段时间后,随着内存被逐渐蚕食才会暴露问题,排查起来相当费劲。
建议大家,尤其是Java开发者,周末有空时可以全局搜一下自己项目里的 .subList 调用。如果发现截取后的集合被作为返回值长期持有,或者被存进了缓存、静态变量等长生命周期的地方,顺手给它套个 new ArrayList<>() 改掉吧。
一个小小的习惯,就能避免一个潜在的大坑。这个问题也欢迎大家在云栈社区分享和交流更多的“坑”与解决方案。
最后,祝大家编码愉快,周末开心!
