线程同步容器
同步容器类
同步容器类包括Vector
和Hashtable
,二者是早期JDK
的一部分,这些同步的封装器类是由Conllections.synchronizedXxx
等工厂方法创建的。
这些类实现线程安全的模式是:将他们的状态封装起来,并对每个公有方法进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
同步容器类都是线程安全的,但是在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算。
例如 “若没有则添加”,当其他线程并发地修改容器时,他们可能会表现出意料之外的行为。
1 | public static Object getLast(Vector list) { |
这些方法看似没有任何问题,从某种程度来说也确实如此——无论多少个线程同时调度他们,也不破坏Vector
。但是从这些调用者角度来看,情况就不同了。
如果 线程A 在包含10个元素的Vector
上调用getLast
,同时 线程B 在同一个Vector
上调用deleteLast
,这些操作的交替执行如下图。
交替调用getLast
和deleteList
时将抛出ArrayIndexOutOfBoundsException
,同步容器类通过其自身的锁来保护它的每个方法,通过获得容器类的锁,我们可以使getLast
和deleteLast
成原子操作,并确保Vector
的大小在调用size
和get
之间不会发生变化。
1 | public static Object getLast(Vector list) { |
在调用size
和相应的get
之间,Vector
的长度可能会发生变化,这种风险在对Vector
中的元素进行迭代时仍然会出现。
1 | for(int i = 0; i < vector.size(); i++) { |
在迭代的过程中,有其他线程并发地修改Vector
时,可能抛出异常,但并不意味着Vector
就不是线程安全的。Vector
的状态仍然是有效的,但是迭代中抛异常显然不是人们所期望的。
我们可以通过加锁来解决不可靠迭代的问题,但是要牺牲一些伸缩性,通过在迭代期间持有Vector
的锁,然而,这样同样会导致其他线程在迭代期间无法访问它,因此降低了并发性。
1 | synchronized (vector) { |
ConcurrentModificationException
无论直接迭代还是for-each
循环,对容器类进行迭代的标准方式都是使用Iterator
,如果有其他线程并发地修改容器,即使是使用迭代器也无法避免在迭代期间对容器加锁。
在设计同步容器类的迭代器时并没有考虑并发修改的问题,并且它们表现出的行为是 “及时失败” 的。这意味着,当他们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException
异常。
这种 “及时失败” 的迭代器并不是一种完善的处理机制,而只是善意的捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化于容器关联起来:如果迭代期间计数器被修改,那么hasNext
或next
将抛出ConcurrentModificationException
。
然而有些时候开发人员并不希望在迭代期间对容器加锁,如果容器的规模很大,或者执行时间很长,长时间对容器加锁会降低程序的可伸缩性,持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和CPU的利用率。
如果不希望在迭代期间对容器加锁,那么一种替代方法就是 “克隆容器“,并在副本上进行迭代,由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出异常,但是在克隆过程中仍然需要对容器加锁。
隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException
,但在某些情况下,迭代器会隐藏起来,如以下程序
1 | public class HiddenIterator { |
该方法可能会抛出ConcurrentModificationException
,因为在打印的过程中,toString
对容器进行迭代,当然真正的问题在于HiddenIterator
不是线程安全的。在使用println
中的set
之前必须先获得锁,但是在代码中通常会忽略。
ConcurrentHashMap
与HashMap
一样,ConcurrentHashMap
也是基于散列的Map
,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性,ConcurrentHashMap
并不是将每个方法都在同一个锁上同步并使得每次只有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。
在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入的操作的线程可以并发地访问Map
,并且一定数量的写入线程可以并发修改Map
,ConcurrentHashMap
带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap
与其他并发容器一起增强了同步容器类:它提供的迭代器不会抛出ConcurrentModificationException
因此不需要再迭代过程中对容器加锁,ConcurrentHashMap
返回的迭代器具有弱一致性,弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反应给容器。
与Hashtable
和synchronizedMap
相比,ConcurrentHashMap
有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap
来替代同步Map
能进一步提高代码的可申缩性。
额外的原子Map操作
一些常见的复合操作,例如:若没有则添加、若相等则移除和若相等则替换等,都已经实现为原子操作并且在ConcurrentMap
的接口中声明。
1 | public interface ConcurrentMap<K, V> extends Map<K, V> { |
CopyOnWriteArrayList
“本篇文章主要摘自《JAVA 并发编程实战》”
最后更新: 2020年05月26日 12:01
原始链接: https://midkuro.gitee.io/2020/05/21/thread-containers/