HashMap源码阅读(超详细)
本文基于Jdk1.8
HashMap继承自AbstractMap,实现了Map,Cloneable,Serializable接口,是一个关联数组、哈希表,允许null键/值、非同步、不保证有序(比如插入的顺序)、也不保证序不随时间变化。其底层数据结构是数组称之为哈希桶,每个桶里面放的是链表,链表中的每个节点,就是哈希表中的每个元素。在JDK8中,当链表长度达到8,会转化成红黑树,以提升它的查询、插入效率。
哈希表
哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表hashtable(key,value) 的做法其实很简单,就是把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。
当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value
hash表有两种实现方式拉链法,探测法
HashMap采用拉链法,数组+链表,结构示意图如下:
属性分析
//buckets的数目,这里使用1<<4代替16强调了容量需要为2的倍数,目的在于允许使用快速按位与操作将每个键的散列码分散到hash表容量范围(而不是使用取余操作以提高效率)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转化为树的阈值
static final int TREEIFY_THRESHOLD = 8;
//树退化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//哈希表的最小树形化容量
//当哈希表中的容量大于这个值时,表中的桶才能进行树形化
//否则桶内元素太多时会扩容,而不是树形化
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
//hash表数组
transient Node<K,V>[] table;
//扩容阈值,哈希表内元素数量的阈值,当哈希表内元素数量超过阈值时,会发生扩容resize()。The next size value at which to resize (capacity * load factor).
int threshold;
//加载因子,用于计算哈希表元素数量的阈值。threshold = capacity* loadFactor;
final float loadFactor;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
两种节点元素
链表结构
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
树结构
节点大致结构可以看出来是一颗红黑树,关于这个红黑树的更多细节,将在下篇博客中讲述
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
综合
为什么要采用两种节点形式呢?
在获取HashMap的元素时,基本分两步:
1. 首先根据hashCode()做hash,然后确定bucket的index;
2. 如果bucket的节点的key不是我们需要的,则通过keys.equals()在链中找。
在Java 8之前的实现中是用链表解决冲突的,极端情况下,在产生碰撞的情况下,进行get时,两步的时间复杂度是O(1)+O(n)。因此,当碰撞很厉害的时候n很大,O(n)的速度显然是影响速度的。因此在Java 8中,利用红黑树替换链表,这样复杂度就变成了O(1)+O(logn)了,这样在n很大的时候,hash表的性能还能保持比较优异。
方法分析
hash
hash基本原理如图所示
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
通过hash获得索引(n - 1) & hash
可以看出hash产生的过程有效利用的高位和低位的信息,降低了碰撞的概率,获得索引可以通过与操作实现得益于hash表的容量是2的幂。
tableSizeFor
public HashMap(int initialCapacity, float loadFactor) {
/**省略此处代码**/
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
当在实例化HashMap实例时,如果给定initialCapacity,这个方法用于找到大于等于initialCapacity的最小的2的幂
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
n=0
时,不带符号位右移以及或操作得到的都是0,最后返回1
n≠0
时
n>>>1使n的二进制为1最高位右移了一位,n |= n>>>1使n的最高位连续两位为1
后续同理,经过几次操作后,n变为一个形如0000 1111 1111 1111的二进制字符串,返回的时候加1,就得到了大于等于initialCapacity的最小的2的幂。由于可能32为全为1的情况,超出了MAXIMUM_CAPACITY,故需要限制
n=cap-1
是为了防止cap就是2的幂导致结果变为2*cap
示例
put
大致思路
1. 对key的hashCode()做hash,然后再计算index;
2. 如果没碰撞直接放到bucket里;
3. 如果碰撞了,以链表的形式存在buckets后;
4. 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
5. 如果节点已经存在就替换old value(保证key的唯一性)
6. 如果bucket满了(超过load factor*current capacity),就要resize。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//表为空,先进行扩容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//当前桶的大小大于阈值时,将链表转换为红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) //容器内总元素个数大于阈值,扩容
resize();
afterNodeInsertion(evict);
return null;
}
get
大致思路如下:
- bucket里的第一个节点,直接命中;
- 如果有冲突,则通过key.equals(k)去查找对应的entry
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。
public V get(Object key) {
Node<K,V> e;
//根据hash值来获取节点
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//数组不为空,数组长度不为0,hashcode映射bucket的位置不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//调用对象的hash方法,
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//树状查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//链表查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
resize
put时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。由于使用两倍扩容,来自同一个桶的元素扩容后要么在同一个索引要么在原位置偏移两次幂的位置。
当表大小n从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了。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//旧表容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold; //旧阈值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
//设置阈值为最大值,避免重复尝试扩容
threshold = Integer.MAX_VALUE;
//不进行扩容
return oldTab;
}//进行扩容,这里不用考虑溢出,capacity是2的幂次
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//根据新的容量 构建新的哈希桶
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
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;
//旧节点的可能位置只有两个,不变或者原位置+oldCap
do {
next = e.next;
//这里与操作得到高位信息,高位为0,在原位置,高位为1,在原位置
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;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
参考文献
Java HashMap工作原理及实现
HashMap源码解析(JDK8)
从头到尾解析Hash 表算法
HashMap 在 JDK 1.8 后新增的红黑树结构
HashMap源码注解 之 静态工具方法hash()、tableSizeFor()