有关HashMap的几个小彩蛋,你想知道的全在这里了

今天闲来无事翻HashMap的源码,结合几篇之前看过的帖子,发现之前看源码时一笔带过,其实蛮有意思的小问题点,今天就整个梳理一下,算是个总结。

hashMap的capacity和size

大家都知道hashMap是一个数组加链表(或红黑树)的结构,在初始化时数组的长度就是capacity,而容器里面放置的<k,v>键值对的个数就是size,这里还是有一点区别的。

初始容量和扩容问题

看过源码的都知道,hashMap的初始容量是16,很多人将这解释为一个经验值,我也如此理解,你如果看的是JDK1.8以后的源码,会发现这里的源码多了一行注释

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

为什么好端端的不写个16,非要写个1<<4呢?其实这里就是作者再告诉我们,容量的默认定义就是2次幂,不管你外界传进来的initcapacity是多少,最终在初始化时都会转化为2的整数次幂来定义。
为什么是2次幂呢?其实是和HashMap的底层涉及有关,我们知道HashMap是一个数组➕链表(之后是红黑树)的结构,根据当前key的Hash值来确定数组中的位置,如果这个位置选择的不好,就会导致数组有一些地方始终放不进去值,有一些地方一直在链式加元素,造成资源的浪费。
这个HashMap中的位置是怎么计算的,我们再看看代码

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
            //重点就是这一句,p代表当前存放的数组位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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;
                }
            }
            ...

核心就是这一句

p = tab[i = (n - 1) & hash]
这里关注下(n - 1) & hash,n是目前的数组容量,hash是当前key的hash值,看到n-1你可能已经有感觉了,我们不妨把n的各种情况模拟一下,假设n=16,hash值从0递增

hash值(n - 1) & hash结果
01111&00000
11111&00011
21111&00102
31111&00113
41111&01004
141111&111014
151111&111115
1601111&100000(链式存储在0后面)

能发现算出来的位置基本是散列开的,很均匀。

那么我们把n换成15试试,n-1 = 14 (1110)

hash值(n - 1) & hash结果
01110&00000
11110&00010
21110&00102
31110&00112
41110&01004
141110&111014
151110&111114

会发现0,2,4等位置都链式存储了两个数据,而1,3等位置始终没有数据存储,这就造成了浪费。
这里面还有一个小彩蛋,其实这种计算数组位置的问题,我的第一想法就是取余运算,那么为什么这里不用取余呢?取余就没这个问题了,其实p&(q-1)就等于p%q,不信的同学可以自己验证下,这里这么写主要还是因为&的性能要优于取余运算,实际上位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。所以这种最底层的计算逻辑,优先考虑位运算,这样就不难理解为什么如此设计了。

hash问题

每一次看源码的时候都是理解其过程,遇到hash函数就跳过了,这一次我们把hash函数单独拎出来品一品,其实也蛮有意思的。

final int hash(Object k) {
   int h = hashSeed;
   if (0 != h && k instanceof String) {
       return sun.misc.Hashing.stringHash32((String) k);
   }

   h ^= k.hashCode();
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

static int indexFor(int h, int length) {
   return h & (length-1);
}

indexFor就是寻找数组下标,这里 h & (length-1)的问题之前已经讲过了,下面我们着重看一些哈希的过程,就是hash这个方法。
首先明确一些概念,HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到他应该保存在数组的哪个下标中。而这个通过键值求取下标的操作就叫做哈希。
HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。
求哈希简单的做法是先求取出键值的hashcode,然后在将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作。
因为按位与的计算方式,也带出来了一些问题,那就是如果hashMap的容量不大时,很容易造成hash值的“相似低位碰撞”。

什么叫“相似低位碰撞”?

举个例子,假设一个hashMap的初始容量是16,length-1 = 15,二进制码就是1111,那么两个高位不同而低位相同的hash值,计算之后的数组加标是相同的,比如
hash1 :0110 0011 & 0000 1111 = 0000 0011
hash2 :0101 0011 & 0000 1111 = 0000 0011
为了避免因为低位相同而造成的hash碰撞,进而降低底层hashMap的存储效率,需要对hash值进行处理,也就是这一段代码

   h ^= k.hashCode();
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);

这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
举个例子,假设目前有一个对象A的hash值是1011000110101110011111010011011,对应的数组长度是16,根据上面的描述相信你已经知道,不管这个数字的前28位是什么,最终产生的结果都是1011,相应的也就发生了碰撞。
假设目前还有一个对象B的hash值是0000000000000000011111010011011,我们通过上面的扰动计算说明下最后的数值
在这里插入图片描述
从上面图中可以看到,之前会产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样了,这就很好的避免了冲突。
其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。

java8的一些改变

关于Java 8中的hash函数,原理和Java 7中基本类似。Java 8中这一步做了优化,只做一次16位右位移异或混合,而不是四次,但原理是不变的。

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

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。以上方法得到的int的hash值,然后再通过h & (table.length -1)来得到该对象在数据中保存的位置。

负载因子的问题

在HashMap中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,也就是说默认情况下,当HashMap中元素个数达到了容量的3/4的时候就会进行自动扩容。
那么这个值为什么是0.75呢?我们看一下官方给出的解释

As a general rule, the default load factor (.75) offers a good tradeoff 
between time and space costs. 
Higher values decrease the space overhead but increase the lookup cost
 (reflected in most of the operations of the HashMap class, including get and put).

大意是什么呢?
就是说,默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。
试想一下,如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容。
那么在HashMap中,最好的情况是这16个元素通过hash算法之后分别落到了16个不同的桶中,否则就必然发生哈希碰撞。而且随着元素越多,哈希碰撞的概率越大,查找速度也会越低。
私人以为0.75还有一个考虑,就是threshold = loadFactor * capacity,而capacity通过前文我们已经分析出来一定是2的倍数,所以这里设置0.75可以保证threshold一定是整数。

HashMap的初始容量

通过前文可以知道初始容量在不赋值的情况下是16,如果赋值,将会初始化为大于赋值容量的第一个2次幂,那么假如我们在代码上下文中清晰的知道一个hashMap会放入多少个值,这个时候能不能按照我们的想法来初始化呢?
比如我们需要往hashMap中放入7个值,于是我们设置initcapacity = 7,这时初始化的hashMap容量是大于7的第一个2次幂,也就是8,可是因为负载因子是0.75,导致我们放入6个元素后hashMap就需要扩容,这显然不是我们所期望的。
有没有什么好一点的办法?其实可以参照guava的设计,

return (int) ((float) expectedSize / 0.75F + 1.0F);

比如我们计划向HashMap中放入7个元素的时候,我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过JDK处理之后,会被设置成16,这就大大的减少了扩容的几率。当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize/0.75 + 1的话,可以有效的减少冲突也可以减小误差。
所以,我们可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
这个算法在guava中有实现,开发的时候,可以直接通过Maps类创建一个HashMap:

Map<String, String> map = Maps.newHashMapWithExpectedSize(7);

其代码实现如下:

public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {

    return new HashMap(capacity(expectedSize));

}

static int capacity(int expectedSize) {

    if (expectedSize < 3) {

        CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");

        return expectedSize + 1;

    } else {

        return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : 2147483647;

    }

}

链表与红黑树的转换

在JDK1.8以后,为了提升哈希碰撞后元素的查找效率,系统会在单链表长度大于8的时候将链表转化为一颗红黑树,转化的过程大家可以看看代码,不在这里赘述,我想要说明的一个问题是,为什么这个数字是8?
在JDK源码的注释中是这么描述的:

     *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

大意是说,理想情况下,在随机哈希码下,存储箱中节点的频率遵循泊松分布,默认大小调整阈值为0.75时,平均参数约为0.5,但由于大小调整的粒度,变化较大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5,k)/*factorial(k))
而在一个理想的hash函数下,红黑树模型是极少会用到的,8以内可以覆盖绝大多数的情况。
(英语有限,大致就是这个意思吧)

参考资料:Hollis(ID:hollischuang)微信公众号的几篇文章

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值