Java集合(一)HashMap实现原理及散列思想

散列

在散列表中,我们所做的也就是为每一个key找到一种类似于上述26进制的函数,使得key可以映射到一个数字中,这样就可以利用数组基于下标随机访问的高效性,迅速在散列表中找到所关联的键值对。
所以散列函数的本质,就是 将一个更大且可能不连续空间(比如所有的单词),映射到一个空间有限的数组里,从而借用数组基于下标O(1)快速随机访问数组元素的能力
但设计一个合理的散列函数是一个非常有挑战的事情。比如,26进制的散列函数就有一个巨大的缺陷,就是它所需要的数组空间太大了,在刚刚的示例代码中,仅表示长度为3位的、只有a-z构成的字符串,就需要开一个接近20000(263)大小的计数数组。假设我们有一个单词是有10个字母,那所需要的2610的计数数组,其下标甚至不能用一个长整型表示出来。
QQ_1721203405391.png
这种时候我们不得不做的事情可能是,对26进制的哈希值再进行一次对大质数取mod的运算,只有这样才能用比较有限的计数数组空间去表示整个哈希表。
然而,取了mod之后,我们很快就会发现,现在可能出现一种情况,把两个不同的单词用26进制表示并取模之后,得到的值很可能是一样的。这个问题被称之为 哈希碰撞,当然也是一个需要处理的问题。
比如如果我们设置的数组大小只有16,那么AA和Q这两个字符串在26进制的哈希函数作用下就是,所对应的哈希表的数组下标就都是0。

JDK的实现

散列函数到底需要怎么设计

  • 整个散列表是建立在数组上的,显然首先要保证散列函数 输出的数值是一个非负整数,因为这个整数将是散列表底层数组的下标。
  • 其次,底层数组的空间不可能是无限的。我们应该要让散列函数 在使用有限数组空间的前提下,导致的哈希冲突尽量少
  • 最后,我们当然也希望散列函数本身的 计算不过于复杂。计算哈希虽然是一个常数的开销,但是反复执行一个复杂的散列函数显然也会拖慢整个程序。

在JDK(以JDK14为例)中Map的实现非常多,我们讲解的HashMap主要实现在 java.util 下的 HashMap 中,这是一个最简单的不考虑并发的、基于散列的Map实现。
找到用于计算哈希值的hash方法:

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

可以发现非常简短,就是对key.hashCode()进行了一次特别的位运算。你可能会对这里的,key.hashcode 和 h>>>16,有一些疑惑,我们来看一看。

hashcode

而这里的hashCode,其实是Java一个非常不错的设计。在Java中每个对象生成时都会产生一个对应的hashcode。 当然数据类型不同,hashcode的计算方式是不一样的,但一定会保证的是两个一样的对象,对应的hashcode也是一样的
所以在比较两个对象是否相等时,我们可以先比较hashcode是否一致,如果不一致,就不需要继续调用equals,从统计意义上来说就大大降低了比较对象相等的代价。当然equals和hashcode的方法也是支持用户重写的。
既然今天要解决的问题是如何统计文本单词数量,我们就一起来看看JDK中对String类型的hashcode是怎么计算的吧,我们进入 java.lang 包查看String类型的实现:

public int hashCode() {
    // The hash or hashIsZero fields are subject to a benign data race,
    // making it crucial to ensure that any observable result of the
    // calculation in this method stays correct under any possible read of
    // these fields. Necessary restrictions to allow this to be correct
    // without explicit memory fences or similar concurrency primitives is
    // that we can ever only write to one of these two fields for a given
    // String instance, and that the computation is idempotent and derived
    // from immutable state
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

Latin和UTF16是两种字符串的编码格式,实现思路其实差不多,我们就来看 StringUTF16 中hashcode的实现:

public static int hashCode(byte[] value) {
    int h = 0;
    int length = value.length >> 1;
    for (int i = 0; i < length; i++) {
        h = 31 * h + getChar(value, i);
    }
    return h;
}

就是对字符串逐位按照下面的方式进行计算,和展开成26进制的想法本质上是相似的。

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

不过一个非常有趣的问题是为什么选择了31?
答案并不是那么直观。首先在各种哈希计算中,我们比较倾向使用奇素数进行乘法运算,而不是用偶数。因为用偶数,尤其是2的幂次,进行乘法,相当于直接对原来的数据进行移位运算;这样溢出的时候,部分位的信息就完全丢失了,可能增加哈希冲突的概率。
而为什么选择了31这个奇怪的数,这是因为计算机在进行移位运算要比普通乘法运算快得多,而31*i可以直接转化为 (i << 5)- i ,这是一个性能比较好的乘法计算方式,现代的编译器都可以推理并自动完成相关的优化。StackOverflow上有一个相关的 讨论 非常不错,也可以参考《effective Java》中的相关章节。

h>>>16

好,我们现在来看 ^ h >>> 16 又是一个什么样的作用呢?它的意思是就是将h右移16位并进行异或操作,不熟悉相关概念的同学可以参考 百度百科。为什么要这么做呢?
哦,这里要先跟你解释一下,刚刚那个hash值计算出来这么大,怎么把它连续地映射到一个小一点的连续数组空间呢?想必你已经猜到了,就是前面说的取模,我们需要将hash值对数组的大小进行一次取模。
那数组大小是多少呢?在 HashMap 中,用于存储所有的{Key,Value}对的真实数组 table ,有一个初始化的容量,但随着插入的元素越来越多,数组的resize机制会被触发,而扩容时,容量永远是2的幂次,这也是为了保证取模运算的高效。马上讲具体实现的时候会展开讲解。
总而言之,我们需要对2的幂次大小的数组进行一次取模计算。但前面也说了 对二的幂次取模相当于直接截取数字比较低的若干位,这在数组元素较少的时候,相当于只使用了数字比较低位的信息,而放弃了高位的信息,可能会增加冲突的概率。
所以,JDK的代码引入了 ^ h >>> 16 这样的位运算,其实就是把高16位的信息叠加到了低16位,这样我们在取模的时候就可以用到高位的信息了。
当然,无论我们选择多优秀的hash函数,只要是把一个更大的空间映射到一个更小的连续数组空间上,那哈希冲突一定是无可避免的。那如何处理冲突呢?
JDK中采用的是开链法。
QQ_1721203484626.png
哈希表内置数组中的每个槽位,存储的是一个链表,链表节点的值存放的就是需要存储的键值对。如果碰到哈希冲突,也就是两个不同的key映射到了数组中的同一个槽位,我们就将该元素直接放到槽位对应链表的尾部。

JDK代码实现

现在,在JDK中具体实现的代码就很好理解啦,table 就是经过散列之后映射到的内部连续数组:

transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //在tab尚未初始化、或者对应槽位链表未初始化时,进行相应的初始化操作
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 查找 key 对应的节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 遍历所有节点 依次查找
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

通过hash函数的计算,我们可以基于这个数组的下标快速访问到key对应的元素,元素存储的是Node类型。
估计你会注意到第21行进行了一个treeifyBin的操作,就是说当哈希冲突产生的链表足够长时,我们就会把它转化成有序的红黑树,以提高对同样hash值的不同key的查找速度。
这是因为在HashMap中Node具体的实现可以是链表或者红黑树。用红黑树的整体思想和开链是一样的,这只是在冲突比较多、链表比较长的情况下的一个优化,具体结构和JDK中另一种典型的Map实现TreeMap一致,我们会在下一讲详细介绍。
好,我们回头看整体的逻辑。
开始的5-8行主要是为了在tab尚未初始化、或者对应槽位链表未初始化时进行相应的初始化操作。 从16行开始,就进入了和待插入key的hash值所对应的链表逐一对比的阶段,目标是找到一个合适的槽位,找到当前链表中的key和待插入的key相同的节点,或者直到遍历到链表的尾部;而如果节点类型是红黑树的话,底层就直接调用了红黑树的查找方法。
这里还有一个比较重要的操作就是第40行的resize函数,帮助动态调整数组所占用的空间,也就是底层的连续数组table的大小。

if (++size > threshold)
        resize();

随着插入的数据越来越多,如果保持table大小不变,一定会遇到更多的哈希冲突,这会让哈希表性能大大下降。所以我们有必要在插入数据越来越多的时候进行哈希表的扩容,也就是resize操作。
这里的threshold就是我们触发扩容机制的阈值,每次插入数据时,如果发现表内的元素多于threshold之后,就会进行resize操作:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 翻倍扩容
    }
    else if (oldThr > 0) // 初始化的时候 capacity 设置为初始化阈值
        newCap = oldThr;
    else {               // 没有初始化 采用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor; // 用容量乘负载因子表示扩容阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                    // 新扩容部分,标识为hi,原来的部分标识为lo
                    // JDK 1.8 之后引入用于解决多线程死循环问题 可参考:https://stackoverflow.com/questions/35534906/java-hashmap-getobject-infinite-loop
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 整体操作就是将j所对应的链表拆成两个部分
                    // 分别放到 j 和 j + oldCap 的槽位
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

看起来 resize 的代码比较复杂,但核心在做的事情很简单,就是要将哈希桶也就是内置的table数组,搬到一个更大的数组中去。主要有两块逻辑我们需要关注一下。
第一部分在第6-26行,主要的工作就是计算当前扩容的数组大小和触发下一次扩容的阈值threshold。
可以看到命中扩容条件的分支都会进入13行的逻辑,也就是每次扩容我们都会扩容一倍的容量。这和c++中STL动态数组的扩容逻辑是相似的,都是为了平衡扩容带来的时间复杂度和占用空间大小的权衡;当然这也是因为我们仍然需要保持数组大小为2的幂次,以提高取模运算的速度。其他行主要是为了处理一些默认参数和初始化的逻辑。
在第22行中,我们还会看到一个很重要的变量loadfactor,也就是负载因子。这是创建HashMap时的一个可选参数,用来帮助我们计算下一次触发扩容的阈值。假设 length 是table的长度,threshold = length * Load factor。在内置数组大小一定的时候,负载因子越高,触发resize的阈值也就越高;
负载因子默认值0.75,是基于经验对空间和时间效率的平衡,如果没有特殊的需求可以不用修改。loadfactor越高,随着插入的元素越来越多,可能导致冲突的概率也会变高;当然也会让我们有机会使用更小的内存,避免更多次的扩容操作。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值