HashMap
HashMap 数据结构
数组查找效率高,增删效率慢,链表增删效率高,查找效率慢 。
hash 表综合了他们的优点,采用键值对形式存储,能根据键快速地取到相应的值,底层结构(JDK 1.8后)是数组+链表+红黑树。

本文分析的源码基于 JDK 1.8 ,数据存储在 Node 节点中。
Node 节点有四个成员变量
final int hash; // 与节点在数组中的下标有关
final K key; // 键
V value; // 值
Node<K,V> next; // 指向当前桶中的下个节点
成员变量分析
// 默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 装填因子,size > 容量 * 装填因子 时,触发扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 桶中的节点数量大于8时,链表将尝试转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 桶中的节点数量小于6时,树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树要求的数组的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储节点的数组,每个数组空间称为一个桶
transient Node<K,V>[] table;
// 表示数组中元素的个数
int size;
// 阈值,size 达到该值时扩容
int threshold
// 装填因子
final float loadFactor;
//迭代器 failfast
int modcount;
注释写的很清楚了
构造函数
// 无参构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// 有参构造函数
public HashMap(int initialCapacity, float loadFactor) {
// ...,略过验证数据
this.loadFactor = loadFactor; // 初始化装填因子
this.threshold = tableSizeFor(initialCapacity); // 初始化阈值
}
tableSizeFor() 通过位移算法,返回大于等于给定目标数值的最小二次幂。比如 传的参数是 9,返回的就是 16;传的参数是 17,返回的就是 32。
为什么要这样设定呢?
在后面的初始化函数中,table 数组的初始大小就是阈值的大小,可以保证 talbe 的大小为 2 的 n 次幂。这又有什么好处?
为什么初始容量是 2 的 n 次幂
后面为元素计算其在数组中的下标时,是通过 hash & (length-1) 计算的。
这个算法其实就是取模运算,但是计算机中,直接求余效率很低,远不如位移运算,为了保证唯一运算和取模运算得到的结果一直,数组容量必须是 2 的 n 次幂。
并且数组容量如果不为 2 的 n 次幂,计算出来的索引下标特别容易冲突,可以减少 hash 碰撞。
hash()
通过 hash() 算法,元素可以定位到自己该存到哪个桶。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 异或运算,相同为 0,不同为 1
}
为什么要选用这个 hash 算法,有什么好处?
混合原始哈希码的高位和低位,以此来加大低位的随机性,减少哈希碰撞
put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) // 数组为空,初始化
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 计算下标
tab[i] = newNode(hash, key, value, null); // 如果该下标处没有元素,直接赋值,否则发生 hash 冲突
else { // 发生了 hash 冲突
Node<K,V> e; K k;
if (p.hash == hash && // 判断 key 是否重复
((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 { // 链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 插入节点
if (binCount >= TREEIFY_THRESHOLD - 1) // 桶中的节点数量为8时,尝试将链表转红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && // 判断 key 是否重复
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 表明有 key 重复,新加入的节点值会覆盖旧的节点值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 数组大小大于阈值,进行扩容
resize();
return null;
}
存入 hash 表中的元素的下标计算公式 :(n - 1) & hash,其中 n 是数组的长度。
当 e 不为空时,说明 key 重复了,新加入的节点值会覆盖旧节点的值。
链表转红黑树条件
桶中的节点数量大于等于 8 时,链表不一定能转化成红黑树。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
....
}
}
如果当前 table 的大小小于 64 ( 此时数组容量过小,很容易发生碰撞,优先考虑扩容 ) 时,只会发生扩容。
所以链表转红黑树的条件: 桶中的节点数量大于等于 8 && table 的大小大于 64。
为什么桶中节点数超过 8 才转红黑树
红黑树的查找性能比链表的查找性能更高,为什么不一开始就直接用红黑树呢?
源码 175 行的注释对这个问题进行了分析: 树节点的大小大约是普通节点大小的两倍,,并且由泊松分布频率表可以看出,桶的长度超过8的概率非常非常小,为了综合时间和空间的平衡,才选择了 8。
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) {
// 扩容
}
else if (oldThr > 0) // 有参初始化
newCap = oldThr; // 旧阈值赋给 newCap
else { // 无参初始化
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;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 扩容
}
return newTab;
}
初始化时,数组容量的大小就是旧阈值的大小。
扩容
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) { // 扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 新容量为旧容量的两倍
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 新阈值为旧阈值的两倍
}
// 初始化 ...
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) // 桶中只有一个节点
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;
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; // 新数组元素 指向 loHead
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //新数组元素 指向 hiHead
}
}
}
}
}
return newTab;
}
扩容后,threshold 和 table 的容量都变成原来的两倍。
当桶中只有一个节点时,新数组中的下标计算公式为 e.hash & (newCap - 1),原数组在新数组的索引下标一定为 i (原来的值),或者 i + oldCap。
举个例子,假设某个元素的 hash 值为 3,扩容前数组的容量为 16,则扩容前该元素的索引下标为 3。

扩容后下标仍然为 3。
如果该元素的 hash 值为 19,则扩容前索引下标为 3。

扩容后索引下标为 3 + 16
当一个桶中的节点有多个时,并且是链表的形式,会把链表拆成两部分。
e.hash & oldCap = 0 时的节点,扩容后所在索引下标不变,否则索引下标为原来的索引加上 oldCap。

最后执行 newTab[j] = loHead 和 newTab[j + oldCap] = hiHead;,把扩容流程画一遍就很清楚了。
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) {
if (first.hash == hash && // 判断第一个节点是不是所找的节点
((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);
}
}
return null; // 不存在
}
根据 key 算出节点所在的数组下标,然后遍历寻找。
安全问题
没有同步措施,可能会导致数据丢失,另外,在 jdk 1.7 中,扩容时可能发生死循环!!(可以搜一下为什么),jdk 1.8 时扩容不会发生死循环了。
HashTable 是线程安全的 hash 表,因为它的方法上加了 synchronized 同步锁,但是这样效率就很低,ConcurrentHashMap 应运而生。
ConcurrentHashMap
成员变量
// 默认初始容量
private static final int DEFAULT_CAPACITY = 16;
// 默认装填因子
private static final float LOAD_FACTOR = 0.75f;
// hash 表,大小一定是 2 的 n 次幂
transient volatile Node<K,V>[] table;
// 扩容时生成的新数组
private transient volatile Node<K,V>[] nextTable;
// -1 时代表初始化,-(1 + 正在扩容的线程数)表示正在扩容 ,默认为 0,通过 CAS 更新
// 为正数时,表示阈值,即下一次扩容时的大小
private transient volatile int sizeCtl;
static final int MOVED = -1; // forwarding 节点的 hash 值,用于扩容
...
static final int HASH_BITS = 0x7fffffff; // 用于普通节点 hash
static class Node<K,V> implements Map.Entry<K,V> { // Node节点
final int hash;
final K key;
volatile V val; // 加了 volatile 修饰,保证了内存可见性
volatile Node<K,V> next;
}
// UNSAFE机制
...
构造函数
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
tableSizeFor() 返回大于等于 initialCapacity + (initialCapacity >>> 1) + 1 最小二次幂。比如:initialCapacity 为 7,返回的就是 16,ininitalCapacity 是 15,返回的就是 32。此时 table 没有初始化。
hash 函数
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
也是混合原始哈希码的高位和低位,以此来加大低位的随机性,减少哈希碰撞
初始化函数
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0) // sizeCtl < 0 代表别的线程正在初始化
Thread.yield(); // 让出 CPU 调度,直到初始化完成
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 如果 sizeCtl 的值为 0,则 CAS 修改为 -1
try {
if ((tab = table) == null || tab.length == 0) {
// 初始化容量为之前的 sizeCtl 或者 默认大小 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 阈值为 table 大小的四分之三
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
我们来看看初始化函数是如何保证线程安全的。
当一个线程发现 sizeCtl 已经被修改了,说明已经有线程正在初始化 table 了,当前线程则一直让出 CPU 调度,直到初始化完成。
put()
ConcurrentHashMap 通过 CAS + synchronized 保证了存入值时的线程安全性,我们来看看具体如何实现的。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 null 不允许为空
if (key == null || value == null) throw new NullPointerException();
// 计算 hash 值
int hash = spread(key.hashCode());
int binCount = 0; // 记录一个桶中的节点个数
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, // 直接 CAS 设置节点
new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 帮助扩容
else {
V oldVal = null;
synchronized (f) { // 对桶中的头结点上锁!!!
if (tabAt(tab, i) == f) {
if (fh >= 0) { // fh > 0,说明是普通链表节点
binCount = 1; // 记录桶中的元素个数
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash && // 节点的 key 相同
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) { // 插入新节点
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
// 树节点,略过
bindCount = 2; // 树节点 bindCount 一直为 2
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 链表转红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
// 更新
addCount(1L, binCount);
return null;
}
如果 节点的 hash > 0,说明是普通链表节点,则遍历循环,在尾部添加新的节点。
当一个线程执行 put() 时,会对桶中的头结点进行加锁操作,保证了同一时间只有一个线程能操作该桶中的元素。


扩容操作
多线程下是如何扩容的?如何保证线程安全?
addCount()
private final void addCount(long x, int check) {
...
// 获取 hash 表中节点的个数
s = sumCount();
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 判断表中的节点个数是否大于等于阈值
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// 根据数组长度得到一个标识,具体的细节本文没有分析
int rs = resizeStamp(n);
if (sc < 0) { // sc < 0 说明正在扩容
// 扩容结束 或者 转移状态变化 或者 扩容线程达到最大数,退出循环
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 扩容还未结束,当前线程加入扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 此时还没有线程扩容,更新 sc,开始扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
sumCount() 能获取 hash 表中节点的个数(HashMap 中是桶的个数),利用了 LongAdder 的思想,具体细节就不讨论了。
当节点的个数达到阈值时就扩容,不再与桶的个数相关。
transfer()
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 计算每条线程负责迁移的桶个数,每条线程最少处理 16 个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) {
try {
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 新表为原来的两倍大小
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n; // 给下一个线程分配任务区间的起始下标,从右往左分配的
}
int nextn = nextTab.length; // 新数组长度
// 新建一个占位对象,hash 值为 MOVED
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; // 用于控制当前线程是否处理下一个桶
boolean finishing = false; // 表示扩容结束
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 代表当前线程的任务处理完毕,每次处理完一个桶,i--
if (--i >= bound || finishing)
advance = false;
// transferIndex <=0 说明所有的桶已被线程分配完毕
else if ((nextIndex = transferIndex) <= 0) { // nextIndex = transferIndex
i = -1;
advance = false;
}
// 首次进入 for 循环时执行,分配当前线程的任务区间
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) { // 扩容结束
int sc;
if (finishing) { // 扩容结束后续工作
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); // 更新阈值为原来的两倍
return;
}
// 每条线程扩容结束后都会执行 sizeCtl - 1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n;
}
}
else if ((f = tabAt(tab, i)) == null) // 当数组上的位置为空时,放置一个占位对象
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED) // f.hash == MOVED,防止别的线程在该桶上执行 put 操作
advance = true;
else {
synchronized (f) { // 锁住桶的头节点
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) { // 普通链表节点
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd); // 迁移完成后,将占位对象设置到该桶上
advance = true;
}
else if (f instanceof TreeBin) {
// 树节点
}
}
}
}
}
}
通过计算 CPU 的个数和哈希表的长度得到每个线程负责迁移的桶个数,每个线程最少处理 16 个桶,并且每个线程负责迁移的数量是平均的。
i 是线程负责开始迁移的下标,bound 是线程负责结束迁移的下标,迁移的顺序是从右往左!!!。

线程分配转移任务的过程:假设 table 的长度为 64,stride 为 16。则 transferIndex 初始值为 64,假设有三个线程 A、B、C。
线程 A 执行扩容,则分配区间为 【64-16,64】=【48,64】,然后 transferIndex 的值更新为 48。
截止线程 B 执行扩容,则分配区间 【32,48】,transferIndex 的值更新为 32。
截止线程 C 执行扩容,分配区间 【16,32】,transferIndex 的值更新为 16。
最后一块区间【0,16】,线程 A、B、C 谁先执行完谁就去负责最后一块区间的转移。
转移链表时,和 HashMap 的思想几乎一样,根据 节点.hash & n 的值为 0 还是 为 1,分为两组,转移时会锁住桶的头节点,这里转移时用的是头插法,大家画下图就发现很简单。
ForwardingNode 占位节点的用处:防止别的线程在该桶上执行 put() 等修改操作。
get()
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0) // 树节点 或者 占位节点(表示当前 hash 表处于扩容状态)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) { // 遍历链表
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
get() 函数没有加锁。当桶中头结点的 hash < 0,代表当前节点是树节点,或者当前 hash 表处于扩容中,则调用 TreeBin 的 find() 方法,或者调用转移节点的 find() 方法,或者被阻塞(这个桶中的节点刚好在转移,头结点被锁住了)。

总结
JDK 1.8 中采用了 Synchronized + CAS 的方式实现了线程安全,每次只锁住当前操作的桶的头结点,减少了锁粒度,提高了效率。
ConcurrentHashMap 中还有很多细节本文没有分析到,以后有时间再补充。