HashMap 与 ConcurrentHashMap 底层实现
系统性学习,异步IT-BLOG
一、HashMap 底层源码
JDK7 版本(数组+链表)
我们存放的 hashMap 都会封装成一个节点对象 Entry(key,value),然后将此节点对象存放到一个数组中,存放前首先需要确定存放的数组下标:① 通过 hash(key) 算法得到 key 的 hashcode,并通过 hashcode的高16位和低16位进行异或操作(如果两个相应bit位相同,则结果为0,否则为1)得到32位的 int值,首先将高16位无符号右移16位与低十六位做异或运算。如果不这样做,而是直接做&运算(相同位的两个数字都为1,则为1;若有一个不为1,则为0)那么高十六位所代表的部分特征就可能被丢失 将高十六位无符号右移之后与低十六位做异或运算使得高十六位的特征与低十六位的特征进行了混合得到的新的数值,这样高位与低位的信息都被保留了 。② int值再与(数组长度-1:底位全为1,高位全为0)进行位运算,获取要存放的下标;③ 如果②中得到相同的值时,判断 key值是否相同,如果相同则新value替换旧value。如果key不相同,将value以链表的形式存放在同一个数组下标下,为了提高存放的速度,新的数据,将存放在原链表的头部。即新数据的 next 指向链表的头元素即可。需要注意的是,每次给链表的头部插入一个新的元素之后,需要将链表的头元素赋值给 table 的下标值。代码展示为 :
画个图理解下:
源码分析:【1】JDK7 中 HashMap 的重要属性和构造器源码展示:用户创建 HashMap 时调用有参构造器时,表示用户自定义数组的大小,但是 hashMap 会判断其是否为2的幂次数,如果不是则将其改为该值的下一个2的幂次数,这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数(例如:用户自定义为15,HashMap 会将其修改为 16)。初始化一个 table 的对象 Entry 其容量如果用户传入则为该值的下一个2的幂等数,负责为默认值16;具体代码展示如下:
【2】进入 HashMap 的 put 方法添加元素的源码展示:方法中嵌套的方法,会单独进行说明。例如 hash(key)方法等等。
【3】通过 hash 算法对 key 值进行计算源码展示:简单理解为,计算 key 的 hash 值即可。后面的那些位运算的目的都是为了提高元素分布的均匀性。如果对于链表来说,如果太长的话,查询还是比较影响性能的。
【4】indexFor 的源码展示:获取 key 要存放的 table 的下标,这里是通过 ‘与’ 运算符进行计算。也是上面初始化容量为什么要使用2的幂次数的原因。举个栗子:16使用二进制表示为 0001 0000 此时 16-1 则为 0000 1111 那么此时 key 的 hash 值与 length-1 的 ‘与’ 操作,只会出现在 0-15 之间。但是如果与 16 ‘与’ 则不能达到这种效果,主要是因为它的底四位不全是 1。类似于我们的取‘模’操作。
【5】进入 addEntry 方法:首先判断是否需要扩容,如果要对数组进行扩容,肯定是新创建一个数组(扩容底层是Arrays.copyOf实现的),将原数组的值全都复制到新的数组当中。此时,会出现一个死循环的问题,就是当调用 transfer 方法进行数组赋值的时候。如果当前数组的下标,存在>2的链表时,多线程的情况下,就会出现死循环。原因是因为,我们在复制链表值的时候,会将链表的顺序进行调换。第一个用户进去后复制完后,基于第一个用户的结果,第二个用户继续复制时,就会发生死循环。你可以画个图玩玩。解决办法:就是不让它扩容,设置自己初始化一个能控制的容量大小即可。
JDK8 版本(红黑树)
因为上面说的 HashMap 可能会存在一个很长的链表。HashMap 的 get性能就会出现问题。JDK8就是将长的链表改为了一颗红黑树(二叉树)和扩容的优化等,能够提高 HashMap 的查询效率。红黑树链接。
源码分析:【1】 HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个 Node的数组。我们来看 Node[JDK1.8]是何物。如下,Node是 HashMap的一个内部类,实现了 Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个 Node对象。
【2】主要看 HashMap 的 put 方法:hash(key) 的算法与 JDK7 中有点区别:少了很多的位运算,主要是 JDK8中采用了红黑树,能够分担一些查询压力。当链表的长度>=8的时候转为红黑树,当红黑树 <= 6 的时候会转为链表。
【3】进入 putVal 方法,源码如下:首先会判断 table 是否为空。如果存放的 key,table中已经存在,则将旧值返回,存入新值。如果当前的需要存放的节点是 TreeNode,则存放在红黑树中。否则存放在链表中(且存放在链表的尾部,也就不存在扩容时的死循环问题),存放前需要对链表的长度进行判断,判断是否大于等于默认值8。如果是的话,就将链表转化为红黑树方式存放。最后判断是否需要对 table 进行扩容操作(链表扩容或者红黑树扩容)。
HashTable 能够解决 HashMap 线程不安全的问题。问题是它给所有的方法都加了 synchronized 同步代码块,严重影响系统的性能。
二、HashMap 的数据插入流程
【1】判断键值对数组 table[i]是否为空或为null,否则执行resize()进行扩容;
【2】根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
【3】判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
【4】判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
【5】遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
【6】插入成功后,判断实际存在的键值对数量 size是否超多了最大容量 threshold,如果超过,进行扩容。
当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当 hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 hashmap中元素的个数,那么预设元素的个数能够有效的提高 hashmap的性能。比如说,我们有1000个元素 new HashMap(1000),但是理论上来讲 new HashMap(1024)更合适,即使是1000,hashmap也自动会将其设置为1024。 但是 new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000,也就是说为了让 0.75 * size > 1000,我们必须这样 new HashMap(2048)才最合适,既考虑了&的问题,也避免了 resize的问题。
三、HashMap 的哈希函数怎么设计的
hash 函数是先通过 key 的 hashcode 的到 32位的 int值,然后让 hashcode 的高16位与低16位进行异或操作。这也叫扰动函数,设计的原因有如下两点:
【1】能够降低 hash碰撞,越分散越好:因为 key.hashCode() 函数调用的是 key键值类型自带的哈希函数,返回 int型散列值。int值范围为[-2^31~2^31-1],前后加起来大概 40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。右位移16位,正好是 32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。最后我们来看一下 Peter Lawley 的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
四、JDK1.8 对比 JDK1.7 做了哪些优化
【1】数组+链表改成了数组+链表或红黑树:防止发生 hash冲突,链表长度过长,将时间复杂度由 O(n)
降为 O(logn)
;
【2】链表的插入方式从头插法改成了尾插法:插入时如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 则遍历链表,将元素放置到链表的最后;因为 1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新 hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如下图所示:
五、平常怎么解决线程不安全问题
Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap可以实现线程安全的Map。HashTable是直接在操作方法上加 synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用 Collections集合工具的内部类,通过传入Map封装出一个 SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap JKD1.7使用分段锁,降低了锁粒度,让并发度大大提高,1.8 使用 CAS自旋锁,保证线程安全。
六、ConcurrentHashMap 底层源码
JDK7 版本(Segment+ReentrenLock)
分段锁机制思想(了解):简而言之,ConcurrentHashMap 在对象中保存了一个 Segment 数组,即将整个 Hash表划分为多个分段;而每个 Segment元素,即每个分段类似于一个Hashtable;在执行 put 操作时首先根据 hash算法定位到元素属于哪个 Segment,然后对该 Segment 加锁即可。因此 ConcurrentHashMap 在多线程并发编程中是线程安全的。简单的原理图如下:一个 Segment 管理一个 Entry 数组(2的幂次数)。 但是其最大并发度受 Segment 的个数限制。因此,在JDK1.8中,ConcurrentHashMap 的实现原理摒弃了这种设计,而是选择了与HashMap 类似的数组+链表+红黑树的方式实现,而加锁则采用 CAS 和 synchronized 实现。
源码分析:【1】ConcurrentHashMap 的初始化工作:从构造器中可以看出,首先会初始化一个 segment 数组和 segment 中包含的 entry 数组。
【2】下面我们看下 put 方法的源码:比较新鲜的地方就是,首先会计算出当前 key 的 hash 值位于哪个 segment 数组下标。然后获取这个 segment 对象。进行put 操作,此时这个 put 操作时加了重入锁的,后续的操作与 JDK 中的 HashMap 都是一样的了。如果不存在直接存放在 Entry[] 数组中,否则存放在链表中。
JDK8 版本(数组+链表+红黑树)
去除了segment 片段锁机制。思想是给 table的每一个下标都加锁,也就是当对下标进行操作时都会加锁(CAS+Synchronize)。ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS操作和 synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。
源码分析:主要查看 put 方法的实现: