《Java核心技术卷》读书笔记-并发(九)- 线程安全的集合

97 阅读5分钟

常用的集合并不安全

常用的集合在多线程场景下并不安全。例如hashmap,并发修改的情况下,是很容易破坏其数据结构的。一个线程可能要开始向表中插入一个新元素。假定在调正散列表各个桶之间的链接关系的过程中,被剥夺了控制权。如果另一个线程也开始遍历同一个链表,可能使用无效的链接并造成混乱,会抛出异常或陷入死循环。

高效的映射、集和队列

并发包提供的映射、有序集和队列的高效实现有:

  • ConcurrentHashMap 可以被多线程安全访问的散列映射表

  • ConcurrentSkipListMap 可以被多线程安全访问的有序的映射表

  • ConcurrentSkipListSet 可以被多线程安全访问的有序集

  • ConcurrentLinkedQueue 可以被多线程安全访问的无边界非阻塞的队列

这些容器使用复杂的算法,通过允许并发地访问数据结构地不同部分来使竞争极小化。

需要注意,集合返回弱一致性的迭代器,这意味着迭代器不一定能反映出它们被构造之后的所有修改,但是,他们不会将同一个值返回两次,也不会抛出ConcurrentModificationException。

映射条目的原子更新

ConcurrentHashMap原来的版本只有为数不多的方法可以实现原子更新。这意味着普通的更新(map.put)并不线程安全。

虽然没有保证线程安全。但是其和普通的HashMap不一样的是,普通的hashmap在多线程场景下可能会被破坏数据结构,丢失链接甚至构成循环。但ConcurrentHashMap不会出现数据结构不可用的情况,只是结果会不可预知。

其原子更新的方式有:

方式一:replace方法会以原子方式用一个新值替换原值


do {

oldValue = map.get(word);

newValue = oldValue == null ? 1 : oldValue : 1;

} while(!map.replace(word, oldValue, newValue))

方式二:使用ConcurrentHashMap<String, AtomicLong> 或在JavaSE8中,还可以使用ConcurrentHashMap<String, LongAdder>


map.putIfAbsent(word, new LongAdder().increment());

方法三:使用compute方法,其可以完成原子更新操作,函数参数分别为键和值:


map.compute(word, (k, v) -> v == null ? 1 : v + 1);

与compute类似的还有computeIfPresent和computeIfAbsent方法。

方法四:merge方法提供了一个初始值和聚合函数。


map.merge(word, 1L, Long:sum);

对于使用compute和merge的注意项:

  1. 如果传入compute或merge的函数返回null,将从映射中删除现有条目。

  2. 使用compute或merge时,要记住你提供给的函数不能做太多工作。这个函数运行时,可能会阻塞对映射的其他更新。当然,这个函数也不能更新映射的其他部分。

对并发散列映射的批操作

有三种不同的操作:

  • 搜索(search)为每个键或值提供一个函数,知道函数生成一个非null的结果。然后搜索终止返回。

  • 归约(reduce)组合所有键或值,这里要使用所提供的一个累加函数。

  • forEach为所有键或值提供一个函数。

每个操作都有4个版本:

  • operationKeys: 处理键。

  • operationValues: 处理值。

  • operation: 处理键和值

  • operationEntries: 处理Map.Entry对象。

这些操作都需要指定一个阈值,当映射包含的元素大于这个阈值,就会并行完成批操作。

这些操作函数中,为操作提供的函数参数会分为转换器函数(Transformer)和消费者(Consumer)。当转换器返回null时,这个值就不会处理,会跳过。如forEach和reduce,都可以提供两个函数,或值提供一个消费函数。

并发集视图

并没有ConcurrentHashSet类供你使用,取而代之的,我们可以使用ConcurrentHashMap的静态newKeySet方法。这个方法实际上是ConcurrentHashMap<K, Boolean>的一个包装器。


Set<String> set = ConcurrentHashMap.<String>newKeySet();

如果原来就有一个映射,可以用keySet方法获取键集,JavaSE8之前是可删不可增的。8之后,通过keySet([默认值])方法可以为集增加元素。注意,对集的修改都会同步在映射中修改。

写拷贝数组

CopyOnWriteArrayList和CopyOnWriteArraySet集合在修改时,其所有的修改线程会对底层数组进行复制,以确保线程安全。当然,这也意味着,迭代器获取到的视图有可能是过时的。因此这类容器适用于迭代的线程数超过修改线程数,且一致性要求不高。

并行数组算法

主要介绍三个Arrays类的并行化操作。

  • Arrays.parallelSort方法:顾名思义,可以并行的对数组排序,支持自定义比较器,但是比较器不能有并行上的副作用。

  • Arrays.parallelSetAll方法:会用一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算对应位置上的值。

  • Arrays.parallelPrefix方法:用一个给定结合操作的前缀的累加结果替换各个数组元素。参考前缀和。

较早的线程安全集合

较早的线程安全集合有Vector和Hashtable,但是现在已经弃用。对于非并发包的工具类,其实我们可以用同步包装器使其线程安全:


List<E> syncArrayList = Collections.synchronizedList(new ArrayList<E>());

Map<K, V> syncHashMap = Collections.synchronizedMap(new HashMap<K, V>());

结果集合的方法使用锁保护,提供线程安全访问。

但是,如果在另一个线程中可能进行修改时要对集合进行迭代,还是需要对迭代加锁:


synchronized(syncHashMap) {

Iterator<K> iter = syncHashMap.keySet().iterator();

while(iter.hasNext()) ...

}

如果迭代过程中被修改集合了,那就会抛出异常。但是还是需要加锁,很麻烦,开销也大,所以并不推荐使用同步包装器,能用并发包中定义的集合就用并发包中定义的集合。