HashMap原理其本质就是那个我们习以为常的hash算法。
Hash算法
自己先来设计一个普通的hash算法:
1. 设计数组的长度(length):8。通常情况下是设计成素数,因为理论上证明取素数发生冲突的概率要小于合数。但是HashMap中数组长度设计为零16,2^ 4,是一个合数,主要是为了优化后续的计算过程;而HashTable初始化长度为11,为素数。
2. 设计一个hash算法:hash = key % length 。这真的是一个再简单不过的hash过程了! HashMap中的hash函数原理与此差距不大,就是在取模之前,将key值投到了一个盲盒中,扰动了一番再抛出来做映射,扰动的目的就是为了增加hash函数的复杂度,在映射的时候分布更加均匀一些,减少发生冲突的概率。如果是我来设计盲盒,我会这样:key = key ^ 2,再这样:key = key / 5,最后再这样: key = key & 7 。这波骚操作一看我就不是出色的设计师。
3. 选取发生冲突的解决算法:拉链法。解决冲突无非就是 开放地址法 和 拉链法。HashMap中就是使用 拉链法 解决的冲突。冲突是否可以避免呢?不能!除非你能保证你的每一个hash值算出来都是不一样的,并且你又足够大的空间能够存储每一个数据。那为什么不用 开放地址法 呢?根据其的原理,开放地址法必须确保有连续的足够的存储空间,时间可能会大量的消耗在扩容上。由此,可以知道HashMap的底层实现就是 数组+链表 (JDK1.8之前),数组+链表+红黑树(JDK1.8)。
4. 设计每一个结点的数据结构 Node:就是每一个位置里面,我想存储些什么数据。最简单的就是直接存储一个int型的数据。这里设计复杂一点:我想存一个key值,用于上面的hash计算,再存一个value值,就是我真实要存储的数据,还要存一个hash值,就是上面计算出来的hash索引位置,最后还需要拉链法解决冲突时的next指针。如出一辙,HashMap里面就是这么设计的:
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
设计好的hash算法可视化长这样:
先来插入几个数据,key = 6、7、8、3、12、34、54、32、64、19、27、35;
value= A、B、E、R、T、 U、 W 、R 、P、 Q、 C、 Z 。
插入之后长这样:
由上图可以看出,这里采用的是尾插法,这正是HashMap的put函数中的插入原理:
//节选putVal函数
//先循环遍历找到链表的最后一个结点
//再在最后一个结点后插入新结点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
再插入几个数据:
//节选putVal函数
//判断哈希值及key值相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//key已存在,只需修改value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
优化hash函数
上面设计的hash算法可能存在几个问题,下面针对存在的问题进行优化:
//节选自putVal函数
//现在的结点数>阈值 threshold 就扩容
//threshold = length * Loadfactor
//Loadfactor默认为0.75,length默认为16
if (++size > threshold)
resize();
例: 6 用二进制表示为 00000110
6 % 8 = 6 ,即6 % (2^3), 二进制下来看,就是取 00000110 末三位的结果,即 110 = 6 ;
34%8 = 2, 34 = 000100010,末三位为 010 ,即2
故%操作可以优化为&操作:
000100010 34
& 000000111 7 (8-1)
000000010 2
JDK1.8的源码:
// 节选自putVal函数
// (n - 1) & hash 即表示取hash值的末4位,即取得映射索引位置
// n = length ,length = 16 = 2^4
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);
明显发现index = 3的链过长,那在查找 key = 51时,需要遍历完整条链,时间复杂度为O(n)。首先可以想到优化为二分查找法,时间复杂度降到O(lg n),由于此处采用链表存储,故可将链表转化为二叉查找树。如下图:
查找key = 51时,只需要比较3次。时间复杂度为O(lg n),与树的高度有关。而JDK1.8种采用的是 红黑树 ,这是因为二叉查找树 在某些特殊情况下会退化为线性查找,例如一次插入 3、4、5、6、7、8、9、10时,是一颗只有右孩子的二叉查找树,时间复杂度最坏情况O(n)。故而 红黑树 在二叉查找树的基础上做了一些限制,尽量保持树的相对平衡。那又为什么没有采用平衡二叉树呢?平衡二叉树的限制条件过于苛刻,插入数据时动不动就调整树形,大量的时间花费在旋转二叉树上。相对平衡二叉树,红黑树 的条件稍微放宽一点。JDK1.8中规定,链长达到8并且结点总数达到64便转化为红黑树。
HashMap源码(JDK 1.8)
//HashMap继承自AbstractMap类
public class HashMap extends AbstractMap
implements Map, Cloneable, Serializable {
一些重要常量:
//默认数组长度,必须为2的幂次方,此处为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//所能容纳的结点最大叔,2^30,超过这个数便不再扩容
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子,计算threshold = length * Loadfactor 时用
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表长度达到8便转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//结点总数达到64才转化为红黑树,否则即便链表长度达到8也不转化为红黑树,仅仅扩容
static final int MIN_TREEIFY_CAPACITY = 64;
一些重要变量:
//table即为hash数组
transient Node[] table;
//size存储当前结点数
transient int size;
//threshold = length * loadFactor
int threshold;
//负载因子:默认为0.75,可以认为是哈希桶的满载程度
final float loadFactor;
Node结点数据结构:
static class Node implements Map.Entry {
final int hash; //存储hash值,便于扩容时优化
final K key;
V value;
Node next; //指向下一个结点指针
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
hash函数:
//计算hash值
//哈希函数(h = key.hashCode()) ^ (h >>> 16)
//每一个不同的key对应的hashCode均不同
// ^ (h >>> 16) 进行扰动
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
查看hashCode函数:
public native int hashCode();
可以看到hashCode返回的是int型数据,为32位二进制位,将hashCode右移16位,前16位补0,得到后16位,h ^ (h >>> 16) 即将前16位与后16位进行异或,使整个32位均参与计算,使得到的结果分布更加的均匀。
异或操作时,只要有一个位置上的数位发生变化,得到的结果就会不同。也是为了能够使hash更加的分布均匀,减少冲突概率。
get函数:
本质就是常规的数组+链表+树的访问。
如上图
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
//计算hash值,取到first结点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判断first结点是否是所求结点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//取first的next结点
if ((e = first.next) != null) {
//判断first所在结点是否是树结点,是则进行树的遍历
if (first instanceof TreeNode)
return ((TreeNode)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;
}
put函数:
插入操作在上文中已演示过,便不再赘述。
插入流程:计算新key的hash值-->判断哈希数组是否已存在-->判断新结点的key值是否已存在-->判断是否插入树中-->判断是否需要树化
JDK1.8源码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, I;
//首先判断该数组是否已存在,不存在则创建
//哈希数组在插入第一个元素时进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//获得新插入结点的索引位置 p
//若新结点位置为空,则直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
//p的key值正好是所求结点,则令e = p,跳出else,
//直接执行下文的e.value = value; 直接修改value值,
//可见put函数可用于结点值的修改
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//插入的是一棵树结点
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
//插入后链表长度达到8,则将链表转化为一棵树
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;
}
//在遍历过程中发现key值已存在,直接break
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//结点已存在,直接修改value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//判断是否满足扩容条件
//threshold = length * loadFactor
//size为哈希桶的总结点数
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
看一眼hashMap的构造函数,有4个!!!如若写在构造器中要写多少遍啊!!既然哈希对象构造出来就是要用的,肯定要调用put函数进行插入操作,那何不直接写一遍在put函数中。
扩容机制
上文讲述了JDK1.7 扩容时的一些弊端,而JDK1.8 针对这些问题做了优化。
在设计数据结构时,每一个node结点都保存了其hash值,我们在映射时只是取了其hash值的后3位,而在扩容后,将其数组长度*2,变为16,那么在映射时只需取其hash值的后4位,这避免了JDK1.7 重新计算hash值时带来的时间开销。而重新映射之后的位置取决于新增加的倒数第4位的值,若是0,则原索引位置不变;若为1,其实就在原索引位置+ 1000(二进制,十进制为8,正好是原来的数组长度值,于是新的索引位置 = 原来的索引位置 + 未扩容前的数组长度)。
以index=3的链为例:
扩容之前取hash值是 hash & 00000111
扩容之后取hash值是 hash & 00001111
据上述原理,只需要看倒数第4位是0还是1
即只需计算 hash & 00001000 (hash & 8 )
将table[3] 指向 第一条链,table[3+8]指向第二条链,即完成了链3的扩容:
这种方式避免了JDK1.7 扩容后元素逆置的问题。
JDK1.8扩容源码:
final Node[] resize() {
Node[] oldTab = table;
//保存扩容前的数据:length 和 threshold
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//原数组超出了最大容量2^30,便不再扩容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//正常扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//修改新的length 和 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[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node 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)e).split(this, newTab, j, oldCap);
//正常链操作
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
//倒数第x的为0的结点串成一条链
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//倒数第x的为1的结点串成另一条链
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;
}
一些其他函数:
//计算threshold值时调用
//用于保证数值为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;
}
一些常见问题:
HashMap 和 HashTable 的区别:
HashSet 和 HashMap 的区别:
HashMap导致多线程死锁的问题:
HashMap是线程不安全的,在不停的put元素时,可能导致扩容的发生,而多线程下,对原哈希桶和新哈希桶的同时操作可能导致结点之间形成死循环,从而导致线程的死锁。要保证线程安全可以使用ConcurrentHashMap。