Java 中的 HashMap
是散列表的实现,有必要 review 一下一个工业级的散列表实现要考量的 Key Point:
散列表的三个基本元素:key、散列函数、value, key 通过散列函数计算得到 value 存储的位置
对一个工业级散列表的实现要遵守几点:首先支持快速查询、删除、插入等操作;其次,内存占用要合理,不要过多浪费内存;最后,性能稳定,极端情况下,散列表的性能也不能退化到无法接受
总之,我们在结合使用场景和具体业务数据考虑设计散列表实现时,要抓住上面的 key point;接下来,从这几个点来验证一下 Java 中的 HashMap
实现
散列函数的设计要点是:首先,不能复杂,太复杂的散列函数也会影响程序性能;其次,散列函数计算后得出的值要尽可能随机和均匀。
复制 static final int hash( Object key) {
int h;
// 先取对象 key 的 hashCode
// 最后返回 h ^ (h >>> 16),将hashCode的高16位移到低16位,然后做异或,保证了很好的随机性,位运算本身是很高效的
return (key == null ) ? 0 : (h = key . hashCode ()) ^ (h >>> 16 );
}
// 在插入元素时计算key要插入的元素位置
// 通过 (n - 1) & hash 来计算位置,n 代表数组的 capacity,hash & (n - 1) 相当于 hash % n, 也就是除留余数法
if ((p = tab[i = (n - 1 ) & hash]) == null )
tab[i] = newNode(hash , key , value , null ) ;
// Object 的 hashCode 返回的是 int
public native int hashCode() ;
可见,HashMap 的散列函数设计分为两步,先对 key 求hash值,然后再根据 HashMap 的节点(本质是一个数组)容量来计算数组中的位置;求 key hash值的方式简单高效,位移后异或随机且高效,所以总体非常符合散列函数的设计要求。( 注:A % B = A & (B - 1) )
HashMap 的实现底层用了数组,数组有容量范围的;装载因子表示了空槽位的多少,计算方式为:已经使用的槽位数/散列表的总长度
。 从装载因子我们可以得出一些结论:当装载因子越大,散列表里的元素就越多,空闲位置就越少,散列冲突的概率就越大,会导致整个插入和查找的时间变长,最糟糕的情况会退化成链表的查找和插入时间复杂度,这是不可忍受的。这个时候就需要进行动态扩容策略来调整装载因子,让他不会太大;
但是当装载因子太小时,会导致浪费内存空间,所以保证装载因子在一个合理的值是比较重要的。
复制 /**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 ; // aka 16
// 初始容量大小为16,如果没指定的话
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f ;
// 装载因子默认是 0.75
// 从 Node 的定义来看,HashMap 采用的是链表法存储元素
static class Node < K , V > implements Map . Entry < K , V > {
final int hash;
final K key;
V value;
// 指向下一个和当前元素有相同位置索引的元素
Node < K , V > next;
// ... 省略了部分代码
}
final Node< K , V > [] resize() {
// .... 省略了部分代码
threshold = newThr;
// 新的容量的计算策略是如果初始为0,就用默认的16大小,如果大于16小于2^30,每次扩容就double size
// 这里使用新的容量申请了一个新的数组
@ SuppressWarnings ({ "rawtypes" , "unchecked" })
Node < K , V >[] newTab = ( Node< K , V > []) new Node [newCap];
table = newTab;
// 老数组如果有元素的话,需要做copy且重新计算索引位置
if (oldTab != null ) {
// 遍历老数组
for ( int j = 0 ; j < oldCap; ++ j) {
Node < K , V > e;
// 不等于null表示元素是一个存在的合法元素,需要copy
if ((e = oldTab[j]) != null ) {
oldTab[j] = null ;
// 如果当前元素没有后继元素,计算新的位置
if ( e . next == null )
newTab[ e . hash & (newCap - 1 )] = e;
else if (e instanceof TreeNode)
// 如果是树节点类型,这里是红黑树,将树中的节点做分裂,因为容量变化,所以节点计算出的位置索引也会发生变化
(( TreeNode< K , V > )e) . split ( this , newTab , j , oldCap);
else { // preserve order
Node < K , V > loHead = null , loTail = null ;
Node < K , V > hiHead = null , hiTail = null ;
Node < K , V > next;
// 查找链表中的每一个元素,来计算他们新的位置
do {
next = e . next ;
if (( e . hash & oldCap) == 0 ) {
if (loTail == null )
loHead = e;
else
loTail . next = e;
loTail = e;
}
else {
if (hiTail == null )
hiHead = e;
else
hiTail . next = e;
hiTail = e;
}
} while ((e = next) != null );
if (loTail != null ) {
loTail . next = null ;
// 使用原来的位置
newTab[j] = loHead;
}
if (hiTail != null ) {
hiTail . next = null ;
// 使用+oldCap的位置
newTab[j + oldCap] = hiHead;
}
// 这里的位置计算有个数学上的技巧,扩容后新元素的位置要么在原来的位置,要么在原来的值+oldCap的位置
}
}
}
}
}
// 启动扩容的地方,当元素个数大于阈值的时候会触发
// 其中 threshold = Capacity * 装载因子,一切就联系起来了
if ( ++ size > threshold)
resize() ;
从源代码分析可见,工业级散列表的实现还是比较复杂的,但是原理和思想才是最重要的: 1. 首先分配一个初始容量,当然也可以通过构造函数来指定,固定一个装载因子,计算出触发扩容的阈值 2. 在每次插入时,判断元素数量是否到达阈值,到了就触发扩容策略 3. 扩容策略是在原来的基础上double,如果没有指定或者为0就使用默认初始容量16 4. 扩容的过程就是新申请一个更大容量(原来的2倍)的数组,将老数据copy过去,重点是位置索引的重新计算,然后是否老数组 5. HashMap 的实现使用了链表法,后面会分析到,还会涉及到红黑树的分裂,这个过程就比较复杂了
无论设计的多么好的散列函数,都会发生散列冲突,当冲突的时候就必须解决散列冲突,常见的方法有:开放寻址法和链表法。
HashMap 采用的方式是链表法
复制 /**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
// 使用数组来存放 Node
transient Node < K , V >[] table;
// 从 Node 的定义来看,HashMap 采用的是链表法存储元素
static class Node < K , V > implements Map . Entry < K , V > {
final int hash;
final K key; // 存放Key
V value; // 存放 Value
// 指向下一个和当前元素有相同位置索引的元素
Node < K , V > next;
// ... 省略了部分代码
}
但是,当某个位置的链表过长时,会造成查找和插入的时间复杂度增加,HashMap 在这里做了一个优化,当链表长度超过某个值时,就将链表转换成红黑树,当红黑树的元素个数小于某个值时,会转成链表.
复制 /**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8 ;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6 ;
所以在插入、查找、删除等操作时,HashMap 可以提供比较高的效率,基本都是 O(1) 的时间复杂度。这得以于 HashMap 在各方面的优化:
对散列函数的优化,使用自定义的hash函数重新计算hash,计算hash的时候使用位操作,效率更高
对散列冲突的优化,当链表性能下降时,转成红黑树,红黑树加速了查找、插入的速度
还可以参考: