HashMap
1. 什么是哈希表
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,在探讨哈希表的性能前,我们先大概了解一下其他数据结构的增删查改的性能
**数组:**采用一段连续的存储单元来存储数据,对指定的下标进行查找,时间复杂度为O(1);通过指定的值在数组中查找,则需要遍历数组,时间复杂度为O(n);删除、插入的操作,则涉及到数组元素的移动,时间复杂度为0(n);
**线性链表:**跟数组相反的,链表中的删除、插入操作只需要处理结点引用即可,时间复杂度为O(1),而查找操作需要的时间复杂度为O(n)
**二叉树:**对一颗平衡的有序二叉树,对其进行增删查改等操作,其平均复杂度为O(logn)
**哈希表:**相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
比如我们要新增或查找某个元素,我们通过把当前元素的哈希值 映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
插入过程如下图所示
哈希冲突
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,HashMap即是采用了链地址法,也就是数组+链表的方式,但是如果同一哈希值的元素都存储在同一个链表上,即哈希值相等的元素过多的时候,会造成查找效率降低,所以在JDK8中采用了数组+yi链表+红黑树的方式,但是一个链表的长度超过**阈值(8)**的时候,将链表转换为红黑树,这样减少了查找时间,性能会更好。
2. HashMap的hash算法
这是HashMap的hash算法,通过这个方法计算出的哈希值决定将元素存储在数组的什么位置上
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
乍看一下就是简单的异或运算和右移运算,但是为什么要异或呢?为什么要移位呢?而且移位16?
在分析这个问题之前,我们需要先看看另一个事情,什么呢?就是 HashMap 如何根据 hash 值找到数组种的对象,我们看看 get 方法的代码:
public V get(Object key) {
Node<K,V> e;
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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
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;
}
我们可以看到 get 方法就是将传进来的 key 用 hash 方法计算出它的哈希值,然后调用 getNode 方法,在 getNode 方法中,我们可以看到这一行代码:
first = tab[(n - 1) & hash]
使用数组的长度减一和 key 的哈希值进行与运算,这行代码就是为什么要让前面的 hash 方法移位并异或。我们分析一下:
首先,假设有一种情况
对象 A 的 hashCode 为 1000010 00111000 10000011 11000000,
对象 B 的 hashCode 为 0111011 10011100 01010000 10100000。
如果数组长度是16,也就是 15 与运算这两个数, 你会发现结果都是 0。这样的散列结果太让人失望了。很明显不是一个好的散列算法。
但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。
总的来说,使用位移 16 位和 异或 就是防止这种极端情况。但是,该方法在一些极端情况下还是有问题,比如:10000000000000000000000000 和 1000000000100000000000000 这两个数,如果数组长度是16,那么即使右移16位,在异或,hash 值还是会重复。但是为了性能,对这种极端情况,JDK 的作者选择了性能。毕竟这是少数情况,为了这种情况去增加 hash 时间,性价比不高。
3. HashMap 为什么使用 & 与运算代替模运算?
知道了 hash 算法的实现原理还有他的一些取舍,我们再看看刚刚说的那个根据hash计算下标的方法:
tab[(n - 1) & hash];
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,除法和求余数(模运算)是最慢的动作。
上面情况下和模运算相同呢?
a % b == (b-1) & a ,当b是2的幂次方时,等式成立。
我们说 & 与运算的定义:与运算 第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0;
当 n 为 16 时, 与运算 101010100101001001101 时,也就是
1111 & 101010100101001001000 结果:1000 = 8
1111 & 101000101101001001001 结果:1001 = 9
1111 & 101010101101101001010 结果: 1010 = 10
1111 & 101100100111001101100 结果: 1100 = 12
可以看到,当 n 为 2 的幂次方的时候,减一之后就会得到 1111 的数字,这个数字正好可以掩码。并且得到的结果取决于 hash 值。因为 hash 值是1,那么最终的结果也是1 ,hash 值是0,最终的结果也是0。
4. HashMap 的容量为什么建议是 2的幂次方?
到这里,我们提了一个关键的问题: HashMap 的容量为什么建议是 2的幂次方?正好可以和上面的话题接上
为什么要 2 的幂次方呢?
我们说,hash 算法的目的是为了让hash值均匀的分布在桶中(数组),那么,如何做到呢?试想一下,如果不使用 2 的幂次方作为数组的长度会怎么样?
假设我们的数组长度是10,还是上面的公式:
1010 & 101010100101001001000 结果:1000 = 8
1010 & 101000101101001001001 结果:1000 = 8
1010 & 101010101101101001010 结果: 1010 = 10
1010 & 101100100111001101100 结果: 1000 = 8
看到结果我们惊呆了,这种散列结果,会导致这些不同的key值全部进入到相同的插槽中,形成链表,性能急剧下降。
所以说,我们一定要保证 & 中的二进制位全为 1,才能最大限度的利用 hash 值,并更好的散列,只有全是1 ,才能有更多的散列结果。如果是 1010,有的散列结果是永远都不会出现的,比如 0111,0101,1111,1110…….,只要 & 之前的数有 0, 对应的 1 肯定就不会出现(因为只有都是1才会为1)。大大限制了散列的范围。
5. HashMap 构造函数
Map<String, Integer> map = new HashMap<>(2);
map.put("one", 1);
Integer one = map.get("one");
System.out.println(one);
一个简单的使用 HashMap 的例子,其中包含了 HashMap 的三个关键步骤:初始化、put、get;
我们先来看 new 操作的时候发生了什么:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
上面是 HashMap 的两个构造方法,其中,我们设置了初始容量为 2, 而默认的加载因子我们之前说过:0.75,当然也可以自己设置,但 0.75 是最均衡的设置,没有特殊要求不要修改该值,加载因子过小,理论上能减少 hash 冲突,但是会频繁的扩容数组,增加 HashMap 中的最耗性能的操作:reHash,而加载因子过大可以节约空间,减少 reHash 操作,但是会导致 hash 冲突频繁。
从代码中我可以看到,如果我们设置的初始化容量小于0,将会抛出异常,如果加载因子小于0也会抛出异常。同时,如果初始容量大于最大容量,则重新设置为最大容量。
我们开最后两行代码,首先,对负载因子进行赋值,这个没什么可说的。
牛逼的是下面一行代码:this.threshold = tableSizeFor(initialCapacity); 可以看的出来这个动作是计算阀值,什么是阈值呢?阀值就是,如果容器中的元素大于阀值了,就需要进行扩容,那么这里的这行代码,就是根据初始容量进行阀值的计算。
我们进入到该方法查看:
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;
}
一通的或运算和无符号右移运算,那么这个运算的的最后结果是什么呢?这里其实就是如果用户输入的值不是2的幂次方(我们通过之前的分析,应该直到初始容量如果不是2的幂次方会有多么不好的结果,增加了哈希冲突)。通过位移运算和或运算,最后得到一定是2的幂次方,并且是那个离那个数最近的数字,我们仔细看看该方法:
首先将容量减1,然后将该数字无符号的右移1,2,4,8,16,即总共移动了32位,在移动的过程中,还对该数字进行或运算,为了方便查看,写一下这个方法的运算过程,假如我输入的是10,明显不是2的幂次方。我们看看会怎么样:
10 = 1010;
n = 9 = 1001;
1001 >>> 1 = 0100;
1001 | 0100 = 1101;
1101 >>> 2 = 0011;
1101 | 0011 = 1111;
1111 >>> 4 = 0000;
1111 | 0000 = 1111;
1111 >>> 8 = 0000;
1111 | 0000 = 1111;
1111 >>> 16 = 0000;
1111 | 0000 = 1111;
最后得到 1111,也就是15,然后判断是否大于最大的容量,如果大于则返回最大容量,否则返回算出来的结果加一,也就是 15 + 1,得到16,刚好就是距离10最近且没有变小的2的幂次数。
如果本来就是一个2的幂次数容量呢,会有什么结果:
16 = 10000;
n = 15 = 1111;
1111 >>> 1 = 0111;
1111 | 0111 = 1111;
...
1111 | 1111 = 1111;
结果会得到一个它本身,但是如果我们不进行先减1的操作就是进右移和或运算的话,就会得到一个上升到更大的2次幂的数,但是这不是我想要的结果,所以,JDK 的作者在之前先减去了1,防止出现这样的问题。
我们仔细观察其算法的过程,可以说,任何一个int 数字,都能找到离他最近的 2 的幂次方数字(并且比他大)。
好了。到这里就完成了初始化,不过请注意,这里设置的阀值并不是最终的阀值,最终的阀值我们会在后面详细说明。这里我们更加关注这个算法。真的牛逼啊。
6. HashMap put方法
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;
// 判断数组是否为null,或者长度为0;第一次调用put方法都会进行里面的resize()方法创建一个数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// hash值跟数组的长度-1进行与运算获取数组下标,之前介绍过
if ((p = tab[i = (n - 1) & hash]) == null)
// 为空的话就新建一个Node放入数组
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果获取的节点跟hash值一样和 key 一样或者equals一样的话,就找到了这个节点
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);
// 当链表的长度大于等于8就转换为红黑树
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;
}
}
// 当e不为null说明这个key已经存在了,如果onlyIfAbsent为false或者原来的value为空就替换为新传进来的value,然后返回旧的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 增加修改次数,如果在调用foreach遍历的时候,调用了put,get方法会使modCount发生变化,从而抛出并发修改错误;
++modCount;
// 如果map的容量大于阈值,就resize()进行扩容和重新散列hash
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
这个方法是 HashMap 的核心方法,在方法中已经写满了注释,该方法的步骤为:
- 判断数组是否为空,如果为空,就创建一个数组,第一次调用 put 方法的时候,都会创建一个数组
- 通过与运算计算出对应哈希值的下标,如果对应下标的位置没有元素,则直接创建一个元素
- 如果有元素,就说明发生了哈希冲突,则进行三种判断:
- 判断两个冲突的key是否相等,equals 方法的价值在这里体现了。如果相等,则将已经存在的值赋给变量e。最后更新e的value,也就是替换操作。
- 如果key不相等,则判断是否是红黑树类型,如果是红黑树,则交给红黑树追加此元素。
- 如果key既不相等,也不是红黑树,则是链表,那么就遍历链表中的每一个key和给定的key是否相等。如果,链表的长度大于等于8了,则将链表改为红黑树,这是Java8 的一个新的优化。
- 最后如果这三个判断返回的 e 不为 null 的话,就说明已经存在这个 key ,更新对应 key 的 value。
- 对维护着迭代器的 modCount 变量加一
- 最后判断当前的容量是否大于阈值,大于则进行扩容和 rehash
7. HashMap 重新散列方法
上面我们说到如果 map 的容量大于阈值的话,就进行扩容和重新散列 hash,而 resize() 方法就是重新散列的核心方法,该方法的实现:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果老的容量大于0
if (oldCap > 0) {
// 如果大于最大容量,就将阈值设置为int的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 不然就将老的容量扩容两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 如果新的容量小于最大的容量和老的容量大于默认的容量(16),就将老的阈值乘以2为新的阈值
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);
}
// 如果新的阈值为0
if (newThr == 0) {
// 新的阈值就等于新的容量乘以负载因子(0.75)
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字段
table = newTab;
// 如果老的数组不为 null
if (oldTab != null) {
// 循环遍历老的数组
for (int j = 0; j < oldCap; ++j) {
// 定义一个节点
Node<K,V> e;
// 如果老数组对应的下标不为空,将对应下标的值赋值给 e
if ((e = oldTab[j]) != null) {
// 设置为空
oldTab[j] = null;
// 如果 e 只有一个元素
if (e.next == null)
// 将该值散列到新的数组中
newTab[e.hash & (newCap - 1)] = e;
// 如果该节点为树
else if (e instanceof TreeNode)
// 调用红黑树 的split 方法,传入当前对象,新数组,当前下标,老数组的容量,目的是将树的数据重新散列到数组中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果 next 节点不为空,也不为红黑树,那就是链表了
else { // preserve order
// loHead lo头部 loTail lo尾部
// hiHead hi头部 hiTail hi尾部
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 如果该值的哈希值跟老容量进行与运算结果为0时,说明该元素的下标位置不变,后面会详解
if ((e.hash & oldCap) == 0) {
// 第一次进来给链头赋值,这部分很像将一个节点插入链表所需要做的操作
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 同lo
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;
}
该方法可以说还是比较复杂的。初始的时候也是调用的这个方法,当链表数超过8的时候同时数组长度小于64的时候也是调用的这个方法。该方法步骤如下:
- 判断容量是否大于0,如果大于0,并且容量已将大于最大值,则设置阀值为 int 最大值,并返回,如果老的容量乘以 2 小于最大容量,且老的容量大于等于16,则更新阀值。也就是乘以2.
- 如果老的容量为0和老的阀值大于0,则新的容量等于老的阀值。注意:这里很重要。还记的我们之前使用new 操作符的时候,会设置阀值为 2 的幂次方,那么这里就用上了那个计算出来的数字,也就是说,就算我们设置的不是2的幂次方,HashMap 也会自动将你的容量设置为2的幂次方。
- 如果老的阀值和容量都不大于0,则认为是一个新的数组,默认初始容量为16,阀值为 16 * 0.75f,也就是 12。
- 如果,新的阀值还是0,那么就使用我们刚刚设置的容量(HashMap 帮我们算的),通过乘以 0.75,得到一个阀值,然后判断算出的阀值是否合法:如果容量小于最大容量并且阀值小于最大容量,那么则使用该阀值,否则使用 int 最大值。
- 将刚刚的阀值设置打当前Map实例的阀值属性中。
- 将刚刚的数组设置到当前Map实例的数组属性中。
- 如果老的数组不是null,则将老数组中的值重新散列到新数组中。如果是null,直接返回新数组。
其中重新散列的过程是最难的,也是最核心的:
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;
newTab[j + oldCap] = hiHead;
}
首先定义了五个变量,其中四个看名字可以大概知道两个为一组的,一组为 higher 位置的链表,一组为 low 位置的链表,那么这里的 higher 和 low 位置是什么意思呢,还有为什么重新散列的时候是用 (e.hash & oldCap) == 0 进行判断呢?
首先我们要明确 HashMap 的容量一定是2的整数次幂
假设当前容量是默认的16
(容量 - 1) & hash 是put方法里面确定插入元素属于哪个数组下标
16 -1 = 15 的二进制为:0000 0000 0000 0000 0000 0000 0000 1111
这个时候与hash值进行"&"计算,算出相应数组下标,决定因素在后四位
16 扩容后 是 32
32-1 = 31 的二进制为:0000 0000 0000 0000 0000 0000 0001 1111
这个时候与hash值进行"&"计算,计算相应数组下标,决定因素在后五位
二者区别就在第五位上
假设现在有一个插入元素的key的hash值二进制第五位是0,进行相应计算,最后计算结果二者是一样的。
也就是说假如咱们一个一个的去计算元素所应该在什么位置时,当元素的key的hash值第五位是0 这个时候放置的位置与之前未扩容之前是一样的。
再次假设现在有一个插入元素的key的hash值二进制第五位是1,进行相应计算,最后计算结果二者相差16,也就是说第一个计算出来的数组下标加上16就是第二计算出来的数组下标。
也就是说,我们可以进行一次判断,判断元素的hash值第五位是0还是1,0则不变还是之前的数组下标即可,1的话就是原本的数组下标加上16就是在新数组的新下标
只需要看第五位其余位置无所谓,那么就让其他位置为0,第五位为1(这不就是扩容前容量16吗),然后与元素hash值做与运算
这不就是咱们所看的这个判断条件吗 (e.hash & oldCap) == 0
所以在两个if判断那里,将两个链表一个放到 j (low 位置)一个放到 j+oldCap (heigh 位置)
(head 和 tail 操作图):
h值二进制第五位是0,进行相应计算,最后计算结果二者是一样的。
也就是说假如咱们一个一个的去计算元素所应该在什么位置时,当元素的key的hash值第五位是0 这个时候放置的位置与之前未扩容之前是一样的。
再次假设现在有一个插入元素的key的hash值二进制第五位是1,进行相应计算,最后计算结果二者相差16,也就是说第一个计算出来的数组下标加上16就是第二计算出来的数组下标。
也就是说,我们可以进行一次判断,判断元素的hash值第五位是0还是1,0则不变还是之前的数组下标即可,1的话就是原本的数组下标加上16就是在新数组的新下标
只需要看第五位其余位置无所谓,那么就让其他位置为0,第五位为1(这不就是扩容前容量16吗),然后与元素hash值做与运算
这不就是咱们所看的这个判断条件吗 (e.hash & oldCap) == 0
所以在两个if判断那里,将两个链表一个放到 j (low 位置)一个放到 j+oldCap (heigh 位置)
(head 和 tail 操作图):