前言
我们在JUC的开篇讲过,juc包主要包含以下部分:线程池 并发集合 同步器 原子变量 锁 并发工具类
那么本次我们就来讲一讲并发集合,这其实也是个面试常见问题,比如你可能会被问到:
- 说出几个你知道的并发集合,它们都是在什么包下面?
- 并发集合是绝对线程安全的吗?并发场景有没有可能出现问题?
- 并发集合是利用了什么来保证线程安全的?
- 你了解并发集合对并发场景做的优化吗?请举例说明
我们将在下文简要介绍并发集合,并回答上述问题
一、基础、同步、并发集合?
相信很多人最开始接触到的集合,就是类似HashMap、ArrayList这种基础集合;
后来在准备面试的时候,明白这些集合类型并不具备多线程安全的能力,又逐渐接触到了Vector、HashTable、ConcurrentHashMap等集合。但其实这些集合并不属于一类。像Vector、HashTable 以及Collection类下如 List synArrayList = Collections.synchronizedList(new ArrayList()) 生成的 SynchronizedList 等。
其原理都是利用了 同步关键字 synchronized,也就是以集合对象本身为锁,调用方法需要先获得锁,所以实际上一次只有一个线程能对集合进行操作,这种集合称为同步集合
基础集合和同步集合都是在 java.util.concurrent.* 包下的,而我们今天要讲的则是像 ConcurrentHashMap 一样,一种同样支持线程安全,但效率更高的集合类型——JUC包下的并发集合
二、常用并发集合
List/Set
CopyOnWriteArrayList : 线程安全的ArrayList
CopyOnWriteArraySet : 线程安全的HashSet
Map
ConcurrentHashMap: 线程安全的HashMap
ConcurrentSkipListMap 线程安全的TreeMap
Queue
ArrayBlockingQueue : 线程安全的有界的阻塞队列 (数组实现)
LinkedBlockingQueue 线程安全的阻塞队列 (l链表实现)
我们从这些类的命名上不难看出,这些类能够 线程安全且支持并发的原理,关键字就是
1.CopyOnWrite —— 写时复制技术
支持多线程读,在写操作时会复制一份数据来写,不影响其他线程读,但此时读的可能是旧数据
2.Concurrent —— 并发技术
使用更小粒度的锁,使其在线程安全的情况下能够支持更高的并发
3.Blocking——阻塞线程
只有队列用了此技术,因为队列只有头尾元素能变动,此时利用 ReentrantLock 锁以及Condition 条件队列 的功能来阻塞或唤醒线程,
三、CHM 如何支持并发
我们想要详细剖析CHM,首先就应该理解其与HashMap有什么不一样,正是这些不同点造就了其支持并发安全的能力,我们先看往里面插入值,差别代码如下:
1.对空key,空value的处理
CHM: if (key == null || value == null) throw new NullPointerException(); HM: 无
出现这样的原因是hashmap用于单线程,当一次get操作获取到null的时候,还能继续用contains辅助判断这个null代表的是值是value,还是该键值对值不存在。
而 ConcurrentHashMap 用于多线程,当一次 get 操作获取到 null 的时候,再使用 contains 辅助判断时,可能数据已经发生了改变,那么我们就无法确认第一次 get 到 null 值的实际含义,即产生了歧义
2.每个桶的首节点
代码如下(示例):
CHM: else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); HM: 无
HashMap的桶里,首节点就是普通的节点。而ConcurrentHashMap在扩容时,会在桶的头节点前插入新的头节点,且指定他的hash值为MOVED = -1,代表该桶正在迁移数据。此时本线程会协助其迁移,协助后再在新的table里去寻找并赋值
3.对空桶赋值
CHM: else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } HM: if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
在HashMap里,如果通过hash计算获得的位置是空桶,那就直接在该位置赋值一个新节点。而在CHM中,对该位置的占据会使用一个CAS操作
4.进入桶后的操作
CHM: synchronized (f) { XXXXX } HM: 无
CHM会使用 synchronized 锁住该桶的头节点,而hashmap则直接桶中找值
5.put后的数目统计
CHM: addCount(1L, binCount); HM: ++modCount;
hashmap直接对总数加1,而CHM则是执行一个方法,该方法会尝试在基数或计数数组里找个位置进行加1,目的是尽可能提高并发
总结来看:我们不难得出一个结论,ConcurrentHashMap的线程安全主要是因为,使用了synchronized关键字和CAS操作,而其在带锁的情况下效率高则是采用了锁细化的技术,比如不锁整个数组,只锁数组中某一个桶。又比如在插入数据后计数时,采用基数和计数数组等多个地方来寻找位置加一,大大降低了并发冲突的可能
四、并发集合的局限性
我们刚刚讲到了一些并发集合用到的技术点,说了并发集合线程安全。但是,我们必须知道,线程安全不是一个是非题,而是一个大的范围,总的来说,安全的范围从严谨到宽松依次为:
- 不可变:变量完全不可变,任何场景都不会有并发安全问题
- 绝对线程安全:允许多线程同时访问修改但保证数据安全
- 相对线程安全:该对象的单个操作是线程安全的,如ConcurrentHashMap
- 线程兼容:通过添加同步措施,可以保证在多线程环境中安全使用这些类的对象,如HashMap
- 线程对立:无法在多线程环境中并发使用代码
比如 final 修饰的变量就是”不可变“级别,比如常用的String对象,我们对其的使用不需担心任何线程安全的问题。
绝对线程安全的比如synchronized关键字修饰的部分,允许多线程访问和修改,但不会有线程安全问题
而我们前面提到的并发集合,则处于相对线程安全级别,这个级别的特征是,对该对象单独的操作是线程安全的,但对于特定顺序的连续调用,可能就会出现问题,所谓问题,其实就是结果不合预期或无法确定。
比如对于 ConcurrentHashMap,我们用100个线程,对某个值进行get(),获取后再加一,最后put()回去,我们不难想象,最后这个值的结果,很可能不是原值+100。而 CopyOnWriteArrayList 这种写时复制的集合,也存在延时问题,最终读到的数据难以确定
所以我们必须搞清楚,类似 ConcurrentHashMap 这种并发集合,仅仅是相对线程安全,如果是对其连续调用且伴随逻辑处理,还需要外部代码提供额外的线程安全保障,才能使其结果符合预期
五、总结 - 前言问题回答
1.说出几个你知道的并发集合,它们都是在什么包下面?
比如 CopyOnWriteArrayList、ConcurrentHashMap、ArrayBlockingQueue等都是并发集合,它们都在JUC(java.util.concurrent) —— java并发包下
2.并发集合是绝对线程安全的吗?并发场景有没有可能出现问题?
java中没有绝对的并发安全的集合,真正安全的只有final修饰的不可变量,如果是final修饰的对象,则需要保证该对象各属性被final修饰。其次则是使用锁和CAS等同步概念的单次操作也可以称为绝对安全,而并发集合虽然采用了上面的技术,但毕竟不是单次操作,在一些特定顺序下去调用方法,出来的结果也不可预料,比如用100个线程并发对ConcurrentHashMap某个值加1,最后可能达不到+100的效果,此时还需要在业务代码中额外添加并发控制的逻辑,因此,尽管并发集合单次的操作是安全的,但多个步骤就无法保证安全了
3.并发集合是利用了什么来保证线程安全的?
主要是利用了锁,包括 ReentrantLock和 synchronized 关键字,还有CAS原子操作,在对可能冲突的位置进行写前,会进行一次竞争来获取操作权
4.你了解并发集合对并发场景做的优化吗?请举例说明
比如读写并发的场景,有的集合用到了写时复制技术,将集合复制一份来化解冲突。而对于会发生写冲突的位置,则主要是将锁细化,把一个锁拆成可伸缩的多个锁,然后针对细化的锁,大量利用for循环 + CAS原语 来取代同步阻塞,减少内核用户态的切换开销