HashMap的源码分析


前言

HashMap实现于Map接口,是一种存储key-value对的容器。
底层数据结构为哈希表,数组,链表,红黑树(当链表的长度大于等8时,链表会转换为红黑树结构)。
数组和链表都有其各自的优点和缺点:
数组连续存储,寻址容易,插入删除操作相对困难;
链表离散存储,寻址相对困难,而插入删除操作容易;
而HashMap结合了这两种数据结构,保留了各自的优点,又弥补了各自的缺点。


一、HashMap重要的几个属性

在了解HashMap底层实现原理之前,需要对HashMap中几个重要的属性有一定的认知,具体属性如下:
在这里插入图片描述

二、HashMap源码分析

1. HashMap的初始化

HashMap一共提供了三个构造方法,无参构造,指定初始容量和指定初始容量及负载因子的构造方法。

  • 无参构造:所有的属性都取默认值,如loadFactory = 0.75F,默认的初始容量为DEFAULT_INITIAL_CAPACITY即16。
  • 指定初始容量:负载因为为默认的0.75F,而指定初始容量之后,HashMap并没有直接将指定的初始容量作为真正的HashMap的容量,而是经过
    计算获取一个比给定整数大或者等于的最接近的2的幂次方整数,如给定3,则设置为4,给定9,则设置为16。
  • 指定初始容量及负载因子:其实该构造方法并不常用,因为负载因子的设定关系到扩容的时机,负载因子设置过大会导致HashMap的hash冲突比较严重,影响查询的效率,设置过小,会导致频繁地进行扩容,影响添加元素的效率。

在这里插入图片描述

2.HashMap容量为什么要设置为2的幂次方整数

HashMap其实就是哈希表,而哈希表数据结构的优点就是存储元素和查询元素的效率特别高,在不考虑hash冲突的情况下时间复杂度可以降为O(1)
所以为了避免hash冲突,HashMap内部进行了很多处理,如容量设置为2的幂次方整数,具体见:HashMap的tableSizeFor

在这里插入图片描述
关于上述源码即可以拿到大于给定的cap最接近的2的幂次方整数。

关于HashMap的容量为什么要设置为2的幂次方整数,其实具体原因需要结合HashMap在put元素的操作,HashMap在putVal的时候需要计算table的index,
即entry桶在table的索引,计算index的原理就是利用(capacity -1) & hash,当容量capacity设置为2的幂次方时,(capacity-1)之后的二进制码最高位之后可以保证全是1,如:

  • 容量为8, 对应的7 的二进制码为0000 0111
  • 容量为16,对应的15的二进制码为0000 1111

这样对hashCode进行按位与&计算之后可以保证结果既可以是基数也可以是偶数,而且结果和hashCode的后几位有关系,只要对应的key的hashCode算法足够好,就可以有效减少hash碰撞的几率。

HashMap在初始化建议根据实际情况设置初始容量大小,指定初始化容量可以有效减少HashMap的扩容resize次数,因为在进行扩容时会导致重建hash表,重建hash表需要重新计算这些数据在新table数组中的位置并进行复制处理,实际上HashMap会采用第一个大于指定数值的2的幂作为初始化容量。

3. HashMap的哈希算法

在HashMap添加元素的时候需要根据key计算对应的hashCode然后再确定该元素对应的数组下标,计算下标的算法为(capacity -1) & hash,其中capacity设置为2的幂次方整数是为了减少hash冲突的第一步,在HashMap的哈希算法中对key的hashCode也进行了一些处理,HashMap的hash算法源码如下:
在这里插入图片描述
引用自HashMap的hash算法

为了减少hash碰撞的几率,HashMap并不是直接用key.hashCode()作为hash值,而是通过上述的hash(Object key)算法将
hashcode 与 hashcode的低16位做异或运算,混合了高位和低位得出的最终hash值,冲突的概率就小多了。

举个例子:

有个蒸笼,第一层是猪肉包、牛肉包、鸡肉包,第二层是白菜包,第三层是豆沙包,第四层是香菇包。这时你来买早餐,你指着第一层说除了猪肉包,随便给我一个包子,
因为外表无法分辨,这时拿到猪肉包的概率就有1/3,如果将二层、三层、四层与一层混合在一起了,那么拿到猪肉包的概率就小多了。

我们的hash(Object key)算法一个道理,最终的hash值混合了高位和低位的信息,掺杂的元素多了,那么最终hash值的随机性越大,
而HashMap的table下标依赖于最终hash值capacity-1的&运算,这里的&运算类似于挑包子的过程,自然冲突就小得多了。

计算过程如下:
在这里插入图片描述
这样的过程叫做扰动函数。

4. HashMap添加元素的过程

HashMap对table的初始化是在第一次put元素的时候,HashMap进行put元素的过程如下:

  1. table为空,为空则调用resize()方法初始化table数组。
  2. table不为空,则根据key的(table的长度-1) & hashCode定位到table数组对应的元素
    • 如果table对应的元素为null,则直接放置在table对应的位置
    • 如果table对应的元素不为null,这时候说明产生了hash碰撞,而HashMap对hash碰撞的处理方式是利用链表或者红黑树,
      当table对应的元素为TreeNode,则放置到红黑树对应的节点,如果不是红黑树结构,这时候就需要遍历链表
      • 遍历链表时发现对应的key已经存在,则进行value覆盖
      • 遍历链表时发现对应的key不存在,则新建一个节点追加到链表的尾部,当追加后的链表长度大于树形阈值TREEIFY_THRESHOLD,则将链表转换为红黑树。
  3. 添加元素后如果table的长度大于扩容的阈值threshold,则进行resize扩容。

在这里插入图片描述

5. HashMap的扩容机制

HashMap扩容的时机取决于扩容阈值threshold,阈值的计算规则为threshold = capicaty * loadFactor
当HashMap的size大于或等于 threshold则HashMap会进行扩容处理。

在这里插入图片描述

JDK1.7 中HashMap添加元素导致死循环

1.7 中 HashMap的链表扩容时转移Entry上的链表时采用的是头插法,多线程的情况下可能会产生链表的闭环,导致死循环。
假如现在有两个线程,put元素时产生了HashMap的扩容机制,流程如下图:
在这里插入图片描述

1.8 对HashMap的改进

  • 1.7 中 HashMap的底层实现是数组 + 单链表,1.8底层实现增加了红黑树,当链表的长度大于等于8(默认)时将链表转换为红黑树。
  • 1.7 中 HashMap的链表使用头插法,多线程的情况下可能会产生链表的闭环,导致死循环,1.8之后,链表添加元素使用尾插法。

HashMap和HashTable的区别

HashTable:
- 继承于Dictionary
- 是线程安全的,几乎所有的方法都加上了synchronized
- key 和 value 都不允许为 null
- 默认的初始容量为 11,负载因子为0.75
- 扩容机制为原容量的两倍 + 1

HashMap:
- 继承于 AbstractMap
- 非线程安全的
- key 和 value 可以为 null
- 默认的初始容量为16,负载因子为0.75,扩容的阈值为capacity * loadFactor
- 扩容机制为原容量的两倍

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值