引言:本文基于JDK1.7,详细分析HashMap的源码。
HashMap简介
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理就是基于此。那么什么是哈希表呢?
在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能。
- 数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)。对应到集合实现,代表就是ArrayList。
- 线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)。对应的集合类是LinkedList。
- 二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。对应的集合类有TreeSet和TreeMap。
- 哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。对应的集合类就是HashMap。
哈希表的主干就是数组。我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。即:
1 | 存储位置 = f(关键字) |
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。这会涉及到哈希冲突。
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀。但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法。而HashMap即是采用了链地址法,也就是数组+链表的方式。
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,急需要简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
HashMap的源码实现
HashMap中的几个重要属性
1 | //最大容量,2的30次方 |
数据结构
HashMap的内部存储结构其实是数组和链表的结合。当实例化一个HashMap时,系统会创建一个长度为Capacity的Entry数组,这个长度被称为容量(Capacity),在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素。 每个bucket中存储一个元素,即一个Entry对象,但每一个Entry对象可以带一个引用变量,用于指向下一个元素,因此,在一个桶中,就有可能生成一个Entry链。 Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。 Entry是HashMap中的一个静态内部类。代码如下:
1 |
|
经过以上分析,HashMap的存储结构图如下:
构造方法
HashMap共有4个构造方法,但都会调用到HashMap(int initialCapacity, float loadFactor)
这个构造方法。
1 | public HashMap(int initialCapacity, float loadFactor) { |
从上面这段代码我们可以看出,在常规构造器中(有一个入参为指定Map的构造器例外),并没有根据传入的initialCapacity去新建一个Entry数组实例,构造方法执行完后哈希表依然是一个空表。
Entry数组的创建是在第一次执行put操作时,执行put操作会先检查当前哈希表是否是个空表,如果是空表就调用inflateTable
方法进行初始化。
此外,如果创建HashMap对象时没有传入initialCapacity 和 loadFactor 这两个参数,会使用默认值。initialCapacity默认为16,loadFactor默认为0.75。
1 | //toSize是用的threshold,而在构造方法中threshold赋值为initialCapacity |
上面贴出了inflateTable
方法的代码,可以看到在这个方法中会重新计算Entry数组的容量,因为在构造HashMap时传入的初始化大小可能不是2的幂,因此先要将这个数转换成2的幂再根据新的容量新建Entry数组。并且在初始化哈希表时会再次重新设置threshold,一般就是capacity*loadFactor。
此外,在初始化哈希表时还会去初始化hashSeed,这个hashSeed用于优化哈希函数,默认为0是不使用替代哈希算法,但是也可以自己去设置hashSeed的值,以达到优化效果。
get方法
get方法相对比较简单,源码如下:
1 | public V get(Object key) { |
put方法
put方法稍微复杂些,代码如下:
1 | public V put(K key, V value) { |
indexFor
方法中,用h & (length-1)
保证获取的index一定在数组范围内。
1 | //返回数组下标 |
比如,默认容量16,length-1=15,h=18,转换成二进制计算为
最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)。
通过以上分析,我们看到,要得到一个元素的存储位置,需要如下几步:
1、获取该元素的key值
2、通过hash方法得到key的散列值,这其中需要用到key的hashcode值。
3、通过indexFor计算得到存储的下标位置。
最后,得到存储的下标位置后,我们就可以将元素放入HashMap中,具体通过addEntry实现:
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。
扩容(resize)
扩容操作通过resize操作实现:
1 | //按新的容量扩容Hash表 |
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法:
1 | //将老的表中的数据拷贝到新的结构中 |
这个方法将老数组中的数据逐个链表地遍历,重新计算后放入新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。
注意:HashMap数组元素长度的设计
通过源码可以发现,hashMap的数组长度一定保持2的次幂,这样做有什么好处呢?
1 | //根据Hash值和Hash表的大小选择合适的槽位 |
如果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这几个位置永远都不会存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
几个注意点
- HashMap是基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变(发生扩容时,元素位置会重新分配)。
- 迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。
- HashMap 的实例有两个参数影响其性能:初始容量 和加载因子。容量 是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。通常,默认加载因子 (.75) 在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
如果很多映射关系要存储在 HashMap 实例中,则相对于按需执行自动的 rehash 操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效地存储。
v1.5.2