fail-fast 与 fail-safe 机制

面试官问:"在遍历 ArrayList 时,如果同时修改,会发生什么?"

候选人小杜答:"会抛出 ConcurrentModificationException。"

面试官点点头:"为什么?"

小杜说:"因为 modCount 和 expectedModCount 不一致。"

面试官追问:"那怎么正确地在遍历时删除元素?"

小杜说:"用 Iterator.remove()..."

面试官追问:"除了 Iterator,还有别的方法吗?"

小杜答不上来。

【面试官心理】 这道题考的是候选人对 Java 并发集合迭代器机制的理解深度。能说出 modCount 机制和三种正确遍历删除方式的候选人,说明对并发编程有一定积累。

一、fail-fast 机制 🔴

1.1 ConcurrentModificationException

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");

for (String s : list) {
    if ("b".equals(s)) {
        list.remove(s); // ❌ 抛出 ConcurrentModificationException
    }
}

1.2 modCount 机制

// ArrayList 的核心字段
protected transient int modCount = 0; // 修改次数
protected int expectedModCount;        // 迭代器期望的修改次数

// 迭代器创建时
private class Itr implements Iterator<E> {
    int cursor = 0;
    int lastRet = -1;
    int expectedModCount = modCount; // 快照:创建时记录修改次数

    public boolean hasNext() {
        return cursor != size;
    }

    public E next() {
        checkForComodification(); // 检查 modCount 是否变化
        // ...
    }

    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

// 每次 add/remove 时:
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    modCount++; // ❌ 修改了 modCount,但迭代器不知道
    return true;
}

1.3 为什么要 fail-fast

// fail-fast 的目的是:尽早发现并发修改
// 避免在遍历时产生不可预期的行为

// 例如:一边遍历一边删除
// 如果不抛异常:可能跳过元素、产生不可预期的结果
// 抛异常:至少让开发者知道问题所在
⚠️

fail-fast 不是线程安全机制,它只是检测"非预期的并发修改"。在单线程中使用 for-each 遍历时手动删除,也会触发 fail-fast。

二、fail-safe 机制 🔴

2.1 什么是 fail-safe

fail-safe:遍历的是集合的快照,修改不影响遍历。

// CopyOnWriteArrayList 的迭代器
static final class COWIterator<E> implements ListIterator<E> {
    private final Object[] snapshot; // 数组快照
    private int cursor;

    COWIterator(Object[] elements, int initialCursor) {
        snapshot = elements; // ❌ 直接持有数组引用(快照)
        cursor = initialCursor;
    }

    public E next() {
        if (!hasNext()) throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }
}

2.2 fail-safe vs fail-fast 对比

维度fail-fastfail-safe
遍历基础实时数据快照
检测并发修改✅ 检测到抛异常❌ 不检测
内存开销有(快照副本)
数据一致性遍历时可能读到旧数据遍历的是快照,总是读到某个时间点的数据
典型实现ArrayList, HashMapCopyOnWriteArrayList, ConcurrentHashMap

三、正确遍历删除的三种方式 🔴

3.1 使用 Iterator.remove()

// ✅ 正确方式一:Iterator.remove()
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if ("b".equals(it.next())) {
        it.remove(); // ✅ 调用迭代器的 remove
    }
}

// 原因:Iterator.remove() 会同步 expectedModCount
// public void remove() {
//     if (lastRet < 0) throw new IllegalStateException();
//     checkForComodification();
//     arrayList.remove(lastRet);
//     expectedModCount = modCount; // ✅ 同步
// }

3.2 使用 removeIf()(JDK 9+)

// ✅ 正确方式二:removeIf(最简洁)
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

list.removeIf(s -> "b".equals(s)); // JDK 9+

// 底层实现:
// 1. 使用 writeLock 保证原子性
// 2. 内部使用条件变量,性能好

3.3 倒序遍历

// ✅ 正确方式三:倒序遍历
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

for (int i = list.size() - 1; i >= 0; i--) {
    if ("b".equals(list.get(i))) {
        list.remove(i); // ✅ 删除后,前面的元素索引不变
    }
}

// 注意:正序遍历时删除,索引会变化,可能跳过元素
// [a, b, c], i=0, 删除 b → [a, c], i=1 → 跳过 c!

四、ConcurrentHashMap 的 fail-safe 🟡

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.put("b", 2);

// ConcurrentHashMap 的迭代器是弱一致性的:
// 遍历时可能读到正在 transfer 的数据(旧值或新值)
// 但不会抛出 ConcurrentModificationException
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    // 可以安全地遍历和修改
    if ("a".equals(entry.getKey())) {
        entry.setValue(10); // ✅ 可以修改值
    }
}

五、追问升级

面试官:"ConcurrentHashMap 的迭代器和 HashMap 的迭代器有什么区别?"

// HashMap 迭代器:fail-fast
// 如果在迭代过程中修改集合,抛出 ConcurrentModificationException

// ConcurrentHashMap 迭代器:弱一致性(weakly consistent)
// 1. 不会抛出 ConcurrentModificationException
// 2. 遍历时可能看不到最近的修改
// 3. 迭代器创建后,集合的修改对迭代器"可见性不确定"
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
map.put("c", 3); // 可能在迭代器中看不到这个新元素

// 原因:迭代器遍历的是某个时刻的部分快照
// 而不是整个集合的完整快照

【面试官心理】 能说出 ConcurrentHashMap 迭代器"弱一致性"特性的候选人,说明对并发集合有深入理解。这是 P6+ 的要求。