「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」
1.不得不说的哈希算法
(1) 哈希是什么
哈希也叫散列,何为散列?顾名思义,把按照一定的算法进行分散。更通俗的讲,哈希就是一个输入经过哈希算法运算后,输出一个与之对应的哈希值,并且无论输入是多大的长度,输出的哈希值长度是一样的。
(2) 哈希算法满足那些条件
(1) 无论何时何地,相同key哈希得到的哈希值是一样的。 (2) 哈希是为了散列,哈希算法应尽量减少哈希冲突(哈希冲突就是不同的key产生相同的哈希值)。 (3) 优秀的哈希算法必然是高效的。
(3) java中的哈希
java的Object类有一个hashCode方法,该方法返回一个int型的哈希值。该方法是一个本地方法,hashCode()方法必须遵循三个协议:
- 同一个java程序中,相同对象无论何时调用hashCode()的方法都返回相同的hashCode。
- 如果两个对象equals()为true,那么也hashCode()方法返回的hashCode也相同。
- 如果两个对象equals()为false,不要求他们的hashCode也不一样。
2.HashMap底层数据结构
我们都知道HashMap是一种键值对的数据结构,而且不难看出是为了方便通过一个键获取对应的值。其实,其内部实现并不是直接的通过equals比较键来获取值的,这种方法最快的情况要比较n遍,时间复杂度是O(n)(也就是只用链表实现)。链表的局限在于查询,而与之链表对应的数组优势恰恰在于查询。
(1)HashMap使用数组底层结构有何难题?
首先第一个问题是key如何和数组下标关联?数组下标是一个数字,前面我们提到的hashCode也是一个数值。因此,key的hashCode就是存放在数组的下标。但是,不相同的key有可能会产生相同的hashCode(如果两个对象equals()为false,不要求他们的hashCode也不一样),这种现象叫做哈希冲突。
(2)数组+链表解决哈希冲突
当发生哈希冲突时,也就是两个不同的key产生了相同的hashCode,它们在数组的下标是一样的,为了不让相同的key被覆盖,多个值以链表形式放到数组中。
(3)数组+链表 get(key)过程
使用数组+链表的底层数据结构有何神秘之处,先通过hashCode得到数组下标,然后再通过equals比较链表上的key。不得而知,相比与直接链表的数据结构,数组+链表避免了全表扫描,后置只是仅仅扫描hashCode相同的链表,从而大幅度提高性能。
(4)jdk8HashMap优化
- 底层数据结构改成 数组 + 链表 + 红黑树,引入红黑树同样也是为了解决链表在查询的缺陷
- 链表头插法优化成尾插法
3.HashMap具体实现
(1)初始化容量大小
hash桶的大小必须是2的n次方,如果初始化的时候传入的大小不是2的n次方,则找往后最近的一个。
(2)初始化容量为什么是2的n次幂
笔者认为总的目的是方便位运算。
笔者认为是为了更高效率的计算哈希桶的位置,如果不是2的n次幂,通过 hash & (size-1)得不到正确的位置。
# jdk7计算哈希桶(数组)的位置
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
(3)哈希扰动
因为hashCode得到的哈希值哈希冲突还是很大,因此需要通过扰动算法使它们分配更加均匀。在jdk8中扰动算法简化成了 hashCode右移16位进行异或运算,jdk7则是更加复杂。
hashCode是32位的int类型,将32位的hashCode右移16位然后再进行异或运算,混合原哈希值的高低位。
# 右移异或运算例示
hashCode = 654321
二进制是 10011111101111110001
前面补0变成32位 00000000000010011111101111110001
右移16位后 00000000000000000000000000001001
异或得到 00000000000010011111101111111000
# java扰动函数源码
static final int hash(Object key) {
int h;
// 右移16位 进行异或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
4.扩容机制
(1) 负载因子
负载因子默认是0.75,也就是负载超过0.75后触发扩容,如果 16* 0.75 = 12,当元素个数大于12时就会触发扩容。
(2) 扩容时数据结果变化
在jdk7hashMap的数据结构时候数组+链表,jdk8后变成数组+链表+红黑树。在jdk8,当触发扩容时,如果链表长达大于8,且哈希桶容量小于64则直接扩容哈希桶,如果哈希桶容量达到64,则把链表转成红黑树。
(3) 扩容时下标计算
在jdk7的时候,扩容需要重新计算下标。但在jdk8不用重新计算下标,将原hashCode & resize 如果为0则下标不变,如果不为0,则下标加上原size长度。
5.hashMap.get()方法实现
1. 具体实现
先计算hashCode,通过hashCode定位哈希桶下标(数组下标),然后通过equals方法比较是否相同,具体实现代码如下。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
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;
}
6.总结
HashMap有很多经典的设计,例如使用数组+链表+红黑树数据结构来提升查询速度、通过与运算计算下标、右移异或混合高低位等等,非常值得我们研究学习。