🔎【Java 源码探索】深入浅出的分析 HashMap(JDK7)
每日一句
有望得到的要努力,无望得到的不介意,则无论输赢姿态都会好看。
概念回顾
HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前 entry 的 next 指向 null),那么对于查找,添加等操作很快,仅需一次寻址即可;
如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为 O(1),因为最新的 Entry 会插入链表头部,急需要简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过 key 对象的 equals 方法逐一比对查找。
所以,性能考虑,HashMap 中的链表出现越少,性能才会越好。
不同 JVM 版本 HashMap 的展现形式
本章内容主要为介绍 JDK7 的版本文章学习。
JDK7
HashMap 的数据结构为:数组 + 链表
JDK8
可以查看相关对应的另外一篇https://xie.infoq.cn/article/19975ef8ca03291839af31874
HashMap 的数据结构为:数组 + 链表 + 红黑树
基本简介
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如 memcached)的核心其实就是在内存中维护一张大的哈希表,而 HashMap 的实现原理就是基于此。
数据结构的分析
针对于多种数据结构进行分析为什么会选择 Hash 表的方式进行存储和查询。
数组
采用一段连续的存储单元来存储数据。
查询操作场景
对于指定下标的查找,时间复杂度为 O(1);
通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为 O(n)。
对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为 O(logn);
插入删除场景
对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为 O(n)。
对应到集合类是 ArrayList。
线性链表
查询操作场景
查找操作需要遍历链表逐一进行比对,复杂度为 O(n)。
插入删除场景
链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为 O(1)。
对应的集合类是 LinkedList。
二叉树
对一棵相对平衡的有序二叉树。
对其进行插入,查找,删除等操作,平均复杂度均为 O(logn)。
对应的集合类有 TreeSet 和 TreeMap。
哈希表
相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为 O(1)。
对应的集合类就是 HashMap。
哈希表的主干就是数组。我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
即:存储位置 = hash(关键字)
其中,这个函数 hash 一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。这会涉及到哈希冲突。
哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀。
Hash 冲突机制
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。
数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?
哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法。
而 HashMap 即是采用了链地址法,也就是数组+链表的方式。
HashMap 的源码实现
存储结构
Hash 桶(bucket)
当实例化一个 HashMap 时,系统会创建一个长度为 Capacity 的 Entry 数组,这个长度被称为容量(Capacity),在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个 bucket 都有自己的索引,系统可以根据索引快速的查找 bucket 中的元素。
每个 bucket 中存储一个元素,即一个 Entry 对象,但每一个 Entry 对象可以带一个引用变量,用于指向下一个元素,因此,在一个桶中,就有可能生成一个 Entry 链。
Entry
HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。 Entry 是 HashMap 中的一个静态内部类。代码如下:
经过以上分析,HashMap 的存储结构图如下:
一个长度为 16 的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。
一般情况是通过 hash(key)%len 获得,也就是元素的 key 的哈希值对数组长度取模得到。
比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以 12、28、108 以及 140 都存储在数组下标为 12 的位置。
在存储一对值时(Key ->Value 对),实际上是存储在一个 Entry 的对象 e 中,程序通过 key 计算出 Entry 对象的存储位置。
Key->Value 的对应关系是通过 key—-Entry—-value 这个过程实现的,所以就有我们表面上知道的 key 存在哪里,value 就存在哪里。
重要属性
先看 HashMap 中的几个重要属性:
构造方法
HashMap 有 4 个构造器,其他构造器如果用户没有传入 initialCapacity 和 loadFactor 这两个参数,会使用默认值。initialCapacity 默认为 16,loadFactory 默认为 0.75。
从上面这段代码我们可以看出,在常规构造器中,并没有马上为数组 table 分配内存空间(有一个入参为指定 Map 的构造器例外),事实上是在执行第一次 put 操作的时候才真正构建 table 数组。
put 操作
如果两个 key 通过 hash%Entry[].length 得到的 index 相同,为了解决这个问题,HashMap 里面用到链式数据结构的一个概念。
上面我们提到过 Entry 类里面有一个 next 属性,作用是指向下一个 Entry。打个比方, 第一个键值对 A 进来,通过计算其 key 的 hash 得到的 index=0,记做:Entry[0] = A。
一会后又进来一个键值对 B,通过计算其 index 也等于 0,HashMap 会这样做:B.next = A,Entry[0] = B,如果又进来 C,index 也等于 0,那么 C.next = B,Entry[0] = C;
这样我们发现 index=0 的地方其实存取了 A,B,C 三个键值对,他们通过 next 这个属性链接在一起。
也就是说数组中存储的是最后插入的元素。到这里为止,HashMap 的大致实现,我们应该已经清楚了。
putForNullKey 方法
key 为 null 的时候,只会放在 hashMap 的 0 位置(即 key 的 hashCode 为 0,对数组长度取余后的下标也是 0),不会有链表
在 HashMap 源码中对 put 方法对 null 做了处理,key 为 null 的判断后进入 putForNullKey(V value)这个方法,里面 for 循环是在 talbe[0]链表中查找 key 为 null 的元素,如果找到,则将 value 重新赋值给这个元素的 value,并返回原来的 value。如果没找到则将这个元素添加到 talbe[0]链表的表头
inflateTable 的源码如下:
inflateTable 这个方法用于为主干数组 table 在内存中分配存储空间,通过 roundUpToPowerOf2(toSize)可以确保 capacity 为大于或等于 toSize 的最接近 toSize 的二次幂,比如 toSize=13,则 capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。
二次幂其实现如下:
roundUpToPowerOf2 中的这段处理使得数组长度一定为 2 的次幂,Integer.highestOneBit 是用来获取最左边的 bit(其他 bit 位为 0)所代表的数值。
在对数组进行空间分配后,会根据 hash 函数计算散列值,其实现如下:
//用了很多的异或,移位等运算,对 key 的 hashcode 进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
从上面的操作看以看出,影响 HashMap 元素的存储位置的只有 key 的值,与 value 值无关。
通过 hash 函数得到散列值后,再通过 indexFor 进一步处理来获取实际的存储位置
其实现如下:
h &(length-1)保证获取的 index 一定在数组范围内,举个例子,默认容量 16,length-1=15,h=18,转换成二进制计算为
最终计算出的 index=2。有些版本的对于此处的计算会使用取模运算,也能保证 index 一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap 中有大量位运算)。
通过以上分析,我们看到,要得到一个元素的存储位置,需要如下几步:
获取该元素的 key 值
通过 hash 方法得到 key 的散列值,其中需要用到 key 的 hashcode 值。
通过 indexFor 计算得到存储的下标位置。
最后,得到存储的下标位置后,我们就可以将元素放入 HashMap 中,具体通过 addEntry 实现:
通过以上代码能够得知,当发生哈希冲突并且 size 大于阈值的时候并且对应的 key 对应的 table 桶的首地址元素不为 null 的情况下:需要进行数组扩容
扩容时,需要新建一个长度为之前数组 2 倍的新的数组,然后将当前的 Entry 数组中的元素全部传输过去,扩容后的新数组长度为之前的 2 倍,所以扩容相对来说是个耗资源的操作。
扩容操作
扩容操作通过 resize 操作实现:
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index 也可能会发生变化,需要重新计算 index,我们先来看看 transfer 这个方法。
这个方法将老数组中的数据逐个链表地遍历,重新计算后放入新的扩容后的数组中,我们的数组索引位置的计算是通过对 key 值的 hashcode 进行 hash 扰乱运算后,再通过和 length-1 进行位运算得到最终数组索引位置。
注意:HashMap 数组元素长度的设计
通过源码可以发现,hashMap 的数组长度一定保持 2 的次幂,这样做有什么好处呢?
如果 length 为 2 的次幂,其二进制表示就是 100….0000;则 length-1 转化为二进制必定是 0111….11 的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,再于 h 与操作。
最后一位都为 0,所以 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不会存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
get 操作
get 方法通过 key 值返回对应 value,如果 key 为 null,直接去 table[0]处检索。我们再看一下 getEntry 这个方法:
get 方法的实现相对简单,key(hashcode)–>hash–>indexFor–>最终索引位置,找到对应位置 table[i],再查看是否有链表,遍历链表,通过 key 的 equals 方法比对查找对应的记录。
在定位到数组位置之后然后遍历链表的时候,e.hash == hash 这个判断没必要,仅通过 equals 判断就可以。
HashMap 中的 hashcode 怎么生成
调用对象 key 的 hashCode 方法,再对这个 hashcode 方法进行一些右移以及异或运算(使的 hashCode 的高位和低位都参与到运算中);通过右移和异或运算可以使 hashMap 的散列化更强,提高 hashMap 的 get 方法的效率
为什么使用 HashCode
HashCode 的存在主要是为了查找的快捷性,HashCode 是用来在散列存储结构中确定对象的存储地址的 ( 用 hashcode 来代表对象在 hash 表中的位置 ) , hashCode 存在的重要的原因之一就是在 HashMap(HashSet 其实就是 HashMap)中使用(其实 Object 类的 hashCode 方法注释已经说明了)。
equals 方法和 hashcode 的关系
若重写了 equals(Object obj)方法,则有必要重写 hashCode()方法
若两个对象 equals(Object obj)返回 true,则 hashCode()有必要也返回相同的 int 数
若两个对象 equals(Object obj)返回 false,则 hashCode()不一定返回不同的 int 数
若两个对象 hashCode()返回相同 int 数,则 equals(Object obj)不一定返回 true
若两个对象 hashCode()返回不同 int 数,则 equals(Object obj)一定返回 false
同一对象在执行期间若已经存储在集合中,则不能修改影响 hashCode 值的相关信息,否则会导致内存泄露问题
总结
HashMap 之所以速度快,因为他使用的是散列表,根据 key 的 hashcode 值生成数组下标(通过内存地址直接查找,不需要判断,但是需要多出很多内存,相当于以空间换时间)
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/7777e6ee68c643ef7b4c4551e】。文章转载请联系作者。
评论