ConcurrentHashMap 类注释
- 所有的操作都是线程安全的,我们在使用时,无需再加锁;
- 多个线程同时进行 put、remove 等操作时并不会阻塞,可以同时进行,和 HashTable 不同,
HashTable 在操作时,会锁住整个 Map; - 迭代过程中,即使 Map 结构被修改,也不会抛 ConcurrentModificationException 异常;
- 除了数组 + 链表 + 红黑树的基本结构外,新增了转移节点,是为了保证扩容时的线程安全的
节点; - 提供了很多 Stream 流式方法,比如说:forEach、search、reduce 等等。
ConcurrentHashMap 和 HashMap 两者区别
相同之处:
- 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同);
- 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以大多数的方法也都是相同的,HashMap 有的方法,ConcurrentHashMap 几乎都有,所以当我们需要从 HashMap 切换到ConcurrentHashMap 时,无需关心两者之间的兼容问题。
不同之处:
- 红黑树结构略有不同,HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁;
- 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。
Put方法
- 如果数组为空,初始化,初始化完成之后,走 2;
- 计算当前槽点有没有值,没有值的话,cas 创建
casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))
,失败继续自旋(for 死循环),直到成功,槽点有值的话,走 3; - 如果槽点是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增,不是转移节点走4;
- 槽点有值的,先锁定当前槽点
synchronized (f)
,保证其余线程不能操作,如果是链表,新增值到链表的尾部,如果是红黑树if (f instanceof TreeBin)
,使用红黑树新增的方法新增; - 新增完成之后 check 需不需要扩容,需要的话去扩容。
数组初始化时的线程安全
数组初始化时,首先通过自旋while((tab = table) == null || tab.length ==0){...}
来保证一定可以初始化成功
然后通过 CAS 设置 SIZECTL 变量的值,来保证同一时刻只能有一个线程对数组进行初始化
当SIZECTL的值小于1时,表示已经有其他线程在初始化数组,则会执行Thread.yield()
让出当前线程,保证只有一个线程进行初始化操作
CAS 成功之后,还会再次判断当前数组是否已经初始化完成if ((tab = table) == null || tab.length == 0) {...}
,如果已经初始化完成,就不会再次初始化
通过自旋 + CAS + 双重 check 等手段保证了数组初始化时的线程安全
新增槽点值时的线程安全
此时为了保证线程安全,做了四处优化:
- 通过自旋死循环保证一定可以新增成功。
在新增之前,通过for (Node[] tab = table;;)
这样的自选来保证新增一定可以成功,一旦
新增成功,就可以退出当前死循环,新增失败的话,会重复新增的步骤,直到新增成功为止。 - 当前槽点为空时,通过 CAS 新增。
没有在判断槽点为空的情况下直接赋值,因为在判断槽点为空和赋值的瞬间,很有可能槽点已经被其他线程赋值了。
所以采用 CAS 算法,能够保证槽点为空的情况下赋值成功,如果恰好槽点已经被其他线程赋值,当前 CAS 操作失败,会再次执行 for 自旋,再走槽点有值的 put 流程,这里就是自旋 + CAS 的结合。 - 当前槽点有值,锁住当前槽点。
put 时,如果当前槽点有值,就是 key 的 hash 冲突的情况,此时槽点上可能是链表或红黑树,们通过锁住synchronized
槽点,来保证同一时刻只会有一个线程能对槽点进行修改 - 红黑树旋转时,锁住红黑树的根节点,保证同一时刻,当前红黑树只能被一个线程旋转
通过自旋 + CAS + 锁(synchronized,Lock)保证新增槽点线程安全
扩容时的线程安全
ConcurrentHashMap 的扩容时机和 HashMap 相同,都是在 put 方法的最后一步检查是否需要扩容,如果需要则进行扩容,但两者扩容的过程完全不同,ConcurrentHashMap 扩容的方法叫做transfer,从 put 方法的 addCount 方法进去,就能找到 transfer 方法
扩容方法的主体思路
- 首先需要把老数组的值全部拷贝到扩容之后的新数组上,先从数组的队尾开始拷贝;
- 拷贝数组的槽点时,先把原数组槽点锁住,保证原数组槽点不能操作,成功拷贝到新数组时,
把原数组槽点赋值为转移节点; - 这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在
扩容完成之前,该槽点对应的数据是不会发生变化的; - 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组中的节点设置成转移节点;
- 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。
扩容中的关键点,就是如何保证是线程安全的,小结有如下几点:
- 拷贝槽点时,会把原数组的槽点锁住
synchronized (f)
; - 拷贝成功之后,会把原数组的槽点设置成转移节点,这样如果有数据需要 put 到该节点时,
发现该槽点是转移节点,会一直等待,直到扩容成功之后,才能继续 put,可以参考 put 方
法中的 helpTransfer 方法; - 从尾到头进行拷贝,拷贝成功就把原数组的槽点设置成转移节点。
- 等扩容拷贝都完成之后,直接把新数组的值赋值给数组容器,之前等待 put 的数据才能继续
put。
ConcurrentHashMap 和 HashMap 的相同点和不同点
- 相同点:
- 都是数组 + 链表 +红黑树的数据结构,所以基本操作的思想相同;
- 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以两者的方法大多都是相似的,可以
互相切换。
- 不同点:
- ConcurrentHashMap 是线程安全的,在多线程环境下,无需加锁,可直接使用;
- 数据结构上,ConcurrentHashMap 多了转移节点,主要用于保证扩容时的线程安全。
ConcurrentHashMap 通过哪些手段保证了线程安全。
- 储存 Map 数据的数组被 volatile 关键字修饰,一旦被修改,立马就能通知其他线程,因为是数组,所以需要改变其内存值,才能真正的发挥出 volatile 的可见特性;
- put 时,如果计算出来的数组下标索引没有值的话,采用无限 for 循环 + CAS 算法,来保证一定可以新增成功,又不会覆盖其他线程 put 进去的值;
- 如果 put 的节点正好在扩容,会等待扩容完成之后,再进行 put ,保证了在扩容时,老数组的值不会发生变化;
- 对数组的槽点进行操作时,会先锁住槽点,保证只有当前线程才能对槽点上的链表或红黑树进行操作;
- 红黑树旋转时,会锁住根节点,保证旋转时的线程安全。
描述一下 CAS 算法在 ConcurrentHashMap 中的应用?
CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都是原子性操作,没有线程安全问题。
ConcurrentHashMap 的 put 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:
- 计算出数组索引下标,拿出下标对应的原值;
- CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出循环,否则不赋值,转到 3;
- 进行下一次 for 循环,重复执行 1,2,直到成功为止。可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。
ConcurrentHashMap 是如何发现当前槽点正在扩容的。
ConcurrentHashMap 新增了一个节点类型,叫做转移节点,当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 Map 正在进行扩容。
发现槽点正在扩容时,put 操作会怎么办?
答:无限 for 循环,或者走到扩容方法中去,帮助扩容,一直等待扩容完成之后,再执行 put 操作。
两种 Map 扩容时,区别?
区别很大,HashMap 是直接在老数据上面进行扩容,多线程环境下,会有线程安全的问题,
而 ConcurrentHashMap 就不太一样,扩容过程是这样的:
- 从数组的队尾开始拷贝;
- 拷贝数组的槽点时,先把原数组槽点锁住,拷贝成功到新数组时,把原数组槽点赋值为转移
节点; - 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组的槽点设置成转移节点;
- 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。
简单来说,通过扩容时给槽点加锁,和发现槽点正在扩容就等待的策略,保证了ConcurrentHashMap可以慢慢一个一个槽点的转移,保证了扩容时的线程安全,转移节点比较重要,平时问的人也比较多。
ConcurrentHashMap 在 Java 7 和 8 中关于线程安全的做法有啥不同?
答:非常不一样,拿 put 方法为例,Java 7 的做法是:
- 把数组进行分段,找到当前 key 对应的是那一段;
- 将当前段锁住,然后再根据 hash 寻找对应的值,进行赋值操作。
Java 7 的做法比较简单,缺点也很明显,就是当我们需要 put 数据时,我们会锁住改该数据对应的某一段,这一段数据可能会有很多,比如我只想 put 一个值,锁住的却是一段数据,导致这一段的其他数据都不能进行写入操作,大大的降低了并发性的效率。Java 8 解决了这个问题,从锁住某一段,修改成锁住某一个槽点,提高了并发效率。不仅仅是 put,删除也是,仅仅是锁住当前槽点,缩小了锁的范围,增大了效率。