gitbook/Java并发编程实战/docs/90201.md

162 lines
11 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 20 | 并发容器:都有哪些“坑”需要我们填?
Java并发包有很大一部分内容都是关于**并发容器**的,因此学习和搞懂这部分的内容很有必要。
Java 1.5之前提供的**同步容器**虽然也能保证线程安全但是性能很差而Java 1.5版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。
## 同步容器及其注意事项
Java中的容器主要可以分为四个大类分别是List、Map、Set和Queue但并不是所有的Java容器都是线程安全的。例如我们常用的ArrayList、HashMap就不是线程安全的。在介绍线程安全的容器之前我们先思考这样一个问题如何将非线程安全的容器变成线程安全的容器
在前面[《12 | 如何用面向对象思想写好并发程序?》](https://time.geekbang.org/column/article/87365)我们讲过实现思路其实很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。
下面我们就以ArrayList为例看看如何将它变成线程安全的。在下面的代码中SafeArrayList内部持有一个ArrayList的实例c所有访问c的方法我们都增加了synchronized关键字需要注意的是我们还增加了一个addIfNotExist()方法这个方法也是用synchronized来保证原子性的。
```
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的开发人员也想到了所以他们在Collections这个类中还提供了一套完备的包装类比如下面的示例代码中分别把ArrayList、HashSet和HashMap包装成了线程安全的List、Set和Map。
```
List list = Collections.
synchronizedList(new ArrayList());
Set set = Collections.
synchronizedSet(new HashSet());
Map map = Collections.
synchronizedMap(new HashMap());
```
我们曾经多次强调,**组合操作需要注意竞态条件问题**例如上面提到的addIfNotExist()方法就包含组合操作。组合操作往往隐藏着竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性,这个一定要注意。
在容器领域**一个容易被忽视的“坑”是用迭代器遍历容器**例如在下面的代码中通过迭代器遍历容器list对每个元素调用foo()方法,这就存在并发问题,这些组合的操作不具备原子性。
```
List list = Collections.
synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
```
而正确做法是下面这样锁住list之后再执行遍历操作。如果你查看Collections内部的包装类源码你会发现包装类的公共方法锁的是对象的this其实就是我们这里的list所以锁住list绝对是线程安全的。
```
List list = Collections.
synchronizedList(new ArrayList());
synchronized (list) {
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
}
```
上面我们提到的这些经过包装后线程安全容器都是基于synchronized这个同步关键字实现的所以也被称为**同步容器**。Java提供的同步容器还有Vector、Stack和Hashtable这三个容器不是基于包装类实现的但同样是基于synchronized实现的对这三个容器的遍历同样要加锁保证互斥。
## 并发容器及其注意事项
Java在1.5版本之前所谓的线程安全的容器,主要指的就是**同步容器**。不过同步容器有个最大的问题那就是性能差所有方法都用synchronized来保证互斥串行度太高了。因此Java在1.5及之后版本提供了性能更高的容器,我们一般称为**并发容器**。
并发容器虽然数量非常多但依然是前面我们提到的四大类List、Map、Set和Queue下面的并发容器关系图基本上把我们经常用的容器都覆盖到了。
![](https://static001.geekbang.org/resource/image/a2/1d/a20efe788caf4f07a4ad027639c80b1d.png)
并发容器关系图
鉴于并发容器的数量太多,再加上篇幅限制,所以我并不会一一详细介绍它们的用法,只是把关键点介绍一下。
### List
List里面只有一个实现类就是**CopyOnWriteArrayList**。CopyOnWrite顾名思义就是写的时候会将共享变量新复制一份出来这样做的好处是读操作完全无锁。
那CopyOnWriteArrayList的实现原理是怎样的呢下面我们就来简单介绍一下
CopyOnWriteArrayList内部维护了一个数组成员变量array就指向这个内部数组所有的读操作都是基于array进行的如下图所示迭代器Iterator遍历的就是array数组。
![](https://static001.geekbang.org/resource/image/38/10/38739130ee9f34b821b5849f4f15e710.png)
执行迭代的内部结构图
如果在遍历array的同时还有一个写操作例如增加元素CopyOnWriteArrayList是如何处理的呢CopyOnWriteArrayList会将array复制一份然后在新复制处理的数组上执行增加元素的操作执行完之后再将array指向这个新的数组。通过下图你可以看到读写是可以并行的遍历操作一直都是基于原array执行而写操作则是基于新array进行。
![](https://static001.geekbang.org/resource/image/b8/89/b861fb667e94c4e6ea0ca9985e63c889.png)
执行增加元素的内部结构图
使用CopyOnWriteArrayList需要注意的“坑”主要有两个方面。一个是应用场景CopyOnWriteArrayList仅适用于写操作非常少的场景而且能够容忍读写的短暂不一致。例如上面的例子中写入的新元素并不能立刻被遍历到。另一个需要注意的是CopyOnWriteArrayList迭代器是只读的不支持增删改。因为迭代器遍历的仅仅是一个快照而对快照进行增删改是没有意义的。
### Map
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap它们从应用的角度来看主要区别在于**ConcurrentHashMap的key是无序的而ConcurrentSkipListMap的key是有序的**。所以如果你需要保证key的顺序就只能使用ConcurrentSkipListMap。
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是它们的key和value都不能为空否则会抛出`NullPointerException`这个运行时异常。下面这个表格总结了Map相关的实现类对于key和value的要求你可以对比学习。
![](https://static001.geekbang.org/resource/image/6d/be/6da9933b6312acf3445f736262425abe.png)
ConcurrentSkipListMap里面的SkipList本身就是一种数据结构中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n)理论上和并发线程数没有关系所以在并发度非常高的情况下若你对ConcurrentHashMap的性能还不满意可以尝试一下ConcurrentSkipListMap。
### Set
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet使用场景可以参考前面讲述的CopyOnWriteArrayList和ConcurrentSkipListMap它们的原理都是一样的这里就不再赘述了。
### Queue
Java并发包里面Queue这类并发容器是最复杂的你可以从以下两个维度来分类。一个维度是**阻塞与非阻塞**,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。另一个维度是**单端与双端**单端指的是只能队尾入队队首出队而双端指的是队首队尾皆可入队出队。Java并发包里**阻塞队列都用Blocking关键字标识单端队列使用Queue标识双端队列使用Deque标识**。
这两个维度组合后可以将Queue细分为四大类分别是
1.**单端阻塞队列**其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列这个队列可以是数组其实现是ArrayBlockingQueue也可以是链表其实现是LinkedBlockingQueue甚至还可以不持有队列其实现是SynchronousQueue此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能性能比LinkedBlockingQueue更好PriorityBlockingQueue支持按照优先级出队DelayQueue支持延时出队。
![](https://static001.geekbang.org/resource/image/59/83/5974a10f5eb0646fa94f7ba505bfcf83.png)
单端阻塞队列示意图
2.**双端阻塞队列**其实现是LinkedBlockingDeque。
![](https://static001.geekbang.org/resource/image/1a/96/1a58ff20f1271d899b93a4f9d54ce396.png)
双端阻塞队列示意图
3.**单端非阻塞队列**其实现是ConcurrentLinkedQueue。
4.**双端非阻塞队列**其实现是ConcurrentLinkedDeque。
另外使用队列时需要格外注意队列是否支持有界所谓有界指的是内部的队列是否有容量限制。实际工作中一般都不建议使用无界的队列因为数据量大了之后很容易导致OOM。上面我们提到的这些Queue中只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的所以**在使用其他无界队列时一定要充分考虑是否存在导致OOM的隐患**。
## 总结
Java并发容器的内容很多但鉴于篇幅有限我们只是对一些关键点进行了梳理和介绍。
而在实际工作中,你不单要清楚每种容器的特性,还要能**选对容器,这才是关键**至于每种容器的用法用的时候看一下API说明就可以了这些容器的使用都不难。在文中我们甚至都没有介绍Java容器的快速失败机制Fail-Fast原因就在于当你选对容器的时候根本不会触发它。
## 课后思考
线上系统CPU突然飙升你怀疑有同学在并发场景里使用了HashMap因为在1.8之前的版本里并发执行HashMap.put()可能会导致CPU飙升到100%,你觉得该如何验证你的猜测呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。