HashMap之三问为什么及性能问题


写在前面:无论在工作中和面试的时候都会遇到关于HashMap问题,这一篇文章我们主要从以下几个方面讲解HashMap。至于分析源码网上已经有很多这样的文章,所以这里就不在以源码为中心讲述。
在这里插入图片描述

HashMap数据结构
在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的键值对会被放在同一个位桶里,当桶中元素较多时,通过key值查找的效率较低。
在这里插入图片描述

而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8),时,将链表转换为红黑树,这样大大减少了查找时间。
在这里插入图片描述

一、加载因子为什么是0.75

先说结果:提高空间利用率和减少查询成本的折中,选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。

加载因子解释:表示Hash表中元素的填满的程度。加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。冲突的机会越大,则查找的成本越高。反之,查找的成本越小。因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。

哈希冲突主要与两个因素有关

  • 填装因子,填装因子是指哈希表中已存入的数据元素个数与哈希地址空间的大小的比值,a=n/m ; a越小,冲突的可能性就越小,相反则冲突可能性较大;但是a越小空间利用率也就越小,a越大,空间利用率越高,为了兼顾哈希冲突和存储空间利用率,通常将a控制在0.6-0.9之间。
  • 与所用的哈希函数有关,如果哈希函数得当,就可以使哈希地址尽可能的均匀分布在哈希地址空间上,从而减少冲突的产生,但一个良好的哈希函数的得来很大程度上取决于大量的实践。

如果想继续了解如何解决hash冲突请看这篇文章:解决hash冲突的三个方法

以上是对加载因子为什么是0.75的解释下文将继续讲解为什么是0.75

HashMap有一个初始容量大小,默认是16

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

为了减少冲突的概率,当hashMap的数组长度到了一个临界值就会触发扩容,把所有元素rehash再放到扩容后的容器中,这是一个非常耗时的操作。

而这个临界值由【加载因子】和当前容器的容量大小来确定:DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR ,即默认情况下是16x0.75=12时,就会触发扩容操作。

所以使用hash容器时尽量预估自己的数据量来设置初始值。具体代码实现自行去研究HashMap的源码。

基础知识补充完毕,回到正题,为什么加载因子要默认是0.75?

hashmap源码注释里找到了这一段

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

注意http://en.wikipedia.org/wiki/Poisson_distribution链接中的关键字:Poisson_distribution 中文翻译为: 泊淞分布

简单翻译一下就是在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。

从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

重申一下使用hash容器请尽量指定初始容量,且是2的幂次方。

关于泊淞分布的知识请看:泊松分布和指数分布:10分钟教程

二、为什么要无符号右移16位后做异或运算

HashMap中哈希算法的关键代码

//重新计算哈希值
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//key如果是null 新hashcode是0 否则 计算新的hashcode
}

//计算数组槽位
  static int indexFor(int h, int length) {
    return h & (length-1);
}

这段代码可以看到哈希算法的细节(h = key.hashCode()) ^ (h >>> 16)

^按位异或运算,只要位不同结果为1,不然结果为0;>>> 无符号右移:右边补0

根据上面的说明我们做一个简单演练

h=key.hashcode() 1111 1101 1101 1111 0101 1101 0010 1111
^
h >>> 16         0000 0000 0000 0000 1111 1101 1101 1111
--------------------------------------------------------
h^(h>>>16)       1111 1101 1101 1111 1010 0000 1111 0000
h=key.hashcode() 1111 1101 1101 1111 0101 1101 0010 1111 

将h无符号右移16为相当于将高区16位移动到了低区的16位,再与原hashcode做异或运算,可以将高低位二进制特征混合起来

从上文计算可知高区的16位与原hashcode相比没有发生变化,低区的16位发生了变化

我们可知通过上面(h = key.hashCode()) ^ (h >>> 16)进行运算可以把高区与低区的二进制特征混合到低区,那么为什么要这么做呢?

我们都知道重新计算出的新哈希值在后面将会参与hashmap中数组槽位的计算,计算公式:(n - 1) & hash,假如这时数组槽位有16个,则槽位计算如下:

hash             1111 1101 1101 1111 1010 0000 1111 0000
&
16 -1            0000 0000 0000 0000 0000 0000 0000 1111
--------------------------------------------------------
(16 - 1) & hash  0000 0000 0000 0000 0000 0000 0000 0000

如果不了解与运算可以看这篇文章:与运算(&)、或运算(|)、异或运算(^)

而在这里采用异或运算而不采用& ,| 运算的原因是,异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向1靠拢,采用|运算计算出来的值会向0靠拢 。
接下来将得到的值与与0xf做&运算 目的是得到后四位的数值,得到后四位的下标在0~15之间 对应的放在哪个桶里面,当然这里存在扩容的问题,根据实际情况确定桶的个数。

三、为什么槽位数必须使用2^n

先说结果:为了让哈希后的结果更加均匀
这个原因我们继续用上面的例子来说明
假如槽位数不是16,而是17,则槽位计算公式变成:(17 - 1) & hash

hash1            1111 1101 1101 1111 1010 0000 1111 0000
&
17 -1            0000 0000 0000 0000 0000 0000 0001 0000
--------------------------------------------------------
(16 - 1) & hash  0000 0000 0000 0000 0000 0000 0001 0000

hash1            1111 1101 1101 1111 1010 0000 1111 1010
&
17 -1            0000 0000 0000 0000 0000 0000 0001 0000
--------------------------------------------------------
(16 - 1) & hash  0000 0000 0000 0000 0000 0000 0001 0000

hash1            1111 1101 1101 1111 1010 1101 1110 1010
&
17 -1            0000 0000 0000 0000 0000 0000 0001 0000
--------------------------------------------------------
(16 - 1) & hash  0000 0000 0000 0000 0000 0000 0000 0000

从上文可以看出,计算结果将会大大趋同,hashcode参加&运算后被更多位的0屏蔽,计算结果只剩下两种0和16,这对于hashmap来说是一种灾难

2、可以通过位运算e.hash & (newCap - 1)来计算,a % (2^n) 等价于 a & (2^n - 1) ,位运算的运算效率高于算术运算,原因是算术运算还是会被转化为位运算。

四、JAVA 8 HashMap改进

java8和java7最大的区别就是节点可以扩展到TreeNodes,TreeNode是一个红黑树结构,可以存储更多的信息。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    final int hash; // inherited from Node<K,V>
    final K key; // inherited from Node<K,V>
    V value; // inherited from Node<K,V>
    Node<K,V> next; // inherited from Node<K,V>
    Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;

使用红黑树的主要优点:在许多数据都位于内部表的同一索引(存储桶)中的情况下,在树中进行搜索花费的时间比链表时间短。
缺点:树比链接列表占用了更多的空间

通过继承,内部可以同时包含 Node(链接列表)和 TreeNode(红黑树)。Oracle决定使用以下规则使用这两个数据结构:

  • 如果内部表中给定索引(存储桶)的节点超过8个,则链表转换为一棵红黑树
  • 如果给定索引(存储桶) )内部表中的节点少于6个,树被转换为链表

五、HashMap性能问题

在正常情况下,get()和put()方法的时间复杂度为O(1)。但是,如果key分布不均,可能put()和get()调用非常慢。put()和get()的性能取决于将数据重新分配到内部数组(存储桶)的不同索引中。如果key的哈希函数使用不当,存储数据将会分配不均,调用put()和get()都会很慢,因为需要遍历整个列表。
key分布不均HashMap。
在这里插入图片描述
key分布均匀HashMap
在这里插入图片描述
在分布均匀的HashMap情况下,获得K将花费3次迭代。两个HashMap都存储相同数量的数据,并且具有相同的内部数组大小。

以下示例,创建了一个哈希函数,该函数将所有数据放入同一存储桶中,然后添加200万个元素。

public class Test {
 
    public static void main(String[] args) {
 
        class MyKey {
            Integer i;
            public MyKey(Integer i){
                this.i =i;
            }
 
            @Override
            public int hashCode() {
                return 1;
            }
 
            @Override
            public boolean equals(Object obj) {}
 
        }
        Date begin = new Date();
        Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
        for (int i=0;i<2_000_000;i++){
            myMap.put( new MyKey(i), "test "+i);
        }
 
        Date end = new Date();
        System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
    }
}

在我的核心i5-2500k @ 3.6Ghz上,使用Java 8 需要超过45分钟(我在45分钟后停止了该过程)。

现在,如果我运行相同的代码,但是这次我使用以下哈希函数

  @Override
    public int hashCode() {
        int key = 2097152-1;
        return key+2097152*i;
}

需要46秒

如果我使用以下哈希函数运行相同的代码,则可以提供更好的哈希重新分区

@Override
public int hashCode() {
return i;
}

需要2秒钟。

使用HashMap时,尽可能使用合适的key,将key散列到尽可能多的存储桶中。字符串对象是一个很好的key,因为它具有良好的哈希功能。整数也很好,因为它们的哈希码是它们自己的值。

如果需要存储大量数据,则应为HashMap创建初始容量。

Map默认大小16,加载因子为0.75。第一个到第11个put()非常快,但是第12个(16 * 0.75)将重新创建一个新的内部数组(及其关联的链表/树),其新容量为32。第13个到第23个将很快,但是第24个(32 * 0.75)将从新扩容。在数量小的情况下,内部阵列的完全恢复速度很快,但在数量大的情况下,可能需要几秒钟到几分钟。通过设置map的大小,可以避免这些自动扩容带来的代价。

但是有一个缺点:如果您将数组大小设置得很高,例如2 ^ 28,而在数组中仅使用2 ^ 26个存储桶,则会浪费大量内存。

  • 14
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

境里婆娑

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值