1. Why ConcurrentHashMap?
在java1.7版本,数组中发生冲突的节点以链表形式相连,在进行HashMap在执行put方法时,通过头插法插入到单链表中。然而put过程中可能会触发扩容操作,此时会将原数组的内容重新散列(rehash)到新的数组中。在多线程环境下,如果多个线程同时执行put方法,可能会使数组某一位置上的链表形成闭环,继而出现死循环,cpu飙升到100%。所以java1.7中的hashmap不是线程安全的。
而同步容器HashTable虽然是线程安全的,但是锁的粒度太大,仅仅在与写操作相关的方法上加了synchronized关键字,那么所有涉及到写操作的线程将竞争同一把锁,串行度太高,效率很低。
很明显,HashTable的优化方案应该在于降低锁的粒度。ConcurrentHashMap在java1.7和java1.8对此作出了改进。
接下来,较为粗糙地阅读了ConcurrentHashMap的源码实现。
2. Java1.7版本的ConcurrentHashMap
在java1.7中,ConcurrentHashMap采用分段锁技术,将数据分成一段一段地存储,给每一段配上一把锁。当一个线程访问某段数据时,仅占用了该段的锁,不影响其他线程访问其他段中的数据。
ConcurrentHashMap是由一个Segment数组和多个HashEntry数组组成。每一个Segment元素都指向一个HashEntry数组。当对某个Segment下属的HashEntry数组中的元素执行写操作时,首先需要获取对应的Segment锁。可以说,一个Segment元素守护着一个HashEntry数组。
此外,ConcurrentHashMap和HashTable一样,key或value不可以存储null,而HashMap是可以存储null。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EHVIxgIU-1609146049951)(/Users/phil/Documents/blog/ConcurrentHashMap.png)]
(1) put方法
put方法首先通过一次hash定位到Segment的位置,然后通过二次hash,在该Segment处找到相应的HashEntry的位置。由于Segment类继承了ReentrantLock,所以带有了锁功能。在插入到链表中的时候,需要首先尝试获取锁。
(2) get方法
get方法首先通过一次hash定位到Segment的位置,然后通过二次hash,找到HashEntry的位置。然后遍历该位置上的链表。
(3)size方法
由于多线程环境下,不能够直接将每个Segment中的元素数count相加而得到总的元素数,因为count在不停地发生变化。如果在统计size时,将所有的写操作都锁住,效率又很低下。
这里采用的方案是,首先尝试多次不加锁地统计每个Segment的元素数,然后比较前后2次统计过程中容器是否发生了变化(通过modCount比较),如果没有变化,就认为期间没有元素加入,直接返回结果;如果发生了变化,就采用加锁的方式统计所有Segment的元素数。
3. Java1.8版本的ConcurrentHashMap
java1.8对于ConcurrentHashMap的设计作出了较大改变,不再使用Segment数据结构,而是使用了与java1.8的HashMap相同的数据结构,Node数组+链表(或红黑树),使用Synchronized和CAS进行并发控制。
3.1 常量
最大容量为2的30次幂
private static final int MAXIMUM_CAPACITY = 1 << 30;
默认容量为16,必须是2的整数次幂。
private static final int DEFAULT_CAPACITY = 16;
最大数组长度
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
默认并发级别,只是兼容以前版本,java1.8不使用
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
默认负载因子
private static final float 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;
用于扩容时的标记位数
private static int RESIZE_STAMP_BITS = 16;
MAX_RESIZERS辅助扩容的最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
sizeCtl中记录size大小的偏移量32-16=16
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
forwardingNodes的hash值,为负数
static final int MOVED = -1; // hash for forwarding nodes
树根节点的hash值,为负数
static final int TREEBIN = -2; // hash for roots of trees
ReservationNode的hash值
static final int RESERVED = -3; // hash for transient reservations
可用的处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
3.2 属性
链表节点结构,只能遍历,不能修改节点的value。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
//不能更新value
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/**
* Virtualized support for map.get(); overridden in subclasses.
*/
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
Node数组
transient volatile Node<K,V>[] table;
在扩容时,要使用的新的Node数组
private transient volatile Node<K,V>[] nextTable;
容器内元素数量的基础值,通过CAS进行更新。
private transient volatile long baseCount;
数组初始化和扩容的控制符。当它是负数时,表示数组正在初始化或者扩容:-1表示初始化,-N表示有N-1个线程正在一起扩容。当它是0时,表示还没有初始化。当它是正数时,表示下一次初始化或者扩容的数组大小。
private transient volatile int sizeCtl;
3.3 构造器
ConcurrentHashMap同样采取了延迟初始化策略,即在构造实例时并不分配空间,在第一次添加元素时才分配。
(1)空参构造器
public ConcurrentHashMap() {
}
(2)设置起始容量的构造器
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
//如果要设置的容量大于等于最大容量的一半,则直接设置容量为最大容量;
//否则,将initialCapacity*1.5+1的结果,取大于等于该结果的最小的2的整数次幂
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//下次初始化时,将以cap作为数组的长度
this.sizeCtl = cap;
}
3.4 添加节点
(1)put(K key, V value)方法不允许key为null,也不允许value为null。
public V put(K key, V value) {
return putVal(key, value, false);
}
putVal方法,主要做了下面几件事:
- 如果数组没有创建,则创建数组;
- 如果已经创建过数组了,就通过两次hash定位到某一个位置;
- 如果该位置上还没节点,则通过CAS技术,将新节点插入;
- 如果已经有节点了,但是该节点是ForwardingNode,说明现在正在扩容,则当前线程开始辅助扩容,并返回新的数组,下一轮操作在新数组上;
- 如果该节点只是普通节点,那么插入时要加锁,每个位置上的所有节点共用一把锁,即该位置上的第一个节点;
- 如果该位置以单链表形式存储节点,则以尾插法插入;如果以红黑树存储,则以红黑树的旋转插入。
- 添加完成后,判断是否需要转换成红黑树;
- 调用addCount方法统计容器的size,并检查是否需要扩容。
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key和value都不能为null
if (key == null || value == null) throw new NullPointerException();
//两次散列,减少冲突
int hash = spread(key.hashCode());
int binCount = 0;
//成功插入后,退出循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果还没有初始化,则进行初始化,即initTable方法,初始化完成后,继续下一轮循环
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//否则,通过(n-1)&hash方法定位到要存放的数组位置i
//该位置的第一个元素赋值给f,如果是空的,则直接通过cas方式(下文介绍)插入,即无锁插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
//如果第一个节点的hash值时MOVED,则说明这是forwardingNode,表示现在正在扩容
else if ((fh = f.hash) == MOVED)
//当前线程辅助扩容,返回新表,下一轮循环将在新表上操作
tab = helpTransfer(tab, f);
//如果以上都不是,就通过加锁的方式,插入到链表或者红黑树中
else {
V oldVal = null;
//每个槽上发生冲突的节点,使用一把锁,这把锁是槽上的第一个节点
synchronized (f) {
//获得锁后,还需要再次验证这个锁是否依然是槽上第一个节点
//如果该锁因为删除等操作,已经不是第一个节点了,就不能锁住这个槽上所有的节点
if (tabAt(tab, i) == f) {
//如果第一个节点hash大于等于0,则表示是链表节点
if (fh >= 0) {
//当前槽上的节点数置为1
binCount = 1;
//指针e在链表上进行遍历,遍历过程中bincount每次加1
for (Node<K,V> e = f;; ++binCount) {
//ek表示链表上每个节点的key
K ek;
//比较每个节点的可用与当前要插入的key是否相同
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
//如果找到了相同的key,则获取key对应的旧值
oldVal = e.val;
//赋上新的值
if (!onlyIfAbsent)
e.val = value;
//退出循环
break;
}
//否则,pred指针指向上一个节点
Node<K,V> pred = e;
//e指向下一个节点,当到达链表尾部时,就将新节点插入到链表尾部(尾插法)
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果是红黑树,则以红黑树的插入方式插入
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//添加完成后,判断是否要转化为红黑树
if (binCount != 0) {
//如果当前槽上的节点数大于等于8,则尝试转化为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
//如果是修改操作,则返回旧值
if (oldVal != null)
return oldVal;
break;
}
}
}
//统计size,同时检查是否需要扩容
addCount(1L, binCount);
return null;
}
initTable方法作数组初始化。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//while循环判断数组是否建好
while ((tab = table) == null || tab.length == 0) {
//获取用于初始化和扩容的控制符sizeCtl,如果该值小于0,则说明已经有其他线程在做初始化了
if ((sc = sizeCtl) < 0)//当前线程让出cpu的使用
Thread.yield();
//否则,通过CAS方式,将sizeCtl赋值为-1,表示当前线程正在初始化数组
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//再次判断数组是否已经建立
if ((tab = table) == null || tab.length == 0) {
//如果sc如果大于0,则使用sc作为容量,否则使用默认容量
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//将n乘以0.75赋值给sc
sc = n - (n >>> 2);
}
} finally {
//当数组容量到达sizeCtl时,要进行扩容
sizeCtl = sc;
}
break;
}
}
return tab;
}
CAS(Compare And Swap)即比较交换技术,是一种乐观的无锁方案。该指令包含了3个参数:共享变量的地址V、该地址处的期望值E、共享遍历的新值N。只有当V==E时,才会将V更新为新值N。
CAS是由硬件支持的CPU指令,具有原子性。
compareAndSwapInt(this, SIZECTL, sc, -1)方法传入了4个参数:this表示当前对象,SIZECTL表示sizeCtl变量距离当前对象起始地址的偏移量,那么this+SIZECTL就是我们上面所说的共享变量sizeCtl的内存地址,sc是期望值,-1是新值。也就是说,只有当sizeCtl等于期待值sc,才会将sizeCtl赋值为-1。
回到开始处,我们将sizeCtl赋值给sc后,进入cas方法前,如果这期间sizeCtl没有发生变化,cas就可以成功将sizeCtl更新为-1,返回true。如果期间sizeCtl被改变,就会与我们的期望值不同,那么就不能更新成功,返回false。
接着,如果当前位置上没有元素,就使用CAS方式直接插入。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
helpTransfer方法。如果正在扩容,多个线程一起帮忙扩容,这样效率更高。
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//ForwardingNode的一个域是nextTable,指向下一个新的数组
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
//调用cas,将sizeCtl加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//辅助传输数据
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
ForwardingNode是一个特殊的节点。在数据传输过程中,ForwardingNode被插入到bins的头部,表示该位置上的节点已经处理。
nextTable域指向新数组,hash值为MOVED(负数),key和value都为null。
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
...
}
transfer方法将数据从旧数组传输到新数组。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//每核搬运的量至少为MIN_TRANSFER_STRIDE(16)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];//创建一个新数组,容量为原来的2倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
//创建一个ForwardingNode,并给nextTable域赋值为新数组
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//advance为true表示当前节点已经处理
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//通过索引i遍历原数组
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//计算nextBound为nextIndex-stride,通过cas赋值给t'r
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
//i更新为nextIndex-1
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//已经完成了传输
if (finishing) {
nextTable = null;
table = nextTab;
//sizeCtl阈值设置为1.5*n,即0.75*n*2
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; // recheck before commit
}
}
//如果该位置上没有要传输的节点,则将ForwardingNode插入这个位置
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//如果某个位置上的首节点的hash为MOVED,则表示这个位置已经处理过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {//否则加锁处理,锁为该位置上的首节点
synchronized (f) {
//再次判断锁是否依然是首节点
if (tabAt(tab, i) == f) {
//将该位置上的节点分成两部分,ln和hn分别为两条链表的首节点
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);
}
//将ln链表插入新数组的i位置,hn链表插入i+n位置
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//将ForwardingNode插入到原数组的位置上,表示该位置已经处理
setTabAt(tab, i, fwd);
advance = true;
}
//如果是红黑树
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//如果扩容后,新数组位置上的节点小于等于6,则要去树变链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
整个扩容过程是多线程并发扩容的,用ForwardingNode表示数组的某个位置为空不用处理或者已经被处理。辅助扩容过程中,当线程遇到ForwardingNode,就继续往后遍历。
扩容完成后,接下来要判断是否要转化成红黑树,如果数组长度小于64,就继续扩容1倍,不用转换。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
3.5 查询节点
查询方法主要做了下面几件事:
- 首先经过两次hash,定位要找的节点在数组中的某个索引位置;
- 如果该位置上的首节点就是要找的节点,直接返回;
- 如果首节点是ForwardingNode,说明正在扩容,并且这个位置上的节点已经全部搬运,那就去新的数组中查找;
- 否则,就进行常规地向下遍历查找,找不到返回null。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//两次hash
int h = spread(key.hashCode());
//定位到该key所在的数组索引位置,e指向该位置上的第一个节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//与第一个节点的key进行比较
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果第一个节点的hash值小于0,表示ForwardingNode节点,说明现在正在扩容,
else if (eh < 0)//并且这个位置上的节点已经全部搬运到新数组中的两个位置上了
//调用ForwardingNode节点的find方法定位到新数组,在两个位置上进行遍历查找
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;
}
}
//找不到返回null
return null;
}
4. 小结
比较java1.7和java1.8中,ConcurrentHashMap不同的实现方式:
-
java1.7中,ConcurrentHashMap由Segment数组+HashEntry数组实现,采用分段锁技术实现并发,以Segment为ReentrantLock。
-
java1.8中,ConcurrentHashMap由Node数组+链表+红黑树实现,采用CAS技术和Synchronized实现并发。以每个位置上的首节点作为锁,降低了锁的粒度,提升了并发度。
java1.8中,使用链表和红黑树来解决冲突,当某个位置上冲突节点过多,如果在单链表上查询,效率很低;而使用红黑树,大大提升了查询效率,从O(n)到O(logn)。