集合上的单次方法调用与“遍历、检查、修改”这类组合操作是不同的问题。面试不只要回答哪个类线程安全,还要知道安全保证覆盖多大的操作范围。
什么是 fail-fast?
ArrayList、HashMap 等普通集合的迭代器通常记录创建时的结构修改计数,遍历过程中发现不符合预期的修改,就可能抛 ConcurrentModificationException。
它是尽力发现错误的机制,不是线程安全机制,也不保证发现所有并发修改。单线程遍历时直接修改集合,同样可能触发这个异常。
List<String> names = new ArrayList<>(Arrays.asList("A", "B", "C"));Iterator<String> it = names.iterator();while (it.hasNext()) { if ("B".equals(it.next())) { it.remove(); }}需要删除元素时,可以使用支持删除的 Iterator.remove,或使用集合支持的 removeIf。Iterator.remove 必须符合调用顺序,一般在 next 后调用一次;它并没有让整个遍历变成多线程安全操作。
增强 for 为什么不能随意 remove?
对普通 Iterable 的增强 for 通常使用迭代器。循环里调用 list.remove(...) 会绕过迭代器自己的状态管理,后续迭代可能检测到修改;也可能因遍历提前结束而没有抛异常,所以“这次没报错”不能证明写法正确。
并非所有值变化都是结构修改,例如 ArrayList.set 通常不改变元素数量;但这也不意味着可随意并发调用 set。
三种常见安全方式
| 方式 | 核心保证 | 使用边界 |
|---|---|---|
| synchronizedList 等同步包装 | 对单次方法做同步保护 | 遍历和组合操作通常还要使用同一把锁 |
| CopyOnWriteArrayList | 写时复制,迭代读取快照 | 写入成本高,迭代不会反映后续修改,迭代器不支持 remove |
| ConcurrentHashMap | 支持高并发访问,迭代弱一致 | 迭代不保证看到某个统一时刻的全局快照 |
“fail-safe”不是集合 API 对这些机制的统一正式分类,复习时直接说快照迭代、弱一致迭代更准确。
线程安全 Map 的组合操作一定安全吗?
不一定。if (!map.containsKey(key)) map.put(key, value) 包含两个独立操作,另一个线程可以插入在它们之间。
使用 ConcurrentHashMap 时,可以通过 putIfAbsent、compute 等提供的原子操作表达对应逻辑。回调应简短,不放耗时远程调用或复杂的相互更新,避免竞争和阻塞。
Arrays.asList 与不可修改列表
Arrays.asList 返回由原数组支持的固定大小列表,通常支持 set,但 add/remove 会抛 UnsupportedOperationException;修改数组与修改列表元素会相互影响。把基本类型数组传进去,例如 int[],得到的通常是包含一个数组元素的列表,不是整数逐项组成的列表。
List.of(JDK 9+)返回不可修改列表,并拒绝 null 元素。Collections.unmodifiableList 返回不可修改视图,原列表通过其他引用修改后,视图可能跟着变化。不可修改列表也不保证其中每个对象不可变;需要可修改副本时,可以显式 new ArrayList<>(source)。
面试回答模板
fail-fast 通过修改计数尽力发现不符合预期的结构修改,可能在单线程或多线程出现,但不提供线程安全保证。普通集合删除优先使用支持的迭代器方法或 removeIf。同步集合通常只保护单次调用,遍历和组合逻辑还要考虑整体同步。CopyOnWriteArrayList 读取快照,ConcurrentHashMap 迭代弱一致,不能把它们都理解为实时完整快照。
自测
- 没有 ConcurrentModificationException 能证明并发修改安全吗?不能。
- synchronizedList 的 for-each 是否自动在整个遍历期间加锁?不会。
- ConcurrentHashMap 的 containsKey 加 put 是一个原子操作吗?不是,应根据意图使用原子 API。