1. 小声哔哔
在并发场景下,HashMap存在并发安全的情况(并发扩容重新散列时因为链表是使用的头插法,会出现环形链表导致get时异常,CPU标高到100%),所以ConcurrentHashMap是并发编程中比较重要的一个工具,本博文将竭力介绍1.7和1.8版本中的ConcurrentHashMap,所以会比较长,建议收藏再看。
2. JDK1.7版本实现
相较于HashMap的线程不安全和HashTable的粗暴式线程安全(每个方法都加了synchronized关键字,所有的读写都有锁,效率低下),JDK1.7版本的ConcurrentHashMap使用了Segment数组+HashEntry数组作为数据结构。Segment内部类继承了ReentrantLock并在put,remove,replace等方法中使用tryLock方式获取锁,达到修改时仅做分段锁而不是对整个hash表全部锁死,增加效率。数据结构图如下:
仅做文字的描述未免有纸上谈兵之感,下面开启源码之旅。
2.1. ConcurrentHashMap初始化
翻看源码,我在初始化ConcurrentHashMap的方法中关键逻辑都加了注释方便理解
/**
* @param initialCapacity 初始容量,注意,这里的初始容量是对HashEntry的影响
* @param loadFactor 负载因子阀值,当HashEntry个数超过此阀值时进行扩容,默认0.75
* @param concurrencyLevel 并发级别,用于控制Segment数组初始大小
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
// 用于定义Segment大小
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
// 保证Segment初始大小为2的幂
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 注意到ssize是受传参concurrencyLevel所影响
// 所以c的最终结果其实是initialCapacity和最接近concurrencyLevel的2的幂次方(向上取最大值)
// 记住这里的c值,会对初始化第一个Segment的HashEntry数组大小有影响
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 影响第一个Segment初始化时HashEntry数组的大小,最小值为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// new Segment[0]的所属对象,需要关注的是这里初始化HashEntry数组时的初始大小cap
ConcurrentHashMap.Segment<K,V> s0 =
new ConcurrentHashMap.Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(ConcurrentHashMap.HashEntry<K,V>[])new ConcurrentHashMap.HashEntry[cap]);
// new出Segment数组的大小,这里的ssize值实际上是受传参concurrencyLevel影响
ConcurrentHashMap.Segment<K,V>[] ss = (ConcurrentHashMap.Segment<K,V>[])new ConcurrentHashMap.Segment[ssize];
// 为Segment数组的第一个元素设置值
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
- 平时调用的时候我们一般会使用无参的构造函数,对应的默认值为initialCapacity:16,loadFactor:0.75,concurrencyLevel:16,
- Segment数组的初始大小为16是根据concurrencyLevel来设置的。
- initialCapacity和concurrencyLevel的比值越大,初始HashEntry数组的容量越大
- 若是自行设置concurrencyLevel值,那么尽量设置为2的幂,否则也会向上取一个2的幂次方的最大值,例如设置3,那么Segment数组的初始值大小为4
2.2. put方法
- ConcurrentHashMap的put方法
/**
* @param key: 需要put的key值
* @param value: 需要put的value,不可为null
*/
public V put(K key, V value) {
ConcurrentHashMap.Segment<K, V> s;
if (value == null)
throw new NullPointerException();
// 根据key值进行hash
// 有兴趣可以看下hash方法,里面对String类型做了特别处理
// 非String类型使用Wang/Jenkins算法,涉及位运算,或许这就是为什么segment大小需要是2的幂的原因
int hash = hash(key);
// 根据hash值决定放在segment的哪个位置
int j = (hash >>> segmentShift) & segmentMask;
// 首先使用UNSAFE.getObject获取j位置上是否为null,若为null则进入ensureSegment方法
if ((s = (ConcurrentHashMap.Segment<K, V>) UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
- ensureSegment方法:初始化k位置的segment并返回Segment
/**
* 初始化k位置的segment并返回
*
* @param k: 需要初始化的segment位置
*/
private Segment<K, V> ensureSegment(int k) {
final Segment<K, V>[] ss = this.segments;
// 偏移量
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K, V> seg;
// 已Volatile方式使用CAS方式获取该内存位置值
if ((seg = (Segment<K, V>) UNSAFE.getObjectVolatile(ss, u)) == null) {
// 还记得初始化ConcurrentHashMap时在Segment的0位置初始化的segment吗,这里讲使用它作为原型
Segment<K, V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int) (cap * lf);
HashEntry<K, V>[] tab = (HashEntry<K, V>[]) new HashEntry[cap];
// 再次确认待初始化位置是否为空
if ((seg = (Segment<K, V>) UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K, V> s = new Segment<K, V>(lf, threshold, tab);
// 自旋至数据更新完成位置
while ((seg = (Segment<K, V>) UNSAFE.getObjectVolatile(ss, u))
== null) {
// 使用CAS命令比较并更新该内存位置上的数据
// compareAndSwapObject参数:
// var1:待操作的对象,var2:需要写入的内存位置
// var3:进行比较的值,var4:拟定写入的值
// 只有在var2存储的值是var3时才会进行更新动作
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
- Segment的put方法:真实的put数据是在这边做的
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 尝试获取锁,若获取锁失败则调用scanAndLockForPut方法尝试初始化一个HashEntry
HashEntry<K, V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K, V>[] tab = table;
// hash出插入到HashEntry的索引值,即第2次hash过程
int index = (tab.length - 1) & hash;
// entryAt方法中根据index索引值获取该内存位置上的HashEntry,调用的还是UNSAFE.getObjectVolatile方法
HashEntry<K, V> first = entryAt(tab, index);
for (HashEntry<K, V> e = first; ; ) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
// 若该index上值不为空且key值或key的hash值相同且value相同,则终止循环并返回oldValue值
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
// 若不满足if中的判断条件则设置e为index索引位置上的HashEntry链表的next,即对链表的下一个节点遍历
e = e.next;
} else {
if (node != null)
// 若刚开始没有获取到锁,但是调用scanAndLockForPut方法获取到锁且创建了node对象,且该node对象就是需要put的HashEntry值,
// 调用setNext其实是调用UNSAFE.putOrderedObject方法将first设置到node的偏移地址中,即为头插法,注意,这里first有可能为null
node.setNext(first);
else
// 若直接获取到锁了,且node为null,则创建HashEntry,注意这里new出的HashEntry的next是first节点,即为头插法
node = new HashEntry<K, V>(hash, key, value, first);
int c = count + 1;
// 若新增的HashEntry节点会导致HashEntry数组溢出,则调用rehash方法扩容并重新hash设置index值
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// 使用CAS将新创建的HashEntry设置到HashEntry数组的指定位置
setEntryAt(tab, index, node);
// 更新modCount的值,记录对HashEntry数组的大小造成影响的操作的数量
// 该值在ConcurrentHashMap获取size值的时候用于判断计算过程中有其他线程操作
++modCount;
// 更新count值,即元素的数量
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
- scanAndLockForPut方法
在Segment的put方法刚开始阶段获取不到锁时会进入此方法进行锁的获取,并有可能会直接new出需要put的HashEntry值
/**
* 在已有线程占用锁的时候进入该方法尝试获取锁
*/
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// 获取第一个HashEntry
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
// 用于控制tryLock次数
int retries = -1; // negative while locating node
// 不停的尝试获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
// 若segment的第一个HashEntry为空,设置node节点
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
// 代码逻辑进入到这里则说明HashEntry头结点不为空,注意此时node仍为null
retries = 0;
else
// 代码逻辑进入到这里则说明,HashEntry头结点不为空且key值与参数中的key equals为false
// 则对e的后续节点进行遍历,注意此时retries仍为-1,则有可能会一直遍历到HashEntry链表的末尾
// HashEntry链表的末尾的next为null,则下一次遍历会进入e == null的逻辑处理
e = e.next;
}
// MAX_SCAN_RETRIES的值很有趣,如果分配给Java虚拟机可用的CPU核数为1则该值等于1,若大于1则等于64
// 1.单核机器:其他条件都不满足时,最多 tryLock 2次便直接获取锁
// 2.2核及以上机器:其他条件都不满足时,最多 tryLock 65次便直接获取锁
else if (++retries > MAX_SCAN_RETRIES) {
// 等烦了,强制获取锁并结束循环
// 注意,若进入到这里,则根据上面的代码逻辑node已经完成初始化
lock();
break;
}
// retries为0时满足(retries & 1) == 0,则头结点不为空
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
// 代码逻辑进入到这里则说明头结点发生了变化,需要重置并进行处理
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
- rehash方法:对HashEntry数组扩容逻辑就是在这里做的
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 新的HashEntry数组长度扩大一倍
int newCapacity = oldCapacity << 1;
// 新的阈值
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,这里减一的原因我的理解如下
// 假设newCapacity为16,二进制为10000,若做与运算则低4位会全部为0,导致hash散列不均匀,减一后二进制为1111,散列会均匀些
int sizeMask = newCapacity - 1;
// 遍历旧的HashEntry数组
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 根据新掩码产生这个HashEntry元素在新的数组中的位置
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
// 若该数组位置上的链表只有一个节点则直接赋值设置
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
// HashEntry[i]位置上的链表超过一个节点,进行遍历
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
// 计算新位置
int k = last.hash & sizeMask;
// 若新位置与前面节点的新位置不同,则将lastIdx和lastRun设置为当前遍历到的位置的节点
// 这么做的最终结果是获取到最后一批(有可能只有一个节点)在新数组中位置相同的链表
// 直接插入到新的HashEntry数组位置上即可,这样可以减少极端情况下后续new HashEntry的动作
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将上面获取到的lastRun设置到新位置,不要忘记这里的lastRun对应的HashEntry元素可能是一个大于1个节点的链表
newTable[lastIdx] = lastRun;
// Clone remaining nodes
// 因为lastRun及其之后的链表已经处理好了,下面的代码逻辑只需要遍历lastRun之前的HashEntry链表,
// 将他们设置到指定位置即可
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
// 还是头插法
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
int nodeIndex = node.hash & sizeMask; // add the new node
// 最后将需要put的HashEntry元素
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
扩容方法中,Doug Lea做了一个极端情况下的链表重定向逻辑处理
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
// 计算新位置
int k = last.hash & sizeMask;
// 若新位置与前面节点的新位置不同,则将lastIdx和lastRun设置为当前遍历到的位置的节点
// 这么做的最终结果是获取到最后一批(有可能只有一个节点)在新数组中位置相同的链表
// 直接插入到新的HashEntry数组位置上即可,这样可以减少极端情况下后续new HashEntry的动作
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将上面获取到的lastRun设置到新位置,不要忘记这里的lastRun对应的HashEntry元素可能是一个大于1个节点的链表
newTable[lastIdx] = lastRun;
注意到代码中扩容时需要对HashEntry链表进行再散列到新HashEntry数组的对应位置,我们可以做一个假设,HashEntry原数组大小为8,现在需要扩容到16,源码中计算链表中的HashEntry元素在HashEntry数组中的位置的逻辑是使用元素的hash值和数组长度减一进行与运算。原数组长度为8时,就是元素hash值和1111进行与运算,扩容后就变成元素hash值和11111进行与运算,这个逻辑下有可能同一个链表中的一半数据不需要更换位置,也可能会出现我注释中说的一种极端情况就是有一大串连续的链表元素都需要更换位置,上面代码就是对这个极端情况进行处理,且注意,处理的是最后一段重定向位置相同的连续链表。
假设原链表如下
扩容到数组长度为4时,假设HashEntry3和HashEntry4重新hash后位置相同,都为2,则最后扩容后的效果如下,注意HashEntry1和HashEntry2是头插法所以顺序相反。
2.3. get方法
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 根据key值获取hash值,这个与put时使用的是同一个方法
int h = hash(key);
// 与put时相同的偏移,获取到在segment内存中的地址
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 使用CAS获取对应内存地址中的Segment值
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 使用for循环遍历HashEntry数组及数组每个位置上HashEntry的链表
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
// 若key值相同或key的hash值相同且key值内存地址相同则返回value
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get方法相对简单,我们注意到get过程中没有加锁,这就导致由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。如果要求强一致性,那么必须使用Collections.synchronizedMap()方法。
2.4. size方法
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 这里RETRIES_BEFORE_LOCK值默认为2,注意这里的retries是先使用再加1,所以最多会进行四次计算size值
// 第一次不加锁计算size值
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
// ensureSegment方法会初始化Segment所有位置的值
// 注意到这里对每个Segment都获取锁了,所以第四次计算size值时会阻塞put等需要锁的方法
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
// 获取每个Segment
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
// 将每个Segment的modCount值相加获取到最终的sum值
// modcount在put, replace, remove以及clear等方法中都会被修改
sum += seg.modCount;
// 该Segment中的元素数量,注意是HashEntry的数量
int c = seg.count;
// 注意,这里计算了size值
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 这里的逻辑如下:前三次获取到的modCount和相同则结束循环,若不相同则进行第四次的加锁统计
// 这里判断的原因是防止计算size的过程中出现其他线程对ConcurrentHashMap有修改
if (sum == last)
break;
last = sum;
}
} finally {
// 判断是否需要释放锁
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
// 若出现了count值异常则返回int的最大值2^31-1
return overflow ? Integer.MAX_VALUE : size;
}
在计算大小时首先不加锁根据Segment的成员变量modCount的和判断在计算过程中是否有线程对ConcurrentHashMap进行修改(modcount在put, replace, remove以及clear等方法中都会被修改),若前次计算modCount的和都不相同则加锁进行第四次计算,此时put方法等需要锁的方法都被阻塞,所以尽量不要频繁调用size方法。
2.5. 总结
- JDK1.7的ConcurrentHashMap使用的是Segment继承的ReentrantLock+CAS实现并发安全
- 决定Segment数组大小的是concurrencyLevel而不是initialCapacity(我以前误解了。。。)且初始化大小一定是2的幂次方
- initialCapacity和concurrencyLevel的比值越大,初始HashEntry数组的容量越大
- JDK1.7的ConcurrentHashMap的数据结构是Segment数组加HashEntry数组,若出现hash冲突但是key值不同的情况会在HashEntry数组上形成HashEntry链表
- 因为get方法没有加锁,所以会出现弱一致性的情况
- size方法若出现第三次计算会默认初始化所有的Segment数组点,且会在每个Segment上加锁,阻塞put等方法,尽量少调用(这个在JDK1.8版本有优化)
- HashEntry数组扩容时链表再散列使用的是头插法
- put一个key-value时,需要hash两次,一次是寻址segment,一次是寻址HashEntry
3. JDK1.8版本实现
在JDK1.8版本中,ConcurrentHashMap去除了Segment而使用Node,查看源码会发现Node的数据结构本身与HashEntry相同,在Node链表长度大于8时会转换为红黑树存储数据。并发安全方面使用CAS加synchronized关键字做并发安全,数据结构图如下。
当Node链表长度大于8时会将链表转换为红黑树,遍历时的时间复杂度由O(n)变为O(log(n))。
JDK1.8版本的ConcurrentHashMap代码及实现逻辑复杂许多,有许多知识参照链接,博文中若有错误请指正,
3.1. 关键属性
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
// table最大容量,为2的30次方
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认table初始容量大小
private static final int DEFAULT_CAPACITY = 16;
// 默认支持并发更新的线程数量
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// table的负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转换为红黑树的节点数阈值,超过这个值,链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 在扩容期间,由红黑树转换为链表的阈值,小于这个值,resize期间红黑树就会转为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 转为红黑树时,红黑树中节点的最小个数
static final int MIN_TREEIFY_CAPACITY = 64;
// 扩容时,并发转移节点(transfer方法)时,每次转移的最小节点数
private static final int MIN_TRANSFER_STRIDE = 16;
// 以下常量定义了特定节点类hash字段的值
static final int MOVED = -1; // ForwardingNode类对象的hash值
static final int TREEBIN = -2; // TreeBin类对象的hash值
static final int RESERVED = -3; // ReservationNode类对象的hash值
static final int HASH_BITS = 0x7fffffff; // 普通Node节点的hash初始值
// table数组
transient volatile Node<K,V>[] table;
// 扩容时,下一个容量大小的talbe,用于将原table元素移动到这个table中
private transient volatile Node<K,V>[] nextTable;
// 基础计数器
private transient volatile long baseCount;
// table初始容量大小以及扩容容量大小的参数,也用于标识table的状态
// 其有几个值来代表也用来代表table的状态:
// -1 :标识table正在初始化
// - N : 标识table正在进行扩容,并且有N - 1个线程一起在进行扩容
// 正数:初始table的大小,如果值大于初始容量大小,则表示扩容后的table大小。
private transient volatile int sizeCtl;
// 扩容时,下一个节点转移的bucket索引下标
private transient volatile int transferIndex;
// 一种自旋锁,是专为防止多处理器并发而引入的一种锁,用于创建CounterCells时使用,
// 主要用于size方法计数时,有并发线程插入而计算修改的节点数量,
// 这个数量会与baseCount计数器汇总后得出size的结果。
private transient volatile int cellsBusy;
// 主要用于size方法计数时,有并发线程插入而计算修改的节点数量,
// 这个数量会与baseCount计数器汇总后得出size的结果。
private transient volatile CounterCell[] counterCells;
// 其他省略
}
3.2. put方法
查看源码会发现put方法的核心实现逻辑是在putVal方法中,代码如下:
/**
* @param key:需要put的key
* @param value: 需要put的value
* @param onlyIfAbsent: 是否需要覆盖旧值,true:不需覆盖,false:需要覆盖,put方法默认覆盖
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 根据key的hashCode方法获取到的h值计算Hash值
// h ^ (h >>> 16)) & 0x7fffffff
int hash = spread(key.hashCode());
// 用于存放链表中节点数量
int binCount = 0;
// 使用自旋不断尝试put
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// Node数组不存在则进行初始化,默认大小为16
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 该索引下Node节点为空则直接New出节点插入,这里的插入使用的是CAS指令执行
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
// head节点为ForwadingNode类型节点,表示table正在扩容,将当前线程也加入到扩容中
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 若key已存在则将该key对应的value设置到oldVal进行return并根据方法传参onlyIfAbsent决定是否需要value覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// 若遍历到Node链表的最后都没有找到相同key值的节点则将当前需要put的数据插入到链表的最后
// JDK1.7是采用的头插法
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) {
// 根据链表长度判断是否需要转换为红黑树,TREEIFY_THRESHOLD=8
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 如果是插入新元素,则将链表或红黑树最新的节点数量加入到CounterCells中
addCount(1L, binCount);
return null;
}
我在代码的关键位置加了注释方便理解,可以看到putVal方法用到了很多方法,如下:
- spread:根据key的hashCode方法返回值计算元素的hash值
- initTable:初始化table,在首次执行put,computeIfAbsent,computIfPresent,compute,merge方法时调用。
- tabAt:用于定位key在table中的索引节点(链表的head节点)。
- casTabAt:采用Unsafe的compareAndSwapObject方法,用CAS的方式更新或替换节点。
- helpTransfer:若发现当前节点正在扩容帮忙扩容。
- treeifyBin:链表转红黑树
- addCount:链表或红黑树节点最新数量添加到CounterCell中。
- spread方法
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
查看源码注释发现作者的意图其实是防止散列不均匀而强制将高16位hash值下移然后与HASH_BITS对应的值0x7fffffff按位与 0x7FFFFFFF 的二进制表示就是除了首位是 0,其余都是1。在源码注释中作者举例连续整数的浮点键会产生hash冲突,导致散列不均匀。
- initTable方法
/**
* 使用sizeCtl的记录值初始化Mode数组
*/
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则说明有线程正在进行初始化动作
// 此时调用yield使当前线程进入就绪态,注意,这里并不会释放锁
Thread.yield(); // lost initialization race; just spin
}
// 使用CAS指令将SIZECTL设置为-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 二次校验待初始化Node数组为空(很重要,这里让我想起了二次校验的单例模式)
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY=16,即Node数组的默认大小为16
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);
}
} finally {
// 设置下次扩容大小
sizeCtl = sc;
}
break;
}
}
return tab;
}
initTable方法主要是负责初始化Node数组,主要的关键逻辑如下:
- 判断sizeCtl小于0则说明有线程正在进行初始化动作,此时调用yield方法但不释放锁等待其他线程初始化完毕
- 使用CAS指令compareAndSwapInt修改SIZECTL值为-1,标识有线程在进行初始化,并在初始化之前再次校验table是否为空
- tabAt方法和casTabAt方法
// 使用CAS指令获取指定位置的元素
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);
}
// 使用CAS指令修改Node数组指定位置的值
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);
}
tabAt方法的getObjectVolatile保证了每次从Node数组中读取某个位置链表引用的时候都是从主内存中读取的,与volatile关键字相似。
casTabAt方法调用的compareAndSwapObject也是使用CAS指令修改的Node数组指定位置Node值
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
for (Node<K,V>[] tab = table;;) {
...
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
...
}
...
}
如上代码,特别需要说明casTabAt在putVal方法中的调用,查看代码发现在调用casTabAt方法时并没有加锁,那么如何保证高并发场景下的插入是线程安全的呢,因为casTabAt实际调用的compareAndSwapObject方法是先比对再插入,在高并发场景下,假设同时有线程A和B进入到这个判断逻辑,线程A插入成功后该位置的值为非null,则线程B的插入会失败,返回false,进入下一次for循环,以上通过for循环+CAS操作,实现并发安全的方式就是无锁算法(lock free)的经典实现。
- addCount方法
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)) {
// 代码逻辑进入到这里则CounterCell不为空,并发修改BASECOUNT失败
CounterCell a; long v; int m;
boolean uncontended = true;
// 如果随机取余一个数组位置为空 或者
// 修改这个槽位的变量失败(出现并发了)
// 执行 fullAddCount 方法。并结束
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 获取Map中的元素个数
s = sumCount();
}
// 检查是否需要扩容
// check为节点链表长度
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 当元素个数>sizeCtl进行扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// rs = Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1))
// rs = n的最高非零位前面的0的个数 + 32768
// rs作为table长度的标识符
int rs = resizeStamp(n);
if (sc < 0) {
// 不需要辅助扩容
// 如果 sc 的高16位左移后不等于 长度标识符(校验异常 sizeCtl 变化了)
// 如果 sc == 标识符 + 1 (扩容结束了,不再有线程进行扩容)(默认第一个线程设置 sc ==rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1)
// sc == rs + 1为BUG,见https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427
// 如果 sc == 标识符 + 65535(帮助线程数已经达到最大)
// 如果 nextTable == null(结束扩容了)
// 如果 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))
transfer(tab, nt);
}
// 作为第一个扩容的线程
// 设置 sc = rs长度标识符的低16位左移至高16位 + 2
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
这里的addCount方法其实也为后面的size方法中计算ConcurrentHashMap大小时候服务,优化了JDK1.7版本中的效率问题。
- sumCount方法
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
// 当CounterCell[]为null时,说明没有并发竞争,直接返回baseCount
// 当不为null时,说明存在并发,则统计CounterCell[] + baseCount的总数
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
3.3. get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算hash值,与put时候调用的是同一个
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;
}
// hash值小于-1,即为红黑树,还记得之前定义的TreeBin节点的hash值吗
else if (eh < 0)
// 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;
}
}
return null;
}
我们看到get方法中首先根据key的hashCode计算了hash值,若头结点的key即为目标key则直接返回key对应的value。若此时链表已经转换为红黑树,则调用find方法查询红黑树中key对应的值并返回。若都不满足则最后使用while循环遍历链表获取数值。
3.4. size方法
public int size() {
// sumCount方法实际上是将baseCount的数值与CounterCell表中并发情况下插入的节点数量进行汇总累加得到一个值
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
4.ConcurrentHashMap在1.7和1.8的区别
对比项 | JDK1.7 | JDK1.8 |
---|---|---|
数据结构 | Segment+HashEntry | Node(数据结构与HashEntry一致) |
并发安全 | ReentrantLock+CAS | CAS+synchronized |
Hash次数 | Segment寻址1次,HashEntry寻址1次,总计2次 | Node寻址一次 |
遍历时间复杂度 | 链表的遍历时间复杂度O(n) | 链表的遍历时间复杂度O(n),转换为红黑树后的时间复杂度O(log(n)) |
计算size方式 | 利用Segment的modCount判断计算过程中是否被修改,若进行第四次统计则会加锁,影响其他线程修改 | 将baseCount的数值与CounterCell表中并发情况下插入的节点数量进行汇总累加得到一个值 |