1. 同步容器及其注意事项
在12. 如何用面向对象思想写好并发程序?介绍了面向对象思想写并发程序。
class SafeArrayList<T> {
// 封装 ArrayList
List<T> c = new ArrayList<>();
// 控制访问路径
synchronized T get(int idx) {
return c.get(idx);
}
synchronized void add(int idx, T t) {
c.add(idx, t);
}
synchronized boolean addIfNotExist(T t) {
if (!c.contains(t)) {
c.add(t);
return true;
}
return false;
}
}
这是很简单的实现线程安全。
Java SDk里面已经有现成的线程安全类,分别把 ArrayList、
HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map。
List list = Collections.synchronizedList(new ArrayList());
Set set = Collections.synchronizedSet(new HashSet());
Map map = Collections.synchronizedMap(new HashMap());
组合操作需要注意竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性。
在容器领域一个容易被忽视的“坑”是用迭代器遍历容器,下面的作法不保证组合操作的原子性。
List list = Collections.synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
正确作法如下:
List list = Collections.synchronizedList(new ArrayList());
synchronized (list) {
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
}
包装类的公共方法锁的是对象的 this,也就是这里的list。
2. 并发容器及其注意事项
同步容器所有方法都是用synchronized加锁,性能差,java1.5之后提供性能更好的容器,我们一般称为并发容器。
常用的并发容器如下:
2.1 List
CopyOnWriteArrayList, CopyOnWrite写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。
CopyOnWriteArrayList内部维护了一个数组,成员变量 array就指向这个内部数组,所有的读操作都是基于 array进行的,如下图所示,迭代器 Iterator 遍历的就是 array数组。
如果在遍历array的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList是如何处理的呢?CopyOnWriteArrayList会将array复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将array指向这个新的数组。通过下图你可以看到,读写是可以并行的,遍历操作一直都是基于原array执行,而写操作则是基于新array进行。
注意的坑:
- 应用场景,CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致。
- CopyOnWriteArrayList迭代器是只读的,不支持增删改。
2.2 Map
两个实现是 ConcurrentHashMap 和 ConcurrentSkipListMap,主要区别在于ConcurrentHashMap 的 key 是无序的,而 ConcurrentSkipListMap 的 key 是有序的。两者的key和value都不允许为null,否则报错。
ConcurrentSkipListMap使用跳表,性能更好点。跳表插入、删除、查询操作平均的时间复杂度是 O(log n),理论上和并发线程数没有关系,所以在并发度非常高的情况下,若你对ConcurrentHashMap 的性能还不满意,可以尝试一下ConcurrentSkipListMap。
2.3 Set
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet,使用场景可以参考前面讲述的CopyOnWriteArrayList和ConcurrentSkipListMap,它们的原理都是一样的。
2.4 Queue
从两个维度来分类。
- 阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞;
- 单端与双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。
阻塞队列都用Blocking关键字标识,单端队列使用Queue 标识,双端队列使用Deque 标识。
- 单端阻塞队列:其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列,这个队列可以是数组(其实现是ArrayBlockingQueue)也可以是链表(其实现是LinkedBlockingQueue);甚至还可以不持有队列(其实现是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能,性能比LinkedBlockingQueue更好;PriorityBlockingQueue支持按照优先级出队;DelayQueue支持延时出队。
- 双端阻塞队列:其实现是 LinkedBlockingDeque。
- 单端非阻塞队列:其实现是 ConcurrentLinkedQueue。
- 双端非阻塞队列:其实现是 ConcurrentLinkedDeque。
只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的,在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患。
3.总结
实际工作,熟悉容器特性,选对容器很关键。
4.课后思考
线上系统 CPU 突然飙升,你怀疑有同学在并发场景里使用了 HashMap,因为在 1.8 之前的版本
里并发执行 HashMap.put() 可能会导致 CPU 飙升到 100%,你觉得该如何验证你的猜测呢?