Java—ConcurrentHashMap
JDK1.7(Segment+ReentrantLock)
ConcurrentHashMap在对象中保存了一个Segment数组。每个Segment元素类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可,不同的Segment可以并发put。而Segment的锁实现其实是ReentrantLock,同一个Segment读写可以并发。
😃无参构造初始化后:
- Segment 数组长度为 16,不可以扩容
- Segment[i] 的默认大小为 2,负载因子是 0.75(超过四分之三才扩容,刚好相等不扩容),得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容,扩容为数量翻倍
- 初始化 segment[0],其他位置还是 null,其它的segment[i]懒加载,通过segment[0]原型进行创建,如果此时segment[0]大小已经是4了,其它segment[i]通过它创建时也是4
😃put流程:
- 为输入的Key做Hash运算,得到hash
- 通过hash,定位到对应的Segment对象(根据hash值的高4位(根据Segment数量决定是高几位,如果是32个Segment,就是高5位),找到对应Segment)
- 获取可重入锁
- 再次通过hash值,定位到Segment当中数组的具体位置
- 插入或覆盖HashEntry对象
- 释放锁
😃get(无锁):
- 为输入的key做Hash运算,得到hash
- 通过hash,定位到对应的Segment对象
- 再次通过hash,定位到Segment当中数组的具体位置
JDK1.8(CAS + synchronized)
有参初始化:
懒汉式初始化桶元素
factor负载因子默认为0.75(只有在初始化时才有用,后面扩容永远根据0.75计算,即使factor构造方法传入为0.5或其它)
capacity表示我想往数组先放capacity个元素,至于数组多大你看着办,比如capacity为16,表明我想先放16个元素,那么初始化大小就会为32(需要为2^n,且不会导致扩容,即初始化大小 > capacity / factor,且初始化大小 = 2 ^ n)
如果capacity为12,factor是0.75,初始化大小就是32
如果capacity是11,factor是0.75,初始化大小就是16
如果capacity是7,factor是0.5,初始化大小就是16
get:无锁获取,因为Node[]和Node中的val和next有volatile
put:
- 检验key和value不能为null
- 进入死循环
- 如果第一次添加元素,初始化table,使用CAS保证线程安全,然后重新进入循环
- 如果头节点为null,CAS将自己设置为头节点,成功则break,失败则重新进入循环
- 如果头节点是ForwardingNode,说明这个桶正在进行扩容,则帮忙进行扩容,然后重新进入循环
- 到了这里说明需要往桶下标的非头节点中添加元素,此时使用synchronized,锁对象是头节点
-
- 再次确认链表头节点有没有被移动
-
-
- 如果是链表,走链表put操作,如果有相同的key则更新,否则添加
- 如果是红黑树,走红黑树put操作,如果有相同的key则更新,否则添加
-
-
- 释放锁,判断是否需要树化,然后break
- 使用累加单元数组进行size累加,判断是否需要扩容
扩容:
- 整个扩容操作分为两个部分:
-
- 第一部分是构建一个 nextTable,它的容量是原来的两倍,这个操作是单线程完成的。
- 第二个部分是将原来 table 中的元素复制到 nextTable 中,主要是遍历复制的过程。遍历是从后往前遍历原table
- 得到当前遍历的数组位置 i,然后利用 tabAt 方法获得 i 位置的元素:
-
- 如果这个位置为空,就在原 table 中的 i 位置放入 forwardNode 节点,这个也是触发并发扩容的关键;
- 如果这个位置是 Node 节点(fh>=0),并且是链表的头节点,就把这个链表分裂成两个链表,把它们分别放在 nextTable 的 i 和 i+n 的位置上(桶下标不变的留在原地,变的放到 i + n 处);处理完后头节点变成forwardNode(如果是链表,则复制值创建新节点对象扩容,如果是一个节点,则挪动元素扩容)
- 如果这个位置是 TreeBin 节点(fh<0),也做一个反序处理,并且判断是否需要 untreefi,把处理的结果分别放在 nextTable 的 i 和 i+n 的位置上;处理完后头节点变成forwardNode
- 遍历所有的节点,就完成复制工作,这时让 nextTable 作为新的 table,并且更新 sizeCtl 为新容量的 0.75 倍 ,完成扩容。
- 扩容时有其余线程来get:
-
- 如果头节点是forwardNode,那么就去新table中get,否则,在原table中查询,又因为迁移链表时是深拷贝扩容,所以get能查找到对应元素,不会出现并发问题
- 扩容时有其余线程来put:
-
- 如果要put的桶下标还不在扩容中,直接put
- 如果put的桶下标正在扩容中,只能阻塞
- 如果put的桶下标是forwarding,此时不能往新table中put,因为此时新table还没扩容完成,put可能产生并发问题,此时会去协助扩容
😃为什么HashMap的key和value可以是null?
首先需要知道HashMap的containsKey的逻辑是:根据key获取Node,然后判断Node是否为null,这样子的话就是精准的containsKey!!!
为什么这样子就是精准的containsKey呢?因为如果你是根据key获取value,然后判断value是否为null,如果此时value本身就允许为null而且为null,方法会返回false,你以为map中不存在这个key,但其实存在,只是它的value为null罢了
如果key为null,这时候containsKey(null)的话,如果得不到node节点,说明为null的key不存在,返回false;如果找得到node节点,说明为null的key存在,返回true。也就是说HashMap的containsKey能解决key的null歧义!
如果value为null,这时候get(user1),发现获得null值,那么这时候是有两种可能:user1的value就是null;user1在map中不存在,所以返回null。这时候就产生了歧义。也就是说HashMap的get没解决value为null的歧义!不过这个时候你只需要containsKey(user1)一下就能解决这个歧义。
就是说在无并发下,你key和value都可以为null,一点问题、一点歧义没有!
一个是containsKey解决key为null的歧义,一个是get + containsKey解决value为null的歧义
但多线程的情况下你containsKey是解决不了value可以为null的歧义的
比如线程a先get(user1),得到null,这时候就不知道是value为null,还是user1在map中不存在。这时候想再containsKey(user1)确认一下。但此时可能是第一种情况,即user1在map中存在,但value为null,不过线程b又来把这个key为user1的键值对给删了,那你此时containsKey(user1)就返回false,你就以为是第二种情况,这就与之前的事实不符!不过HashMap本身就不是线程安全的,可以容忍这种错误!
ConcurrentHashMap的key和value为什么不能是null?
根据HashMap的分析,要让ConcurrentHashMap的key和value允许为null,就要解决并发下value允许为null产生的歧义问题!
这时候就有两种办法:
- get查询时加锁!保证其它线程不会来删除,但这明显是会影响效率的!这对追求高并发效率的ConcurrentHashMap是肯定不能容忍的
- 直接不允许value为null!暴力简单高效,ConcurrentHashMap采用了这个方式
当你不允许value为null时,此时key为null是完全可以的,因为containsKey可以解决key为null的歧义