java并发编程实践 5.1 同步容器

缘起

《java并发编程实践》 5.1节

分析

jdk提供了丰富的并发容器. 本章就是介绍这些容器的. 从5.1节 同步容器开始. 所谓同步容器,包含

  1. Vector
  2. HashTable
  3. Collections.synchronizedXXX 方法获取的包装非线程安全的容器(如Map, List,Set等)得到的wrapper(包装容器)

三类. 为什么叫做同步容器? 因为他们的每个方法都通过自己实现的锁层(参见4.4节的最佳实践)实现了同步化——这样一次只能有一根线程能访问容器的状态.

正如4.4节看到的,就算容器中每个方法都是同步的,但是组合起来(如putIfAbsent方法这种check-then-act)未必是线程安全的. 所以同步容器遵循一个支持客户端加锁(所谓客户端加锁参见4.4节)的同步策略——需要让我们知道(以文档化或者其他什么形式)应该使用哪个锁,这样我们才能针对容器添加新的原子操作.例如我们为Vector添加原子的getLast方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public E getLast(Vector v) {
synchronized(v) {
int lastIndex = v.size() - 1;
return v.get(lastIndex);
}
}

public void traverse(Vector v) {
synchronized(v) {
for(int i = 0; i < v.size(); i++) {
System.out.println(v.get(i));
}
}
}

客户端加锁虽然可以解决线程安全问题,但是带来的问题也是明显的——我们完全阻止了其他线程在此期间对Vector状态的访问, 这削弱了并发性. 举个简单的例子——traverse的时候, 如果只是只读访问——例如上面只是打印. 那么完全可以支持其他线程进行只读操作.

虽然Vector是一个几乎废弃的容器,但是就算是现在更加”现代化”的容器,其实也没有完全消除复合操作产生的问题.

Collection迭代的标准方式是使用iterator(迭代器)——不论你是显式进行(就像上面的代码)的还是通过Java5引入的forEach新语法。但是如上所见——如果你在迭代的过程中有其他线程可能并发修改容器状态的话,你在迭代的过程中总是无可避免的需要加锁的. 但是当时在设计同步容器返回的迭代器的时候,并没有考虑到并发修改的问题, 所以让这些迭代器是fast-fail的(参见【1】).

意思是当迭代器察觉到容器状态在迭代期间被修改的话,就在next方法中直接抛出ConcurrentModificationException异常(注意,这个异常完全可能出现在单线程代码中,就像【1】中展示的那样在遍历的过程中移除元素一样),而不是继续迭代下去.

而且值得注意的是,对modCount是否等于expectedModCount的检查并没有在同步状态下进行的(出于对性能的影响的考虑.) 所以是存在一定风险的——看到modCount的过期数据,导致迭代器并没有发现容器状态已经被修改了.

但是其实在迭代期间加锁真的好么? 其实不好

  1. 如果上面traverse方法中对每个元素进行的操作并不仅仅是像上面打印那么简单的,而也是需要持有锁的操作的话,程序就有死锁的风险.
  2. 如果需要遍历的元素数量足够多,这就意味着有一根线程会在相当长的时间内把持这把锁,那么就可能存在线程饥饿的风险——破坏了程序的可伸缩性与吞吐量, 影响CPU的效能.

最后我们来看看下面一个程序——它隐含使用了容器的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HiddenIterator {
private final Set<Integer> set = new HashSet<Integer>();

public void add(Integer i) {synchronized(set) {set.add(i);}}
public void remove(Integer i) {synchronized(set) {set.remove(i);}}

public void addTenIntegers() {
Random r = new Random();
for(int i = 0; i<10;i++) {
add(r.nextInt());
System.out.println("DEBUG INFO: Now the set is: " + set);
}
}
}

上面的代码在多线程环境下可能抛出 ConcurrentModificationException. 因为第11行打印set其实就是对set进行遍历,而遍历的时候没加锁. 则如果此时有别的线程调用了add方法的话,修改了modCount,并且让处在遍历的线程发觉了modCount的改变,那么就会抛出ConcurrentModificationException.

解决办法是用java.util.Collections.SynchronizedSet(线程安全的wrapper) 而不是 HashSet.

因为java.util.Collections.SynchronizedSet的toString方法的实现源码是

1
2
3
public String toString() {
synchronized (mutex) {return c.toString();} // 加了锁的,mutex就是set本身
}

则不再抛出ConcurrentModificationException. 其实不仅仅是toString,hashCode、equals、containsAll、removAll 等会导致容器遍历的方法都会隐藏的堆容器进行迭代,如果迭代期间没有加锁,就可能导致抛出ConcurrentModificationException.

参考

【1】https://yfsyfs.github.io/2019/06/11/HashMap-%E7%AD%89%E9%9B%86%E5%90%88%E4%B8%AD%E7%9A%84%E5%BF%AB%E9%80%9F%E5%A4%B1%E8%B4%A5%E6%9C%BA%E5%88%B6%EF%BC%88fast-fail%EF%BC%89/