前言

本文隶属于专栏《100个问题搞定Java并发》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和参考文献请见100个问题搞定Java并发

正文

WHY

JDK 中很多容器类在面对复合操作会存在问题,无论在直接迭代还是在 Java 5.0 引人的 for - each 循环语法中,对容器类进行迭代的标准方式都是使用 Iterator ,然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。

在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”( fail - fast )的。

这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个 ConcurrentModificationException 异常。

WHAT

这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。

它们采用的实现方式是,将计数器的变化与容器关联起来:

如果在迭代期间计数器被修改,那么 hasNext 或 next 将抛出 ConcurrentModificationException

然而,这种检査是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。

这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。

单线程删除

在单线程代码中也可能抛出 ConcurrentModificationException 异常。

当对象直接从容器中删除而不是通过 Iterator.remove 来删除时,就容易抛出这个异常。

错误示例

package com.shockang.study.java.concurrent.iterator;

import java.util.ArrayList;
import java.util.List;

public class Test1 {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("1");
		list.add("2");
		for (String s : list) {
			if ("2".equals(s)) {
				list.remove(s);
			}
		}
		System.out.println(list);
	}
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
	at java.util.ArrayList$Itr.next(ArrayList.java:859)
	at com.shockang.study.java.concurrent.iterator.Test1.main(Test1.java:11)

正确示例

示例 1

package com.shockang.study.java.concurrent.iterator;

import java.util.ArrayList;
import java.util.List;

public class Test2 {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("1");
		list.add("2");
		for (String s : list) {
			if ("1".equals(s)) {
				list.remove(s);
			}
		}
		System.out.println(list);
	}
}
[2]

示例 2

package com.shockang.study.java.concurrent.iterator;

import java.util.ArrayList;
import java.util.List;

public class Test3 {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("1");
		list.add("2");
		for (String s : list) {
			if ("2".equals(s)) {
				list.remove(s);
				break;
			}
		}
		System.out.println(list);
	}
}
[1]

示例 3

package com.shockang.study.java.concurrent.iterator;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Test4 {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("1");
		list.add("2");
		Iterator<String> it = list.iterator();
		while (it.hasNext()) {
			String next = it.next();
			if ("2".equals(next)) {
				it.remove();
			}
		}
		System.out.println(list);
	}
}
[1]

示例 4

package com.shockang.study.java.concurrent.iterator;

import java.util.ArrayList;
import java.util.List;

public class Test5 {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("1");
		list.add("2");
		list.removeIf("2"::equals);
		System.out.println(list);
	}
}
[1]

迭代期间加锁

然而,有时候开发人员并不希望在迭代期间对容器加锁。

例如,某些线程在可以访问容器之前,必须等待迭代过程结束,如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待,同时也可能存在死锁的风险。

关于死锁请参考我的这篇博客——死锁、活锁和饥饿是什么意思?

即使不存在饥饿或者死锁等风险,长时间地对容器加锁也会降低程序的可伸缩性。

持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和 CPU 的利用率。

如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。

参考我的这篇博客——一篇文章搞懂 CopyOnWriteArrayList

由于副本被封闭在线程内,因此其他线程不会在选代期间对其进行修改,这样就避免了抛出 ConcurrentModificationException (在克隆过程中仍然需要对容器加锁)。

在克隆容器时存在显著的性能开销。

这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

隐蔽的迭代器

虽然加锁可以防止迭代器抛出 ConcurrentModificationException ,但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。

实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来。

容器的 hashCodeequals 等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。

同样, containsAllremoveAllretainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。

所有这些间接的迭代操作都可能抛出 ConcurrentModificationException 。

上一篇 下一篇