HashMap源码解读——深入理解HashMap高效的原因

一、前言

  Java的容器是面试中的必考点,最近为了准备春招,我开始阅读容器的源码。今天研究了一下HashMap的源码,颇有心得,所以写篇博客分享一下HashMap的实现原理。内容主要包括HashMap的底层结构,hash函数的原理,以及HashMap的容量机制等内容。内容很多,但是这些内容彼此相辅相成,并不适合分开来叙述,所以将它们放在一起进行讲解。相信大家看完这篇博客,将清楚的理解HashMap高效的秘诀。


二、解析

 2.1 什么是Hash

  Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数(此处引用其他博客)。简单来说,就是将一个任意类型的数据,根据一定的算法,计算出一个标识它的int类型的整数,或者一个字符串,也就是hash

  注意:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同;但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做hash碰撞


 2.2 HashMap的底层结构

  我们首先来谈一谈HashMap的底层结构,即HashMap是如何保存数据的,若连这个都不清楚,那其余的也无从谈起。HashMap的结构概括说来就是:数组 + 链表

  我们知道,HashMap中的元素都是Key - Value类型的,我们姑且将每一个元素称为一个节点Node。在HashMap中,所有的Node都是存在一个数组中,而这个数组的声明如下:

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;

  可以看到,这个数组table的类型是Node类型,其实就是我们说的Key - Value。那当我们进行put操作时,元素将如何存入这个数组中呢?这时候就要用到我们前面提到的Hash了。当我们往HashMap中存入一个元素时,HaspMap底层会调用hash函数,计算出元素keyhash值,然后再用这个hash值与HashMap的总容量进行求余,得到的余数就是这个元素在数组中存放的下标。

  既然如此,那就可能会出现hash碰撞的情况——即两个不同的元素,根据以上方法求出的下标值却相等。这要如何解决呢?HashMap的做法就是采用 数组+链表 的方式解决:在存储元素的数组中,每个位置并不是存储一个单独的Node,而是存储一个链表,而这个Node就是链表中的一个节点,当一个元素要放入数组的某个位置时,若这个位置已经有元素了,那就将这个元素接在最后一个元素的后面。如下图所示,数组下标为1的位置有三个元素,它们共同形成一个链表。

  我们来看看HashMapNode的代码,帮助我们理解数组+链表的结构。通过下面的代码可以看到,NodeHashMap的一个内部类,他有四个成员变量:

  • hash:记录节点的hash值;
  • key:记录节点的key值;
  • value:记录节点的value值;
  • next:记录当前节点的下一个节点;

  而链表的结构,就是通过next成员变量来实现的。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    
    //其余方法......
}

 2.3 HashMap的容量机制——高效秘诀

  理解了HashMap的底层结构之后,我们再来探索它高效的秘诀。我们知道,HashMap是优化了查找速度的一种集合,查询效率极高。而在HashMap中,查询一个元素的步骤如下:

  1. 首先通过向hash函数传入需要查找的元素的key值,hash函数计算出key的hash值;
  2. hash值与总容量进行取模运算,计算出数组元素在数组中的下标;
  3. 根据数组下标获得元素所在的链表;
  4. 从链表的第一个节往后依次比较key值;
  5. 找到key值相等的节点返回;

  以上步骤可以归结为以下代码(注意:以下代码是我从源码中抽取出来组合在一起的,实际上它们并不在一个方法中):

Object get(Object key){
    // 1:获取key的hash值
    int h = hash(key);
    
    // 2-3:从数组中获取元素所在的链表
    int len = table.length;
    Node n = table[ h & (len - 1) ]; // 重点:这里使用 h&(len-1) 取代了 h%len
    
    // 4-5:遍历链表n,并返回查找结果(代码省略)
    ......
}

  上面的代码只有一个地方可能让人疑惑,那就是取模操作%被按位与运算&所取代。上面的代码中,数组的中括号中本应该是h%len,但是大家去查阅源码,会发现实际写的是h & (len-1)。这是什么意思呢,其实在特殊情况下,这就是取模运算。下面我们就来讲解一下满足 h & (len-1) == h % len的特殊情况。

  这种特殊情况就是:一个数对2^n取模,等价于这个数对2^n - 1做与运算,即num % 2^n == num & (2^n -1)。我们举个例子来说明这个公式的原理:假设上面的公式中,n==3,即我们要对2^3,也就是8取模,8转换成二进制是1000,而2^3-1 == 7,转换成二进制就是0111,然后与一个数做与运算,即num & (2^3 -1),结果将得到num转换成二进制后的末尾三位。而我们看num / 8,实际上就是二进制的num向右移动三位,移掉掉的那三位就是num / 8的余数,即num % 8。而移掉的三位数,不正是我们通过num & (2^3 -1)获得的吗。比方说10 % 8 == 2,而10 & (7) = 1010 & 0111 == 0010 == 2。这个地方需要好好理解一下,如果实在不理解,那就记住这个结论。

  在HashMap中,保证了存储元素的数组的大小一定是2^n,所以在内部,通过hash值与数组容量取余的操作,都用上面说的与运算取代了。这样做的好处是,与运算直接操作内存,效率极高,而在HashMap中,获取数组下标是一个非常频繁的操作,无论是get还是put都要用上,所以这种优化对HashMap的查询效率有很多的提升。在HashMap中,有两个静态变量,分别是默认初始容量最大容量,可以看到,它们都是都是2的n次方,而且没有直接写成数字,而是一个移位公式,如 1 << 4,就是为了提醒大家HashMap的容量机制。

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

  说到这里,可能有人会有疑问了:HashMap不是有构造器,可以指定初始容量吗,如果我们指定一个不是2^n的容量,不就破坏了这种机制吗?答案当然是不会的,我们虽然可以指定HashMap的初始容量,但是不代表它会直接使用我们指定的容量。当我们为HashMap指定一个初始容量时,它不会直接使用这个容量,而是计算出第一个大于等于这个容量的且满足2^n的数,若这个数大于HashMap运行的最大值,则直接使用最大值。而且我们知道,Java中的大多数容器都有自动扩容机制,包括HashMap,而HashMap为了满足容量一定是2^n,扩容时是在原来的基础上乘2,因为2^n乘以2还是满足2^n

  其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。(此段引用参考博客)


 2.4 解析hash方法

  接下来,我们再来看看HashMap源码中的计算哈希值的hash函数是如何实现的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

  以上是JDK1.8hash函数的实现(其他版本的hash方法有所差异,但是原理是一样的),简介明了:方法接收一个参数,也就是Nodekey值,然后判断key值是否为null,若为null,则hash值为0;否则调用keyhashCode方法获取hash值,并将hash值右移16位后与原hash值做异或运算。这个方法还是很好理解的,除了一个地方,就是为什么要将hash值右移16位后做与运算呢,调用hashCode方法获取的hash值不能直接用吗?这么做的原因还是为了优化。

  我们如何定义一个hash算法的优劣?其中的一个重要因素就是尽量少发生hash碰撞。大家可以试想一下,HashMap最坏的情况是什么样子:所有存入其中的元素,通过hash值计算出来的下标都是一样的,都放在数组的同一个位置,组成一个链表。这样的情况下,HashMap便完全失去了意义,和一个普通的链表又有什么区别。而好的hash函数,可以使碰撞发生的概率大大减少,让元素在数组中分别均匀,从而提高查找效率。

  而源码中的异或运算,实际上就是为了降低hash碰撞进行的扰动计算。为什么这么说呢,举个简单的例子:

HashMap的容量:8  ->  转换成二进制:1000

两个要存如HashMap中的元素的hash值如下(下面两个hash值只有最后4位完全匹配):
    1、 0010 1010 0111 1001 0010 0101
    2、 0101 1101 1111 0100 0111 0101

这两个hash值与容量8取模后得到:
    1、0101
    2、0101

  可以看到,上面例子中的两个hash值差别巨大,但是它们和容量8进行取模后的结果却是一样的,结果发生了hash碰撞。因为容量对于容量8来说,取模的做法是与8-1也就是7做按位与运算,而7转换成二进制的结果是0111,也就是说,取模的结果实际上就是取hash值的后3位,而hash值的前29位无论怎样,都不会影响结果。所以就是上面两个hash值差异巨大,但是后三位相同,导致它们求出的下标是相同的。这种情况下,发生hash碰撞的几率将会大大增加。所以,为了充分利用计算出的hash值的每一位,HashMap的源码做出了一个优化,将计算出的hash值向右移动16位,让后让移动后的值与原hash值做与运算,计算出新的值。为什么是16位呢,因为int32位的,16位正好是32的一半。这样,就充分利用了hash值的每一位,减少了hash碰撞的发生。


 2.5 JDK1.8对HashMap结构的优化——红黑树

  其实从JDK1.8开始,HashMap已经不再是简单的数组+链表的存储结构,而是做出了一个巨大的变动,在HashMap的数据存储中引入了红黑树,变成了数组+链表+树的结构。下面我们来简单的谈一谈这种结构。

  首先我们还是要回归之前谈过的HashMap最坏情况的问题:HashMap中,所有的元素都在数组的同一个位置,在一条链表上。这时候,HashMap和一个链表基本上没什么区别,之前的那些查询优化也就没效果了。这时候查询一个元素的时间复杂度是多少?当然是和遍历链表一样——O(n)。当然,这只是极端的情况,正常情况下不会出现,但是大部分元素集中在少数几条链表上这种情况还是很常见的,比如key是自定义类型,而程序员提供了不好的hashCode方法,得到的hash值经常发生碰撞。

  为了当发生以上情况时效率不至于太慢,JDK1.8改变了HashMap的存储结构——HashMap中的某一条链表元素过多时,底层就会将其转换为一棵红黑树。而红黑树的查询时间复杂度为O(log n),相比于链表的O(n)来说要快上不少。在HashMap中有下面三个带有默认值的静态变量,用来控制树化过程:

/**
 * 桶的树化阈值:
 *     即 链表转成红黑树的阈值,在存储数据时,
 *     当链表长度 > 该值时,则将链表转换成红黑树
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 桶的链表还原阈值:
 *     即 红黑树转为链表的阈值,当在扩容(resize())时
 *     (此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,
 *     当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 最小树形化容量阈值:
 *     即 当哈希表中的总容量 > 该值时,才允许将链表转换成红黑树,
 *     否则,当元素太多时,则直接扩容,而不是树形化
 *     为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
 */
static final int MIN_TREEIFY_CAPACITY = 64;

三、总结

  上面的内容对HashMap的底层存储,效率优化机制做了一个较为详细的介绍,相信看完之后会对HashMap有一个较为深入的理解。但是,这些只是HashMap的一部分,想要真正了解HashMap,还是要自己结合源码,仔细的阅读。希望我写的这篇博客能够对一些人有所帮助。


四、参考

  以上内容大部分参看下面两篇博客后,根据自己的理解编写:

posted @ 2020-02-25 17:58  特务依昂  阅读(1122)  评论(1编辑  收藏  举报