上次的番外篇介绍了一下hashmap的实现原理,那么这次就来介绍一下concurrentHashmap的实现原理。
什么是concurrentHashmap?
顾名思义,concurrent = 同时发生的, 所以concurrentHashmap大概可以翻译为并发hashmap。它也确实可以用于处理高并发的场景。如果说Hashmap是一个公共无密码的储物箱,那么ConcurrentHashmap就是一个带锁的储物箱。concurrentHashmap 最大的特点在于锁分段技术,也就是segment,这是其区别于Hashmap的最大的特点。此外,concurrentHashmap修改了其基本存储单元Entry,其中的一些参数使用volatile进行修饰保证其可见性。(版本jdk7.0)
为什么我们要用ConcurrentHashmap?
在上次的Hashmap的介绍中我们粗略的介绍了一下Hashmap的问题点,就是在并发的情况下会出现环形链表导致死循环的出现。那经过了这些天的研究,我又更深层次的理解了一下环形链表的出现的原因。(版本为jdk7)
首先,我们知道hashmap是一个链表数组,其hash冲突的解决办法为链地址法。如图所示
在一般情况下该存储结构不会发生问题,发生问题导致产生环路的条件如下:
- 存在并发访问
- 需要进行扩容
我们结合源码看一下:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
// 找节点位置
int i = indexFor(hash, table.length);
//遍历数组,查找到了就返回原值如果没有就添加新entry节点
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i); // 注意这里,进行新的节点的添加
return null;
}
上述为put一个元素的源码
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 新增一个节点并将节点头指针改为这个新增的节点,因为第四个参数表示next对象
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold) //注意这里,进行扩容
resize(2 * table.length);
}
上述为添加新entry节点的源码,注意这里还是先添加节点后扩容的。而ConcurrentHashmap则是先判断是否需要扩容,如果需要扩容先扩容再添entry节点,这样的好处可以避免无效扩容(即扩容后不添加节点元素)
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity]; //1.0
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash); //1.1
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); //1.2
}
这是jdk7.0版本的扩容代码,主要分为三步:
1.0 --创建新数组
1.1 --复制原数组中的数据
1.2 --重新确定threshold(阈值)
再看一下transfer代码
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; //step 1.0
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//查找位置
e.next = newTable[i];//注意这里,将节点指向新节点
newTable[i] = e;//注意这里,头插法插入新数组
e = next;//注意这里,进行下一次的插入
}
}
}
这里的代码就比较有意思,它是将原数组中的值通过头插法插入到新数组中。接下来我们看一张图,
线程1还没开始扩容但准备扩容,也就是到了step 1.0的代码位置。这时该死的线程2来了,由于transfer函数中的table是公用的,也就是说每一个线程都有一个原数组的备份,table中e所指的对象都是同一个对象。有了这个前提,我们来看下面这张图
线程2完成了扩容,此时线程1才开始扩容(这个只是偶然情况,专业术语叫做竞态条件),那么线程1开始时指向了A 这个节点,执行后面的语句后A.next -> 新数组中的B, 又有B.next -> A ,于是就存在了环形链路。
出现了环形链表之后,在之后的读取(get)中会存在死循环
此外hashmap在多线程put非NULL元素后,get操作得到NULL值;多线程put操作,会导致元素丢失。有兴趣的技术人可以自行查阅一下。
相比于hashmap的问题,concurrentHashmap则有了更多的优势,首先它支持并发访问,也称作并发容器,其次,它先判断扩容后存储元素避免了无效扩容(扩容了但不插入节点的问题),再次,concurrentHashmap优化了entry的结构,用volatile修饰value和next使其保持可见性。最牛逼的一点,concurrentHashmap使用了锁分段技术,使得每一段(segment)都配上了一把锁,使得可以并发访问不同的分段。(JDK 7)
ConcurrentHashmap 源码分析
jdk7中的ConcurrentHashmap的类图大致如上图所示,下面开始源码分析:
先看一下hashEntry的代码
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value;
final HashEntry<K,V> next;
}
注意这里的value用volatile 修饰,保证其可见性,其他的成员变量都用final修饰,可以防止链表结构被破坏。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile int count; //Segment中元素的数量
transient int modCount; // 修改次数
transient int threshold;// 阈值(扩容临界值)
transient volatile HashEntry<K,V>[] table; // hashentry节点数组
final float loadFactor; //负载因子
}
了解完了节点的结构后,下面我们来看一下concurrentHashmap中的初始化方法
初始化concurrentHashmap
/* ---------------- Constants -------------- */
/**
* 最大的容量,是2的幂次方(java 数组索引和分配的最大值约为 1<<30,32位的hash值前面两位用于控制)
*/
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 数组容量,为2的幂次方,
* 1<=DEFAULT_CAPACITY<=MAXIMUM_CAPACITY
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 最大的数组容量(被toArray和其他数组方法调用时获取所需要)
*/
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 默认的并发等级.
* 为12、13、14、15、16表示segment数组大小默认为16
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 负载因子,考虑到红黑树和链表的平均检索时间,取0.75为宜。
* 这样接近O(1)
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
* >=8时进行扩容该节点会红黑树化
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
* <= 6 时进行扩容该节点仍为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小的链表数组容量(至少为4倍的TREEIFY_THRESHOLD。即32)
* 以防止扩容和红黑树化阈值的冲突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Minimum number of rebinnings per transfer step. Ranges are
* subdivided to allow multiple resizer threads. This value
* serves as a lower bound to avoid resizers encountering
* excessive memory contention. The value should be at least
* DEFAULT_CAPACITY.
*/
private static final int MIN_TRANSFER_STRIDE = 16;
/**
* 扩容戳,和resizeStamp函数有关
* Must be at least 6 for 32bit arrays.(至少6位以满足32位的数组)
* rs(RESIZE_STAMP_BITS) = 1 << (RESIZE_STAMP_BITS - 1)
* rs(6) = 1 << (6-1) = 32
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* 最大的可扩容线程数
* 线程在扩容时会将高RESIZE_STAMP_BITS作为扩容后的标记,高 32- RESIZE_STAMP_BITS 为作为扩容线程数
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
/**
* The bit shift for recording size stamp in sizeCtl.
* 扩容戳的位偏移
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// ...
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;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize - 1;
this.segments = Segment.newArray(ssize);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
//最小Segment中存储元素的个数为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//创建segments数组并初始化第一个Segment,其余的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);
this.segments = ss;
}
在该初始化中有一些参数:
- loadFactor 负载因子
- initialCapacity 初始化容量大小,等于段* 段的容量
- concurrencyLevel 并发级别,用于确定段的长度,如concurrencyLevel = 13,14,15,16 时segment段大小均为16
- sshift 表示并发级别(段个数)所占的位数,用于确定段偏移的大小。段偏移 = 32 - sshift 表示之后再散列时向右移位的位数,这个之后会讲到
- ssize 表示segment的大小,为不低于concurrencyLevel的2的幂次方。
- segmentShift 段偏移,之后会讲到,用于段的再散列
- segmentMask 段掩码,之后会讲到,用于段的再散列去取高位n位。
- MAXIMUM_CAPACITY 为最大的段个数
- c,cap 用于确定每个segment的容量,也为2的幂次方,loadfactor也适用于每个segment中的对象。
初始化的过程介绍:
- 进行参数验证
- 判断并发等级是否超过最大值,如果超过就设置并发等级为最大值、
- 根据并发等级获取ssize(段的长度)以及sshift
- 计算segmentshift(段偏移) = 32 - sshift,之后在再散列时确定需要与运算的高位数据偏移量(高位向右移动的位数,使得高位变低位)。
- 计算segmentmask(段掩码) = ssize -1, 即取偏移后低位segmentmask位进行再散列。(即原高位n为的数据可以决定段位置)
- 计算每个segment中hashEntry的容量。即为cap,默认情况下initialCapacity等于16,loadFactor等于0.75,通过计算cap=1,threshold=0。
往concurrentHashmap插入元素
首先我们来看一下Segment的结构
图片来源:
https://blog.csdn.net/m0_37135421/article/details/80551884
static final class Segment<K, V> extends ReentrantLock implements Serializable {
/**
* scanAndLockForPut中自旋循环获取锁的最大自旋次数。
*/
static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
/**
* 链表数组,数组中的每一个元素代表了一个链表的头部
*/
transient volatile HashEntry<K, V>[] table;
/**
* 用于记录每个Segment桶中键值对的个数
*/
transient int count;
/**
* 对table的修改次数
*/
transient int modCount;
/**
* 阈值,Segment里面元素的数量超过这个值依旧就会对Segment进行扩容
*/
transient int threshold;
/**
* 负载因子,用于确定threshold,默认是1
*/
final float loadFactor;
}
static final class HashEntry<K, V> {
final int hash;
final K key;
volatile V value; //设置可见性
volatile HashEntry<K, V> next; //不再用final关键字,采用unsafe操作保证并发安全
}
segment使用了可重入锁reentrantlock来保证每次对段的操作具有原子性,每次在对某一个段进行操作时,首先获取段的锁,之后进行操作。并且段与段之间的操作由于存在不同的锁因此互不干扰。
下面来看一下put方法
// ConcurrentHashMap类的put()方法
public V put(K key, V value) {
Segment<K,V> s;
//concurrentHashMap不允许key/value为空
if (value == null)
throw new NullPointerException();
//hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
int hash = hash(key);
//返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment,即进行再散列操作
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null) s = ensureSegment(j);
// 调用Segment类的put方法
return s.put(key, hash, value, false);
}
// Segment类的put()方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 注意这里,这里进行加锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value); //如果加锁失败,则调用该方法
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 根据hash计算在table[]数组中的位置
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) { //若不为null,则持续查找,知道找到key和hash值相同的节点,将其value更新
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;
}
else { //如果在链表中没有找到对应的node
if (node != null) //如果scanAndLockForPut方法中已经返回的对应的node,则将其插入first之前
node.setNext(first);
else //否则,new一个新的HashEntry
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 判断table[]是否需要扩容,并通过rehash()函数完成扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else //设置node到Hash表的index索引处
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
put操作的步骤:
- 判断value是否为空
- 对key进行散列
- 根据key的散列值确定数据存储的segment(段)的位置
- 将key,value 键值对插入到segment中的hashEntry中,如果存在就返回旧值,如果不存在就创建新节点。注意这里加锁进行插入。
从concurrentHashmap获取元素
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//先定位Segment,再定位HashEntry
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
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操作就相对比较简单,只要根据再散列的值确定段位置,之后根据键值对确定hashentry的位置即可。
concurrentHashmap如何实现扩容?
- 首先判断segment 里面的hashentry数组是否达到阈值,如果超过了,就进行扩容,之后插入元素
- 扩容一般为2倍扩容,将原数组中的元素进行再散列后插入新数组。为了高效,concurrentHashmap只对某个segment 进行扩容而不对整个容器进行扩容。
-------------------------- 未完待续 -----------------------------