IT老哥node
老哥是经过自学进入大厂作资深Java工程师,天天分享技术干货,助你进大厂算法
有不少东西以前在学的时候没怎么注意,笔者也是在重温HashMap的时候发现有不少能够去细究的问题,最终是会回归于数学的,如HashMap的加载因子为何是0.75? 数组
本文主要对如下内容进行介绍:缓存
为何HashMap须要加载因子?数据结构
解决冲突有什么方法?less
为何加载因子必定是0.75?而不是0.8,0.6?dom
HashMap的底层是哈希表,是存储键值对的结构类型,它须要经过必定的计算才能够肯定数据在哈希表中的存储位置:函数
`static final int hash(Object key) {` `int h;` `return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);` `}` `// AbstractMap` `public int hashCode() {` `int h = 0;` `Iterator<Entry<K,V>> i = entrySet().iterator();` `while (i.hasNext())` `h += i.next().hashCode();` `return h;` `}`
通常的数据结构,不是查询快就是插入快,HashMap就是一个插入慢、查询快的数据结构。性能
但这种数据结构容易产生两种问题:① 若是空间利用率高,那么通过的哈希算法计算存储位置的时候,会发现不少存储位置已经有数据了(哈希冲突);② 若是为了不发生哈希冲突,增大数组容量,就会致使空间利用率不高。spa
而加载因子就是表示Hash表中元素的填满程度。
加载因子 = 填入表中的元素个数 / 散列表的长度
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减少,但空间浪费了更多了,并且还会提升扩容rehash操做的次数。
冲突的机会越大,说明须要查找的数据还须要经过另外一个途径查找,这样查找的成本就越高。所以,必须在“冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷。
因此咱们也能知道,影响查找效率的因素主要有这几种:
散列函数是否能够将哈希表中的数据均匀地散列?
怎么处理冲突?
哈希表的加载因子怎么选择?
本文主要对后两个问题进行介绍。
`Hi = (H(key) + di) MOD m,其中i=1,2,…,k(k<=m-1)`
H(key)为哈希函数,m为哈希表表长,di为增量序列,i为已发生冲突的次数。其中,开放定址法根据步长不一样能够分为3种:
简单地说,就是以当前冲突位置为起点,步长为1循环查找,直到找到一个空的位置,若是循环完了都占不到位置,就说明容器已经满了。举个栗子,就像你在饭点去街上吃饭,挨家去看是否有位置同样。
相对于线性探查法,这就至关于的步长为di = i2来循环查找,直到找到空的位置。以上面那个例子来看,如今你不是挨家去看有没有位置了,而是拿手机算去第i2家店,而后去问这家店有没有位置。
这个就是取随机数来做为步长。仍是用上面的例子,此次就是彻底按心情去选一家店问有没有位置了。
但开放定址法有这些缺点:
这种方法创建起来的哈希表,当冲突多的时候数据容易堆集在一块儿,这时候对查找不友好;
删除结点的时候不能简单将结点的空间置空,不然将截断在它填入散列表以后的同义词结点查找路径。所以若是要删除结点,只能在被删结点上添加删除标记,而不能真正删除结点;
若是哈希表的空间已经满了,还须要创建一个溢出表,来存入多出来的元素。
`Hi = RHi(key), 其中i=1,2,…,k`
RHi()函数是不一样于H()的哈希函数,用于同义词发生地址冲突时,计算出另外一个哈希函数地址,直到不发生冲突位置。这种方法不容易产生堆集,可是会增长计算时间。
因此再哈希法的缺点是:增长了计算时间。
假设哈希函数的值域为[0, m-1],设向量HashTable[0,…,m-1]为基本表,每一个份量存放一个记录,另外还设置了向量OverTable[0,…,v]为溢出表。基本表中存储的是关键字的记录,一旦发生冲突,无论他们哈希函数获得的哈希地址是什么,都填入溢出表。
但这个方法的缺点在于:查找冲突数据的时候,须要遍历溢出表才能获得数据。
将冲突位置的元素构形成链表。在添加数据的时候,若是哈希地址与哈希表上的元素冲突,就放在这个位置的链表上。
拉链法的优势:
处理冲突的方式简单,且无堆集现象,非同义词毫不会发生冲突,所以平均查找长度较短;
因为拉链法中各链表上的结点空间是动态申请的,因此它更适合造表前没法肯定表长的状况;
删除结点操做易于实现,只要简单地删除链表上的相应的结点便可。
拉链法的缺点:须要额外的存储空间。
从HashMap的底层结构中咱们能够看到,HashMap采用是数组+链表/红黑树的组合来做为底层结构,也就是开放地址法+链地址法的方式来实现HashMap。
从上文咱们知道,HashMap的底层其实也是哈希表(散列表),而解决冲突的方式是链地址法。HashMap的初始容量大小默认是16,为了减小冲突发生的几率,当HashMap的数组长度到达一个临界值的时候,就会触发扩容,把全部元素rehash以后再放在扩容后的容器中,这是一个至关耗时的操做。
而这个临界值就是由加载因子和当前容器的容量大小来肯定的:
临界值 = DEFAULT\_INITIAL\_CAPACITY * DEFAULT\_LOAD\_FACTOR
即默认状况下是16x0.75=12时,就会触发扩容操做。
那么为何选择了0.75做为HashMap的加载因子呢?这个跟一个统计学里很重要的原理——泊松分布有关。
泊松分布是统计学和几率学常见的离散几率分布,适用于描述单位时间内随机事件发生的次数的几率分布。有兴趣的读者能够看看维基百科或者阮一峰老师的这篇文章:泊松分布和指数分布:10分钟教程[1]
等号的左边,P 表示几率,N表示某种函数关系,t 表示时间,n 表示数量。等号的右边,λ 表示事件的频率。
在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`
在理想状况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的状况下,节点出如今频率在Hash桶(表)中遵循参数平均为0.5的泊松分布。忽略方差,即X = λt,P(λt = k),其中λt = 0.5的状况,按公式:
计算结果如上述的列表所示,当一个bin中的链表长度达到8个元素的时候,几率为0.00000006,几乎是一个不可能事件。
因此咱们能够知道,其实常数0.5是做为参数代入泊松分布来计算的,而加载因子0.75是做为一个条件,当HashMap长度为length/size ≥ 0.75时就扩容,在这个条件下,冲突后的拉链长度和几率结果为:
`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`
HashMap中除了哈希算法以外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在建立时的容量,加载因子是哈希表在其容量自动扩容以前能够达到多满的一种度量。
在维基百科来描述加载因子:
对于开放定址法,加载因子是特别重要因素,应严格限制在0.7-0.8如下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。所以,一些采用开放定址法的hash库,如Java的系统库限制了加载因子为0.75,超过此值将resize散列表。
在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减小扩容rehash操做次数,因此,通常在使用HashMap时建议根据预估值设置初始容量,以便减小扩容操做。
选择0.75做为默认的加载因子,彻底是时间和空间成本上寻求的一种折衷选择。