1、hashmap 的数据结构
要知道 hashmap 是什么,首先要搞清楚它的数据结构,在 java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap 也不例外。Hashmap 实际上是一个数组和链表的结合体(在数据结构中,一般称之为 “链表散列 “),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。
从图中我们可以看到一个 hashmap 就是一个数组结构,当新建一个 hashmap 的时候,就会初始化一个数组。我们来看看 java 代码:
/**
* The table, resized as necessary. Length MUST Always be a power of two.
* FIXME 这里需要注意这句话,至于原因后面会讲到
*/
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
final int hash;
Entry<K,V> next;
..........
}
上面的 Entry 就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。
当我们往 hashmap 中 put 元素的时候,先根据 key 的 hash 值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从 hashmap 中 get 元素时,首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么 hashmap 的 get 效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~
2、hash 算法
我们可以看到在 hashmap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。前面说过 hashmap 的数据结构是数组和链表的结合,所以我们当然希望这个 hashmap 里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。
所以我们首先想到的就是把 hashcode 对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模” 运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java 中时这样做的,
static int indexFor(int h, int length) {
return h & (length-1);
}
首先算得 key 得 hashcode 值,然后跟数组的长度 - 1 做一次 “与” 运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是 2 的 4 次方,那么 hashcode 就会和 2 的 4 次方 - 1 做 “与” 运算。很多人都有这个疑问,为什么 hashmap 的数组初始化大小都是 2 的次方大小时,hashmap 的效率最高,我以 2 的 4 次方举例,来解释一下为什么数组大小为 2 的幂时 hashmap 访问的性能最高。
看下图,左边两组是数组长度为 16(2 的 4 次方),右边两组是数组长度为 15。两组的 hashcode 均为 8 和 9,但是很明显,当它们和 1110 “与” 的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8 和 9 会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到 8 或者 9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为 15 的时候,hashcode 的值会与 14(1110)进行 “与”,那么最后一位永远是 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
所以说,当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index 相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
说到这里,我们再回头看一下 hashmap 中默认的数组大小是多少,查看源代码可以得知是 16,为什么是 16,而不是 15,也不是 20 呢,看到上面 annegu 的解释之后我们就清楚了吧,显然是因为 16 是 2 的整数次幂的原因,16-1的二进制是 1 1 1 1 ,能够尽量报错hash值的结果,在小数据量的情况下 16 比 15 和 20 更能减少 key 之间的碰撞,而加快查询的效率。
所以,在存储大容量数据的时候,最好预先指定 hashmap 的 size 为 2 的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的 2 次幂来初始化的,代码如下 (HashMap 的构造方法中):
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
3、hashmap 的 resize
当 hashmap 中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对 hashmap 的数组进行扩容,数组扩容这个操作也会出现在 ArrayList 中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的 “均摊” 原理,就释然了,而在 hashmap 数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是 resize。
那么 hashmap 什么时候进行扩容呢?当 hashmap 中的元素个数超过数组大小 * loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,也就是说,默认情况下,数组大小为 16,那么当 hashmap 中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 hashmap 中元素的个数,那么预设元素的个数能够有效的提高 hashmap 的性能。比如说,我们有 1000 个元素 new HashMap (1000), 但是理论上来讲 new HashMap (1024) 更合适,不过上面 annegu 已经说过,即使是 1000,hashmap 也自动会将其设置为 1024。 但是 new HashMap (1024) 还不是更合适的,因为 0.75*1000 < 1000, 也就是说为了让 0.75 * size > 1000, 我们必须这样 new HashMap (2048) 才最合适,既考虑了 & 的问题,也避免了 resize 的问题。
- 如果需要扩容,调用扩容的方法 resize ()
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//判断是否有超出扩容的最大值,如果达到最大值则不进行扩容操作
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
// transfer()方法把原数组中的值放到新数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//设置hashmap扩容后为新的数组引用
table = newTable;
//设置hashmap扩容新的阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
- transfer () 在实际扩容时候把原来数组中的元素放入新的数组中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//通过key值的hash值和新数组的大小算出在当前数组中的存放位置
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
4、key 的 hashcode 与 equals 方法改写
在第一部分 hashmap 的数据结构中,就写了 get 方法的过程:首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。所以,hashcode 与 equals 方法对于找到对应元素是两个关键方法。
Hashmap 的 key 可以是任何类型的对象,例如 User 这种对象,为了保证两个具有相同属性的 user 的 hashcode 相同,我们就需要改写 hashcode 方法,比方把 hashcode 值的计算与 User 对象的 id 关联起来,那么只要 user 对象拥有相同 id,那么他们的 hashcode 也能保持一致了,这样就可以找到在 hashmap 数组中的位置了。如果这个位置上有多个元素,还需要用 key 的 equals 方法在对应位置的链表中找到需要的元素,所以只改写了 hashcode 方法是不够的,equals 方法也是需要改写滴~当然啦,按正常思维逻辑,equals 方法一般都会根据实际的业务内容来定义,例如根据 user 对象的 id 来判断两个 user 是否相等。
在改写 equals 方法的时候,需要满足以下三点:
(1) 自反性:就是说 a.equals (a) 必须为 true。
(2) 对称性:就是说 a.equals (b)=true 的话,b.equals (a) 也必须为 true。
(3) 传递性:就是说 a.equals (b)=true,并且 b.equals (\c)=true 的话,a.equals (\c) 也必须为 true。
通过改写 key 对象的 equals 和 hashcode 方法,我们可以将任意的业务对象作为 map 的 key (前提是你确实有这样的需要)。
总结:
本文主要描述了 HashMap 的结构,和 hashmap 中 hash 函数的实现,以及该实现的特性,同时描述了 hashmap 中 resize 带来性能消耗的根本原因,以及将普通的域模型对象作为 key 的基本要求。尤其是 hash 函数的实现,可以说是整个 HashMap 的精髓所在,只有真正理解了这个 hash 函数,才可以说对 HashMap 有了一定的理解。