HashMap
HashMap
01、简介
HashMap是java集合框架中非常重要的一种类型,也是开发业务系统时最受欢迎的数据类型之一;
HashMap是一个散列表,数据是以键值对(Key-Value)的形式存储;
HashMap 实现了 Map 接口,根据键的 HashCode 值存储数据,具有很快的访问速度,最多允许一条记录的键为 null,不支持线程同步;
HashMap 是无序的,不会记录插入的顺序;
HashMap 继承于AbstractMap,实现了 Map、Cloneable、java.io.Serializable 接口:
02、数据结构
在JDK1.8以前,HashMap底层是由数组+链表实现的,数组是HashMap的主体,查询复杂度是O(1),链表是为了解决hash冲突而存在的("拉链法"解决冲突),查询复杂度是O(n)。
JDK1.8开始,对HashMap做了一次优化,当链表长度大于阈值(默认是8)并且主体数组的长度大于64时,链表就会转换为红黑树存储,红黑树相对链表而言能提高查询性能,查询复杂度是O(logn),但是结构也变得更复杂。
当链表阈值大于8,但是数组长度小于64时,jdk不会将链表变为红黑树,而是选择进行数组的扩容。这样做的目的是因为红黑树结构附加了很多左旋、右旋、变色这些操作来保持树的平衡,而数组元素少时,数组搜索时间相对要快一些。
阈值定义相关源码如下:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
/**
* 默认初始容量为16,必须是2的k次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认负载因子(例如初始容量是16,则当容量达到16*0.75=12时,会触发扩容)
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 桶上的链表长度大于等于8时,链表转化成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 桶上的红黑树大小小于等于6时,红黑树转化为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当主数组容量大于64时,链表才能转为红黑树
*/
static final int MIN_TREEIFY_CAPACITY = 64;
....
}
数组里面都是Key-Value的实例,在JDK1.8之前每个数组元素叫Entry,JDK1.8以后叫Node。
03、put流程
从HashMap的put方法分析得出以下流程
注意:jdk7和jdk8的put实现有细微区别,整体流程差不多。
04、get流程
从HashMap的get方法分析得出以下流程
注意:jdk7和jdk8的get实现有细微区别,整体流程差不多。
05、哈希冲突
针对HashMap的hash冲突场景,通俗的讲就是在put操作中,不同的key计算出的hashcode相同导致的冲突,这时候我们是直接覆盖存储还是追加存储或者其他方式解决呢?
了解Hash冲突如何解决需要首先了解Hash算法和Hash表的概念。
1.Hash算法就是把任意长度的输入通过散列算法变成固定长度的输出,这个输出结果就是一个散列值。
2.Hash表又叫做“散列表”,它是通过key直接访问到内存存储位置的数据结构,在具体的实现上,我们通过Hash函数,把key映射到表中的某个位置,来获取这个位置的数据,从而加快数据的查找。
Hash冲突是由于哈希算法,被计算的数据是无限的,而计算后的结果的范围是有限的,总会存在不同的数据,经过计算之后得到值是一样,那么这个情况下就会出现所谓的哈希冲突。
解决Hash冲突的方式有四种:
开放定址法
也称为线性探测法,就是从发生冲突的那个位置开始,按照一定次序从Hash表找到一个空闲位置,然后把发生冲突的元素存入到该空闲位置,我们熟悉的java-ThreadLocal机制就用到了线性探测法来解决Hash冲突。
如上图,在Hash表索引1的位置存了key=name,再向它添加key=hobby的时候,假设计算得到的索引也是1,那么这个时候发生哈希冲突,而开放开放定址法就是按照顺序向前找到一个空闲的位置,来存储这个冲突的key。链式寻址法
简单理解就是把存在Hash冲突的key,在冲突位置上拉出来一个链表,以单向链表来存储,HashMap在jdk1.8就是采用此方式解决Hash冲突的(还有红黑树方式,红黑树是为了优化Hash链表过长导致时间复杂度增加的问题)。
如上图,存在冲突的key直接是以单向链表的方式去进行存储的。再Hash法
就是通过某个Hash函数计算的key,存在冲突的时候,再用另外一个Hash函数对这个key进行再次Hash,一直运算,直到不再产生Hash冲突为止,这种方式会增加计算的一个时间,在性能上会有一些影响。建立公共溢出区
就是把Hash表分为基本表和溢出表两个部分,凡是存在冲突的元素,均放到溢出表中。
06、扩容机制
1. 什么时候会需要扩容?
在首次调用put方法的时候,初始化数组table
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容
loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表
2. 扩容流程说明
hashMap的扩容逻辑是底层的resize方法,分析源码可以得出结论是进行扩容时会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。因此我们在编写程序中,要尽量避免resize()的触发。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
JDK1.8的扩容源码resize()解读如下:
final Node<K,V>[] resize() {
//得到当前数组
Node<K,V>[] oldTab = table;
//如果当前数组等于null长度返回0,否则返回当前数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//当前阀值点 默认是12(16*0.75)
int oldThr = threshold;
int newCap, newThr = 0;
//如果老的数组长度大于0
//开始计算扩容后的大小
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
//修改阈值为int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
/*
没超过最大值,就扩充为原来的2倍
1)(newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
2)oldCap >= DEFAULT_INITIAL_CAPACITY 原数组长度大于等于数组初始化长度16
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
//老阈值点大于0 直接赋值
else if (oldThr > 0) // 老阈值赋值给新的数组长度
newCap = oldThr;
else {// 直接使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//新的阀值 默认原来是12 乘以2之后变为24
threshold = newThr;
//创建新的哈希表
@SuppressWarnings({"rawtypes","unchecked"})
//newCap是新的数组长度--》32
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//判断旧数组是否等于空
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
//遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//原来的数据赋值为null 便于GC回收
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 { // 采用链表处理冲突
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//通过上述讲解的原理来计算节点的新位置
do {
// 原索引
next = e.next;
//这里来判断如果等于true e这个节点在resize之后不需要移动位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
07、JDK的差异
JDK1.7采用的是头插法,JDK1.8采用的是尾插法。
JDK1.8以前 底层数据结构是数组+链表,JDK1.8之后是数组+链表or红黑树。
08、常用方法列表
方法 | 描述 |
---|---|
clear() | 删除 hashMap 中的所有键/值对 |
clone() | 复制一份 hashMap |
isEmpty() | 判断 hashMap 是否为空 |
size() | 计算 hashMap 中键/值对的数量 |
put() | 将键/值对添加到 hashMap 中 |
putAll() | 将所有键/值对添加到 hashMap 中 |
putIfAbsent() | 如果 hashMap 中不存在指定的键,则将指定的键/值对插入到 hashMap 中。 |
remove() | 删除 hashMap 中指定键 key 的映射关系 |
containsKey() | 检查 hashMap 中是否存在指定的 key 对应的映射关系。 |
containsValue() | 检查 hashMap 中是否存在指定的 value 对应的映射关系。 |
replace() | 替换 hashMap 中是指定的 key 对应的 value。 |
replaceAll() | 将 hashMap 中的所有映射关系替换成给定的函数所执行的结果。 |
get() | 获取指定 key 对应对 value |
getOrDefault() | 获取指定 key 对应对 value,如果找不到 key ,则返回设置的默认值 |
forEach() | 对 hashMap 中的每个映射执行指定的操作。 |
entrySet() | 返回 hashMap 中所有映射项的集合集合视图。 |
keySet() | 返回 hashMap 中所有 key 组成的集合视图。 |
values() | 返回 hashMap 中存在的所有 value 值。 |
merge() | 添加键值对到 hashMap 中 |
compute() | 对 hashMap 中指定 key 的值进行重新计算 |
computeIfAbsent() | 对 hashMap 中指定 key 的值进行重新计算,如果不存在这个 key,则添加到 hasMap 中 |
computeIfPresent() | 对 hashMap 中指定 key 的值进行重新计算,前提是该 key 存在于 hashMap 中。 |
09、hashmap实现策略模式
参考:https://www.cnblogs.com/keeya/p/13187727.html