【个人总结】深入理解HashMap(源码详解)

1.认识哈希表

顺序结构中,元素本身与其存储位置之间没有对应的关系,想要查找某个元素必须进行多次比较,遍历整个结构,查找的效率取决于比较的次数,也就是说时间复杂度为O(n)。

哈希表为一种理想的搜索方法,可以不经过任何比较,一次直接从表中得到想要的元素,复杂度仅为O(1)。哈希表构造了一种存储结构,通过某种函数(hash)得到元素的哈希值,使元素的存储位置与元素之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

2.hash方法的原理

我们来看一下HashMap中的hash()方法:

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

在HashMap的put()与get()方法中都会进行hash运算:

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

在说明hash()方法的作用前,我们先看看hashCode()方法。

我们都知道hashCode()是Object类的方法,但是这个哈希值是一个int类型,理论上的范围从-2147483648 到 2147483648,很显然如果直接使用这个哈希值是不合理的。

HashMap默认数组容量为16,所以使用哈希值之前要对其进行一个取模运算 (n - 1) & hash,就是对哈希值和数组长度-1进行一个与运算。

取模操作使用 & 运算,而不使用 % 运算是因为它更加高效,当一个数b为2的n次方时存在这样一个关系:
a % b = a & (b-1)

这也解释了为什么HashMap的扩容是以二次方展开。

我们在调用put(putval方法)与get(getNode方法)方法时也会进行取模运算:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
     HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
     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);
}
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) {}
}

回到我们要讲的hash()方法,在获取了键值的哈希值后进行了什么运算,他们有什么作用。

看下面这个图:

img

首先对hashCode()得到的值右移了16位(h >>> 16),然后与其原来的哈希值h进行异或运算得到最终的哈希值。

为什么要将取哈希值的操作弄得如此复杂呢,因为这样操作混合了原来哈希值的高位和低位,所以低位的随机性加大了(掺杂了部分高位的特征,高位的信息也得到了保留),也就是像上文hashCode()方法中所说的一样,目的都是为了增加随机性,让数据元素更加均衡的分布,减少碰撞

在此说明一下有符号右移 >> 与无符号右移 >>> 的区别。

在Java中,数值二进制的第一位为符号位,负数在计算机中会以补码(符号位不变,原码取反再加1)的形式表示,比如int类型中10000000 00000000 00000000 00000000表示最大的负数,即为-2^31。

  • 有符号右移位 >> ,使用符号扩展:若符号为正,则在高位插入0;若符号为负,则在高位插入1
  • 无符号右移位 >>>,使用零扩展:无论正负,都在高位插入0

3.扩容机制

我们都知道数组一旦初始化后大小就无法改变了,所以就有了ArrayList这种“动态数组”,可以自动扩容。

HashMap 的底层用的也是数组。向 HashMap 里不停地添加元素,当数组无法装载更多元素时,就需要对数组进行扩容,以便装入更多的元素。

当然,数组是无法自动扩容的,所以如果要扩容的话,就需要新建一个大的数组,然后把小数组的元素复制过去。

HashMap 的扩容是通过 resize() 方法来实现的,JDK 8 中加入了红黑树,比较复杂,为了便于理解,在此就还使用 JDK 7 的源码,搞清楚了 JDK 7 的,我们后面再详细说明 JDK 8 和 JDK 7 之间的区别

// newCapacity为新的容量
void resize(int newCapacity) {
    // 小数组,临时过度下
    Entry[] oldTable = table;
    // 扩容前的容量
    int oldCapacity = oldTable.length;
    // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1 << 30
    // 移位运算通常可以用来代替乘法运算和除法运算。比如 0001 (1) 左移1位 0010 (2) 变为原来的两倍,左移n位扩大2的n次倍。
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 初始化一个新的数组(大容量)
    Entry[] newTable = new Entry[newCapacity];
    // 把小数组的元素转移到大数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 引用新的大数组
    table = newTable;
    // 重新计算阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

其中的transfer源码:

void transfer(Entry[] newTable, boolean rehash) {
    // 新的容量
    int newCapacity = newTable.length;
    // 遍历小数组
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 拉链法(开散列法),相同 key 上的不同值
            Entry<K,V> next = e.next;
            // 是否需要重新计算 hash
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 根据大数组的容量,和键的 hash 计算元素在数组中的下标
            int i = indexFor(e.hash, newCapacity);

            // 同一位置上的新元素被放在链表的头部(头插法)
            e.next = newTable[i];

            // 放在新的数组上
            newTable[i] = e;

            // 链表上的下一个元素
            e = next;
        }
    }
}

开散列法还可以叫作链地址法,就是将相同哈希值的元素归为一个子集合,每个子集合称为一个桶(所以这又双叒叕可以叫作哈希桶),每个桶通过一个单链表连起来,各个链表的头结点存储在哈希表中。

在这里插入图片描述

数组扩容后,对原数组的元素重新计算hash值放在新数组中,因此在旧数组中同一个链表上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上

在JDK8中则不需要重新计算索引,它是这样的:

在哈希表中有两个哈希值key1和key2

扩容前的长度为16:

  • n - 1 二进制: 0000 1111

  • key1 哈希值的最后 8 位(低8位)为 0000 0101

  • key2 哈希值的最后 8 位(低8位)为 0001 0101

  • 进行(n - 1) & hash 运算后发生了哈希冲突,索引都在(0000 0101)上。

扩容后的长度为32:

  • n - 1 二进制: 0001 1111
  • key1 哈希值的最后 8 位为 0000 0101
  • key2 哈希值的最后 8 位为 0001 0101
  • key1 进行(n - 1) & hash 运算后,索引为 0000 0101。
  • key2 进行(n - 1) & hash 运算后,索引为 0001 0101。

所以当HashMap数组扩容后:

  • key1索引不变

  • key2索引从 5 (0101) -> 21 (1 0101),也就是原来的索引 + 原来的容量。

JDK 8 不需要像 JDK 7 那样重新计算 hash,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话就表示索引没变,是1的话,索引就变成了“原索引+原来的容量”。

该设计既省去了重新计算hash的时间,同时,由于新增的1 bit是0还是1是随机的,因此扩容的过程,可以均匀地把之前的节点分散到新的位置上。

JDK8的源码:

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;
        }
        // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize上限
    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 { // preserve order
                    // 链表优化重 hash 的代码块
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    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;
}

4.默认负载因子为什么0.75

哈希表的数据结构存在一种矛盾的问题:

  • 数组的容量过小,经过哈希计算后的下标,容易出现冲突;
  • 数组的容量过大,导致空间利用率不高。

负载因子则表示HashMap中数据填满的程度:

负载因子 = 填入哈希表中的数据个数 / 哈希表的长度

这就说明:

  • 加载因子越小,填满的数据就越少,哈希冲突的概率越少,但浪费了空间,而且还会提高扩容的触发几率。
  • 加载因子越大,填满的数据就越多,空间利用率就越高,但哈希冲突的几率就变大了。

使用哈希表必须在哈希冲突空间利用率之间进行取舍,

因此,为了减少发生哈希冲突的概率,当HashMap数组元素填满到一个临界值时就会发生扩容;

临界值 = 数组容量 * 负载因子

比如:HashMap的默认容量为16:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

负载因子为0.75:

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

意思是当数组中元素数量达到16 * 0.75 = 12 的时候会触发扩容机制。

理解了什么是负载因子之后,我们来谈谈它为什么是0.75。

这个链接详细阐述了该问题:https://segmentfault.com/a/1190000023308658

里面提到了一个概念:二项分布

我们在向HashMap中put数据时存在两种情况,碰撞,不碰撞。

可以设想实验的hash值是随机的,并且他们经过hash运算都会映射到hash表的长度的地址空间上,那么这个结果也是随机的。

所以,每次put的时候就相当于我们在扔一个16面(我们先假设默认长度为16)的骰子,扔骰子实验那肯定是相互独立的。碰撞发生即扔了n次有出现重复数字。

经过一系列计算后得出了这个结论:

img

结合HashMap容量的要求,负载因子乘以16最好是一个整数,

前文提到了容量必须是2的n次幂,

除了 0.75,0.5~1 之间还有 0.625(5/8)、0.875(7/8)可选,从中位数的角度,挑 0.75 比较完美。

另外,维基百科上说,拉链法(解决哈希冲突的一种)的加载因子最好限制在 0.7-0.8以下,超过0.8,查表时的CPU缓存不命中(cache missing)会按照指数曲线上升。

综上所述,0.75是个比较完美的选择。

5.关于红黑树与链表转换

我们都知道当链表中的元素数量大于8,它就会被转换为红黑树的结构,反之小于6,则会转换回链表。

链表树化阈值:

static final int TREEIFY_THRESHOLD = 8;

红黑树解树化阈值:

static final int UNTREEIFY_THRESHOLD = 6;

到底是否应该把链表变为红黑树还要看当前整个哈希表的元素个数是否超过这个值

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
  • 若链表的长度 > 8,但是整个哈希表的元素个数 < 64,此时只是做扩容(哈希表长度变为原来的一倍)
  • 若链表的长度 > 8,整个哈希表的元素个数 > 64,此时才会将链表转为RBTree

关于这个数值为什么是8,JDK8的注释文档中有这样一段:

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.  Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

大致意思是:

因为TreeNode的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。

并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。

如果一个用户的数据他的hashcode值分布十分好的情况下,就会很少使用到tree结构。

在理想情况下,我们使用随机的hashcode值,loadfactor为0.75情况,尽管由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布。下面就是计算的结果:桶里出现1个的概率到为8的概率。桶里面大于8的概率已经小于一千万分之一了。

虽然这段话是表示 jdk 8中为什么拉链长度超过8的时候进行了红黑树转换

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值