前言
HashMap是一个不管在工作中还是面试中,都经常会碰到的一种集合,对其进行学习,不管对其他源码的学习还是更合理的使用HashMap上都有很大的帮助。
本篇主体以HashMap最常用的put,remove而展开的源码解析。
灵魂一问
了解过HashMap的人应该都知道HashMap的默认容量是2n,扩容也是按两倍扩容。 这里大家肯定想说:
为什么HashMap容量都是2的n次幂呢?
HashMap是利用hash值来计算应该存放在数组中的位置。如果hash值大于当前容量呢,该怎么计算,一般都会想到用求余的方法:index = hash % size,这样能分散得出在当前容量的table中的位置。不过这样真的好吗?
求余运算在编译后是这样的:a % b就相当与a - (a / b) * b 的运算。是多步运算。
而 & 运算,编译后就是一条CPU指令,效率要比求余快得多。而当b是2n时,a % b 的结果等同于 (b-1) & a。
而HashMap就是通过这样,高效的算出一个桶的index在哪里。
成员变量
话不多说切入正题。
这里大致描述每个变量的作用,后面源码分析中,会进一步看到变量如何使用。
- 默认容量 24
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 最大容量 230
static final int MAXIMUM_CAPACITY = 1 << 30;
由上面两个变量以及HashMap的扩容为2倍的机制,不禁会有一个疑问。
为什么HashMap容量都是2的n次幂呢?
大家都知道HashMap是利用hash值来计算应该存放在数组中的位置。如果hash值大于当前容量呢,该怎么计算,
- 默认加载因子(笔者比较喜欢称作扩容系数)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 树化阈值(链表长度到达这个值就会转换为树结构)
static final int TREEIFY_THRESHOLD = 8;
- 解除树形阈值(树的节点树小于这个长度就会退化为链表结构)
static final int UNTREEIFY_THRESHOLD = 6;
- 最小树化容量(当Node数组容量小于这个值时不会树化,只会扩容)
static final int MIN_TREEIFY_CAPACITY = 64;
- 桶数组
transient Node<K,V>[] table;
- 桶集合(用来提供hashMap的集合操作,本身不存储元素)
transient Set<Map.Entry<K,V>> entrySet;
- 容量大小
transient int size;
- 修改的次数
transient int modCount;
hashMap在迭代的时候,会把modCount传入,作为期望值(类似于乐观锁)如果迭代的时候其他线程进行了增删改操作,modCount就会改变,改变后继续迭代会抛出异常ConcurrentModificationException。
- 节点数组(大部分都把它叫桶数组)
transient Node<K,V>[] table;
- 扩容阈值
int threshold;
到达这个值,HashMap就会执行扩容
桶的结构
本篇源码都是JDK1.8版本的,在1.8之后,HashMap的桶内结构分为两种,一种是链表,还有一种树结构。
链表
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //hash值
final K key; //键
V value; //值
Node<K,V> next; //下一个节点
...
}
树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; //左子节点
TreeNode<K,V> right; //右子节点
TreeNode<K,V> prev; //前节点
boolean red; //是否为红节点
}
这里继承了LinkHashMap的Entry,而LinkHashMap的Entry又继承了HashMap的Node。所以这里的树节点,不仅是红黑树结构,还包含着一个双向链表。类似下图这种关系,除了树的根节点是链表的首节点之外,链表顺序与树节点顺序没有什么关系(后面源码之中也会验证这一点)。
红黑树是一种能够自平衡的二叉搜索树,有如下性质
1.每个结点要么是红的要么是黑的。
2.根结点是黑的。
3.每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。
4.如果一个结点是红的,那么它的两个儿子都是黑的。
5.对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点。
源码解析
构造函数
HashMap在实例化的时候(除了通过传Map参数进行实例化)并没有进行初始化,没有开辟数组空间,是一种懒加载的方式,真正使用的时候才去开辟数组空间。
无参构造函数
//无参构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
指定初始化容量
//使用默认加载因子大小及传入的大小,调用构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
指定初始化容量及加载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//tableSizeFor这个方法会找出第一个大于initialCapacity的2^n
this.threshold = tableSizeFor(initialCapacity);
}
传入Map进行构造
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//获取m的容量大小
int s = m.size();
if (s > 0) {
if (table == null) {
//到这里说明现在的桶数组为空
//通过加载因子与传入Map的大小进行计算要初始化的容量
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
//如果计算结果大于现在的阈值,则寻找大于并且最接近的2^n赋值给阈值
threshold = tableSizeFor(t);
}
else if (s > threshold)
//到这里说明原来的桶数组不为空(不是构造函数调用)并且传入的Map容量大于阈值,则进行扩容
resize();
//将Map所有节点循环放入
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
resize 扩容
这个方法在很多地方都会调用,所以放到最前面来分析
final Node<K,V>[] resize() {
//旧的桶数组
Node<K,V>[] oldTab = table;
//旧桶数组的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧桶数组扩容阈值
int oldThr = threshold;
//声明新的桶数组容量及扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//到这说明原先的HashMap已经初始化过了
if (oldCap >= MAXIMUM_CAPACITY) {
//如果旧桶数组容量已经大于最大值那么将扩容阈值调整为整形最大值,并且返回旧桶数组
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的容量为旧的容量左移一位,及扩大两倍,
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//如果新的容量和就的容量都小于定义的最大容量,则新的扩容阈值为原来的2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
//到这里说明oldCap = 0 并且 就的扩容阈值大于0,即第一次进行扩容初始化,这种是使用了指定容量的构造函数的情况
//将新容量设置为阈值(及大于指定容量的第一个2^n)
newCap = oldThr;
else {
//到这里也是第一次进行扩容初始化,是使用了无参构造函数oldThr、oldCap都为0
//将新的容量设置默认初始容量、新的扩容阈值为 默认加载因子(0.75)* 默认初始化容量 = 12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//如果到这新的扩容阈值还是为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;
if (e.next == null)
//到这里说明原来的桶没有发生hash冲突为单节点,直接放入计算的新位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//到这里说明原来的桶是树形的,则调用split方法进行重新构建桶(这里主要是利用树节点中的链表结构重新构建树或退化成链表)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//到这里说明原来桶就是链表的,通过尾插法构建链表再放入新的桶的位置(1.8以前是用头插法会导致死循环问题)
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;
}
put 向HashMap中放入键值对
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal
/*
hash key的hash值
key 键
value 值
onlyIfAbsent 只在不存在的时候才put
evict 供LinkHashMap使用,判断是否移除最早的Node节点
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//这里tab为桶数组,p为该key计算出hash值的桶,n为桶数组大小,i为index下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//如果桶数组为空或长度为0,则进行一次resize
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果该位置桶为空,那么直接置入一个新的Node节点
tab[i] = newNode(hash, key, value, null);
else {
//进到这里说明hash冲突了
//这里的e是后面要替换value的节点,k是冲突节点的key
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//这里都是与桶的首节点做比较的,如果hash值相等 并且 (key相等 或者 key的equals方法返回true) 那么将e指向p
e = p;
else if (p instanceof TreeNode)
//如果是树节点,那么调用putTreeVal进行新增节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//到这里说明hash值与首节点不相等或者equals方法返回false,并且不是树形结构
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准备转换为红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//到这里找到桶链表中hash值相等 并且 equals方法返回true的节点,跳出循环
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果e不为空,并且onlyIfAbsent为false,替换调e的value值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//HashMap这个方法实现为空,预留给LinkHashMap实现
afterNodeAccess(e);
return oldValue;
}
}
//修改了内容,将modeCount加一
++modCount;
if (++size > threshold)
//如果size大于扩容门槛,则进行扩容
resize();
//HashMap这个方法实现为空,预留给LinkHashMap实现
afterNodeInsertion(evict);
return null;
}
putTreeVal
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
//键的对象类型
Class<?> kc = null;
//是否搜索过
boolean searched = false;
//树的根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
//p -> 当前节点,dir -> 方向, ph -> 当前节点hash值,pk -> 当前节点的键
int dir, ph; K pk;
//比较要放入的节点与当前节点的hash值的大小,确定要寻找左子节点还是右子节点
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
//当前节点的键与要插入的键相等,则返回这个节点,在putValue中进行覆盖
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//到这说明键的类没有实现comparable接口 或 实现了comparable接口并且与当前节点的key的类是一样的
if (!searched) {
//如果没有搜索过,就去寻找子节点中key值相等的节点,找到就返回
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
//到这毫无办法了......但还是想办法比较出两个key的大小来确定查找方向,要么大要么小,不能相等,tieBreakOrder是如果两者class不一样,则用class的name比较,如果一样,则用hash值来进行表决
dir = tieBreakOrder(k, pk);
}
//记录当前节点
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
//根据方向查找子节点,子节点空的时候到这
Node<K,V> xpn = xp.next;
//生成一个新的树节点,维护链表,在xp节点后插入
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
//插入到红黑树中
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//balanceInsertion重新平衡红黑树
//将平衡之后的根节点移到链表的最前面
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
这边可以看到在hash冲突的时候判断键是否一致是这样的
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
所以自己定义的类作为键的话,除了要重写hashCode方法让哈希值更加分散外,还要去实现equals方法,防止相同内容的实例,却是两个key,没办法进行覆盖。
还有一点值得注意的是,在树节点查找时,会通过比较key大小来确认查找方向。如果你的Map要存放很多元素,并且key为自己声明的对象,那么对象实现comparable接口,能够减少那些去寻找比较方法的时间,来提高效率。
remove 删除元素
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode
/**
* @param hash 键的哈希值
* @param key 键
* @param value 只要当matchValue为true时用到,在value值相等的时候进行删除
* @param matchValue 如果value值匹配,再进行删除
* @param movable 如果是false,则删除的时候不进行移动其他节点
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
//tab -> 桶数组, p -> 通过hash算出的节点, n -> 当前桶数组大小, index -> 通过hash算出的下标
Node<K,V>[] tab; Node<K,V> p; int n, index;
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;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//到这里当前节点的hash值与key的相等,并且key值与当前节点key相等,说明桶的首节点就是要删除的节点
node = p;
else if ((e = p.next) != null) {
//到这说明要删除的节点不是首节点
if (p instanceof TreeNode)
//如果是树节点,则调用getTreeNode查找键相等的节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
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)
//如果是树节点,则调用removeTreeNode删除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
//如果node是首节点,则直接将首节点改为node的下一节点
tab[index] = node.next;
else
//将节点从链表中摘除
p.next = node.next;
//修改次数+1
++modCount;
//桶的大小-1
--size;
//这里实现是空实现,给LinkHashMap调用的
afterNodeRemoval(node);
return node;
}
}
return null;
}
关于removeTreeNode函数,因为篇幅过长,这里不进行展开,用于查找后继节点,进行删除,之后通过变色、旋转来达到符合红黑树性质的状态。
结尾
因为考虑到篇幅原因,这里对HashMap使用的红黑树不多做展开,后续再整理一篇有关红黑树的文章详细介绍HashMap中的红黑树。
希望本篇文章能给你带来一些帮助。