HashMap源码分析 —— HashMap为什么是无序的?

HashMapd的put方法为什么是无序的?

参考:https://blog.csdn.net/login_sonata/article/details/76598675 (有删改和补充)

题外话:最近有朋友问我一个问题,也是我曾经初学HashMap时问过老师的问题。所以我打算写一篇博客来回答,以前看过不少博客,但是没想过写。初次尝试,请多指教。

问题:为什么说HashMap 是无序的?但是某种情况下看起来却好像是有序的?

某种情况指的是下面这种情况:

HashMap<Integer,Integer> hashMap = new HashMap<>();
     hashMap.put(1,1);
     hashMap.put(2,2);
     hashMap.put(3,3);
     hashMap.put(4,4);
     hashMap.put(5,5);
     hashMap.put(6,6);

 for (Integer s: hashMap.keySet()) {
    System.out.print(hashMap.get(s) + " ");
 }

如果按照上面的方式向HashMap中添加数据,那输出结果肯定是:

1 2 3 4 5 6

看到这个输出结果, 有人就会对HashMap的无序产生怀疑了,这里为什么是有序的呢?
(你也可以改变添加数据的顺序,比如先添加 “2” 再添加 “1”,最后结果还是这样)

带着这个疑问我们再来看一个例子:

HashMap<Integer,Integer> hashMap = new HashMap<>();
    hashMap.put(1,1);
    hashMap.put(2,2);
    hashMap.put(3,3);
    hashMap.put(4,4);
    hashMap.put(5,5);
    hashMap.put(6,6);
    hashMap.put(65536,65536);
    
for (Integer s: hashMap.keySet()) { 
   System.out.print(hashMap.get(s) + " ");
}

我们思考一下,你觉得会输出什么? 还会是1 2 3 4 5 6 65536这样的顺序吗?

运行结果是:1 65536 2 3 4 5 6

看完这个例子之后,很显然HashMap是无序的,因为它有自己的一套算法。

那我们回过来想,为什么在第一个例子中会出现HashMap有序的情况呢?

想要知道答案就来分析HashMap源码吧。

原因(源码分析):

数据结构源码实现 两个方面来讲解(以JDK 1.8为例)

数据结构:

HashMap的数据结构由 数组 + 链表 + 红黑树(JDK 1.8版本才加入的红黑树)
在这里插入图片描述
我们看这幅图应该需要明白两个问题:

  1. 数据底层具体存储的元素是什么(就是上图中的小黑点是什么)?
    从源码中(HashMap类第395行)可以看到一个字段 Node[] table(即Hash桶数组),
    这个字段就是上图中的数组, Node 类型就是小黑点, Node之间通过 next字段链接成链
    表。下面是Node类型的具体代码:
 static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // 用来定义数组索引位置
    final K key;
    V value;
    Node<K,V> next; // 链表中下一个Node

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
 }
  1. 这样存储的方式有什么优点?
    HashMap是使用哈希表来存储的,为了解决Hash冲突使用的是链地址法(相关文档: Hash讲解)。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash(就是调用源码中的hash方法,在 源码的第337行)后,得到被扰乱的hash值,然后通过位与运算获取数组下标(调用 源码的第630行进行位与运算操作得到数组下标),最后把数据放在该数组下标链表上。

例如程序执行下面代码:

     hashMap.put(65536,65536);

系统将调用 “65536” 这个 keyhashCode() 方法得到其 hash值 (该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储在数组中的位置,有时两个key会定位到数组中相同的位置,表示发生了Hash碰撞(碰撞之后就会生成链表)。当然Hash算法计算结果在越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组(Node[] table)的大小,并在此基础上设计好的hash算法减少Hash碰撞。所以好的Hash算法和扩容机制至关重要。

在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段
从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化:

	int threshold;             // 扩容阈值 
	final float loadFactor;    // 负载因子
	transient int modCount;  // 出现线程问题时,负责及时抛异常
	transient int size;     // HashMap中实际存在的Node数量

首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数threshold = length * Load factor
也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

结合负载因子的定义公式可知,threshold就是在此Load factorlength(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,比如内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length容纳最大键值对数量threshold的区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考 为什么一般hashtable的桶数会取一个素数

这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能。

功能实现

HashMap的内部功能实现很多,本文主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个具有代表性的点深入讲解。

  1. 确定哈希桶数组索引位置
    不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。先看看源码的实现(方法一 + 方法二):
	// 方法一,jdk1.8 & jdk1.7都有:
	static final int hash(Object key) { 
		int h; 
		return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
	} 


	// 方法二,jdk1.7有,jdk1.8没有这个方法,把这个取消了,置换成了一行代码。
	static int indexFor(int h, int length) {
 		return h & (length-1); 
	}
这里的Hash算法本质上就是三步:
(1)取key的hashCode值,h = key.hashCode();
(2) 高位参与运算,h ^ (h >>> 16);
(3) 取模运算,h & (length-1)。

对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处

这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

下面举例说明下,ntable[]的长度:
在这里插入图片描述

  1. 分析HashMap的put方法
    HashMap的put方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习,源码在本文最后可以找到。
    在这里插入图片描述

  2. 扩容机制

HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组

首先举个例子直观感受下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
在这里插入图片描述

简单说就是换一个更大的数组重新映射。下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果
在这里插入图片描述

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
在这里插入图片描述

因此,我们在扩充HashMap的时候,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
在这里插入图片描述
这个设计省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了

最后,我们应该知道为什么HashMap是无序的了吗?

总而言之,就是出现了哈希冲突所以才导致无序。

感谢我所参考文档的提供者们。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值