java的map(HashMap 与 ConcurrentHashMap)原理探究
what:
java中map的大体的结构如下:
hashmap:
a、HashMap 是基于 Map 接口的非同步实现,线程不安全,是为了快速存取而设计的;它采用 key-value 键值对的形式存放元素(并封装成 Node 对象),允许使用 null 键和 null 值,但只允许存在一个键为 null,并且存放在 Node[0] 的位置,但是允许存在多个 value 为 null 的情况。
b、在 JDK7 及之前的版本,HashMap 的数据结构可以看成“数组+链表”,在 JDK8 及之后的版本,数据结构可以看成"数组+链表+红黑树"。转换为红黑树的目的是:当链表中元素较多时,也能保证HashMap的存取效率(备注:链表转为红黑树只有在数组的长度大于等于64才会触发)。
c、HashMap 有两个影响性能的关键参数:“初始容量”和“加载因子”:
容量 capacity:哈希表中数组的数量,默认初始容量是16,容量必须是2的N次幂。这种容量要求,目的:这是为了提高计算机的执行效率,例如:取模%,可以直接&上“capacity-1”;
加载因子 loadfactor:即capacity的最大使用量,默认是0.75。该值越小,hash的冲突碰撞就越少;
扩容阈值 threshold:threshold = capacity * loadfactor;
d、采用 Fail-Fast 机制。具体参考文章:https://www.cnblogs.com/sfzlstudy/p/16333503.html
JDK7和JDK8上面的差异概述:
1、数据结构:在 JDK7 及之前的版本,HashMap 的数据结构可以看成“数组+链表”,在 JDK8 及之后的版本,数据结构可以看成"数组+链表+红黑树",当链表的长度超过8时,链表就会尝试转换成红黑树(具体见下面的详解),从而降低时间复杂度(由O(n) 变成了 O(logN)),提高了效率;
2、对数据重哈希:JDK8 及之后的版本,对 hash() 方法进行了优化,重新计算 hash 值时,让 hashCode 的高16位参与异或运算,目的是在 table 的 length较小的时候,在进行计算元素存储位置时,也让高位也参与运算。
3、在 JDK7 及之前的版本,在添加元素的时候,采用头插法,所以在扩容的时候,会导致之前元素相对位置倒置了,在多线程环境下扩容可能造成环形链表而导致死循环的问题。DK1.8之后使用的是尾插法,扩容是不会改变元素的相对位置。
4、扩容时重新计算元素的存储位置的方式:JDK7 及之前的版本重新计算存储位置是直接使用 hash & (table.length-1);JDK8 使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度(具体见下面的详解)。
5、JDK7 是先扩容后插入,这就导致无论这次插入是否发生hash冲突都需要进行扩容,但如果这次插入并没有发生Hash冲突的话,那么就会造成一次无效扩容;JDK8是先插入再扩容的,优点是减少这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容。
hash:
JDK8 及之后的版本,对 hash() 方法进行了优化,让 hashCode 的高16位参与异或运算。目的是:即使 table 数组的长度较小,在计算元素存储位置时,也能让高位也参与运算。如下:
(key == null)? 0 : ( h = key.hashcode()) ^ (h >>> 16)
key-value数据插入:
上面“链表长度是否大于8”的判断操作,具体见:https://www.cnblogs.com/sfzlstudy/p/16334528.html
hashMap扩容:
1、重新建立一个新的数组,长度为原数组的两倍(实际长度为2的n次幂);
2、遍历旧数组的每个数据,重新计算每个元素在新数组中的存储位置(一次性完成)。计算方法(很巧妙,使用长度扩2倍的特点):使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度。具体参考文档:https://www.cnblogs.com/sfzlstudy/p/16334690.html
ConcurrentHashMap:
JDK8和JDK7中的差异:
1、数据结构:JDK7 的数据结构是 Segment数组 + HashEntry数组 + 链表,JDK8 的数据结构是 HashEntry数组 + 链表 + 红黑树,当链表的长度超过8时,链表就会尝试转换成红黑树,从而降低时间复杂度(由O(n) 变成了 O(logN)),提高了效率;
2、锁的实现:JDK7的锁是segment,是基于ReentronLock实现的,segment包含多个HashEntry;而JDK8 降低了锁的粒度,采用 table 数组元素作为锁,从而实现对每行数据进行加锁,进一步减少并发冲突的概率,并使用 synchronized 来代替 ReentrantLock。因为在低粒度的加锁方式中,synchronized 并不比 ReentrantLock 差,在粗粒度加锁中ReentrantLock 可以通过 Condition 来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了。
3、统计集合中元素个数 size 的方式:JDK7 是先尝试 2次(RETRIES_BEFORE_LOCK)通过不锁住 segment 的方式来统计各个 segment 大小(每个 segment 中使用一个 volatile 修饰的计数器coun)。如果统计的过程中,容器的 count 发生了变化,则再采用加锁(所有 segment 段加锁)的方式来统计所有Segment的大小;在 JDK8 中,对于size的计算,在扩容和 addCount() 方法中就已经有处理了,等到调用 size() 时直接返回元素的个数(有全局值)。
JDK8:
ConcurrentHashMap 的底层数据结构依然采用“数组+链表+红黑树”,但是在实现线程安全性方面,抛弃了 JDK7 版本的 Segment分段锁的概念,而是采用了 synchronized + CAS 算法来保证线程安全。在ConcurrentHashMap中,大量使用 Unsafe.compareAndSwapXXX 的方法,这类方法是利用一个CAS算法实现无锁化的修改值操作,可以大大减少使用加锁造成的性能消耗。这个算法的基本思想就是不断比较当前内存中的变量值和你预期变量值是否相等,如果相等,则接受修改的值,否则拒绝你的而操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。
即:而对于锁的粒度,调整为对每个数组元素加锁(就是对每个链表或红黑树加锁)
扩容机制:
ConcurrentHashMap 为了减少扩容带来的时间影响,在扩容过程中主要使用 sizeCtl 和 transferIndex 这两个属性来协调多线程之间的并发操作,并且在扩容过程中大部分数据可以做到访问不阻塞,整个扩容操作分为以下几个步骤:
1、根据 CPU 核数和数组长度,计算每个线程应该处理的桶数量,如果CPU为单核,则使用一个线程处理所有桶;
2、根据当前数组长度n,新建一个两倍长度的数组 nextTable(该这个步骤是单线程执行的);
3、原来 table 中的元素复制到 nextTable 中,这里允许多线程进行操作,具体操作步骤如下:
3.1、初始化 ForwardingNode 对象,充当占位节点,hash 值为 -1,该占位对象存在时表示集合正在扩容状态;
ForwardingNode 的 key、value、next 属性均为 null 。a、占位作用,用于标识数组该位置的桶已经迁移完毕;转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作。
3.2、通过 for 循环从右往左依次迁移当前线程所负责数组,具体如下:
a、当前桶没有元素,则直接通过 CAS 放置一个 ForwardingNode 占位对象;
b、节点的 hash 值为 MOVE,也就是 -1(即 ForwardingNode 节点),则直接跳过,继续处理下一个桶中的节点;
c、当前桶节点加上 synchronized 锁。通过 CAS 把低位节点 lowNode 设置到新数组的 i 位置,高位节点 highNode 设置到 i+n 的位置(i 表示在原数组的位置,n表示原数组的长度)(见上面JDK8中的hashmap的hash方法);如果数组中的节点是链表结构,则顺序遍历链表并使用头插法进行构造新链表。
d、迁移完成后,将 ForwardingNode 占位符对象设置到当前桶位置上;
4、每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行减 1 操作,当最后一条线程扩容结束后,需要重新检查一遍数组,防止有遗漏未成功迁移的桶。扩容结束后,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值。
sizeCtl:是一个控制标识符。a、负数代表正在进行初始化或扩容操作:-1代表正在初始化,-N 表示有N-1个线程正在进行扩容操作;b、0代表hash表还没有被初始化,正数表示下一次进行扩容的大小,这一点类似于扩容阈值的概念。
put:
多线程,分以下2种情况:
1、ConcurrentHashMap 正在进行扩容操作,也就是当前桶位置上被插入了 ForwardingNode 节点,那么当前线程也要协助进行扩容,协助扩容时会调用 helpTransfer() 方法,当方法被调用的时候,当前 ConcurrentHashMap 一定已经有了 nextTable 对象,首先拿到这个 nextTable 对象,调用transfer方法。
2、插入的节点是非空且不是 ForwardingNode 节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable 的 synchronized 要好得多。
JDK7:
ConcurrentHashMap 使用“分段锁”机制实现线程安全,数据结构可以看成是"Segment数组+HashEntry数组+链表",一个 ConcurrentHashMap 实例中包含若干个 Segment 实例组成的数组,每个 Segment 实例又包含由若干个桶,每个桶中都是由若干个 HashEntry 对象链接起来的链表。
因为Segment 类继承 ReentrantLock 类,所以能充当锁的角色,通过 segment 段将 ConcurrentHashMap 划分为不同的部分,就可以使用不同的锁来控制对哈希表不同部分的修改,从而允许多个写操作并发进行,默认支持 16 个线程执行并发写操作,及任意数量线程的读操作。
put:
1、对 key 值进行哈希,并使用哈希的结果与 segmentFor() 方法, 计算出元素具体分到哪个 segment 中。插入元素前,先使用 lock() 对该 segment 加锁,之后再使用头插法插入元素。如果其他线程经过计算也是放在这个 segment 下,则需要先获取锁,如果计算得出放在其他的 segment,则正常执行,不会影响效率,以此实现线程安全,这样就能够保证只要多个修改操作不发生在同一个 segment 时,它们就可以并发进行。
2、在将元素插入到 segment 前,会检查本次插入会不会导致 segment 中元素的数量超过阈值。如果会,那么就先对 segment 进行扩容和重哈希操作,然后再进行插入。而重哈希操作,实际上是对 ConcurrentHashMap 的某个 segment 的重哈希,因此 ConcurrentHashMap 的每个 segment 段所包含的桶位也就不尽相同。
读操作不加锁:
HashEntry 类中,key,hash 和 next 域都被声明为 final 的,value 域被 volatile 所修饰,因此 HashEntry 对象几乎是不可变的,通过 HashEntry 对象的不变性来降低读操作对加锁的需求。
注意:next 域被声明为 final,意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改 next 引用值,因此所有的节点的修改只能从头部开始。但是对于 remove 操作,需要将要删除节点的前面所有节点整个复制一遍,复制的最后一个节点指向要删除结点的下一个结点。