HashMap总结

一、什么是HashMap

(1)HashMap 是基于 Map 接口的非同步实现,线程不安全,是为了快速存取而设计的;它采用 key-value 键值对的形式存放元素(并封装成 Node 对象),允许使用 null 键和 null 值,但只允许存在一个键为 null,并且存放在 Node[0] 的位置,不过允许存在多个 value 为 null 的情况。

(2)在 JDK1.7 及之前的版本,HashMap 的数据结构可以看成“数组+链表”,在 JDK1.8 及之后的版本,数据结构可以看成"数组+链表或红黑树",也就是说 HashMap  底层采用数组实现,数组的每个位置都存储一个单向链表,当链表的长度超过一定的阈值时,就会转换成红黑树。转换的目的是当链表中元素较多时,也能保证HashMap的存取效率(备注:链表转为红黑树只有在数组的长度大于等于64且才会触发)

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

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

(4)采用 Fail-Fast 机制,底层通过一个 modCount 值记录修改的次数,对 HashMap 的修改操作都会增加这个值。迭代器在初始过程中会将这个值赋给 exceptedModCount ,在迭代的过程中,如果发现 modCount 和 exceptedModCount 的值不一致,代表有其他线程修改了Map,就立刻抛出异常。

二、HashMap的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 值与 (tablel.length-1) 进行位与&运算,得出元素应该放入数组的哪个位置。

为什么 HashMap 的底层数组长度总是2的n次方幂?因为当 length 为2的n次方时,h & (length - 1) 就相当于对 length 取模,而且速度比直接取模要快得多,二者等价不等效,这是HashMap在性能上的一个优化

(3)将 key-value 添加到数组中:

① 如果计算出的数组位置上为空,那么直接将这个元素插入放到该位置中。

② 如果数组该位置上已经存在链表,则使用 equals() 比较链表上是否存在 key 相同的节点,如果为true,则替换原元素;如果不存在,则在链表的尾部插入新节点(Jdk1.7及以前的版本使用的头插法)

③ 如果插入元素后,如果链表的节点数是否超过8个,则调用 treeifyBin() 将链表节点转为红黑树节点。

④ 最后判断 HashMap 总容量是否超过阈值 threshold,则调用 resize() 方法进行扩容,扩容后数组的长度变成原来的2倍。

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

  • 开放地址法(线性探测再散列、二次探测再散列、伪随机探测再散列):当冲突发生时,在散列表中形成一个探测序列,沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址为止(即该地址单元为空)。如果是插入的情况,在探查到开放的地址,则可将待插入的新结点存入该地址单元,如果是查找的情况,探查到开放的地址则表明表中无待查的关键字,即查找失败。
  • 再哈希法:产生冲突时,使用另外的哈希函数计算出一个新的哈希地址、直到冲突不再发生
  • 建立一个公共溢出区:把冲突的记录都放在另一个存储空间,不放在表里面。

三、HashMap扩容的过程

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

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

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

问题:为什么扩容时节点重 hash 只可能分布在原索引位置或者 原索引长度+oldCap 位置呢?换句话说,扩容时使用节点的hash值跟oldCap进行位与运算,以此决定将节点分布到原索引位置或者原索引+oldCap位置上的原理是什么呢?

假设老表的容量为16,则新表容量为16*2=32,假设节点1的hash值为 0000 0000 0000 0000 0000 1111 0000 1010,节点2的hash值为 0000 0000 0000 0000 0000 1111 0001 1010。

那么节点1和节点2在老表的索引位置计算如下图计算1,由于老表的长度限制,节点1和节点2的索引位置只取决于节点hash值的最后4位。再看计算2,计算2为元素在新表中的索引计算,可以看出如果两个节点在老表的索引位置相同,则新表的索引位置只取决于节点hash值倒数第5位的值,而此位置的值刚好为老表的容量值16,此时节点在新表的索引位置只有两种情况:原索引位置和 原索引+oldCap位置(在此例中即为10和10+16=26)。由于结果只取决于节点hash值的倒数第5位,而此位置的值刚好为老表的容量值16,因此此时新表的索引位置的计算可以替换为计算3,直接使用节点的hash值与老表的容量16进行位于运算,如果结果为0则该节点在新表的索引位置为原索引位置,否则该节点在新表的索引位置为 原索引+ oldCap 位置。

四、HashMap 链表转换成红黑树

当数组中某个位置的节点达到8个时,会触发 treeifyBin() 方法将链表节点(Node)转红黑树节点(TreeNode,间接继承Node),转成红黑树节点后,其实链表的结构还存在,通过next属性维持,红黑树节点在进行操作时都会维护链表的结构,并不是转为红黑树节点后,链表结构就不存在了。当数组中某个位置的节点在移除后达到6个时,并且该索引位置的节点为红黑树节点,会触发 untreeify() 将红黑树节点转化成链表节点。

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

当链表长过长时会转换成红黑树,那能不能使用AVL树替代呢?

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

五、HashMap 在 JDK1.7 和 JDK1.8 有哪些区别?

① 数据结构:在 JDK1.7 及之前的版本,HashMap 的数据结构可以看成“数组+链表”,在 JDK1.8 及之后的版本,数据结构可以看成"数组+链表+红黑树",当链表的长度超过8时,链表就会转换成红黑树,从而降低时间复杂度(由O(n) 变成了 O(logN)),提高了效率

② 对数据重哈希:JDK1.8 及之后的版本,对 hash() 方法进行了优化,重新计算 hash 值时,让 hashCode 的高16位参与异或运算,目的是在 table 的 length较小的时候,在进行计算元素存储位置时,也让高位也参与运算。

③ 在 JDK1.7 及之前的版本,在添加元素的时候,采用头插法,所以在扩容的时候,会导致之前元素相对位置倒置了,在多线程环境下扩容可能造成环形链表而导致死循环的问题。JDK1.8之后使用的是尾插法,扩容是不会改变元素的相对位置

④ 扩容时重新计算元素的存储位置的方式:JDK1.7 及之前的版本重新计算存储位置是直接使用 hash & (table.length-1);JDK1.8 使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度。

⑤ JDK1.7 是先扩容后插入,这就导致无论这次插入是否发生hash冲突都需要进行扩容,但如果这次插入并没有发生Hash冲突的话,那么就会造成一次无效扩容;JDK1.8是先插入再扩容的,优点是减少这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容。

六、线程不安全的体现?如何变成线程安全

无论在 JDK1.7 还是 JDK1.8 的版本中,HashMap 都是线程不安全的,HashMap 的线程不安全主要体现在以下两个方面:

  • 在JDK1.7及以前的版本,表现为在多线程环境下进行扩容,由于采用头插法,位于同一索引位置的节点顺序会反掉,导致可能出现死循环的情况
  • 在JDK1.8及以后的版本,表现为在多线程环境下添加元素,可能会出现数据丢失的情况

如果想使用线程安全的 Map 容器,可以使用以下几种方式:

(1)使用线程安全的 Hashtable,它底层的每个方法都使用了 synchronized 保证线程同步,所以每次都锁住整张表,在性能方面会相对比较低。

除了线程安全性方面,Hashtable 和 HashMap 的不同之处还有:

  • 继承的父类:两者都实现了 Map 接口,但 HashMap 继承自 AbstractMap 类,而 Hashtable 继承自 Dictionary 类
  • 遍历方式:HashMap 仅支持 Iterator 的遍历方式,但 Hashtable 实现了 Enumeration 接口,所以支持Iterator和Enumeration两种遍历方式
  • 使用方式:HashMap 允许 null 键和 null 值,Hashtable 不允许 null  键和 null 值
  • 数据结构:HashMap 底层使用“数组+链表+红黑树”,Hashtable 底层使用“数组+链表”
  • 初始容量及扩容方式:HashMap 的默认初始容量为16,每次扩容为原来的2倍;Hashtable 默认初始容量为11,每次扩容为原来的2倍+1。
  • 元素的hash值:HashMap的hash值是重新计算过的,Hashtable直接使用Object的hashCode;

之所以会出现初始容量以及元素hash值计算方式的不同,是因为 HashMap 和 Hashtable 设计时的侧重点不同。Hashtable 的侧重点是哈希结果更加均匀,使得哈希冲突减少,当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。而 HashMap 则更加关注哈希的计算效率问题,在取模计算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法。

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

(3)使用ConcurrentHashMap集合。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值