1.引子
并发编程中使用HashMap可能导致程序死循环。因为多线程会put方法添加键值对时将导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
另外Hashtable只是简单地使用阻塞式锁(synchronized关键字)来保证线程安全,在并发编程中使用HashTable效率较低。
基于此,ConcurrentHashMap解决了上面的两方面的不足,它能够高效安全地对键值对进行读写操作。
ConcurrentHashMap的锁分段技术可有效提升并发访问率。Hashtable效率低的原因是多个线程竞争同一把锁。如果将容器中的数据划分为不同的部分或段,并为这些不同的段分别分配一把锁,当将线程要访问某数据,只需要竞争此数据所属分段的锁,而其他段的数据还是能被其他线程访问。这样就将锁的粒度细化了,实现了更高效的并发处理。
现在商业市场主要还是使用JDK1.8作为开发、生产环境,这里对ConcurrentHashMap的分析基于JDK1.8。
2 结构
ConcurrentHashMap在JDK1.7和JDK1.8中的内部实现有较大的差异。JDK1.7中,ConcurrentHashMap直接使用Segment类型的数组segments作为分段锁数组(segments是成员变量),同时又是每个分段数据的的容器,每个segments又包含一个Entry数组。可将segments的每个元素看作一个HashMap对象,基于此可进一步想象,ConcurrentHashMap包含多个HashMap,每个HashMap是一个分段数据集,每个HashMap上有一把锁。
而在JDK1.8中,ConcurrentHashMap还是像HashMap一样,使用Node类型数组table作用为哈希表(table是其成员变量),将Node对象作为储存包含键值对节点的容器,不像JDK1.7中可以直接看到RentantLock锁。虽然也定义了Segment静态内部类,但JDK1.8中只有在(反)序列化方法中使用了这个类,只是为了(反)序列化时与JDK1.7相兼容而已。与JDK1.7的版本相比,这里Segment的重要性已经降低太多了,它与ConcurrentHashMap之间的类关系只是依赖而已。
JDK1.7 | ![]() |
---|---|
JDK1.8 | ![]() |
1)常量与成员变量
常量
private static final int MAXIMUM_CAPACITY = 1 << 30; //table数组的最大长度
private static final int DEFAULT_CAPACITY = 16; //table数组的默认长度
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; //toArray相关方法,将Cmap转成数组会用到的最大长度
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;//默认并发线程数
private static final float LOAD_FACTOR = 0.75f;//加载因子
static final int TREEIFY_THRESHOLD = 8;//从链表转为红黑树的阀值
static final int UNTREEIFY_THRESHOLD = 6;//从红黑树转化为链表的阀值
static final int MIN_TREEIFY_CAPACITY = 64;//从链表转为红黑树时table的最小长度
private static final int MIN_TRANSFER_STRIDE = 16; //多线程扩容时每个线程处理的最少哈希桶个数
private static int RESIZE_STAMP_BITS = 16;//sizeCtl中生成标记的位数,16位,sizeCtl的高16位是标记位
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
RESIZE_STAMP_BITS :是sizeCtl中生成标记的位数,sizeCtl的高16位是标记符。
RESIZE_STAMP_SHIFT :表示获取sizeCtl中标记符所需位移的位数,sizeCtl是int类型(32位),因此需要右移16位(32 - RESIZE_STAMP_BITS)。
MAX_RESIZERS:表示最大的扩容线程数,sizeCtl的低16位是扩容线程数,低16全为1时可得最大值,国此最大扩容线程数是65535
MOVED :转移节点的hash,所有转移节点的hash都是常量-1。
TREEBIN: 红黑树哈希桶的hash,为常量-2。
RESERVED: 临时保存节点的hash,为常量-3。
HASH_BITS:正常节点的hash位,在spread方法中会用到这个常量,最终使正常节点的hash的最高位设为0.
成员变量
transient volatile Node<K,V>[] table;
private transient volatile Node<K,V>[] nextTable;
private transient volatile long baseCount;
private transient volatile int sizeCtl;
private transient volatile int transferIndex;
private transient volatile int cellsBusy;
private transient volatile CounterCell[] counterCells;
// views
private transient KeySetView<K,V> keySet;
private transient ValuesView<K,V> values;
private transient EntrySetView<K,V> entrySet;
table :哈希表,其长度始终是2的幂次方。
nextTable: 下个要用到的哈希表,在重新调用哈希表table的容量是会用到且只有此时不为null。这主要在保证resize时并发访问,ConcurrentHashMap还是可用的。
baseCount: 键值对个数的计数器,主要在没有线程竞争的时候使用。
sizeCtl: table初始化和大小调整控制的依据。 如果为负,则table将被初始化或resize:-1用于初始化,若为其它负数时,|sizeCtl|=(1 +正在resize的线程数)。若sizeCtl是非负数且table为null时,它表示数组table初始化时的长度。初始化之后,表示下次要扩容的阀值。
transferIndex: resize时要拆分的nextTable表索引。
cellsBusy: 基于CAS的自旋锁,它会在resize和创建CounterCells时被使用。
counterCells:当有多线程同时添加或移除节点(键值对)时,它的每个元素记录一个线程添加或移除节点的个数。这在并发多线程竞争时,计算真实的键值对个数时会用到这个成员变量。
而keySet 、values、entrySet就是各种视图。
2)静态内部类Node及其子类
Node是一个存储键值对的基本类,正常节点的hash属性是非负数,而它的一些子类的hash属性是负数常量,这非正常子类不保存具体意义的键值对。哈希桶是单向链表时,不使用Node子类,它直接使用Node本类表示。
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;
}
//......
}
ForwardingNode是Node的一个子类,它的hash属性是常量-1,它主要起着指示当前正在扩容的标识性作用,它不保存键值对。若需要查找键值对,需要调用其find方法在Cmp的nextTable上去查找(扩容期间,原table不可用,只能在nextTable上去查找,ForwardingNode重写了父类的find方法)。
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;
}
//......
}
ReservationNode是Node的一个子类,它也不保存键值对,没有后继节点,只是为 computeIfAbsent 、compute这两个方法单独设计的类,如果哈希桶是这种类类型,则表明当前正在初始化哈希桶,还没完全将键值对添加到哈希桶中。若要查找键值对,find方法始终返回空。
static final class ReservationNode<K,V> extends Node<K,V> {
ReservationNode() {
super(RESERVED, null, null, null);
}
Node<K,V> find(int h, Object k) {
return null;
}
}
TreeBin是Node的另一个子类,它的hash属性是常量-2,它不保存键值对,此类型节点指示当前哈希桶是红黑树结构,它是代表一个红黑树哈希桶,而实际的能存入键值对的红黑树是其属性root, root是红黑树的根节点。若需要查找键值对,需要调用其find方法从红黑树根节点root开始遍历查找。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
//......
}
TreeNode是Node的另一个子类,它是正常节点(hash是非负数),它表示红黑树节点,它可以保存键值对。
static final class TreeNode<K,V> extends Node<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,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
//......
}
3)数据结构
与HashMap类似,ConcurrentHashMap的基本数据结构是"数组+链表(或红黑树)",当哈希桶所含的节点个数达到相应的阀值时,哈希桶的数据结构会进行相应的变化,即单向链表和红黑树进行相互转化。但另外引入了ForwardingNode和ReservationNode、TreeBin这三类节点类型,这三类节点类型都是非正常节点,它们各自的hash属性是负数常量(链表节点Node、红黑树节点TreeNode这两种正常节点的hash属性是非负数变量)。其实这里的红黑树也与HashMap的红黑树不尽相同,这里不是将表示根节点的TreeNode作为哈希桶容器,而是另外用了TreeBin来表示哈希桶,TreeBin的属性root指向了红黑树的根节点,通过这个属性可以找到红黑树。
4) 初始化
ConcurrentHashMap有多个构造方法,我们直接研究其参数最多的构造方法。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
参数concurrencyLevel代表预估的并发线程数,它会影响table的长度。构造方法首先通过“if (initialCapacity < concurrencyLevel)”确定用户设定的初始容量和并发级别是否匹配。如果inititalCapacity小于concurrencyLevel,就让inititalCapacity重设为concurrencyLevel。如果环境中真的有concurrencyLevel个线程,那么就应该有concurrencyLevel个锁,因为每个锁管理一段数据,那么就至少要有concurrencyLevel个段数据,每段数据至少包含一个哈希桶,也就是说至少table的长度至少要是concurrencyLevel。
根据“thread=loadFactor*capacity”公式可以看出,“(1.0 + (long)initialCapacity / loadFactor)”算出理论上table的最小长度size,这里加"+1"的原因是考虑不能整除时,要保留小数位的值,只能在整数位进一。而这个长度可size可能并是我们真正会使用的table的长度,因为我们要使用按位与的哈希算法来确定table的下标,就必须使table的长度为2的幂次方,而此时的size可能并不是2的幂次方,我们需要进一步处理。tableSizeFor()方法很巧妙使用了位运算,此方法能求出一个大于等于size的最小且是2的幂次方的值cap。如initialCapacity为14,loadFactor为0.75,concurrencyLevel为4,那么size=20,cap=32.所以最终初始化table时,其长度是32.
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
3 核心API
1)添加键值对
put(K,V)和putIfAbsent(K,V)都是直接调用putVal(K,V,boolean)方法。putVal方法包含了添加键值对的流程框架。
public V put(K key, V value) {
return putVal(key, value, false);
}
public V putIfAbsent(K key, V value) {
return putVal(key, value, true);
}
putVal()方法的主要逻辑:①先确认table是空,若为空则将其初始化。②再根据位运算“(n-1)& hash”取余求出table的索引i,并进一步获取table下标为i的元素f,此f代表一个哈希桶,它一个单向链表或红黑树。③然后再确认f是否为空,若f为空,就使用CAS更新将f初始化。若CAS更新成功,则退出死循环,否则将再次进入“for (Node<K,V>[] tab = table;😉”循环重试(自旋)。此时并没有使用阻塞锁,而是使用基于CAS的自旋锁。④若f的hash是-1,则表明table正在resize,要调用helpTransfer加速扩容。helpTransfer方法结束后,自旋重试添加键值对 ⑤若f不为空且其hash也不是-1时,就使用阻塞锁(synchronized关键字)将对象f锁住,再遍历链表f,查找此链表上是否存在Key对应的节点e。⑥若链表f上存在这样的节点e,就将e的val属性更新为当前需要添加键值对的value 。⑦若链表上不存在这样的节点e,就新构建一个节点,并将这个节点添加链表的尾部⑧若f是红黑树结构,就使用其特有的putTreeVal()方法进行键值对的添加。⑧若f的数据结构是ReservationNode等其他类型节点就同样需要自旋重试,等待状态正常时再来添加键值。⑨若链表长度超过了树形化的阀值,将链表转化为红黑树。链表上若存在这样的e节点,将e节点的原value直接返回;若不存在就调用addCount()来更新元素个数,再返回null 。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); //再散列,求key对应的hash
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(); // table是空就初始化table
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
//键值对要添加到的哈希桶为空时,只要CAS成功初始化此哈希桶即可退出循环。此时不需要加锁
//这个添加键值对的node是这个哈希桶的头节点
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)//MOVED是常量-1
//哈希桶不为空且hash为-1,表明table正在resize,使用helpTransfer加速扩容
tab = helpTransfer(tab, f);
else {
//要添加键值对的Key可能在链表或红黑树上f了,需要遍历它
V oldVal = null;
synchronized (f) { //锁定此哈希桶
//"if (tabAt(tab, i) == f)"主要是保证f是我们要遍历的目标哈希桶。
// 在执行“((fh = f.hash) == MOVED)”时,可能会有其它线程修改了table
if (tabAt(tab, i) == f) {
if (fh >= 0) { //f是单向链表
binCount = 1;//链表长度
for (Node<K,V> e = f;; ++binCount) {//开始遍历链表f
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {//哈希桶f上已存在这个Key对应的节点
oldVal = e.val;//保存原Value,最后需要返回它
if (!onlyIfAbsent)
e.val = value; //将原节点的val属性更新成新添加的value
break; //退出遍历链表的循环
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
//遍历完链表发现,哈希桶f上不存在这个Key对应的节点。
//需要构建一个Node节点,将此节点添加在链表的尾部
pred.next = new Node<K,V>(hash, key,
value, null);
break;//退出遍历链表的循环
}
}
}
else if (f instanceof TreeBin) {
// 哈希桶f是红黑树型结构,使用其特有的putTreeVal方法添加键值对。
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) {
// 只要保证f是单向链表或红黑树,bineCount的最小值就是1。
// 那么也就一定会进入当前if分支
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); //链表长度超过了树形化的阀值,将链表转化为红黑树
if (oldVal != null)
//原value不为空,直接返回这个值。
// 这时没有添加新的节点,不需要调用addCount方法去更新元素个数
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
putVal方法执行流程详细分析
(1)putVal()对键值都做了非空判断,这与HashMap有所不同,ConcurrentHashMap的Key和Value都不能为null。
(2)计算hash
而计算hash值的方法spread(int)的算法也与HashMap不同。"(h ^ (h >>> 16))"是HashMap计算hash的方式。此处在此基础之上,将“(h ^ (h >>> 16))”的结果和0x7fffffff进行按位与运算 (HASH_BITS是常量0x7fffffff),0x7fffffff的进二进制形式是“01111111_11111111_11111111_11111111”。根据位运算特点可知,经y=x&0x7fffffff计算后,y与x的四字节二进制形式可能只有最高位不同(y与x可能是相等的),y的最高位一定是0。这里和HASH_BITS按位与运算的目的是将其最高位强制设为0。因为计算机中数字的最高位是符号位,所以spread()方法返回值始终是非负数。
HASH_BITS的注释是“ usable bits of normal node hash”,那么所有正常节点的hash属性都非负数,因为才有putVal()方法体中的“if (fh >= 0) ”,只有哈希桶的头节点是正常节点才会遍历所哈希桶的所有节点。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
(3)初始化table
其主要逻辑是:先根据构造方法中计算出的sizeCtl来决定数组table长度,当状态合适时就将table初始化,再将sizeCtl设为原值的四分之三(初始化后sizeCtl为正数时,它表示扩容的阀值)。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
//只有sizeCtl是非负数时,才表示table需要初始化。
// sizeCtl是负数表明其他线程可能正在初始化,当前线程放弃对table初始化的竞争,进入自旋状态。
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// CAS尝试将sizeCtl为-1,主要目的让其他线程得到通知。
//其他线程检测有sizeCtl是-1,这些线程就能知道某线程正在初始化table,这些线程就会放弃初始化table
try {
if ((tab = table) == null || tab.length == 0) {
//这里sc不可能小于0,只可能等于0,若sc=0,将其设为默认容量
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); //sc=0.75*n
}
} finally {
sizeCtl = sc; //无条件将sizeCtl设为原值的3/4
}
break; //CAS更新成功后,一定会将table初始化,可以退出自旋了。
}
}
return tab;
}
(4) 确定哈希桶
确定哈希桶的主要是找到哈希桶在哈希表table中的索引。
“tabAt(tab, i = (n - 1) & hash))”可看出也是像HashMap利用位运算“(n - 1) & hash”取余来确定索引。这种利用位运算取余算法的基本前提是n必须是2的幂次方,所以table的长度也一直是2的幂次方。
而tabAt(Node[],int)方法也是简单地调用Unsafe类的getObjectVolatile方法。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
(5)哈希桶为空,利用CAS初始化哈希桶(的头节点)
casTabAt(Node[],int)简单调用Unsafe类的CAS方法更新table中i索引的元素。
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);
}
(6)哈希桶的头节点的hash是-1,表明这个是forward节点(正常节点的hash是非负数),table正在resize,使用helpTransfer加速扩容。
ForwardingNode是Node的子类,其构造方法中调用了父类的构造方法Node(int, K, V , Node<K,V> )。可以看出ForawrdingNode所有实例的hash都是常量-1(MOVED是常量-1),其next属性为null,表明其没有后继节点。ForwardingNode类的设计目的本就是在转移扩容期间,将一个实例添加在哈希桶的头部,且其无后继节点,它是检测是否在转移扩容期间的flag标记。
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//f是转移节点,且其节点的下个table不为空。
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
//扩容时的标记位
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
//局部变量与成员变量相等,即nextTablet和table没有被其它线程修改
(sc = sizeCtl) < 0) {//sizeCtl小于0,表明正在扩容(-1时正在初始化table,显然此时不可能初始化table)
/**
* 若sc右移16位(取其高16位)与rs不等(表明标记位rs变化了)
* 或cs等于rs+1(扩容可以结束了,没有线程在扩容了。默认第一个线程设置sc=rs<<16+2,第一个线程结束扩容sizeCtl自减1,此时sc和rs就相差1)
* 或sc = rs + MAX_RESIZERS(已经达到了最大扩容线程数)
* 或transferIndex<=0(下标正在调整,扩容结束)
* 时,退出循环
*/
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || //RESIZE_STAMP_SHIFT是常量16
sc == rs + MAX_RESIZERS || transferIndex <= 0) //MAX_RESIZERS是常量65535,表示最大扩容线程数
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { //CAS将sizeCtl加1
transfer(tab, nextTab);//CAS更新成功,table进行转移扩容(将tab的元素转移到nextTab)
break;
}
}
return nextTab;
}
return table;
}
(7)若哈希桶上没有key对应的节点,在添加节点后还需要调用addCount重新计数元素个数
2)获取键的值
根据键获取值的方法主要有get(Object)、getOrDefault(Object,V)这两个,而getOrDefault方法又是委托get(Object)实现的,因此我们重点关注get(Object)方法。
public V getOrDefault(Object key, V defaultValue) {
V v;
return (v = get(key)) == null ? defaultValue : v;
}
这里的get(Obejct)方法和HashMap的get方法类似。先确定Key对应的节点可能存在的某个桶上,然后再遍历这个桶上的所有节点,如果遍历过程中找到这样的节点,就返回节点的val属性,若遍历完后没找到这样的节点,就返回null 。值得注意的是当哈希桶的头节点是非正常节点时,不能直接按照常规的方式获取节点的val属性,此时需要调用其特定类类型的find()方法。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode()); //算出hash
//key对应的节点应可能在数组table的"(n - 1) & h)"索引元素上时。
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
//因为h>=0,所以eh>=0,此时e是正常节点,且是e是链表的头节点(红黑树TreeBin的hash常量-2)
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val; //在哈希桶的头节点上时,可直接返回此节点的val.
}
else if (eh < 0)//查找的节点不是哈希桶的头节点,且头节点是非正常节点,使用其特定的find(int,Object)查找
//正常节点的hash是非负数,可能是MOVED(转移)节点、TREEBIN(红黑树)根节点或RESERVED(暂存)节点。
//只有以上三种节点是负数。
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;//在链表上找到此节点,返回此节点的val
}
}
return null;//没找到返回null
}
3) 移除键值对
remove(Object,Object)和remove(Object,Object)方法均调用replaceNode(Object,V,Object)来实现自己的功能。
public boolean remove(Object key, Object value) {
if (key == null)
throw new NullPointerException();
return value != null && replaceNode(key, null, value) != null;
}
public V remove(Object key) {
return replaceNode(key, null, null);
}
replaceNode()方法比较长,但主要逻辑还是很清楚的。
此方法是四个remove/replace方法的核心实现,也就是说另外的replace(K,V,V) 、replace(K,V)两个API也是基于replaceNode方法实现。
根据传入replaceNode方法的参数value是否为空,来确定是移除键值对还是替换某个键的值。当参数value为空,表明将移除键值对,而参数value非空则表明要替换某个键的值。
replaceNode的核心逻辑:先根据Key的hash确定节点可能在哪个哈希桶上,若计算出的哈希桶f为空,哈希桶还未被初始化,表明哈希表上不可能存在Key对应的节点,退出死循环,返回空。此哈希桶f的头节点是转移节点,表明table正有扩容,调用helpTransfer方法多线程加速扩容。在helpTransfer方法结束后,自旋重试再来移除键值对或替换某个键的值。若哈希桶的头节点是非转移节点,就使用阻塞锁(synchronized关键字)将此哈希桶f锁住,准备遍历这个哈希桶中的所有节点。若哈希桶是链表结构,就遍历链表查找Key对应的节点e。若遍历过程中找到这样的节点e,且条件允许(即预期被替换值(移除值)与实际被替换值(移除值)相等),就更新节点e的val属性或将e的前后节点直接链接起来以移除节点e .若哈希桶是红黑树,也要遍历红黑树查找节点e,其处理流程与链表相似。若哈希桶的数据结构是ReservationNode等其他类型节点就同样需要自旋重试,等待状态正常时再来移除键值对或替换某个键的值。
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break; //Key对应的哈希桶为nul,它不可能在哈希表table上,退出死循环
else if ((fh = f.hash) == MOVED)
//和putVal类似,检测有table正有扩容,多线程加速扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
synchronized (f) { //锁住这个哈希桶
if (tabAt(tab, i) == f) { //保证table没有被其他线程修改
//fh>0表明f是正常节点,且是链表的头节点(红黑色根节点treeBin的hash是常量-2)
if (fh >= 0) {
validated = true; //f是链表,验证通过
for (Node<K,V> e = f, pred = null;;) {//开始遍历链表
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {//找到了Key对应的节点
V ev = e.val;
//cv预期被替换的原value, ev是实际会被替换的原value
//若预期被替换值为空或预期被替换值与实际被替换值相等,就执行value替换或节点移除操作
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;//保存节点的原val,方法最后需要返回此值
//传入当前方法replaceNode的参数value非空,表明调用者想修改节点的val属性
//参数value为空,表明调用者想移除节点
if (value != null)
//"replace(K,V)"和"replace(K,V,V)"传入当前replaceNode方法的参数value均非空。
//预期替换后的新value不为空,就更新此节点的val属性,
//将其设为预期替换后的value.
e.val = value;
else if (pred != null)
// "remove(Object,Ojbect)"和"remove(Object)"传入当前replaceNode方法的参数value都是null
//预期替换后的新value为空,表明这是个移除节点的操作,而前驱节点pred非空,表明e不是链表的头节点。
//将前当前节点e的前驱节点和后继节点直接利用next属性越过自身e将两者链接起来。当前节点e也就被移除了。
pred.next = e.next;
else
//value为空,pred为空,表明这是个移除节点的操作且e是链表的头节点
//将e的后继节点作为链表新的头节点,这个原头节点e也就被移除了
setTabAt(tab, i, e.next);
}
break;//找到节点,退出遍历
}
pred = e;
if ((e = e.next) == null)//遍历完链表也需退出遍历过程
break;
}
}
else if (f instanceof TreeBin) {
validated = true;//f是红黑树,验证通过
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) { //找到了Key对应节点
V pv = p.val;
// 若预期被替换值为空或预期被替换值与实际被替换值相等,就执行value替换或节点移除操作
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;//保存节点的原val,方法最后需要返回此值
if (value != null)
//value非空,进行节点value替换(val属性更新)
p.val = value;
else if (t.removeTreeNode(p)) //value为空,进行节点移除
//若移除这个节点p后,红黑树的节点个数太少,则需要将红黑树转为链表,
// 并将这个链表放在table上。
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
//只要f是链表或红黑树,validated就会被设为true. 这种情况下可能进行了节点的移除或节点的val属性更新
//而f若是Forwarding这类标识型节点,根本就不可能会执行节点的移除和节点的val属性更新,
// 因为这类节点不存放具体有意义的键值对。
if (validated) {
//节点的原value不为空,表明确实执行节点的移除或节点的val属性更新操作,返回原value
if (oldVal != null) {
if (value == null)
//value为空,执行了移除节点操作,需要更新元素个数。
addCount(-1L, -1);
return oldVal;
}
//没找到Key对应节点或找到节点但条件不满足(这里的条件不满足是指预期被替换值与实际被替换值不等),
// 将返回null
break;
}
}
}
return null;
}
4)获取键值对个数
size()方法获取键值对个数,主要是通过sumCount()实现的,它忽略了sumCount计算出负数的结果,直接返回零。
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
而sumCount方法逻辑也比较简单,将baseCount与counterCells中各元素的value属性进行累加。
baseCount表示在无线程竞争时键值对的个数,counterCells表示各个线程竞争并发时添加或移除的键值对的个数(还没来得及同步到baseCount中)。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
之前分析putVal()和replaceNode()方法时,均见到调用addCount()更新键值对个数,现在是时候对addCount方法进行简要的分析了。
addCount()方法主要逻辑:先CAS尝试将节点个数变化值直接更新到baseCount。若CAS更新baseCount失败,就准备将节点个数变化值记录在CounterCell。若CAS更新到CounterCell的value属性中也失败了,就使用fullAddCount方法完整地更新,直到CAS写入成功,然后结束addCount方法。若CAS更新到CounterCell的value属性中成功了,且check<=0就不进行扩容检测,只检测是否有线程竞争,此时可以结束addCount方法了。若check>=0就进行扩容检测,若应该扩容且当前有其他线程正在扩容,CAS更新sizeCtl成功,当前线程就调用transfer加速扩容;若应该扩容但当前却没有线程扩容,CAS更新sizeCtl成功,当前线程就作为第一个扩容线程调用transfer开始扩容。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
//尝试直接用CAS更新baseCount失败,准备将节点个数变化值记录在CounterCell中
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 || //as的长度为0,as数组还未被初始化
// 记录当前线程中的节点个数变化值的CounterCell未初始化
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
//尝试CAS更新,将节点个数变化值写入CounterCell中也失败了,这表明当前存在有线程竞争
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
//一次CAS写入CounterCell失败,就调用fullAddCount方法进行完整的将写入,直到CAS写入成功fullAddCount方法才能返回
fullAddCount(x, uncontended);
return; //fullCount
}
if (check <= 1)
//CAS将节点个数变化值写入CounterCell成功,且check<=1,不执行下面if块中的扩容检测,只检测是否有线程竞争
return;
s = sumCount();//计算当前真实的键值对个数
}
if (check >= 0) {//check>=0时,需要检测扩容。
//键值对个数大于sizeCtl(扩容的阀值)且table长度小于最大长度(还可以扩容)
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) {//sizeCtl小于0,表示在扩容(-1时正在初始化table,显然此时不可能初始化table)
/**
* 若sc右移16位(取其高16位)与rs不等(表明sizeCtl变化了)
* 或cs等于rs+1(扩容可以结束了,没有线程在扩容了。默认第一个线程设置sc=rs<<16+2,
* 第一个线程结束扩容sizeCtl自减1,此时sc和rs就相差1)
* 或sc = rs + MAX_RESIZERS(已经达到了最大扩容线程数)
* 或transferIndex<=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))//CAS将sizeCtl加1(表示增加一个线程帮助扩容)
//CAS成功执行tansfer()方法当前加速table扩容
transfer(tab, nt);
}
//sc>=0,表示当前不在扩容,而while条件中size>sizeCtl表明应该扩容,因此现在开始尝试扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))//CAS尝试将rs设为sizeCtl的高16位、将2设为sizeCtl的低16位,更新后sizeCtl是负数
//CAS更新sizeCtl成功,就执行tansfer()方法进行扩容
transfer(tab, null);
s = sumCount();//重新计算键值对个数,然后回到while条件中检测是否应该扩容。
}
}
}
4.总结
1)与JDK1.7相比,在JDK1.8 中ConcurrentHashMap使用了更细粒度的锁,JDK1.7中多个哈希桶对应一把锁,而JDK1.8中一个哈希桶对应一把锁,且不一定会用到这个锁(如在添加键值对时恰好是为哈希桶添加头节点,此时使用CAS自旋)。这能够显著提升并发的效率。
2)与JDK1.7相比,在JDK1.8 ConcurrentHashMap代码逻辑更清晰易懂。在JDK1.7中构造方法需要对segments数组进行初始化,里面包含不少的位运算,需要计算段偏移量、段掩码等。而JDK1.8 中构造方法主要是计算初始化容量,没有太多位运算。
3)与JDK1.7相比,在JDK1.8 ConcurrentHashMap对哈希桶的定位更快. 因为在JDK1.7中需要两次散列计算,先要根据通过segmentFor(int)定位到Segment,再继续在segment中找到哈希桶HashEntry的下标。而JDK1.8中只需要一次散列计算后,即可定位到节点所在的哈希桶。