HashMap深入浅出
HashMap基本概念
HashMap是基于哈希表的一种实现,允许null值和null键。
HashMap
有两个参数影响其性能:初始容量和加载因子初始容量:初始容量是哈希表在创建时的容量
加载因子:是触发哈希表扩容的一个重要参数,当哈希表中的条目数超过加载因子和当前容量的乘积时,则要对哈希表进行rehash操作(重建内部数据结构)—来自百度百科
常见的数据结构
在正式开始之前,我们先来说下常见的数据结构,我们使用最多的当属数组和链表
数组
数组用于存储相同类型数据的集合,在内存中一块连续的存储空间,一旦开辟,无法再改变其大小,如需扩容,需要开辟一块新的较大的存储空间,然后将原数组的内容拷贝到新数组。
从数组中获取元素
由于数组在内存中存储空间连续和存储相同元素(每个元素占用的空间固定且相同)这2个特点,所以从数组中获取一个结果特别快,通过数组起始地址+元素下标*元素占用的空间大小就能计算出想要获取的元素地址,所以通过指定下标的查找方式,时间复杂度为O(1);如果通过遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n)
对于插入删除操作,涉及到数组元素的移动,其平均复杂度为O(n)
线性链表
对于链表的查找需要从头节点或者尾节点逐一遍历,时间复杂度为O(n),而对于插入或删除操作(不包含查找的过程)只需要对几个指针进行修改即可,所以时间复杂度为O(1)
二叉查找树
哈希表
相比上面几种数据结构,哈希表中进行添加、删除、查找操作,性能都十分之高,在不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。那哈希表是如何保证增删查操作的性能都如此之高的呢?在介绍数组的时候,我们说过数组通过下标查询一个数的时候时间复杂度是O(1),其实哈希表就是利用这种特性,哈希表的主干就是数组。
哈希表通过把关键字映射到表中某个位置来访问记录,可以通过函数 存储位置=f(关键字)
来表示,这个函数我们称为哈希函数或散列函数,这个函数设计的好坏直接影响哈希表的优劣。
哈希冲突
我们知道哈希表是通过哈希函数计算出实际存储地址的下标,但是如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,通过对某个元素进行哈希运算后,得到的存储地址已经被其他元素占用了,这就是所谓的哈希冲突,也叫哈希碰撞。
哈希表的主干是数组,而数组在内存中是一块连续固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址不发生冲突,只能保证尽可能得到的散列地址分布均匀。
解决方案
哈希冲突是没办法完全避免的,那么哈希冲突如何解决呢?常见的解决方案有以下几种:
-
开放寻址法:有以下3种
-
线性探测
按顺序决定值时,如果某数据计算的存储地址已经被占用,则在原来的存储地址上往后加一个单位,直至不发生哈希冲突 -
再平方探测
按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上先加1的平方个单位,若仍然存在则减1的平方个单位。随之是2的平方,3的平方等等。直至不发生哈希冲突。 -
伪随机探测
按顺序决定值时,如果某数据已经存在,通过随机函数随机生成一个数,在原来值的基础上加上随机数,直至不发生哈希冲突。
-
-
再散列法(再哈希):对于冲突的哈希值再次进行哈希处理,直到没有哈希冲突。
-
链地址法(拉链法):对于相同的值,使用链表进行连接(HashMap采用该方法解决哈希冲突)
HashMap的实现原理
通过上面的介绍我们知道HashMap
基于哈希表实现的,当出现哈希冲突时,采用链地址法解决哈希冲突,所以我们可以得出下面示意图:
节点的表示
链表节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
上面代码是HashMap中对节点的代码表示,其中hash
是通过哈希算法计算出来的散列值,由于可能需要扩容对原来数据需要进行rehash操作,为了避免重复计算,所以使用该字段进行缓存哈希值。
红黑树节点
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
两者的关系
核心属性
/**实际存储的key-value键值对的个数*/
transient int size;
/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;
/**负载因子,代表了table的填充度有多少,默认是0.75
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;
/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
transient int modCount;
hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
^
按位异或运算,只要位不同结果为1,不然结果为0;
<<
左移:右边空出的位补0,左边的位将从字头挤掉,左移一位其值相当于乘2;
>>
右移:右边的位被挤掉,右移一位其值相当于除以2
由于hashCode
方法返回值是int
类型,而int
类型在java语言中占用4字节(32bit),这块有个疑问为什么要进行位移和异或运算?直接使用hashCode
方法的返回值可以不?
我们知道哈希表最终是要通过哈希函数计算出一个存储位置的(也就是数组的下标),而HashMap中默认数组的初始长度是16,那HashMap是如何算出数组的下标的呢?其实就是通过&运算h & (length-1)
上面这种方式如果高位不同,地位相同,很容易产生哈希冲突,为了避免过多的哈希冲突,就出现了上面方法的实现方式,通过位移和异或操作使所有位的数值都参与运算来降低哈希冲突的概率,如下图所示:
put方法解析
源码分析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
计算key的哈希值,然后调用putVal
方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 定义的局部变量
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果数组为空,调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过(n - 1) & hash计算出的存储位置为空,直接创建Node对象并赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 走到这个分支说明存储位置已经被其他元素占用,说明存在哈希冲突
// 如果该key已经在HashMap中存在则使用e变量指向它,后面会对其值做覆盖操作
Node<K,V> e; K k;
// 处理key值相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 处理红黑树:走TreeNode的putTreeVal操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 处理链表:遍历链表,分两种情况
// 1.当前节点的后继节点有数据
// 判断后继节点的key是否相等,相等则跳出循环(此时e变量指向相同key节点),否则继续循环
// 2.当前节点的后继节点为空
// 1)创建Node对象并赋值给后继节点
// 2)判断链表的长度是否达到8,达到8将链表转换成红黑树并跳出循环
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方法中会进行数组长度判断
//若小于64,则优先进行数组扩容而不是转化为红黑树
treeifyBin(tab, hash);
// 走这个分支始终会跳出循环
break;
}
// 存在相同key直接跳出循环
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
break;
// 遍历下一个节点
p = e;
}
}
// 处理key已经存在的情况,上面3种情况的处理,如果已经存在相同的key,则会使用e变量指向该节点
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
++modCount;
// 判断是否需要对数组进行扩容
// 触发扩容的条件:数组实际使用的空间超过了当前数组容量*加载因子(默认是0.75)
if (++size > threshold)
// 进行扩容操作
resize();
afterNodeInsertion(evict);
return null;
}
put方法流程图
扩容机制(resize方法)
HashMap的主干是基于数组实现的,由于数组一旦开辟空间就无法动态扩容的特点,当数组空间不足时,会开辟一个更大容量的数组空间,将旧数组的值复制到新的数组空间。那么就会有3个疑问?
- 不扩容会出现什么问题?
- HashMap什么时候触发扩容机制
- 如何将旧数据进行迁移的
前两个问题,我想我们已经有了答案:
答案一:不扩容,数组大小固定,不断添加元素必然会出现大量哈希冲突,而HashMap采用链地址的方式解决哈希冲突,当出现冲突时,需要遍历链表进行比对,严重影响查询效率,所以扩容的主要目的就是减少哈希冲突,同时提高查询效率。
答案二:扩容触发时机可以从上面put方法可以知道,JDK1.8HashMap
的第662~663行就是扩容的触发时机(即++size > threshold
),其中threshold=当前数组容量*加载因子(默认0.75)
问题三的答案就是下面要说的resize的具体实现,该方法可以大致拆分成2个部分,容量阈值的推导过程和数据迁移部分
容量&阈值的推导过程
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) { // MAXIMUM_CAPACITY = 2^30
// 设置阈值为int的最大值
threshold = Integer.MAX_VALUE; // Integer.MAX_VALUE = 2^31-1
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值扩大1倍
newThr = oldThr << 1;
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // oldThr = 0 使用默认参数进行初始化
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 16 * 0.75 = 12
}
// 计算阈值最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
数据迁移过程
// 创建新的数组
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;
// 1.处理单节点的情况
if (e.next == null)
// 直接计算存储下标
newTab[e.hash & (newCap - 1)] = e;
// 2.处理红黑树节点的情况
else if (e instanceof TreeNode)
// 调用TreeNode#split方法进行处理
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 3.处理链表的情况
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;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
resize流程图
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) {
// 第一个节点是否key相同
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);
}
}
// 没有找到返回null
return null;
}