ForkJoinTask.join()调用偶发ConcurrentModificationException排查
问题背景
基于ForkJoinTask实现爬虫的页面链接扫描、递归爬取逻辑时,调用.join()方法收集子任务执行结果偶发ConcurrentModificationException异常,异常栈指向join调用行,尝试用ReentrantLock包裹join相关代码段未解决问题。
关联实现代码
Spider[] newSpiders = crawlForURLs("a", "href").stream() .filter(url -> SpiderUtils.isLinkURLValid(url)) .map(url -> new Spider(url).fork()).toArray(Spider[]::new); for (Spider c : newSpiders) { Image[] crawledImages = c.join(); }
异常对应代码位置
com.eulerity.hackathon.imagefinder.Spider.compute (Spider.java:85):对应代码为Image[] crawledImages = c.join();com.eulerity.hackathon.imagefinder.ImageFinder.doPost (ImageFinder.java:45):对应代码为images = SpiderUtils.commonPool.invoke(baseSpider);
根因分析
ConcurrentModificationException指向join行不代表异常由join方法本身抛出,加锁无效是因为锁的覆盖范围完全错误,核心触发逻辑如下:
- 该异常是集合fail-fast机制的标准表现:遍历非线程安全集合时,集合内部维护的修改计数(modCount)和遍历开始时记录的预期值不一致,即遍历过程中集合被执行了新增/删除等结构修改操作。ForkJoinPool的工作窃取机制会让
fork()提交的子任务立刻由池内工作线程并行执行,不会等代码走到join()逻辑才开始运行。 - 最高频触发点:
crawlForURLs返回的是全局共享的非线程安全集合(比如爬虫全局维护的已爬链接集合、待爬队列),在对该集合执行stream().filter().map()遍历的同时,已经fork出去的子任务正在修改这个共享集合,直接触发异常。仅给后续join循环加锁,完全没覆盖前面stream遍历集合、子任务修改集合的代码路径,起不到互斥作用。 - 次高频触发点:多个Spider子任务同时往同一个普通
ArrayList/HashSet/HashMap这类非线程安全集合写入爬取结果、已访问标记,当join触发当前线程帮着执行其他子任务时,就可能在遍历集合的过程中碰到其他线程修改集合结构的情况,抛出异常。
修复方案
- 替换所有跨ForkJoinTask共享的非线程安全集合:已访问URL集合用
ConcurrentHashMap.newKeySet()实现;如果需要多任务共同汇总爬取结果,要么用Collections.synchronizedList包装集合,要么让每个子任务返回独立的结果数组,等所有子任务join完成后再在当前线程统一合并,禁止多个子任务同时往同一个普通非线程安全集合写数据。 - 调整
crawlForURLs返回逻辑:不要直接返回全局共享的链接集合,每次爬取完当前页面的链接后,返回当前页面独立的集合快照(比如new ArrayList<>(当前页爬取到的临时链接集合)),保证stream遍历的集合是当前任务私有的,不会被其他并行任务修改。 - 遍历集合时避免结构修改:如果确实需要在遍历过程中调整集合内容,不要直接用for-each循环遍历原集合,要么用迭代器提供的
remove()方法操作,要么遍历前先生成集合快照(比如new ArrayList<>(原集合)),遍历快照对象,从根源避免遍历过程中集合结构被改动。 - 锁使用修正:如果选择用互斥锁保护共享状态,必须把所有读、写共享集合的代码路径全放在同一个锁的保护范围内,不能只锁join片段,否则锁完全不会生效。
内容的提问来源于stack exchange,提问作者WubbaLubbaDubbDubb
相关产品推荐
相关产品推荐

