龙空技术网

20 | 并发容器:都有哪些“坑”需要我们填?

人生即修行 48

前言:

如今各位老铁们对“java中的容器主要分为”大概比较关注,你们都需要分析一些“java中的容器主要分为”的相关资讯。那么小编同时在网上收集了一些有关“java中的容器主要分为””的相关资讯,希望朋友们能喜欢,小伙伴们一起来了解一下吧!

Java并发包中有一大部分关于并发的,因此学习和搞懂很有必要。

Java1.5之前提供的并发容器虽然也能保证线程安全,但是性能很差。jdk1.5之后提供了并发容器在性能方面的很多优化,并且容器的类型也更加丰富了,下面我们就来学习这部分内容。

同步容器及其注意事项

Java中的容器主要分为四大类,分别是 list map set queue,但并不是所有的容器都是线程安全的,例如 ArrayList hashmap就不是安全的。那么让我们来思考一个问题,如何将线程不安全的容器转变为安全的呢?

我们的思路也很简单,就是把非安全的容器包装在对象内部,控制好访问路径就可以了。

多次强调,组合操作需要注意竞态条件的问题,组合操作往往隐藏着竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性,这个一定要注意。

在容器领域一个容易被忽视的坑就是迭代器遍历容器的问题,例如如下:

对每个元素调用 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,下面的并发容器关系图,基本上把我们经常用的容器都覆盖到了。

并发容器 我们不会一一介绍,只会选择有代表性的重点分析下。

(一)List

List 里面有一个实现类:CopyOnWriteArrayList,CopyOnWrite 顾名思义就是写的时候共享变量复制一份出来,这样做的好处就是读操作完全无锁。

CopyOnWriteArrayList 实现原理是什么样的?

CopyOnWriteArrayList 内部维护了一个数组。成员变量array就是指向这个内部数组,所有的读操作都是基于array进行的,迭代器 Iterator 遍历的就是 array 数组。

如果在遍历 array 的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList 是如何处理的呢?CopyOnWriteArrayList 会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将 array 指向这个新的数组。通过下图你可以看到,读写是可以并行的,遍历操作一直都是基于原 array 执行,而写操作则是基于新 array 进行。

使用 CopyOnWriteArrayList 需要注意的“坑”主要有两个方面。一个是应用场景,CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致。例如上面的例子中,写入的新元素并不能立刻被遍历到。另一个需要注意的是,CopyOnWriteArrayList 迭代器是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。

(二)Map

Map 接口的两个实现是 ConcurrentHashMap 和 ConcurrentSkipListMap,它们从应用的角度来看,主要区别在于 ConcurrentHashMap 的 key 是无序的,而 ConcurrentSkipListMap 的 key 是有序的。所以如果你需要保证 key 的顺序,就只能使用 ConcurrentSkipListMap。

使用 ConcurrentHashMap 和 ConcurrentSkipListMap 需要注意的地方是,它们的 key 和 value 都不能为空,否则会抛出NullPointerException这个运行时异常。下面这个表格总结了 Map 相关的实现类对于 key 和 value 的要求,你可以对比学习。

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 支持延时出队。

2.双端阻塞队列:其实现是 LinkedBlockingDeque。

3.单端非阻塞队列:其实现是 ConcurrentLinkedQueue。

4.双端非阻塞队列:其实现是 ConcurrentLinkedDeque。

另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM。上面我们提到的这些 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患

总结

Java 并发容器的内容很多,但鉴于篇幅有限,我们只是对一些关键点进行了梳理和介绍。

而在实际工作中,你不单要清楚每种容器的特性,还要能选对容器,这才是关键,至于每种容器的用法,用的时候看一下 API 说明就可以了,这些容器的使用都不难。在文中,我们甚至都没有介绍 Java 容器的快速失败机制(Fail-Fast),原因就在于当你选对容器的时候,根本不会触发它。

这些容器 我觉得 有必要 挨个 来分析源码级别解读下 哈哈哈哈

标签: #java中的容器主要分为