Moosphan / Android-Daily-Interview

:pushpin:每工作日更新一道 Android 面试题,小聚成河,大聚成江,共勉之~

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

2019-06-12:谈谈对于ConcurrentHashMap的理解?

MoJieBlog opened this issue · comments

2019-06-12:谈谈对于ConcurrentHashMap的理解?

从JDK1.2起,就有了HashMap,正如前一篇文章所说,HashMap不是线程安全的,因此多线程操作时需要格外小心。
在JDK1.5中,伟大的Doug Lea给我们带来了concurrent包,从此Map也有安全的了。
ConcurrentHashMap具体是怎么实现线程安全的呢,肯定不可能是每个方法加synchronized,那样就变成了HashTable。

从ConcurrentHashMap代码中可以看出,它引入了一个“分段锁”的概念,具体可以理解为把一个大的Map拆分成N个小的HashTable,根据key.hashCode()来决定把key放到哪个HashTable中。

在ConcurrentHashMap中,就是把Map分成了N个Segment,put和get的时候,都是现根据key.hashCode()算出放到哪个Segment中:
ConcurrentHashMap中默认是把segments初始化为长度为16的数组。
总结:以上就是ConcurrentHashMap的工作机制,通过把整个Map分为N个Segment(类似HashTable),可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。

并发集合常见的有 ConcurrentHashMap、ConcurrentLinkedQueue、ConcurrentLinkedDeque 等。并发集合位于java.util.concurrent 包 下 ,是 jdk1.5之 后 才 有 的。

在 java 中有普通集合、同步(线程安全)的集合、并发集合。普通集合通常性能最高,但是不保证多线程的安全性和并发的可靠性。线程安全集合仅仅是给集合添加了 synchronized 同步锁,严重牺牲了性能,而且对并发的效率就更低了,并发集合则通过复杂的策略不仅保证了多线程的安全又提高的并发时的效率ConcurrentHashMap 是线程安全的 HashMap 的实现,默认构造同样有 initialCapacity 和 loadFactor 属性,不过还多了一个 concurrencyLevel 属性,三属性默认值分别为 16、0.75 及 16其内部使用锁分段技术,维持这锁Segment 的数组,在 Segment 数组中又存放着 Entity[]数组,内部 hash 算法将数据较均匀分布在不同锁中。

put 操作:并没有在此方法上加上 synchronized,首先对 key.hashcode 进行 hash 操作,得到 key 的 hash 值。hash操作的算法和map也不同,根据此hash值计算并获取其对应的数组中的Segment对象(继承自ReentrantLock),接着调用此 Segment 对象的 put 方法来完成当前操作。 ConcurrentHashMap 基于 concurrencyLevel 划分出了多个 Segment 来对 key-value 进行存储,从而避免每次 put 操作都得锁住整个数组。在默认的情况下,最佳情况下可允许 16 个线程并发无阻塞的操作集合对象,尽可能地减少并发时的阻塞现象。

get(key) 首先对 key.hashCode 进行 hash 操作,基于其值找到对应的 Segment 对象,调用其 get 方法完成当前操作。而 Segment 的 get 操作首先通过 hash 值和对象数组大小减 1 的值进行按位与操作来获取数组上对应位置的HashEntry。

在这个步骤中,可能会因为对象数组大小的改变,以及数组上对应位置的 HashEntry 产生不一致性,那么 ConcurrentHashMap 是如何保证的?

对象数组大小的改变只有在 put 操作时有可能发生,由于 HashEntry 对象数组对应的变量是 volatile 类型的,因此可以保证如 HashEntry 对象数组大小发生改变,读操作可看到最新的对象数组大小。

在获取到了 HashEntry 对象后,怎么能保证它及其 next 属性构成的链表上的对象不会改变呢?

这点ConcurrentHashMap 采用了一个简单的方式,即 HashEntry 对象中的 hash、key、next 属性都是 final 的,这也就意味着没办法插入一个HashEntry对象到基于next属性构成的链表中间或末尾。这样就可以保证当获取到HashEntry对象后,其基于 next 属性构建的链表是不会发生变化的。

ConcurrentHashMap 默认情况下采用将数据分为 16 个段进行存储,并且 16 个段分别持有各自不同的锁Segment,锁仅用于 put 和 remove 等改变集合对象的操作,基于 volatile 及 HashEntry 链表的不变性实现了读取的不加锁。这些方式使得 ConcurrentHashMap 能够保持极好的并发支持,尤其是对于读远比插入和删除频繁的 Map而言,而它采用的这些方法也可谓是对于 Java 内存模型、并发机制深刻掌握的体现。
总结:ConcurrentHashMap 比起Hashmap,是线程安全的,比起HashTable是高效的。

这个不常用把 源码没研究过 只是简单知道这玩意是同步的 采用的是分段锁来实现的,是线程安全的

没了,告辞

如果有必要我再看这块的源码,暂时略感没有必要

看了大概几篇关于 ConcurrentHashMap 的文章, 大概总结一下, 基于 java 8, jdk 8(其实我也不知道哪个描述更合适)
ConcurrentHashMap 在 java8 已经摒弃了 Segment 的概念, 而是直接使用 Node 数组+链表+红黑树的数据结构来实现;
使用 volatile synchronized 和 CAS 控制并发操作;

sizeCtl

private transient volatile int sizeCtl;
sizeCtl 高 16 位是 length 生成的标识符, 低 16 位是扩容的线程数
生成的标识符, 就是 -1, -2, -n 等;
负数代表正在进行初始化或扩容操作;
0 代表 hash 表还没有被初始化;
-1 代表 hash 表正在初始化;
-N 表示有 N-1 个线程正在进行扩容操作;
其余情况:
如果 table 未初始化, 表示 table 需要初始化的大小;
如果 table 初始化完成, 表示 table 的容量;

putVal

如果没有初始化就先调用 initTable()方法来进行初始化过程;
在初始化过程, 如果遇到其他线程, 也正在进行初始化操作, 当前线程调用 yield方法, 重新进入就绪状态, 等待 cpu 分配时间片;
通过计算 hash 值来确定放在数组的哪个位置;
如果这个位置为空则直接添加, 如果不为空的话, 则取出这个节点;
如果取出来的节点的 hash 值是 MOVED(-1)的话, 则表示有其他线程正在对这个数组进行扩容, 当前线程协助扩容;
如果取出来的节点不为空, 也没有在扩容, 则通过 synchronized 进行添加操作;
如果当前位置是链表, 则遍历整个链表, 如果遇到同一个 key, 则覆盖掉 value, 否则的话则添加到链表的末尾;
如果当前位置是树的话, 则调用 putTreeVal 方法把这个元素添加到树中去;
最后在添加完成之后, 会判断在该节点处共有多少个节点, 如果达到 8 个以上了的话, 则调用 treeifyBin 方法来尝试将链表转为树, 或者扩容数组;
如果添加成功就调用 addCount()方法统计 size, 并且检查是否需要扩容;

helpTransfer-transfer

sizeCtl 高 16 位是 length 生成的标识符, 低 16 位是扩容的线程数;
判断是否并发修改了, 判断是否还在扩容;
如果还在扩容, 判断标识符是否变化, 判断扩容是否结束, 判断是否达到最大线程数, 判断扩容转移下标是否在调整, 扩容结束, 如果满足任意条件, 结束循环;
原数组长度为 n, 所以我们有 n 个迁移任务;
让每个线程每次负责一个小任务是最简单的, 每做完一个任务再检测是否有其他没做完的任务, 帮助迁移就可以了;
而 Doug Lea 使用了一个 stride, 简单理解就是步长, 每个线程每次负责迁移其中的一部分, 如每次迁移 16 个小任务;
所以我们就需要一个全局的调度者, 来安排哪个线程执行哪几个任务, 这个就是属性 transferIndex 的作用;
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置, 然后从后往前的 stride 个任务属于第一个线程;
然后将 transferIndex 指向新的位置, 再往前的 stride 个任务属于第二个线程, 依此类推;
当然第二个线程不是真的一定指代了第二个线程, 也可以是同一个线程, 其实就是将一个大的迁移任务分为了一个个任务包;

get

首先 get 使用 tabAt 计算得到 node 节点, 是一个 cas 操作;
1.. 计算 hash 值, 定位到 table 索引位置, 如果是首节点符合就返回;
2.. 如果遇到扩容的时候, 会调用标志正在扩容节点 ForwardingNode 的 find 方法, 查找该节点, 匹配就返回;
3.. 检查 table 是否为空, 如果为空, 返回 null;
4.. 检查 table[i] 处桶位不为空, 如果为空, 则返回 null;
5.. 先检查 table[i] 的头结点的 key 是否满足条件, 是则返回头结点的 value, 否则分别根据树, 链表查询;

参考

https://www.cnblogs.com/zerotomax/p/8687425.html
https://my.oschina.net/hosee/blog/675884
https://www.cnblogs.com/study-everyday/p/6430462.html
https://blog.csdn.net/u010412719/article/details/52145145
https://www.jianshu.com/p/c0642afe03e0
https://www.jianshu.com/p/23b84ba9a498
https://juejin.im/post/5c8276216fb9a049d51a4cd6

transfer
https://www.jianshu.com/p/39b747c99d32
https://www.jianshu.com/p/2829fe36a8dd
https://www.cnblogs.com/xbfchder/p/11100468.html