Java 8新特性用错等于“埋雷”——Stream流遍历 ConcurrentModificationException、Lambda表达式内存泄漏

6 阅读3分钟

8年Java开发,踩过的坑比写过的代码还多。今天聊两个让我线上P0过、半夜起来修过的问题:Stream遍历时莫名其妙抛ConcurrentModificationException,以及Lambda表达式“以为很优雅,结果内存泄漏”

一、Stream流遍历的“隐形雷”:ConcurrentModificationException

1. 现象:代码看起来没问题,运行就报错

java

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.stream().forEach(item -> {
    if ("B".equals(item)) {
        list.remove(item);   // ❌ 这里会抛异常
    }
});

异常信息:

text

java.util.ConcurrentModificationException

2. 为什么?——Stream遍历时不能修改原集合

Stream底层使用了迭代器(Iterator) ,遍历过程中会维护一个modCount(修改次数计数器)。
当你用list.remove()直接修改原集合时,modCount变了,但迭代器不知道,于是抛异常。

简单记:Stream只读,不能边遍历边增删改原集合

3. 正确做法(3种,按场景选)

✅ 方案1:用 removeIf(推荐,最简洁)

java

list.removeIf("B"::equals);

✅ 方案2:收集要删除的元素,遍历完后统一删

java

List<String> toRemove = new ArrayList<>();
list.stream().forEach(item -> {
    if ("B".equals(item)) {
        toRemove.add(item);
    }
});
list.removeAll(toRemove);

✅ 方案3:用 Iterator 手动遍历

java

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if ("B".equals(it.next())) {
        it.remove();   // ✅ 安全
    }
}

4. 扩展陷阱:filter 里也不能直接删

java

// ❌ 错误
list.stream()
    .filter(item -> {
        if ("B".equals(item)) list.remove(item);
        return true;
    })
    .collect(Collectors.toList());

filter 里做副作用操作(删除、修改原集合)同样是雷。


二、Lambda表达式的“内存泄漏”:你以为优雅,其实GC回收不掉

1. 现象:服务跑几天就OOM,堆dump里全是某个匿名类

2. 为什么?——Lambda捕获了外部引用

Lambda表达式如果捕获了外部对象的引用,并且这个Lambda被长期持有(比如放进静态集合、线程池、监听器),那么外部对象也无法被GC回收。

典型错误场景:

java

public class Handler {
    private static List<Runnable> tasks = new ArrayList<>();

    public void addTask() {
        // ❌ 这里捕获了 this(整个Handler实例)
        Runnable r = () -> System.out.println(this.toString());
        tasks.add(r);   // tasks是static,永远不释放,导致Handler无法GC
    }
}

3. 真实线上案例(我踩过的)

一个Spring Boot项目,在循环里用Lambda往ExecutorService提交任务,每个Lambda都捕获了当前HttpServletRequest对象。
请求结束后,这些Lambda还留在队列里,导致Request对象无法回收。
跑了3天,老年代占满,Full GC频繁,最终OOM。

4. 正确做法(3条原则)

✅ 原则1:避免捕获不必要的this

java

// ❌ 隐式捕获this
Runnable r = () -> System.out.println(this.field);

// ✅ 用局部变量
String field = this.field;
Runnable r = () -> System.out.println(field);

✅ 原则2:Lambda里不要持有大对象引用

java

// ❌ 捕获整个request
request.getParameterMap();  

// ✅ 只取需要的数据
String param = request.getParameter("id");
Runnable r = () -> process(param);

✅ 原则3:不要将Lambda长期存放到静态集合/线程池中

java

// ❌ 静态List存Lambda
private static List<Callable<String>> cache = new ArrayList<>();

// ✅ 改用普通局部变量,或及时清理

5. 如何排查Lambda内存泄漏?

  1. dump堆jmap -dump:live,format=b,file=heap.hprof <pid>
  2. 用MAT(Eclipse Memory Analyzer)打开,查看Leak Suspects
  3. 搜索$$Lambda$类,看哪些被大量实例化且无法回收
  4. 追踪这些Lambda的引用链,找到谁在持有它们

三、总结:两个“优雅陷阱”的保命口诀

新特性错误用法正确姿势
Stream遍历时 remove/add 原集合removeIfIterator.remove()、先收集后删除
Lambda长期持有捕获外部引用的Lambda避免捕获this、避免存静态集合、用局部变量替代

一句话记:

Stream只读不写,Lambda用完即扔。


四、互动一下

你在项目中遇到过哪些“以为很优雅,结果线上炸了”的Java 8写法?
评论区见,一起避雷👇


如果你觉得有用,点个赞/收藏,产假期间我会继续输出Java实战踩坑系列。