文章目录
ConcurrentHashMap 1.7
一、数据结构
- ConcurrentHashMap在1.7的结构图如下:
-
ConcurrentHashMap由Segment数组组成,Segment继承了ReentrantLock可以提供锁的功能,也表示并发度,是最大并行访问的线程数量,每一个Segment内部包含一个HashEntry数组用于元素存储,
-
HashEntry则是一个K-V的存储单元,尾部可以挂HashEntry使用链地址法解决hash冲突
-
1.7中的ConcurrentHashMap源码大约1600行,去除大量注释外,我们只需要关注核心方法,
1.1 HashEntry
- 如下是HashEntry的核心属性,setNext用于线程安全的设置尾节点,使用CAS机制。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}
}
1.2 Segment
- ConcurrentHashMap由若干Segment组成(默认16),Segment继承ReentrantLock,数量表示并发度,是最大并行访问的线程数量,每一个Segment内部包含一个HashEntry数组用于元素存储
- 如下给出了Segment的属性和全部方法,但是省去了方法体,我们先了解Segment的方法,Segment内部持有HashEntry的数组并且具备锁的功能,它包含put、rehash、remove、clear等,ConcurrentHashMap很多方法底层也是调用这些方法
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//最大扫描尝试
static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
//保存元素的HashEntry
transient volatile HashEntry<K,V>[] table;
//元素个数
transient int count;
//修改次数
transient int modCount;
//阈值
transient int threshold;
//负载因子
final float loadFactor;
//构造函数
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
//省略...
}
//添加方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
}
//扩容方法
private void rehash(HashEntry<K,V> node) {
//省略...
}
//put操作扫描加锁
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//省略...
}
//remove和replace操作扫描加锁
private void scanAndLock(Object key, int hash) {
//省略...
}
//移除方法
final V remove(Object key, int hash, Object value) {
//省略...
}
//替换
final boolean replace(K key, int hash, V oldValue, V newValue) {
//省略...
}
//替换
final V replace(K key, int hash, V value) {
//省略...
}
//清除
final void clear() {
//省略...
}
}
二、初始化
- 容量(16):必须是2的幂次
- 负载因子(0.75):小于1
- 并发度(16):必须是2的幂次
- HashEntry(2): 在自定义的情况下会根据并发度和初始容量计算并且也是2的幂次,容量/并发度= HashEntry大小 ,比如容量是128并发度是16,那HashEntry大小就是8
- 初始化的时候只会初始化第一个Segment,其余的用的时候才会初始化
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
//省略其他初始化参数设置和校验
// create segments and segments[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];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
//初始化好的segments数组目前只包含第一个s0
this.segments = ss;
}
三、Segment方法
3.1 put
- put操作先定位Segment,再定位HashEntry,需要进行2次Hash操作,下面是先定位到Segment
public V put(K key, V value) {
Segment<K,V> s;
//1.value不能为null
if (value == null) throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
//2.定位Segemnt,如果为null就先初始化,CAS操作
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);
}
- 下面是调用Segment的put操作,操作需要加锁,如果tryLock失败成功就继续执行,如果tryLock失败,则进去scanAndLockForPut尝试一定次数的自旋,先看看tryLock成功的场景
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//1.tryLock成功,node为null
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
//3.entryAt通过hash定位并获取table中指定下标处的头元素(内部使用volatile保证线程安全)
HashEntry<K,V> first = entryAt(tab, index);
//4.从链表头结点开始遍历
for (HashEntry<K,V> e = first;;) {
//5.如果头结点不为空,那么往后遍历,一旦找到一样的key就替换并break,内部会处理onlyIfAbsent的情况
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;
}
break;
}
e = e.next;
}
//6.到这里有两种情况:第一种情况是first为空,说明定位到的HashEntry数组该位置没有元素,
//第二种情况是first不为空,但是链表遍历到最后了,链表中不存在key这个键,
//这两种情况都需要将新的key构造节点并插入
else {
//7.node不为null,说明在scanAndLockForPut里面自旋等待锁的时候,线程并没有傻等着,而是已经把节点构造好了,
//既然构造好了,那还等啥,直接头插法设置next即可,并且这个setNext是线程安全的,在前面的HashEntry已经提过
if (node != null)
node.setNext(first);
else
//8.到这里说明node还没构造好,可能是tryLock一下就成功了还没来得及构造节点,那就构造一下
node = new HashEntry<K,V>(hash, key, value, first);
//9.插入之后Segment持有的元素加一
int c = count + 1;
//10.判断是否需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
//11.如果不扩容,因为是头插法,node成了新的头,自然要把node设置到HashEntry数组的指定位置,
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//12.解锁
unlock();
}
//13.返回旧值
return oldValue;
}
- scanAndLockForPut:put操作加锁,里面会尝试查询key对应节点是否存在,如果没有则会预创建一个节点,这种预创建的思想有利于后面提高性能
- 并且内部尝试自旋的次数是受限的(单核1次多核64次),自旋过程会访问该HashEntry后面链接的元素,将其加入Cpu高速缓存利于提高性能,而且回在必要的时候把要插入的节点构造好后面就不需要再构造了,
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//1.获取头结点
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
//2.自旋尝试,尝试次数(单核1次多核64次)
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
//头结点为null,或者找到了尾节点,那么retries变量置0
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
//一旦找到,retries变量置0
retries = 0;
else
//不断遍历将变量加入到CPU高速缓存,提高性能
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
//这里retries为什么要为1?我猜测是这样的,也就是每一次retires置0之后的下一次循环一定会进来这里判断一次,假设头节点e为null
//构建了一个新的节点,那么下一次就会进入else if判断,会将retries自增为1,但是不会进去,因此就会执行下面第三个逻辑,进去之
//后就会检查头节点是否有改变,这样我们发现,也就是一旦做了构造新节点或者找到存在的节点的话,就会在下一个tryLock失败会后判
//断是否有其他线程改变了头节点
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
- 注意这里面有三个分支条件,每次循环只会走其中一个分支,第一个分支逻辑:持续遍历该链表(else),如果节点链中不存在要插入的节点,则预创建一个节点(if),如果存在,停止遍历(else if)
- 第二个分支逻辑:retries达到最大尝试次数,阻塞加锁,下次获取到锁后就会退出方法
- 第三个分支逻辑:retries为1,并且firsy被更改(因为此时自己还未获取到锁,first有可能被其他线程修改),如果修改过entryForHash会获取到新的头结点设置到first,然后重新遍历。
3.2 get
- get操作不需要加锁,先通过hash值定位到Segement,然后遍历HashEntry,代码就不贴了,核心在下面:
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
UNSAFE.getObjectVolatile(segments, u))
- 核心是利用UNSAFE.getObjectVolatile,根据指定内存的位置保证能够读取到变量的最新数据。
3.3 rehash
- 扩容条件:元素个数大于阈值触发扩容,
- 扩容什么?:注意扩容是针对HashEntry来说的,Segement指定之后是不会扩容的,也就是并发度不会修改,但是每一个Segement内部的HashEntry数组可扩容
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
//1.容量翻倍,计算新阈值,创建新HashEntry数组,(计算掩码用于后面与运算)
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
//2.循环遍历,复制旧数组的值到新数组
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
//3.不为null才需要复制过去,先计算新的下标
HashEntry<K,V> next = e.next;
int idx = e.hash & sizeMask;
//4.处理没有next的节点(一个slot桶里面只有一个节点)
if (next == null) // Single node on list
newTable[idx] = e;
//5.处理后面有后继节点的情况
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
//6.这里遍历是为了找到这样一个节点last,在laste后面的子
//节点在新数组中的位置都是一样的,比如链表后面跟了6个节点,假设编号1-6
//从低4个开始,456在新数组中的定位是一样的,那么就直接把4号节点就是lastRun
//然后直接把4号节点放到新数组即可,后面的56不需要复制了,节约复制的开销
for (HashEntry<K,V> last = next;last != null;last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//7.按照前面的解释,直接把4号节点复制过去
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//8.复制剩余节点,构造新节点加入到新数组
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);
}
}
}
}
//9.扩容过程中处理的是旧的数组,node其实还没有处理,将node加进去
int nodeIndex = node.hash & sizeMask; // add the new node
//node设置next,再直接插入
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
- rehash的过程是在加了锁之后做的,因此只会有一个线程扩容(对一个Segment而言),扩容就是一个复制的过程,注意所做的优化,就是避免没有必要的复制,这里面隐含了一个小知识,就是扩容之后原来的节点在数组中所处的下标只会有2种可能,要么不变,要么就是原来的下标加上x,(x是原来的容量也是新容量的一半)。
四、ConcurrentHashMap方法
4.1 size
- size不是Segment的方法,是ConcurrentHashMap的方法;
- size的原理是:求所有Segment的修改次数之和,连续计算2次,如果没有变化,那就说明过程中集合没有被修改,就返回size。如果不一致,那就会将前面的过程尝试一定次数,如果还不行,就需要加锁计算,加锁后会初始化全部的Segment。
- 由此可见:size是一个有可能影响效率的动作,
public int size() {
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 (;;) {
//1.如果尝试达到了限制,就加锁,默认是2次
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
//2.求所有的Segment的修改次数,注意写入才会改变modCount,读取不会
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
//3.如果连续两次一样,就返回
if (sum == last)
break;
//4.如果不一样,就记下当前的次数,待下一次统计之后再比较
last = sum;
}
} finally {
//5.如果加了锁,就解锁
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
- size操作在高并发下可能影响性能,因为有可能要对全部Segment加锁,
4.2 remove
- ConcurrentHashMap的remove方法是调用Segement的remove方法实现的
final V remove(Object key, int hash, Object value) {
//1.尝试加锁失败就加锁
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
//1.找到了Key
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
V v = e.value;
//2.检查value,如果指定了value在value相等时才删除,没有指定就相当于指定为null
if (value == null || value == v || value.equals(v)) {
if (pred == null)
setEntryAt(tab, index, next);
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
- remove方法没有太多特殊的,它需要加锁,另外会支持指定value的删除,如果只是指定key相当于value指定为null
4.3 containsKey
- containsKey判断是否包含key,会先定位到segment,然后遍历HashEntry数组
4.4 containsValue
- containsValue性能很差,因为它需要遍历全部的Segemnt来查找,同时遍历的时候会统计修改次数,如果被修改了,那么就需要加锁,有点类似于size方法,需要慎用
4.5 clear
- clear比较简单,就是遍历全部的Segment,调用Segment的clear方法,Segment的clear就是加锁,然后将所有节点置为null。
五、小结
- 初始化
1.延迟加载:只会初始化第一个Segemnt,其余的在用的时候才会初始化,
2.所有大小都是2的幂次,容量,并发度,HashEntry大小
3.默认并发度16,加载因子0.75,HashEntry大小2
- 如何提高性能并保证线程安全?
1.使用分段锁技术,Segment代表一个锁,不同Segment之间的加锁操作互不影响由此提高并发修改的线程
数来提高性能
2.既然Segemnt互不影响,不同Segment的写操作加锁自然就不存在线程安全问题,但是对于同一个Segement的写(删除)操作
需要加锁
- 加锁的操作和过程细节?
1.put,remove,replace等写操作需要加锁
2.先尝试获取锁,不行就会自选尝试一定次数的加锁,如果还不成功,就会阻塞式获取锁
3.加锁中自旋次数是1或者64,取决于CPU核心数,并且会不断访问链表上的数据保证其在高速缓存提高性能
4.加锁自旋的过程,可能会将需要插入的节点构造好(等待锁的时候做点事情,不要干等着啊)
- size操作
连续统计两次如果没有被修改,那就返回size,反之全局加锁,可能影响性能
- 扩容
1.扩容只针对Segment里面的HashEntry数组扩容,并发度不会变,元素个数超过阈值就会触发
2.只会单线程扩容(jdk8可以多线程扩容)
3.扩容通过复制的方式,用到了一个优化手段,将链表尾部定位到一个桶的元素全部挂过去,不用一个
一个复制
- ConcurrentHashMap的两个思想:懒加载和预创建
- Segement数量:建议CPU核心数+1(不过会转换为2的幂次),过小导致锁冲突高,过大的话,每一个Segemnt里面的元素少,CPU缓存命中率降低(前面说的put操作的时候,那个自旋里面的遍历操作没意义了,试想Segment无限大,ConcurrentHashMap就是一个数组,这时候前面的遍历没有意义,不能将可能要访问的数据加入到缓存)