HashMap 和 ConcurrentHashMap 核心原理

1,020 阅读9分钟

HashMap 和 ConcurrentHashMap 原理、源码分析

一、HashMap原理

图片.png

1.什么是HashMap:

(1)HashMap 是基于Map接口的非同步实现,线程不安全,为了快速存取而设计,相对于 arrayList 查询快增删慢,LinkedList增删快查询慢的一种折中的方案。采用key-value兼职对的形式存放元素,并封装成Node对象 允许使用null为key或value,并且放在Node[0]位置。

(2)JDK1.7 及之前的版本使用数组+链表,JDK1.8之后的版本可以看成“ 数组+链表+红黑树” ,当数组长度大于64,或者链表长度大于8,则链表转换为红黑树 ,小于6转为链表,6-8可以看做缓冲区防止链表红黑树经常转换,损耗性能。转换的目的是当链表中元素较多时,也能保证HashMap的存取效率。

HashMap 在进行插入和删除时有可能会触发红黑树的插入平衡调整(balanceInsertion方法)或删除平衡调整(balanceDeletion )方法,调整的方式主要有以下手段:左旋转(rotateLeft方法)、右旋转(rotateRight方法)、改变节点颜色(x.red = false、x.red = true),进行调整的原因是为了维持红黑树的数据结构。

当链表长过长时会转换成红黑树,那能不能使用AVL树替代呢?AVL树是完全平衡二叉树,要求每个结点的左右子树的高度之差的绝对值最多为1,而红黑树通过适当的放低该条件(红黑树限制从根到叶子的最长的可能路径不多于最短的可能路径的两倍长,结果是这个树大致上是平衡的),以此来减少插入/删除时的平衡调整耗时,从而获取更好的性能,虽然会导致红黑树的查询会比AVL稍慢,但相比插入/删除时获取的时间,这个付出在大多数情况下显然是值得的。

(3)HashMap 有两个影响性能的关键参数:“初始容量”和“加载因子”:

    容量 capacity:就是哈希表中数组的数量,默认初始容量是16,容量必须是2的N次幂,这是为了提高计算机的执行效率。
    加载因子 loadfactor:在 HashMap 扩容之前,容量可以达到多满的程度,默认值为 0.75
    扩容阈值 threshold = capacity * loadfactor
    

2. HashMap put() 和get()方法

(1)put方法执行过程

1.重新计算hash值

通过添加元素key的hashcode,调用hash()计算重新计算hash值,防止质量低下的hashCode()函数出现,从而使hash值尽量分布均匀。

JDK8 及之后的版本,对 hash() 方法进行了优化,重新计算 hash 值时,让 hashCode 的高16位参与异或运算,目的是即使 table 数组的长度较小,在计算元素存储位置时,也能让高位也参与运算。

(key == null)? 0 : ( h = key.hashcode()) ^ (h >>> 16)

2.计算元素放在数组中的那个位置: 将二次hash的值与(table.length-1)进行位与& 运算,得到放入元素的数组下标

0000000100

1001010101

0000000100 按位与&运算得到的结果

3.将key-value添加到HashMap中:

  • 如果计算出数组位置上为空,直接将元素插入到该位置中
  • 如果数组已存在里阿尼宝,使用equals()方法比较链表是是否存在key相同的节点,如果为true,则更新该元素,如果不存在key ,则在链表尾部插入该节点。
  • 插入元素后,如果链表节点超过8个,则调用 treeifyBin()将链表转换为红黑树。如果小于6 则转换为链表
  • 最后判断HashMap总容量是否超过法制 threshold,超过则调用 resize()方法进行扩容,扩容后数组长度变成原来的两倍。

图片.png (2)get方法过程 计算hash值,通过与运算定位到table(数组)索引位置,如果是首节点符合就返回 不符合,则遍历整个链表查找

在 HashMap 中,当发生hash冲突时,解决方式是采用拉链法,也就是将所有哈希值相同的记录都放在同一个链表中,除此之外,解决hash冲突的方式有:

开放地址法(线性探测再散列、二次探测再散列、伪随机探测再散列):当冲突发生时,在散列表中形成一个探测序列,
沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址为止(即该地址单元为空)。
如果是插入的情况,在探查到开放的地址,则可将待插入的新结点存入该地址单元,如果是查找的情况,探查到开放的地址则表明表中无待查的关键字,即查找失败。

再哈希法:产生冲突时,使用另外的哈希函数计算出一个新的哈希地址、直到冲突不再发生

建立一个公共溢出区:把冲突的记录都放在另一个存储空间,不放在表里面。

3.HashMap扩容机制

(1)重新建立一个新的数组,长度为原数组的两倍;

(2)遍历旧数组的每个数据,重新计算每个元素在新数组中的存储位置。使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度。

(3)将旧数组上的每个数据使用尾插法逐个转移到新数组中,并重新设置扩容阈值。

4.线程不安全体现,如何变成线程安全

首先HashMap是线程不安全的,其主要体现:

  1. 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  2. 在jdk1.8中,在多线程环境下,put 会发生数据覆盖的情况。

(1)使用HashTable (保留类不推荐使用)

(2)使用Collections.synchronizedMap()方法来获取一个线程安全的集合,底层原理是使用synchronized来保证线程同步。

(3)使用 ConcurrentHashMap 集合。

二、ConcurrentHashMap 原理(JDK8):

1。实现原理

数据结构 :synchronized+CAS(乐观锁)+Node+红⿊树Node的val和next都⽤volatile修饰, 保证可见性 查找,替换,赋值操作都使⽤CAS。

在ConcurrentHashMap中,大量使用Unsafe.compareAndSwapXXX 的方法,这类方法是利用一个CAS算法实现无锁化的修改值操作,可以大大减少使用加锁造成的性能消耗。这个算法的基本思想就是不断比较当前内存中的变量值和你预期变量值是否相等,如果相等,则接受修改的值,否则拒绝你的而操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。

锁 :锁住的是链表的head节点,不影响其他元素的读写,锁粒度更细,效率更⾼ 扩容时,阻塞所有的读写操作、并发扩容.

读操作⽆锁Node节点的val和next使⽤volatile修饰,读写线程对该变量互相可见,可以保证不会读取到脏数据。 数组⽤volatile修饰,保证扩容时被读线程感知

2.CurrentHashMap put()/ get()方法

put过程很清晰,对当前table进行无条件自循环CAS,直到put成功。

流程如下:

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 如果没有hash冲突就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
  5. 最后一个如果Hash冲突时会形成Node链表,在链表长度超过8,Node数组超过64时会将链表结构转换为红黑树的结构,break再一次进入循环
  6. .如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

ConcurrentHashMap的get操作流程:

  • 计算hash值,通过与运算定位到table(数组)索引位置,如果是首节点符合就返回
  • 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
  • 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

3.扩容方法transfer()

总结

JDK1.8版本的CurrentHashMap的实现原理

JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组 链表 红黑树的实现方式来设计,内部大量采用CAS操作,这里我简要介绍下CAS。

CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。