文章目录
ConcurrentHashMap
之前我们已经分析完HashMap的源码,也知道了HashMap内部的相关运行机制,可是HashMap本身并不是一个线程安全
的容器类。那有线程安全的HashMap吗? 当然有,HashTable就是线程安全的,但是它十分低效。当然,JUC包有提供一个线程安全且高效的HashMap实现,那就是ConcurrentHashMap。
本文就针对ConcurrentHashMap的实现原理进行分析,基于JDK1.7版本。
ConcurrentHashMap源码分析
ConcurrentHashMap内部大致结构
ConcurrentHashMap采用的是“分段锁”的策略,ConcurrentHashMap的底层结构就是一个Segment数组。
final Segment<K,V>[] segments
我们来看一下Segment类:
1. Segment类
static final class Segment<K,V> extends ReentrantLock implements Serializable
我们可以看到Segment类继承ReentrantLock类,至于ReentrantLock,可以看一下JDK源码系列 ReentrantLock 公平锁和非公平锁的实现原理,其实它和Synchronized关键字类似,都是可重入锁,但是功能比Synchronized关键字强大。
既然Segment继承了ReentrantLock,那么说明Segment本身就是一个锁。
Segment类似一个HashMap,一个Segment维护着一个HashEntry数组。
transient volatile HashEntry<K,V>[] table;
HashEntry类是我们真正用来存储的key-value键值对的数据结构,也是ConcurrentHashMap的最小逻辑存储单元。一个ConcurrentHashMap内部维护着一个Segment数组,每个Segment各自维护着一个HashEntry数组。
HashEntry实现:
/*HashEntry和Hash的Node节点基本一模一样 都是一个链表结构*/
static final class HashEntry<K,V> {
final int hash; //存储hash值
final K key;
volatile V value;
volatile HashEntry<K,V> next; //当前节点下一个节点的指针
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
/*设置下一个节点*/
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}
/*Unsafe*/
static final sun.misc.Unsafe UNSAFE;
/*next节点的地址偏移量*/
static final long nextOffset;
/*静态块 通过UnSafe获取当前节点的下一个节点的地址偏移量*/
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
我们可以看到其实HashEntry和我们之前分析的JDK8的HashMap的内部类Node几乎一模一样。
不过这里出现了点新东西UnSafe
类,其实也不算新东西,这个类在JUC包中十分常见。有兴趣可以自己去了解一下,这个类非常重要,这里不赘述。
1.1 Segment参数分析:
/*存储key-value键值对*/
transient volatile HashEntry<K,V>[] table;
/*Segment内存存储元素的数量*/
transient int count;
/*fast-fail操作 遍历该Segment时 若其他线程对该Segment进行更改操作 抛出异常*/
transient int modCount;
/*阈值*/
transient int threshold;
/*负载因子*/
final float loadFactor;
/*scanAndLockForPut方法tryLock的最大次数 单核1 多核64*/
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
这些参数和HashMap的参数几乎一样,就不做过多的讲解。
1.2 Segment构造器
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf; //设置负载因子
this.threshold = threshold; //设置阈值
this.table = tab; //设置该Segment的table数组
}
1.3 Segment的核心API
put(K key, int hash, V value, boolean onlyIfAbsent)
/*onlyIfAbsent为true的话,若该key原本就存在table中 那么value不进行更新*/
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//tryLock是ReentrantLock的方法 尝试获取锁
//在进行写入过程成 先获取当前Segment的独占锁
//若获取锁成功 则node设置为null 若没有获取到锁 则采用scanAndLockForPut方法
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
//旧的值
V oldValue;
try {
/*获取Segment的内部数组*/
HashEntry<K,V>[] tab = table;
/*获取key值在table中的位置*/
int index = (tab.length - 1) & hash;
/*获取桶的第一个节点*/
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))) {
/*若命中 获取旧值*/
oldValue = e.value;
if (!onlyIfAbsent) {
//修改值
e.value = value;
//modCount加一 用于快速失败
++modCount;
}
break;
}
e = e.next;
}
else {
/*如果node不为空 即tryLock时没有获取独占锁 设置node的下一个节点为first*/
if (node != null)
node.setNext(first);
else
//若node为空 直接新建一个节点 插入当前链表的头部
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
/*若当前table容纳的元素个数超过阈值 则进行扩容*/
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
/*若没有达到阈值 将node链表插入当前桶*/
setEntryAt(tab, index, node);
++modCount; //fast-fail
count = c;
oldValue = null; //help GC
break;
}
}
} finally {
unlock(); //解锁
}
return oldValue;
}
整个put的流程梳理一下:
- 先通过tryLock尝试获取当前Segment的独占锁,获取失败的话,调用
scanAndLockForPut(key, hash, value)
方法。成功则进入下一步。 - 根据key的hash值获取它在当前哈希表中桶的位置。
- 对桶进行遍历,若找到匹配key,根据onlyIfAbsent的值判断是否对value进行更新,操作完,退出循环。
- 若没有找到匹配的key,新建一个Entry节点。
- 若当前table存储元素个数超过阈值,进行扩容操作。
- 若没有超过阈值,则将新建节点设置为当前桶的头结点。
我们来分析一下在put中出现的函数:
scanAndLockForPut(key, hash, value) : 当put方法快速尝试获取独占锁失败时,会进入该方法。
private ConcurrentHashMap.HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
/*通过Segment和hash值获取当前key值在table中的位置 并返回桶的第一个结点*/
ConcurrentHashMap.HashEntry<K,V> first = entryForHash(this, hash);
ConcurrentHashMap.HashEntry<K,V> e = first;
ConcurrentHashMap.HashEntry<K,V> node = null;
/*retries为重试次数*/
int retries = -1; // negative while locating node
/*循环 尝试获取锁 直至获取锁成功*/
while (!tryLock()) {
ConcurrentHashMap.HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
/*若第一个节点为空*/
if (e == null) {
if (node == null)
/*这里顺便初始化node节点*/
node = new ConcurrentHashMap.HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
/*若key值匹配*/
else if (key.equals(e.key))
retries = 0;
else
/*遍历*/
e = e.next;
}
/*若重试的次数超过MAX_SCAN_RETRIES 则直接使用lock()*/
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
/*当头结点由于并发原因有新的节点插入了链表成为新的表头 则重置头结点和retries 相当于重新进行一次scanAndLockForPut方法*/
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
我们来理一下scanAndLockForPut的过程,它是通过循环tryLock获取锁的。这个过程中会增加retries的值,若大于MAX_SCAN_RETRIES的话(以免自旋获取锁时间过长占用过多资源),直接进行lock(),进入Sync队列中等待获取锁,lock()方法会阻塞当前线程,直至当前线程获取独占锁成功。(使用lock的话会让当前线程进入WAITING状态(不会占用CPU资源),直至被LockSupport.unpark()唤醒)
而(retries & 1) == 0 &&(f = entryForHash(this, hash)) != first
判断说明当前的Segment有其他线程进行put操作且已经释放了锁,那么当前线程有很大可能能重新获取锁,于是就重置retries和first的值。
其实scanAndLockForPut就做了一件事,使当前线程获取Segment的独占锁,顺带实例化一下node节点(可能执行也可能不执行 不执行是直接一次tryLock就获取了锁)。
rehash(node) 函数:
当执行put的时候,新建节点若使count超过阈值,则会执行该方法:
private void rehash(ConcurrentHashMap.HashEntry<K,V> node) {
// 获取table
ConcurrentHashMap.HashEntry<K,V>[] oldTable = table;
// 旧的capacity
int oldCapacity = oldTable.length;
// 扩容一倍
int newCapacity = oldCapacity << 1;
// 新的阈值
threshold = (int)(newCapacity * loadFactor);
// 创建新的table
ConcurrentHashMap.HashEntry<K,V>[] newTable =
(ConcurrentHashMap.HashEntry<K,V>[]) new ConcurrentHashMap.HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
ConcurrentHashMap.HashEntry<K,V> e = oldTable[i];
if (e != null) {
ConcurrentHashMap.HashEntry<K,V> next = e.next;
//获取e在新table中的位置
int idx = e.hash & sizeMask;
/*若next结点为空*/
if (next == null) // Single node on list
/*直接移动到新的Table*/
newTable[idx] = e;
else {
//不为空 进行遍历 e的链表的表头
ConcurrentHashMap.HashEntry<K,V> lastRun = e;
int lastIdx = idx;
//e的下一个结点开始遍历
for (ConcurrentHashMap.HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
newTable[lastIdx] = lastRun;
/*再次遍历链表*/
for (ConcurrentHashMap.HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
ConcurrentHashMap.HashEntry<K,V> n = newTable[k];
/*采用头插法插入*/
newTable[k] = new ConcurrentHashMap.HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
/*将新的结点添加到table中*/
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
我们来看一下这个for (ConcurrentHashMap.HashEntry<K,V> last = next; last != null; last = last.next)
这个for循环做了什么。
其实就是找到lastRun这个位置,然后将后面的节点进行一个整体的搬迁。注意: 后面的节点高位全是1。
其实我还是觉得JDK8 HashMap那样采取loHead,hiHead比较好一点,只需要一次for循环。
因为该这个for循环可能会遍历到最后一个节点,那么此处for循环对下面的for循环并没有任何帮助。
其实rehash的过程和HashMap的resize过程一样的,只是细节有点不一样而已,而且这里容量为2的幂次的好处也不再赘述。具体可以看JDK源码系列 HashMap源码剖析。
remove(Object key, int hash, Object value)
接下来我们来看下remove方法:
final V remove(Object key, int hash, Object value) {
/*尝试获取独占锁*/
if (!tryLock())
/*获取不到则进入scanAndLock(key, hash)方法获取锁*/
scanAndLock(key, hash);
V oldValue = null;
try {
ConcurrentHashMap.HashEntry<K,V>[] tab = table;
/*获取key在table中的锁*/
int index = (tab.length - 1) & hash;
ConcurrentHashMap.HashEntry<K,V> e = entryAt(tab, index);
/*记录前一个*/
ConcurrentHashMap.HashEntry<K,V> pred = null;
/*对桶进行遍历*/
while (e != null) {
K k;
ConcurrentHashMap.HashEntry<K,V> next = e.next;
//命中
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
/*值匹配*/
if (value == null || value == v || value.equals(v)) {
/*如果前驱节点为空*/
if (pred == null)
/*直接设置next节点为表头*/
setEntryAt(tab, index, next);
else
/*将前驱节点的后继指针指向当前节点的后继节点*/
pred.setNext(next);
++modCount; //fast-fail
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
remove操作十分简单:先进行一次快速获取锁,若失败,则进入scanAndLock(key, hash)
方法尝试获取锁,该方法和上面的scanAndLockForPut
几乎相同,都是尝试获取锁,直至成功。然后获取锁成功后,遍历桶,删除目标节点。
replace方法和remove方法相似,就不赘述了。
clear()
final void clear() {
lock(); //加锁
try {
//遍历哈希表
ConcurrentHashMap.HashEntry<K,V>[] tab = table;
for (int i = 0; i < tab.length ; i++)
setEntryAt(tab, i, null);
++modCount;
count = 0;
} finally {
unlock();
}
}
说个小细节: ReentrantLock不会自动释放锁,所以我们需要将解锁任务放在finally块中保证锁资源一定会释放。
以上就是Segment的全部内容,相比HashMap1.7版本就是多了一个锁的释放和锁的获取,以及采用UnSafe类通过地址偏移量进行值的相关操作。
2.ConcurrentHashMap相关API
分析完Segment相关的API,接下来我们来对ConcurrentHashMap进行分析。
2.1 ConcurrentHashMap参数
//Map默认的初始大小为16 一定是2的幂次
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认的负载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认的并发级别
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//map的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//每个Segment的最小容量 必须是2的幂次
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大的Segment的数量 必须是2的幂次
static final int MAX_SEGMENTS = 1 << 16;
//这个参数在调用size()的时候会需要 即在进行加锁前重试的次数为2
static final int RETRIES_BEFORE_LOCK = 2;
//以下参数后面再重点讲解
final int segmentMask;
final int segmentShift;
2.2.ConcurrentHashMap构造器
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//Map的最大并发等级为1<<16=65536,也就是最大并发数为65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
//2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//这两个变量用来定位Segment 下面再讲
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
//initialCapacity是整个Map的容量大小
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//这里根据initialCapacity计算每个Segment数组中可以分到的容量大小
//例如initialCapacity为64, 那么16个Segment的话,每个容量为4
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
//cap的最小值是2 因为cap*0.75约为2,不会导致Segment插入一个数就马上扩容
while (cap < c)
cap <<= 1;
// 创建Segment数组 并创建数组的第一个元素Segment[0] 其余的Segment延迟初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//往数组写入segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
当我们使用构造函数构造Segment数组后,Segment数组的大小将不能再发生改变。
通过构造函数我们来将一下几个参数的具体作用:
DEFAULT_CONCURRENCY_LEVEL:Concurrent容器的并发级别,默认是16,说明同一时间可以有16个线程对HashMap进行更改操作。支持最大的并发量为1<<16(65536)。
关于segmentMask和segmentShift两个值的作用 :
segmentMask和segmentShift这两个全局变量的主要作用是用来定位Segment的。
int j=(hash>>>segmentShift)&segmentMask
segmentMask: 段掩码,假如segment数组长度为16,则段掩码为16-1=15;segment数组长度为32,则段掩码为32-1=31。这样子得到的二进制低位bit都是1,可以更好地保证散列的均匀性。(这个地方和根据key的hash值寻找在哈希表的位置其实是一样道理的(hash&len-1
))。
segmentShift: 由上面代码可得构造器2的sshift幂次等于ssize,segmentShift=32-sshift。由上面代码可得,Segment数组的大小ssize是由concurrentLevel所决定的,而且ssize必定是2的幂次。
若Segment数组的长度为16,那么sshift为4。且计算出来的hashCode是32位的,那么segmentShift则为16-4=12,经过无符号左移segmentShift后,只保留了高4位(其余的位置不需要),然后与segmentShift进行位运算来定位到Segment。
小技巧:如果当前哈希表容量为16,那么会取高四位来进行Segment的定位。其实高几位我们完全可以由segmentMask来获取。假设容量是16,那么segmentMask-1=15,其二进制是0000 0000 0000 1111,则取hash的高4位。若容量是32,那么segmentMask-1=31,其二进制是0000 0000 0001 1111,取hash的高5位来定位。
2.3.ConcurrentHashMap的核心API
put(K key, V value):
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
//通过hash函数计算出当前的hash
int hash = hash(key.hashCode());
//通过segmentShift和segmentMask来定位Segment的位置
int j = (hash >>> segmentShift) & segmentMask;
//调用UnSafe类获取相应的Segment 若为空 调用ensureSegment
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
//否则调用Segment类的put函数
return s.put(key, hash, value, false);
}
put函数的流程:
- 调用hash函数对key的hashCode进行再hash。
- 调用
hash >>> segmentShift) & segmentMask
获取Segment的定位。 - 如果定位的Segment还为初始化,调用ensureSegment(j)进行初始化。
- 调用Segment的put函数。
我们来看一下ensureSegment函数:
ensureSegment: ConcurrentHashMap初始化的时候会初始化第一个槽Segment[0]。对于其他槽来说,在插入第一个值的时候需要进行初始化。
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
//获取该Segment的地址偏移量
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//获取segment[0]
Segment<K,V> proto = ss[0];
//获取segment[0]的长度
int cap = proto.table.length;
//获取segment[0]的负载因子
float lf = proto.loadFactor;
//获得阈值
int threshold = (int)(cap * lf);
//重新检查segment[k]是否已经被其他线程初始化
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//新建一个segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//再次判断segment[u]是否被初始化
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//采用cas来进行初始化
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
ensureSegment的作用就是用来进行初始化Segment的,对于并发操作采用CAS来进行控制。
putIfAbsent(K key, V value): 作用是不存在即创建,若存在则不进行修改。
public V putIfAbsent(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, true);
}
代码和put基本一致,只是调用Segment的put函数时,只是了onlyIfAbsent为true,该值的作用之前有解释,不做赘述。
get(Object key): 获取指定key值的value
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key.hashCode());
//对Segment进行定位
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//若该槽已经初始化
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//通过tab.length - 1) & h定位在table中的桶的位置 再进行遍历
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;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get函数执行流程:
- 计算 hash 值,找到 segment 数组中的具体位置定位。
- 找到当前key值在Segment的table数组中的具体位置。
- 遍历桶,存在则返回对应value,否则返回null。
其他API基本都是get的衍生,例如containsKey,replace,这些就不做分析了。
我们着重来看一下size() 这个函数的源码:
public int size() {
//获取Segment数组
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; //判断是否溢出
long sum; //计算modCount的总和
long last = 0L; // previous sum
int retries = -1; // 重试的次数
try {
for (;;) {
//如果统计两次都发生modCount值增加的话 直接锁住整个Segment数组
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
//遍历Segment数组
for (int j = 0; j < segments.length; ++j) {
//获取segment[j]
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
//加上modCount
sum += seg.modCount;
//获取segment[j]存储元素的个数
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum; //记录上一次的sum
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
ConcurrentMap是采用分段锁实现的,那么当我们想统计所有整个ConcurrentHashMap所存储的容量时,可以调用size()函数来获取。
我们来看看是源码的实现size()的流程。
- 先遍历一次记录所有槽的modCount的总和sum。
- 再进行两次遍历,若统计过程中sum没有发生变化,说明其间没有线程对Segment数组进行更改操作,那么直接返回答案。
- 若两次遍历后,sum都发生变化,那么将锁住整个Segment数组进行统计。
以上便是,ConcurrentHashMap的全部源码讲解,若有错误的地方,请指出,感谢。
参考文章