Java Map中的核心特性——扩容、初始化与懒加载、哈希计算、位运算、并发

Map中的集合核心特性

自动扩容

​ 最小可用原则,容量超过一定阈值便自动进行扩容。

  • 扩容是通过resize方法来实现的。扩容发生在putVal方法的最后,即写入元素之后才会判断是否需要扩容操作,当自增后的size大于之前所计算好的阈值threshold,即执行resize操作。

    图片

  • 通过位运算<<1进行容量扩充,即扩容1倍,同时新的阈值newThr也扩容为老阈值的1倍

    图片

    扩容时,总共存在三个问题

    • 哈希桶数组中某个位置只有1个元素,即不存在哈希冲突时,则直接将该元素copy至新哈希桶数组的对应位置即可。

    • 哈希桶数组中某个位置的节点为树节点时,则执行红黑树的扩容操作。

    • 哈希桶数组中某个位置的节点为普通节点时,则执行链表扩容操作,在JDK1.8中,为了避免之前版本中并发扩容所导致的死链问题,引入了高低位链表辅助进行扩容操作

    图片

    针对扩容时出现的问题的解答

为什么JDK1.7扩容时会产生并发死锁问题,也就是我们常温的为什么hashmap是线程不安全的?

​ 主要原因是:并发,即多线程同时访问HashMap

​ hashmap是线程不安全造成的影响主要有两个方面:1 、高并发下,hashmap会出现扩容的死锁问题;2、数据会丢失,会造成数据的脏读

怎么产生的环形链表死循环问题?

​ JDK1.7 使用的是数组+单链表的数据结构会先进行扩容再插入,执行的是头插法,先将原位置的数据移到后一位,再插入数据到该位置;会出现逆序和环形链表死循环问题

​ JDK1.8 使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率),会先进行插入再进行扩容,执行的是尾插法,直接插到链表尾部/红黑数树,不会出现逆序和环形链表死循环问题

线程不安全是因为:数组确定了就不会修改,想扩容则申请新的数组,把老数据进行迁移,涉及的源码:Entry[] newTable = new Entry[newCapacity]; 当迁移时,单线程迁移没有问题,当有多个线程需要扩容时,都申请了数组,则可能数据迁移不成功,造成JVM内存溢出,并造成大量GC,则当线程迁移时,造成死锁。

​ 在扩容时,在哈希冲突的时候,产生的链表形成环,当有key在成环链表中时,则成死循环。

  • JDK1.7单线程下的扩容

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fmXapgbo-1617181681474)(D:\chencan\img\blog_gif\test_1.gif)]

  • JDK1.7多线程下的扩容:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WDRa5MGK-1617181681476)(D:\chencan\img\blog_gif\test_2.gif)]

​ 在JDK1.8及以后,用的ConcurrentHashMap解决死锁的问题,ConcurrentHashMap是线程安全的对死锁问题进行了改进,采用四组指针,分为高位指针和低位指针,hashcode & 数组容量长度,分成两部分迁移,避免头插链表成环。

ConcurrentHashMap 的线程安全机制:CAS + 锁

​ ConcurrentHashMap保证线程安全的原理思路:

  • T1线程加元素A,CAS判断节点是否为空,为空则用CAS把节点放入

  • T1线程再加元素B,CAS再判断节点是否为空,不为空则加锁后放入,T1线程都可访问

  • T2线程,get(key)时,当没有锁,则直接拿

    扩容原理:ConcurrentHashMap中的put()方法比hashmap()对一个for循环的原因,多个线程需插入值,但CAS算法只能有一个成功,ConcurrentHashMap中加入for循环保证不成功的线程继续放入值,而不失效**(自旋)**

初始化与懒加载

​ 初始化的时候只会设置默认的负载因子,并不会进行其他初始化的操作,在首次使用的时候才会进行初始化。

当new一个新的HashMap的时候,不会立即对哈希数组进行初始化,而是在首次put元素的时候,通过resize()方法进行初始化。

图片

resize()中会设置默认的初始化容量DEFAULT_INITIAL_CAPACITY为16,扩容的阈值为0.75*16 = 12,即哈希桶数组中元素达到12个便进行扩容操作。

最后创建容量为16的Node数组,并赋值给成员变量哈希桶table,即完成了HashMap的初始化操作。

图片

哈希计算

​ 哈希表以哈希命名,足以说明哈希计算在该数据结构中的重要程度。而在实现中,JDK并没有直接使用Object的native方法返回的hashCode作为最终的哈希值,而是进行了二次加工。

以下分别为HashMap与ConcurrentHashMap计算hash值的方法,核心的计算逻辑相同,都是使用key对应的hashCode与其hashCode右移16位的结果进行异或操作。此处,将高16位与低16位进行异或的操作称之为扰动函数,目的是将高位的特征融入到低位之中,降低哈希冲突的概率。

图片

举个例子来理解下扰动函数的作用:

hashCode(key1) = 0000 0000 0000 1111 0000 0000 0000 0010
hashCode(key2) = 0000 0000 0000 0000 0000 0000 0000 0010

若HashMap容量为4,在不使用扰动函数的情况下,key1与key2的hashCode注定会冲突(后两位相同,均为10)。

经过扰动函数处理后,可见key1与key2 hashcode的后两位不同,上述的哈希冲突也就避免了。

hashCode(key1) ^ (hashCode(key1) >>> 16)
0000 0000 0000 1111 0000 0000 0000 1101

hashCode(key2) ^ (hashCode(key2) >>> 16)
0000 0000 0000 0000 0000 0000 0000 0010

这种增益会随着HashMap容量的减少而增加。

此外,ConcurrentHashMap中经过扰乱函数处理之后,需要与HASH_BITS做与运算,HASH_BITS为0x7ffffff,即只有最高位为0,这样运算的结果使hashCode永远为正数。在ConcurrentHashMap中,预定义了几个特殊节点的hashCode,如:MOVED、TREEBIN、RESERVED,它们的hashCode均定义为负值。因此,将普通节点的hashCode限定为正数,也就是为了防止与这些特殊节点的hashCode产生冲突。

  1. 哈希冲突

    通过哈希运算,可以将不同的输入值映射到指定的区间范围内,随之而来的是哈希冲突问题。考虑一个极端的case,假设所有的输入元素经过哈希运算之后,都映射到同一个哈希桶中,那么查询的复杂度将不再是O(1),而是O(n),相当于线性表的顺序遍历。因此,哈希冲突是影响哈希计算性能的重要因素之一。**哈希冲突如何解决呢?主要从两个方面考虑,一方面是避免冲突,另一方面是在冲突时合理地解决冲突,尽可能提高查询效率。**前者在上面的章节中已经进行介绍,即通过扰动函数来增加hashCode的随机性,避免冲突。针对后者,HashMap中给出了两种方案:拉链表与红黑树

拉链法

在JDK1.8之前,HashMap中是采用拉链表的方法来解决冲突,即当计算出的hashCode对应的桶上已经存在元素,但两者key不同时,会基于桶中已存在的元素拉出一条链表,将新元素链到已存在元素的前面。当查询存在冲突的哈希桶时,会顺序遍历冲突链上的元素。同一key的判断逻辑如下图所示,先判断hash值是否相同,再比较key的地址或值是否相同。

图片

(1)死链

​ 在JDK1.8之前,HashMap在并发场景下扩容时存在一个bug,形成死链,导致get该位置元素的时候,会死循环,使CPU利用率高居不下。这也说明了HashMap不适于用在高并发的场景,高并发应该优先考虑JUC中的ConcurrentHashMap。然而,精益求精的JDK开发者们并没有选择绕过问题,而是选择直面问题并解决它。在JDK1.8之中引入了高低位链表(双端链表)

什么是高低位链表呢?——ConcurrentHashMap对死锁的改进问题

在扩容时,哈希桶数组buckets会扩容一倍,以容量为8的HashMap为例,原有容量8扩容至16,将[0, 7]称为低位,[8, 15]称为高位,低位对应loHead、loTail,高位对应hiHead、hiTail。

扩容时会依次遍历旧buckets数组的每一个位置上面的元素:

  • 若不存在冲突,则重新进行hash取模,并copy到新buckets数组中的对应位置。

  • 若存在冲突元素,则采用高低位链表进行处理。通过e.hash & oldCap来判断取模后是落在高位还是低位。

    举个例子:

    假设当前元素hashCode为0001(忽略高位),其运算结果等于0,说明扩容后结果不变,取模后还是落在低位[0, 7],即0001 & 1000 = 0000,还是原位置,再用低位链表将这类的元素链接起来。假设当前元素的hashCode为1001, 其运算结果不为0,即1001 & 1000 = 1000 ,扩容后会落在高位,新的位置刚好是旧数组索引(1) + 旧数据长度(8) = 9,再用高位链表将这些元素链接起来。

    最后,将高低位链表的头节点分别放在扩容后数组newTab的指定位置上,即完成了扩容操作。这种实现降低了对共享资源newTab的访问频次,先组织冲突节点,最后再放入newTab的指定位置。避免了JDK1.8之前每遍历一个元素就放入newTab中,从而导致并发扩容下的死链问题。

图片

红黑树

​ **在JDK1.8之中,HashMap引入了红黑树来处理哈希冲突问题,而不再是拉链表。**那么为什么要引入红黑树来替代链表呢?虽然链表的插入性能是O(1),但查询性能确是O(n),当哈希冲突元素非常多时,这种查询性能是难以接受的。因此,在JDK1.8中,如果冲突链上的元素数量大于8,并且哈希桶数组的长度大于64时,会使用红黑树代替链表来解决哈希冲突,此时的节点会被封装成TreeNode而不再是Node(TreeNode其实继承了Node,以利用多态特性),使查询具备O(logn)的性能。

为什么选择红黑树?

红黑树,它是一种平衡的二叉树搜索树,类似地还有AVL树。两者核心的区别是AVL树追求“绝对平衡”,在插入、删除节点时,成本要高于红黑树,但也因此拥有了更好的查询性能,适用于读多写少的场景。然而,对于HashMap而言,读写操作其实难分伯仲,因此选择红黑树也算是在读写性能上的一种折中。

位运算

  1. 确定哈希桶数组大小

    找到大于等于给定值的最小2的整数次幂。否则会强转成2的整数次幂。

    tableSizeFor根据输入容量大小cap来计算最终哈希桶数组的容量大小,找到大于等于给定值cap的最小2的整数次幂。

    图片

并发

1. 悲观锁

全表锁

HashTable中采用了全表锁,即所有操作均上锁,串行执行,如下图中的put方法所示,采用synchronized关键字修饰。这样虽然保证了线程安全,但是在多核处理器时代也极大地影响了计算性能,这也致使HashTable逐渐不被使用。

图片
在这里插入图片描述

分段锁

针对HashTable中锁粒度过粗的问题,在JDK1.8之前ConcurrentHashMap引入了分段锁机制

整体的存储结构如下图所示,在原有结构的基础上拆分出多个segment,每个segment下再挂载原来的entry(上文中经常提到的哈希桶数组),每次操作只需要锁定元素所在的segment,不需要锁定整个表。因此,锁定的范围更小,并发度也会得到提升。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BBamrwEa-1617181681487)(D:\chencan\img\blog_gif\ConcurrentHashMap.png)]

在这里插入图片描述

2. 乐观锁

Synchronized+CAS

​ **虽然引入了分段锁的机制,即可以保证线程安全,又可以解决锁粒度过粗导致的性能低下问题,**但是对于追求极致性能的工程师来说,这还不是性能的天花板。因此,在JDK1.8中,ConcurrentHashMap摒弃了分段锁,使用了乐观锁的实现方式
在这里插入图片描述

放弃分段锁的原因

  • 使用segment之后,会增加ConcurrentHashMap的存储空间。

  • 当单个segment过大时,并发性能会急剧下降。

JDK 1.8 中,废弃了之前的segment结构,沿用了与HashMap中的类似的Node数组结构。

图片

ConcurrentHashMap中的乐观锁是采用synchronized+CAS进行实现的

举例:

put()方法:

​ (1) 当put的元素在哈希桶数组中不存在时,则直接CAS进行写操作。

图片

​ 这里涉及到了两个重要的操作,tabAt与casTabAt。可以看出,这里面都使用了Unsafe类的方法。Unsafe这个类在日常的开发过程中比较罕见。我们通常对Java语言的认知是:Java语言是安全的,所有操作都基于JVM,在安全可控的范围内进行。然而,Unsafe这个类会打破这个边界,使Java拥有C的能力,可以操作任意内存地址,是一把双刃剑。这里使用到了前文中所提到的ASHIFT,来计算出指定元素的起始内存地址,再通过getObjectVolatile与compareAndSwapObject分别进行取值与CAS操作。

​ 在获取哈希桶数组中指定位置的元素时为什么不能直接get而是要使用getObjectVolatile呢?

​ 因为在JVM的内存模型中,每个线程有自己的工作内存,也就是栈中的局部变量表,它是主存的一份copy。因此,线程1对某个共享资源进行了更新操作,并写入到主存,而线程2的工作内存之中可能还是旧值,脏数据便产生了。Java中的volatile是用来解决上述问题,保证可见性,任意线程对volatile关键字修饰的变量进行更新时,会使其它线程中该变量的副本失效,需要从主存中获取最新值。虽然ConcurrentHashMap中的Node数组是由volatile修饰的,可以保证可见性,但是Node数组中元素是不具备可见性的。因此,在获取数据时通过Unsafe的方法直接到主存中拿,保证获取的数据是最新的。

  • 以上问题涉及,ConcurrentHashMap是如何保证读到的数据不是脏数据的呢?——volatile

    对于可见性,Java提供了volatile关键字来保证可见性、有序性。但不保证原子性。

    普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

    volatile关键字对于基本类型的修改可以在随后对多个线程的读保持一致,但是对于引用类型如数组,实体bean,仅仅保证引用的可见性,但并不保证引用内容的可见性。禁止进行指令重排序。

    总结下来

    第一:使用volatile关键字会强制将修改的值立即写入主存。

    第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

    第三:由于线程1的工作内存中缓存变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。

图片

​ (2)当put的元素在哈希桶数组中存在,并且不处于扩容状态时,则使用synchronized锁定哈希桶数组中第i个位置中的第一个元素f(头节点2),接着进行double check,类似于DCL单例模式的思想

double check 是为了防止当前线程获得锁之后,进行扩容操作,元素的位置发生了改变

​ 校验通过后,会遍历当前冲突链上的元素,并选择合适的位置进行put操作。此外,ConcurrentHashMap也沿用了HashMap中解决哈希冲突的方案,链表+红黑树。这里只有在发生哈希冲突的情况下才使用synchronized锁定头节点,其实是比分段锁更细粒度的锁实现,只在特定场景下锁定其中一个哈希桶,降低锁的影响范围。

图片

Java Map针对并发场景解决方案的演进方向可以归结为,从悲观锁到乐观锁,从粗粒度锁到细粒度锁

3. 并发求和

CounterCell是JDK1.8中引入用来并发求和的利器,而在这之前采用的是**【尝试无锁求和】+【冲突时加锁重试】**的策略


参考:阿里技术

posted @ 2021-03-31 17:14  your_棒棒糖  阅读(195)  评论(0编辑  收藏  举报