常用的集合并不安全
常用的集合在多线程场景下并不安全。例如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的注意项:
-
如果传入compute或merge的函数返回null,将从映射中删除现有条目。
-
使用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()) ...
}
如果迭代过程中被修改集合了,那就会抛出异常。但是还是需要加锁,很麻烦,开销也大,所以并不推荐使用同步包装器,能用并发包中定义的集合就用并发包中定义的集合。