HashMap是非常常用的一种集合,是Key,Value键值对的方式存储数据的。虽然用过无数次,对它的认识只是停留在表面,现在想静下心来阅读一下源码。
网上公布的一些互联网大厂的面试题,有很多问题都涉及到集合的内容,而HashMap由于结构比较复杂,更是重中之重,问的几率很高。因此分析一下源码还是很有必要的。
看一下HashMap类的定义会发现,它继承了AbstractMap
抽象类,并且实现了Map
接口
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
...
}
我们打开Map.java源码,发现它是一个接口,并且使用了泛型,来定义Key,Value的类型。
既然它是一个接口,并且是一个顶层接口,是一个高度的抽象。那它就要负责把Map体系具备的共性操作、结构进行抽象定义。
因此我们可以看到下面的类结构中,我们常见的方法都能看到。
除了常用的方法定义,我们关注一下Entry这个内部接口。它是做什么的呢?
Map内部对于数据的组织是用Entry这个结构定义的,也就是说我们平时传入的Key,Value值实际是被组织成Entry这个类型存储在Map底层的数组当中的,Entry是Map中存储数据的最小数据单元。Set
从这个定义就知道了
而对于Map的不同实现,内部的数据结构也是不同的,因此不同的实现在自己的内部都会实现这个Entry接口,来定义自己的数据单元结构。
如下图的LinkedHashMap、ConcurrentHashMap等,都有自己对Entry接口的实现。
可以看到它是一个抽象类,实现了Map接口。从这个结构我们就能猜出,它把一些通用逻辑都抽取上来,形成一个抽象类,这样节省了开发量,同时易于维护。
public abstract class AbstractMap<K,V> implements Map<K,V> {
...
}
对于无法抽象,需要实现类具体实现的方法,内部抛出一个异常,这样强制实现,否则就会抛出异常,比如常用的put方法
/**
* {@inheritDoc}
*
* @implSpec
* This implementation always throws an
* UnsupportedOperationException.
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
* @throws IllegalArgumentException {@inheritDoc}
*/
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
这种模式,我们在编写业务代码的时候,也是要借鉴的,很好的思想。
类结构如下:
上一章我们提到了HashMap的底层是 数组+链表+红黑树,我们先对红黑树的代码进行一下分析。
先看一组HashMap中定义的常量,这组常量和数组大小的设置相关
/**
* 数组容量默认值 16
* 这里使用了位运算符,效率高
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 数组的最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 数组扩容因子的默认值
*
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 这个就是底层的数组,可以看到是Node类型的,它是对Map.Entry的实现
* 在第一次使用时初始化,并根据需要调整大小。当分配时,长度总是2的整数倍。
*/
transient Node<K,V>[] table;
/**
* 触发数组扩容的阈值,大小等于 table的长度*数组扩容因子
* 初始化是0,可以通过带参数的构造方法,设置初始值
* 当数组的容量大于threshold的时候就会触发扩容操作resize()
*/
int threshold;
/**
* 数组中包含的Node个数,注意与数组的长度不是一个概念,size<=数组长度.
*/
transient int size;
先看一个代码片段:逻辑很简单,就是使用默认的构造器创建一个HashMap对象,然后put值进去
Map data = new HashMap<>();
data.put("name","zhangsan");
这再简单不过的两行代码,后面隐藏的逻辑还是比较多的,我们逐一分析一下:
1.无参构造方法
什么也没做,只是将扩容因子设置成了默认的0.75(不明白继续往下看),此时HashMap底层的数组是null
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
另外还提供 public HashMap(int initialCapacity)
和 public HashMap(int initialCapacity, float loadFactor)
,分别可以指定初始化的数组大小,和扩容因子。
2.put方法
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with key, or
* null if there was no mapping for key.
* (A null return can also indicate that the map
* previously associated null with key.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
// 这里使用了方法重载,实际调用的是这个方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 数组为空,也就是第一次添加值得时候,会调调用重新设置数组长度的方法
// 【①】将数组长度定义为默认的16,且扩容阈值为12
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 【②】当新放入一个值到HashMap的数组中时,应该放在数组的哪个位置呢?按照下面的算法
// i=通过将Key取Hash值与数组长度进行取模。i也就是要添加的元素应该放入数组中的哪个位置
// 如果tab[i]上没有元素是null,那么就创建一个Node节点,并放入tab[i]
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 【③】如果tab[i]这个位置上有值,那么判断要放入的值得key与当前位置上的值得key是否相同
// 判断标准就是 key的hash相等并且equals也返回true
// 如果相等,那么就是同一个key,只是更新对应的值就可以
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 {
// 如果tab[i]这个位置上是单向链表,那么顺着链表方向向后找
// 1. 直到找到链表的末尾,把要放入的值放到链表末尾,并且判断链表是否需要转红黑树
// 2. 或者找到一个链表上的节点,key与要放入的值得key相同,那么就把这个节点记录下来
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 【④】链表长度如果>=7那么就将链表转为红黑树
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不是空,说明在数组中,与要放入的值得Key相等的Node
// 【⑥】这时 判断onlyIfAbsent,false代表可以用传入的值覆盖原有的值,否则不可以
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 【⑤】当数组的长度+1大于扩容阈值的时候,那么进行扩容
if (++size > threshold)
resize();
// 这里提供了钩子方法,HashMap的实现类,可以通过实现这个方法,在节点插入后做一些事情
afterNodeInsertion(evict);
return null;
}
通过阅读以上代码得出以下几点:
【①】处:
HashMap初始化的时候,底层的数组是Null。
只有当第一次向HashMap中放入值得时候,数组才会初始化,初始化长度是16. 扩容的阈值threshold的大小是 16*0.75=12
【②】处:
当向HashMap中新增一个值的时候,程序如何判断应该放在数组的什么位置?答案就是用 key的hash值与数组的长度取模,结果作为数组的下标,就是要放入的位置。
【③】处:
代码【②】处,当发现要放入的位置有节点的时候,且key与已有节点的key相等的时候(hashcode与equals都相等认为是相等),单个的Node会转换为单项链表。
这就是为什么重写equals就要重写hashcode的原因,与我的另一篇博客equals方法知多少中也提到了这点。如果不能保证equals相等hashcode也相等的逻辑,当HashMap中使用对象作为Key的时候,就会出现逻辑混乱,明明是一致的对象,会变为不一致
【④】处:
当链表的长度大于8的时候,会转为红黑树。
【⑤】处:
HashMap底层的数组是动态扩容的,当数组的长度大于threshold
扩容阈值的时候,就会触发扩容操作。
【⑥】处:
onlyIfAbsent这个开关的使用,要放入的Key与已有的节点的Key相同的会后,是否可以覆盖原有的Value。默认是可以的覆盖的。与默认实现相对应的public V putIfAbsent(K key, V value)
,如果调用这个方法新增数据,当Key相同的时候,不会覆盖已有的Value。
进一步思考一下,既然HashMap底层数组是动态扩容的,那么如果我们通过默认的构造器声明的一个HashMap对象,数组大小默认是16,如果我们有大量的数据要存储到HashMap对象中,那么数组就会不断的扩容。这样就会造成性能问题,因此会有一个说法,如果能预先估算出放入HashMap数据量的大小,那么创建HashMap对象的时候就要调用HashMap(int initialCapacity)
构造方法创建对象,避免反复的扩容操作。
另外,还有三个方法void afterNodeAccess(Node
、void afterNodeInsertion(boolean evict)
、void afterNodeRemoval(Node
也需要注意一下,这三个方法是钩子方法。这种思想也是值得借鉴的在这里插入代码片
上面①~⑤处的代码,经常出现在面试题中,值得反复推敲
3.数组扩容方法resize()
final Node<K,V>[] resize() {
// 获取HashMap底层的Node类型数组
Node<K,V>[] oldTab = table;
// 获取原有数组 的容量,如果为空,长度为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 获得输入扩容的阈值
int oldThr = threshold;
// 定义数组扩容后的容量,和扩容后新的扩容阈值
int newCap, newThr = 0;
// 如果原有数组长度大于0
if (oldCap > 0) {
// 【①】如果数组的容量大于等于最大值了,那么就不会触发扩容操作。也就是数组的最大容量是MAXIMUM_CAPACITY
if (oldCap >= MAXIMUM_CAPACITY) {
//设置扩容阈值为Integer最大值 MAXIMUM_CAPACITY*2-1
threshold = Integer.MAX_VALUE;
//返回原有数组,数组没有扩容
return oldTab;
}
// 【②】如果数组的容量大于16 且 小于 MAXIMUM_CAPACITY最大容量的一半,那么设置扩容后的容量大小为原大小的二倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新阈值 设置为 原阈值的2倍
newThr = oldThr << 1; // double threshold
}
// 【③】如果数组长度是0,且设置了扩容阈值。那么扩容后的容量等于设置的扩容阈值。
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 【④】通过无参构造方法创建的HashMap对象,第一次添加值得时候,那么扩容后的容量为16,扩容阈值为12
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的扩容阈值==0,新
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 设置HashMap底层数组的扩容阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 按照扩容后的容量大小,创建一个新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 【⑤】将HashMap底层的数组引用指向新创建的数组
table = newTab;
// 如果HashMap原数组等于空,什么都不做
// 如果不等于空,那么进行内容的复制,将原数组中的元素复制到新数组中
if (oldTab != null) {
// 遍历原数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果当前元素是null,那么不处理
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果当前元素是Node节点,且不是链表结构。那么把当前元素放到HashMap的数组中
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 { // 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;
}
}
}
}
}
return newTab;
}
通过阅读以上代码得出以下几点:
【①】处:
可以知道,HashMap底层的数组是有最大长度的,最大长度=MAXIMUM_CAPACITY=int类型最大值/2+1。
【②】处:
扩容后的大小为原大小的2倍。
【③】处:
如果创建HashMap对象的时候,使用有参构造方法,那么数组大小等于传入的扩容阈值参数大小。也就是说创建HashMap的时候可以指定数组大小。
【④】处:
如果创建HashMap对象的时候,使用无参构造方法,那么数组容量会初始化为16
【⑤】处:
可以知道,扩容不是在原有数组上扩容,而是创建新的数组,把原有数组的内容复制到新数组。
4.删除操作
/**
* Implements Map.remove and related methods
*
* @param hash key对应的Hash值
* @param key 要删除的Node的Key
* @param value 要删除Node的Value
* @param matchValue 在寻找要删除的Node的时候,除了匹配Key,是否还要匹配Value
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 数组不为空,且根据key计算的数组下标上存在Node节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//【①】如果 当前位置 上的Node的Key==要删除的Key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 如果当前位置上的Node是红黑树
if (p instanceof TreeNode)
//在红黑树上查找要删除的Node节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 如果当前位置上的Node节点是链表,找到链表上的节点
else {
// 【②】从链表的头逐项向下找,直到找到为止
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 如果找到了删除的节点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果找到的节点是红黑树对象,那么执行红黑树的删除方法
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 如果找到的节点就是Node,非单项链表,非红黑树,那么将数组元素设置为null
else if (node == p)
tab[index] = node.next;
// 如果是单项链表,将找到的节点的前一个节点的next指向找到节点的next,也就是删除了这个节点
else
p.next = node.next;
// 修改次数加1
++modCount;
// 数组内的Node个数减一
--size; // 钩子方法,提供删除节点的回调
afterNodeRemoval(node);
return node;
}
}
return null;
}
以上代码值得注意的地方
【①】处:
这里又涉及到了之前反复提到的一个准则,如果重写了equals那么也必须保证重写hashcode,保证如果两个对象的equals相等,那么他们的hashcode也要相等。
【②】处
可以看到单项链表的查找效率是比较低的,当要在单项链表中查找数据的时候,是从到尾逐项比对的。而红黑树是遵循了左大又小的规则建立的,查找效率是要高于单项链表的,所以会出现前文提到的,单项链表达到一定程度的时候,会转化为红黑树,就是出于对性能的考虑。
另外还有个重载的方法:public boolean remove(Object key, Object value)
意思是根据key和value两项去查找对应的Node节点进行删除。
上一节中提到,当调用put(K key,V value)向HashMap中添加数据的时候,会根据Key的hash值与数组的长度取模,作为数组的下标,把数据放在数组的这个位置。如果有多个待添加的数据的Key,经过取下标算法计算后的值都是一样的,也就是出现了hash冲突,这时候存储在该位置的单个Node就会转换为单项链表。
HashMap.Node的源码:
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
可以看到除了正常的Key 和Value数据外,还有一个指针next,指向下一个变量,因此通过这种结构,Node之间就可以组成单项链表的结构,类似下图
这种结构有如下特点:
当单项链表的长度超出限制的时候就会转换为红黑树,红黑树是由TreeNode组成的,我们先看一下它的结构
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);
}
}
HashMap内部结构相对比较复杂,掌握底层的原理是很重要的,一个是在面试中能够为自己加分。再有再实际工作中,也可以借鉴源码中很多比较好的思想,同时也可以编写出更合理的代码,比如初始化HashMap的时候指定大小能够防止频繁的扩容带来的性能问题。