Open Yikun opened 9 years ago
好文章!
@DeppWang 右边是eclipse的watch view
请问楼主,put方法的afterNodeAccess(e);是什么意思?
Initial capacity The capacity is the number of buckets in the hash table(bucket的数量) Capacity就是bucket的大小 这里说得不大好,有点歧义 当bucket中的entries的数目(改成:hashmap中元素个数大于)大于capacity*load factor时就需要调整bucket的大小为当前的2倍
@Yailm 感谢指正,表述确实不准确,已经修改。
@DeppWang 可以认为是在node处理后做的一些事情,相信你可以从代码中找到答案。
如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树
应该是列表的长度大于等于TREEIFY_THRESHOLD,且bucket的大小小于MIN_TREEIFY_CAPACITY才会把链表转成红黑树;
文中说:当Entry数量达到桶数量的75%时(很多文章说使用的桶数量达到了75%,但看代码不是) 我调试代码看到的loadFactor确实是0.75
@WellLiao 恩,这部分来自于关于Java集合的小抄,我看原文作者已经更新了,我也刷新一下,谢谢提醒。
你前面讲resize的时候并不会重新计算hash值,这里是不是写错了,应该是重新计算index.
博主有个地方写错了,"不妨思考一下,在n - 1为15(0x1111)时",15表示为16进制是0xF,不是0x1111。
好文,点赞
佩服佩服!对于冲突碰撞方案有一点疑惑: 如果hashCode本身比较小,有效的1都出现在低16位里,(h = key.hashCode()) ^ (h >>> 16) 的结果就0,再通过(n-1) & hash计算桶位置时,就是必然碰撞了。抓住这点可以制造无限碰撞的HashMap来测试性能下限。
void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null; if (b == null) head = a; else b.after = a; if (a != null) a.before = b; else last = b; 这里命名条件语句已经排除了访问尾部节点的情况 这里的判断有何意义呢? if (last == null) head = p; else { p.before = last; last.after = p; } tail = p; ++modCount; } }
请帮我解惑一下 谢谢
很棒的分析!
@bauerctu 每个key的hashCode一般都不一样,“(h = key.hashCode()) ^ (h >>> 16) 的结果就0”的情况自然很少,(h = key.hashCode()) ^ (h >>> 16) 和 (n-1) & hash jdk只采用了一种方式来计算桶位,你说的这种情况不存在喔
文章真的很赞,学习到了很多东西。不过我对下面这个存在一点疑问
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
这个应该不是随机的吧,这个应该是对应 entry
的 key 的 hash 值中固定的一位吧。如果 hashmap 的容量为 16 的话,那么对应的 key 的 hash 的后四位则为 bucket 的 index;如果 hashmap 的容量为 32 的话,那个对应的 key 的 hash 的后五位则为 bucket 的 index。而对应的 key 的 hash 的话,在进行 put 进入 hashmap 中的时候就计算好了。
博主。 文中提到,扩容中根据新增位为1或者0,决定节点的新位置,这很巧妙。我想你的描述不准确。巧妙处应在于node存储了计算后的hash(hashcode^ (h >>> 16)),这样扩容迁移时,只需要进行将存储的hash &(newLength-1)就能得到新位置,无需重新进行前面的hash过程。当然,jdk是将hash&oldLength,从而得到新增位为1或0,从而将同一位置下的链表节点或者树节点分为位置变更组和位置未变组。在这个地方体现的更巧妙之处在于,由于hashmap的length设计为2^N,并且扩容是以2倍进行的。如此,每个节点的新位置只会是原位置或者原位置+oldLength,并且这两个可能的位置都只能由位于原位置的节点占据,而不可能为其他位置的节点占据,这节省了不少操作。想象下,若hashmap的长度为常见的素数,那么扩容后,原位置的节点会被散列到比2多得多的节点,这很麻烦。 另外,https://tech.meituan.com/2016/06/24/java-hashmap.html,此篇文章发于2016年06月24日,参考了你,但没给出来源。
大佬,第一张图的数学和化学的hash是不是应该是相同的呀?
如果bucket满了(超过load factorcurrent capacity),就要resize。 这里,你看一下++size > threshold这个判断,这个size是entry的个数,不是bucket~ 所以应该是entry个数超了load factorcurrent capacity,就会resize。
1. 概述
从本文你可以学习到:
当我们执行下面的操作时:
运行结果是
发生了什么呢?下面是一个大致的结构,希望我们对HashMap的结构有一个感性的认识:
在官方文档中是这样描述HashMap的:
几个关键的信息:基于Map接口实现、允许null键/值、非同步、不保证有序(比如插入的顺序)、也不保证序不随时间变化。
2. 两个重要的参数
在HashMap中有两个很重要的参数,容量(Capacity)和负载因子(Load factor)
简单的说,Capacity就是buckets的数目,Load factor就是buckets填满程度的最大比例。如果对迭代性能要求很高的话不要把
capacity
设置过大,也不要把load factor
设置过小。当bucket填充的数目(即hashmap中元素的个数)大于capacity*load factor
时就需要调整buckets的数目为当前的2倍。3. put函数的实现
put函数大致的思路为:
具体代码的实现如下:
4. get函数的实现
在理解了put之后,get就很简单了。大致思路如下:
具体代码的实现如下:
5. hash函数的实现
在get和put的过程中,计算下标时,先对hashCode进行hash操作,然后再通过hash值进一步计算下标,如下图所示:
在对hashCode()计算hash时具体实现是这样的:
可以看到这个函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或。其中代码注释是这样写的:
在设计hash函数时,因为目前的table长度n为2的幂,而计算下标的时候,是这样实现的(使用
&
位操作,而非%
求余):设计者认为这方法很容易发生碰撞。为什么这么说呢?不妨思考一下,在n - 1为15(0x1111)时,其实散列真正生效的只是低4bit的有效位,当然容易碰撞了。
因此,设计者想了一个顾全大局的方法(综合考虑了速度、作用、质量),就是把高16bit和低16bit异或了一下。设计者还解释到因为现在大多数的hashCode的分布已经很不错了,就算是发生了碰撞也用
O(logn)
的tree去做了。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。如果还是产生了频繁的碰撞,会发生什么问题呢?作者注释说,他们使用树来处理频繁的碰撞(we use trees to handle large sets of collisions in bins),在JEP-180中,描述了这个问题:
之前已经提过,在获取HashMap的元素时,基本分两步:
在Java 8之前的实现中是用链表解决冲突的,在产生碰撞的情况下,进行get时,两步的时间复杂度是O(1)+O(n)。因此,当碰撞很厉害的时候n很大,O(n)的速度显然是影响速度的。
因此在Java 8中,利用红黑树替换链表,这样复杂度就变成了O(1)+O(logn)了,这样在n很大的时候,能够比较理想的解决这个问题,在Java 8:HashMap的性能提升一文中有性能测试的结果。
6. resize的实现
当put时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。resize的注释是这样描述的:
大致意思就是说,当超过限制的时候会resize,然而又因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
下面是代码的具体实现:
7. 总结
我们现在可以回答开始的几个问题,加深对HashMap的理解:
1. 什么时候会使用HashMap?他有什么特点? 是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。
2. 你知道HashMap的工作原理吗? 通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用? 通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
4. 你知道hash的实现吗?为什么要这样实现? 在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:
(h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办? 如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。
关于Java集合的小抄中是这样描述的:
参考资料
HashMap的工作原理 Java 8:HashMap的性能提升 JEP 180: Handle Frequent HashMap Collisions with Balanced Trees ConurrentHashMap和Hashtable的区别 HashMap和Hashtable的区别