1.定义
HashMap是实现了Map接口的key-value集合,实现了所有map的操作,允许key和value为null,hashMap不是线程安全的。
从上面的定义提出两个疑问
1.HashMap内部的实现是怎样的?
2.HashMap不是线程安全的,是因为什么?
下文的分析将围绕这两个问题开始,另外本文源码来自与JDK_1.8.0_131
2.结构
2.1 类图结构
如上图所示,是HashMap的类图结构,其主要实现、继承了以下接口和类:
1.Map 接口: 定义将键值映射到值的对象,Map规定不能包含重复的键值,每个键最多可以映射一个值,这个接口是用来替换Dictionary类。
2.AbstractMap 类: 提供了一个Map骨架的实现,尽量减少了实现Map接口所需要的工作量
3.Cloneable 接口: 实现了该接口的类可以显示的调用Object.clone()方法,合法的对该类实例进行字段复制,如果没有实现Cloneable接口的实例上调用Obejct.clone()方法,会抛出CloneNotSupportException异常。正常情况下,实现了Cloneable接口的类会以公共方法重写Object.clone()
4.Serializable 接口: 实现了该接口标示了类可以被序列化和反序列化,具体的 查询序列化详解
2.2 基本属性及构造方法
2.2.1 基本属性
HashMap的基本属性如代码所示:
//默认的初始化容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的容量,容量的值必须是2的幂并且小于最大的容量,最大值为2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//加载因子默认值为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//计数阈值,超过这个值将会使用树形结构替代链表结构
static final int TREEIFY_THRESHOLD = 8;
//由树形结构转换成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//树形结构最小的容量为64
static final int MIN_TREEIFY_CAPACITY = 64;
//链表数组
transient Node[] table;
//HashMap中value的集合
transient Set> entrySet;
//HashMap的长度
transient int size;
//调整大小的下一个大小值
int threshold;
//hashtable的加载因子
final float loadFactor;
2.2.2 构造方法
HashMap提供了4个构造方法,如下所示
1.public HashMap(int initialCapacity, float loadFactor):需要传入自定义的initialCapacity(初始化容量),loadFactor(加载因子)
2.public HashMap(int initialCapacity):需要传入自定义的initialCapacity(初始化容量),实际在平时的使用过程中如果可以大概知道数据量,建议使用这种构造方法,原因是指定了HashMap的容量之后,可以避免没必要的扩容操作,从而减少了浪费。
3.public HashMap():默认的构造方法,按照初始值创建HashMap
4.public HashMap(Map extends K, ? extends V> m):需要传入一个Map集合
3.原理
3.1 内部结构
如上图所示,是HashMap内部实现的结构图,正如2.2.1中介绍的其内部是个Node
3.2 实现
3.2.1 添加元素
如下为HashMap添加元素的put方法的源码,
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* hash:为hash值
* key:保存的key
* value:保存的value
* onlyIfAbsent:如果为true,不改变已经存在的值
* evict:如果为false,表示table处于创造模式
**/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
//判断table是否为空或者长度为0,若是则调用resize()新建数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//然后定位当前需要保存的值在数组中位置,如果没有冲突,即当前位置上没有值则新增节点并将其添加
if ((p = tab[i = (n - 1) & hash]) == null)
//这里当hash=0时,即key值为空时,该值将会保存在tab[0]的位置
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
//判断是否为新增的元素是否为第一个元素(hash与key去判断),如果是则获取第一个元素
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 {
// 是链表节点
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;
}
// 如果hash与key相等 则终止循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 节点的引用向后移动
p = e;
}
}
// 存在key对应的value时,按照条件替换并返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
通过源码可以看出,添加一个元素到HashMap中大体上可以分为三个步骤:
1.定位:首先计算出key对应的hash值,在将(tab.length()-1) & hash方式定位出当前添加的key-value在Node
(tab.length()-1) & hash运算等于对tab.length()进行取模运算,但是&操作相对于%操作,性能肯定是更优的,疑问为什么更优)
2.解决hash冲突:
首先介绍下解决Hash冲突的方式实际上有两种,第一种是开放寻址,指的是当桶中位置被占据时,将通过探寻的方式,来寻找到未被占据的哈希桶,第二种是分离链接,将每个哈希桶当做链表的头,当发生hash冲突时,在链表中进行操作。而HashMap中使用的方式是第二种方式,这一步是HashMap实现的关键,其步骤如下:
1.根据定位在tab中的位置,找到根节点,若根节点为空则直接新增节点,若不为空则分为三种情况处理
2.判断新加入的key-value,是不是加在根节点,若是则获取
3.若不是,则判断是否为TreeNode类型,若是TreeNode类型则新增TreeNode节点
4.若不是,则循环处理(这里也有两种情况,一种是添加的key已经存在,则根据条件覆盖原值,另一种是key不存在,则在链表尾部添加节点),还有点需要注意的是,链表中添加元素会去判断,若链表的长度大于设定的阈值8,会将链表结构转换成树形结构
3.扩容:判断当前HashMap的size是否大于阈值threshold,如果大于了阈值则进行扩容操作,
3.2.2 具体细节
在上面的整体流程中,可以大概理解HashMap是如何工作的,下面将详细介绍一下操作实现的细节:
1.新增节点:这个方法比较简单就是调用Node类的构造方法,创建一个Node节点对象
2.新增TreeNode:这个详细可参考JAVA学习-红黑树详解
3.链表转树形:具体分为两步,第一将所有的节点变成TreeNode类型的,第二构建红黑树,具体代码可以去查看treeifyBin()方法的代码实现。
4.扩容:如下代码是HashMap中的扩容操作的具体实现,从代码中可以看出扩容操作主要分为以下几步:
- 计算新容量:根据老数组的容量确定扩容后的容量值,一般扩容为老容量的2倍
- 创建新数组:根据新的容量创建数组,需要尽量避免扩容的发生,因为产生新的数组必然是会消耗一定的内存空间。
- 元素放置到新数组:循环遍历老数组,将元素重新放置到新数组中,分为3种情况如下所示
根节点:定位在新数组中的位置,通过e.hash & (newCap - 1)进行定位,直接放置到根节点的位置
树形节点:树形节点其实方式和链表节点是类似的,但是其中会考虑是否将树转换为链表的情况,稍微复杂点,若想了解的更加清楚可以查看源码中的split方法进行学习。
-
链表节点:对于链表节点代码中使用了两个引用去记录,当e.hash & oldCap的结果为0时使用loTail记录,反而使用hiHead,后面根据定位将整条链表赋值给新的数组,值得注意的是这里面并未倒置链表。
其中有几点存在疑问的:- 第一:定位为什么不是j就是j+oldCap?
首先扩容操作之后新的容量变为旧容量的两倍,则若原容量为n=16(10000),n-1=15(01111),则扩容之后n=32(100000),n-1=31(011111),而在HashMap中定位元素的位置是通过(n-1) & hash的方式,可以比较扩容前与扩容后的n-1,实际上就是高位上多了个1,那么现在假设元素hash值高位上为0,则运算后该元素在扩容前后的位置并不会发生变化,而假设元素hash的高位上是1,则运算后该元素在扩容前的位置为h,则扩容后的位置为h+newCap/2,即为h+oldCap,从而可以看出扩容后定位要么是扩容前的位置,要么是扩容前的位置+老的容量
- 第二:e.hash & oldCap这个是来判断什么?
通过问题1,这个问题就很明显了e.hash & oldCap是用来判断hash值高位上的值是0还是1的,可以看出假如是1则&运算后得到的结果肯定为大于0,反而肯定等于0
final Node[] resize() {
Node[] oldTab = table;
//获取老的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//扩容的阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//超过最大的容量值为2^30,则返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}//新容量为老容量的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩容的阈值也变为原来的两倍
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);
}
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 { // 链表重新装填到新数组中
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node 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;
}
}
}
}
}
return newTab;
}
3.2.3 查找元素
如下代码是HashMap中查找元素实际调用的代码
final Node getNode(int hash, Object key) {
Node[] tab; Node 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)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;
}
其实通过上面代码可以清晰看出它的思想,就是去根据hash遍历查找,首先第一步,肯定是找到在数组中头节点的位置即通过(n-1) & hash值定位到其头节点,这种方式也就是在新增元素时的定位操作,下面就是几种情况去处理:
- 1.判断头节点是不是查找的元素,若是则直接返回了,若不是则继续查找。
- 2.判断是否为TreeNode节点,若是则遍历红黑树查找,详细可参考JAVA学习-红黑树详解
- 3.判断是否为链表节点,若是则遍历链表查找
4.总结
通过本文可以了解以下几点:
1.HashMap的内部实现是数组+(链表/红黑树实现的),即回答了本文提出的第一个问题
2.HashMap内部是如何解决hash冲突以及如何进行扩容操作的。
需要注意的是,HashMap不是线程安全的,因此在涉及线程安全的情况下尽量不要使用HashMap,再就是在使用HashMap时可以尽量减少其扩容的操作,在可估算数据量的情况下可以指定HashMap的初始化容量,当然这是相对的,若数据量太大,还是建议别这样做。还有一点就是本文是基于JDK_1.8.0_131,还有很多细节的实现并未做详细的说明,如果想要了解的更多,可以阅读源码进行学习,若发现有存在问题的地方,望指正。
本文主要参考链接如下
Java8系列之重新认识HashMap