通俗理解hashMap

930 阅读6分钟

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

1.不得不说的哈希算法

(1) 哈希是什么

哈希也叫散列,何为散列?顾名思义,把按照一定的算法进行分散。更通俗的讲,哈希就是一个输入经过哈希算法运算后,输出一个与之对应的哈希值,并且无论输入是多大的长度,输出的哈希值长度是一样的。

(2) 哈希算法满足那些条件

(1) 无论何时何地,相同key哈希得到的哈希值是一样的。 (2) 哈希是为了散列,哈希算法应尽量减少哈希冲突(哈希冲突就是不同的key产生相同的哈希值)。 (3) 优秀的哈希算法必然是高效的。

(3) java中的哈希

java的Object类有一个hashCode方法,该方法返回一个int型的哈希值。该方法是一个本地方法,hashCode()方法必须遵循三个协议:

  • 同一个java程序中,相同对象无论何时调用hashCode()的方法都返回相同的hashCode。
  • 如果两个对象equals()为true,那么也hashCode()方法返回的hashCode也相同。
  • 如果两个对象equals()为false,不要求他们的hashCode也不一样。

2.HashMap底层数据结构

我们都知道HashMap是一种键值对的数据结构,而且不难看出是为了方便通过一个键获取对应的值。其实,其内部实现并不是直接的通过equals比较键来获取值的,这种方法最快的情况要比较n遍,时间复杂度是O(n)(也就是只用链表实现)。链表的局限在于查询,而与之链表对应的数组优势恰恰在于查询。

(1)HashMap使用数组底层结构有何难题?

首先第一个问题是key如何和数组下标关联?数组下标是一个数字,前面我们提到的hashCode也是一个数值。因此,key的hashCode就是存放在数组的下标。但是,不相同的key有可能会产生相同的hashCode(如果两个对象equals()为false,不要求他们的hashCode也不一样),这种现象叫做哈希冲突。

(2)数组+链表解决哈希冲突

当发生哈希冲突时,也就是两个不同的key产生了相同的hashCode,它们在数组的下标是一样的,为了不让相同的key被覆盖,多个值以链表形式放到数组中。

(3)数组+链表 get(key)过程

使用数组+链表的底层数据结构有何神秘之处,先通过hashCode得到数组下标,然后再通过equals比较链表上的key。不得而知,相比与直接链表的数据结构,数组+链表避免了全表扫描,后置只是仅仅扫描hashCode相同的链表,从而大幅度提高性能。

(4)jdk8HashMap优化

  • 底层数据结构改成 数组 + 链表 + 红黑树,引入红黑树同样也是为了解决链表在查询的缺陷
  • 链表头插法优化成尾插法

3.HashMap具体实现

(1)初始化容量大小

hash桶的大小必须是2的n次方,如果初始化的时候传入的大小不是2的n次方,则找往后最近的一个。

(2)初始化容量为什么是2的n次幂

笔者认为总的目的是方便位运算。

笔者认为是为了更高效率的计算哈希桶的位置,如果不是2的n次幂,通过 hash & (size-1)得不到正确的位置。

# jdk7计算哈希桶(数组)的位置
/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

(3)哈希扰动

因为hashCode得到的哈希值哈希冲突还是很大,因此需要通过扰动算法使它们分配更加均匀。在jdk8中扰动算法简化成了 hashCode右移16位进行异或运算,jdk7则是更加复杂。

hashCode是32位的int类型,将32位的hashCode右移16位然后再进行异或运算,混合原哈希值的高低位。

# 右移异或运算例示
hashCode = 654321
二进制是 10011111101111110001
前面补0变成32位 00000000000010011111101111110001
右移16位后      00000000000000000000000000001001
异或得到        00000000000010011111101111111000
    # java扰动函数源码
    static final int hash(Object key) {
        int h;
      // 右移16位 进行异或
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

4.扩容机制

(1) 负载因子

负载因子默认是0.75,也就是负载超过0.75后触发扩容,如果 16* 0.75 = 12,当元素个数大于12时就会触发扩容。

(2) 扩容时数据结果变化

在jdk7hashMap的数据结构时候数组+链表,jdk8后变成数组+链表+红黑树。在jdk8,当触发扩容时,如果链表长达大于8,且哈希桶容量小于64则直接扩容哈希桶,如果哈希桶容量达到64,则把链表转成红黑树。

(3) 扩容时下标计算

在jdk7的时候,扩容需要重新计算下标。但在jdk8不用重新计算下标,将原hashCode & resize 如果为0则下标不变,如果不为0,则下标加上原size长度。

5.hashMap.get()方法实现

1. 具体实现

先计算hashCode,通过hashCode定位哈希桶下标(数组下标),然后通过equals方法比较是否相同,具体实现代码如下。


    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * Implements Map.get and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

6.总结

HashMap有很多经典的设计,例如使用数组+链表+红黑树数据结构来提升查询速度、通过与运算计算下标、右移异或混合高低位等等,非常值得我们研究学习。