深度探讨多线程Map的底层实现(SynchronizedMap/ConcurrentHashMap)

文章详细解析了Collections.synchronizedMap的原理,以及ConcurrentHashMap的结构、线程同步策略和get、put操作的无锁设计。着重介绍了ConcurrentHashMap的Segment和Segment数组的锁机制,以及其与Hashtable的区别。
摘要由CSDN通过智能技术生成

Collections.SynchronizedMap 底层实现原理

Collections.synchronizedMap()实现原理是 Collections 定义了一个 SynchronizedMap 的内部类,并返回这个类的实例。
在这里插入图片描述

SynchronizedMap 这个类实现了 Map 接口,在调用方法时使用 synchronized 来保证线程同
步,当然了实际上操作的还是我们传入的 HashMap 实例,简单的说就是Collections.synchronizedMap()方法帮我们在操作 HashMap 时自动添加了 synchronized来实现线程同步,类似的其它 Collections.synchronizedXX 方法也是类似原理)Mutex 在构造时默认赋值为 this,即所有方法都用的同一个锁。
在这里插入图片描述

ConcurrentHashMap 底层实现原理

ConcurrentHashMap 主要有三大结构:整个 Hash 表,segment(段),HashEntry(节点)。每个 segment 就相当于一个 HashTable。ConcurrentHashMap 将锁加在 segment 上(每个段
上),这样我们在对 segment1 操作的时候,同时也可以对 segment2 中的数据操作,这样效率就会高很多。

Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。Put 和remove 方法中有 lock()和 unlock()(都是使用的 this 对象,lock()在代码开始,unlock
在 finally 中)。

在这里插入图片描述

Segment 的结构和 HashMap 类似,是一种数组和链表结构, 一个 Segment 里包含一个HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
segments 数组的长度 ssize 通过 concurrencyLevel(并发等级默认是 16)计算得出。为了能通过按位与的哈希算法来定位 segments 数组的索引,必须保证 segments 数组的长度是2 的 N 次方(power-of-two size),所以必须计算出一个是大于或等于 concurrencyLevel的最小的 2 的 N 次方值来作为 segments 数组的长度。

初始化每个 Segment。输入参数 initialCapacity 是ConcurrentHashMap 的初始化容量,loadfactor 是每个 segment 的负载因子,在构造方法里需要通过这两个参数来初始化数组中的每个 segment。segment 里 HashEntry 数组的长度,它等于 initialCapacity 除以 ssize的倍数(总大小除以 segments 数组长度),HashEntry 的长度也是 2 的 N 次方
在这里插入图片描述

我们知道 HashTable 容器的 get 方法是需要加锁的,那么 ConcurrentHashMap 的 get操作是如何做到不加锁的呢?原因是它的 get 方法里将要使用的共享变量都定义成volatile,之所以不会读到过期的值,是根据 java 内存模型的 happen before 原则,对volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get操作也能拿到最新的值。

如何扩容。扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再 hash 后插入到新的数组里。为了高效 ConcurrentHashMap 不会对整个容器进行扩容,而只对某个 segment 进行扩容。

其中有一个 Segment 数组,每个 Segment 中都有一个锁,因此 Segment 相当于一个多线程安全的 HashMap,采用分段加锁。每个 Segment 中有一个 Entry 数组,Entry 中成员 value 是volatile 修饰,其他成员通过 final 修饰。get 操作不用加锁,put 和 remove 操作需要加锁,因为 value 通过 volatile 保证可见性。

两个 hash 过程,第一次找到所在的桶,并将桶锁定,第二次执行写操作。而读操作不加锁Collections.SynchronizedMap 和 Hashtable 都是整个表的锁,与 ConcurrentHashMap 锁粒度不同ConcurrentHashMap 不允许 key 或 value 为 null 值。
ConcurrentHashMap 允许一边更新、一边遍历,也就是说在 Iterator 对象遍历的时候,ConcurrentHashMap 也可以进行 remove,put 操作,且遍历的数据会随着 remove,put 操作
产出变化,相当于有多个线程在操作同一个 map(可以在 foreach keyset 时 remove 对象,HashMap 不可以)
java.util.concurrent.ConcurrentHashMap<K,V> JDK1.8

本文的分析的源码是 JDK8 的版本,与 JDK6 的版本有很大的差异。实现线程安全的思想也已经完全变了,它摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用 CAS 算法。它沿用了与它同时期的 HashMap 版本的思想,底层依然由“数组”+链表+红黑树的方式思想,大于 8 个转换为红黑树。默认初始大小 16,负载因子也是 0.75,定位元素的方法也是先 hashCode(),再无符号右移 16 位异或,再(n-1)&hash
取消 segments 字段,直接采用 transient volatile Node<K,V>[] table;保存数据,采用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

put 函数流程:
1、判断 put 进来的 key 和 value 是否为 null,如果为 null 抛异常。(ConcurrentHashMap的 key、value 不能为 null)。
2、随后进入无限循环(没有判断条件的 for 循环),何时插入成功,何时退出。
3、在无限循环中,若 table 数组为空(底层数组加链表),则调用 initTable(),初始化 table;
4、若 table 不为空,先 hashCode(),再无符号右移 16 位异或,再(n-1)&hash,定位到table 中的位置,如果该位置为空(说明还没有发生哈希冲突),则使用 CAS 将新的节点放入 table 中。
5、如果该位置不为空,且该节点的 hash 值为 MOVED(即为 forward 节点,哈希值为-1,其中含有指向 nextTable 的指针,class ForwardingNode 中有 nexttable 变量),说明此时正在扩容,且该节点已经扩容完毕,如果还有剩余任务(任务没分配完)该线程执行helpTransfer 方法,帮助其他线程完成扩容,如果已经没有剩余任务,则该线程可以直接操作新数组 nextTable 进行 put。
6、如果该位置不为空,且该节点不是 forward 节点。对桶中的第一个结点(即 table表中的结点,哈希值相同的链表的第一个节点)进行加锁(锁是该结点,如果此时还有其他线程想来 put,会阻塞)(如果不加锁,可能在遍历链表的过程中,又有其他线程放进来一个相同的元素,但此时我已经遍历过,发现没有相同的,这样就会产生两个相同的),对该桶进行遍历,桶中的结点的 hash 值与 key 值与给定的 hash 值和 key 值相等,则根据标识选择是否进行更新操作(用给定的 value 值替换该结点的 value 值),若遍历完桶仍没有找到hash 值与 key 值和指定的 hash 值与 key 值相等的结点,则直接新生一个结点并赋值为之前最后一个结点的下一个结点。
7、若 binCount 值达到红黑树转化的阈值,则将桶中的结构转化为红黑树存储,最后,增加 binCount 的值。最后调用 addcount 方法,将 concurrenthashmap 的 size 加 1,调用
size()方法时会用到这个值。
扩容 transfer()函数流程:
整个扩容操作分为两个部分
第一部分是构建一个 nextTable,它的容量是原来的两倍,这个操作是单线程完成的。
第二个部分就是将原来table中的元素复制到nextTable中,这里允许
多线程进行操作。其他线程调用 helptransfer 方法来协助扩容时,首先拿到 nextTable 数组,再调用transfer 方法。给新来的线程分配任务(默认是 16 个桶一个任务)。

遍历自己所分到的桶:
1、桶中元素不存在,则通过 CAS 操作设置桶中第一个元素为 ForwardingNode,其 Hash值为 MOVED(-1),同时该元素含有新的数组引用
此时若其他线程进行 put 操作,发现第一个元素的 hash 值为-1 则代表正在进行扩容操作(并且表明该桶已经完成扩容操作了,可以直接在新的数组中重新进行 hash 和插入操作),该线程就可以去帮助扩容,或者没有任务则不用参与,此时可以去直接操作新的数组了
2、桶中元素存在且 hash 值为-1,则说明该桶已经被处理了(本不会出现多个线程任务重叠的情况,这里主要是该线程在执行完所有的任务后会再次进行检查,再次核对)
3、桶中为链表或者红黑树结构,则需要获取桶锁,防止其他线程对该桶进行 put 操作,然后处理方式同 HashMap 的处理方式一样,对桶中元素分为 2 类,分别代表当前桶中和要迁移到新桶中的元素。设置完毕后代表桶迁移工作已经完成,旧数组中该桶可以设置成ForwardingNode 了,已经完成从 table 复制到 nextTable 的节点,要设置为 forward

get 函数流程:
1、根据 k 计算出 hash 值,找到对应的数组 index
2、如果该 index 位置无元素则直接返回 null
3、如果该 index 位置有元素
如果第一个元素的 hash 值小于 0,则该节点可能为 ForwardingNode 或者红黑树节点
TreeBin
如果是 ForwardingNode(表示当前正在进行扩容,且已经扩容完成),使用新的数组
来进行查找
如果是红黑树节点 TreeBin,使用红黑树的查找方式来进行查找
如果第一个元素的 hash 大于等于 0,则为链表结构,依次遍历即可找到对应的元素,
也就是读的时候不会加锁,同时有 put,不会阻塞。
读不加锁是因为使用了 volatile(用在 transient volatile Node<K,V>[] table),happens-before

ConcurrentHashmap 和 Hashtable 不允许 key 和 value 为 null:
ConcurrentHashmap 和 Hashtable 都是支持并发的,这样会有一个问题,当你通过 get(k)
获取对应的 value 时,如果获取到的是 null 时,你无法判断,它是 put(k,v)的时候 value为 null,还是这个 key 从来没有做过映射。HashMap 是非并发的可以通过 contains(key)来做这个判断。而支持并发的 Map 在调用 m.contains(key)和 m.get(key),m 可能已经不同了。
ConcurrentHashmap 和 Hashtable 都 不 允 许 key 和 value 为 null ,Collections.synchronizedMap 和 HashMap 的 key 和 value 都可以为 null(因为就是包装了hashmap),TreeMap 的 key 不可为空(非线程安全,需要排序),value 可以

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值