ConcurrentHashMap源码分析(2)——JDK1.8的实现
ConcurrentHashMap是线程安全且高效的HashMap的实现,在并发编程的情景下使用的非常广泛。它是怎么来的呢?
通过对HashMap的分析,我们知道当插入的元素超过临界值就会触发HashMap的扩容机制(也就是rehash,重新计算元素位置,将其都扔进新容器中)。在多线程的场景下,可能会造成闭环链表,进而导致get操作会出现死循环。因此,HashMap可以说是线程不安全的。
HashTable和HashMap的原理差不多,唯一的区别在于:1.HashTable不允许K和V为null;2.HashTable是线程安全的。但是,它在多线程下所有的get/put的操作都加上了synchronized关键字来锁住整个table。也就是说,我给整个哈希表加上了一把大锁。所有的线程都在竞争一把锁,只要有一个线程访问到或者操作一个对象,那其他线程就只能阻塞。将操作串行化,它是安全的,但是简单粗暴的手段带来的弊端就是HashTable的效率低下。
其实吧,也并不是没药了。HashTable也不是没有优化的空间了,它性能差主要就是因为所有的操作都得去竞争一把锁。这一把锁锁住整个table简直不要太粗暴,完全可以温柔一点。比方说,以前我只用一把锁,现在我多加几把锁,每一把锁负责锁一段数据。这样一来,在多线程的情景下,我对不同的数据集的操作就不用竞争一把大锁了。毕竟他们拥有不同的hash值,不会因为rehash造成线程不安全,彼此之间保持距离、互不影响,从而使得并发效率也可以得到极大的提升!上述这一过程,体现的就是ConcurrentHashMap的“分段锁”思想(也叫锁分离),引入多把锁分别对应控制多个小table,借此来降低锁的粒度(当然这只是jdk1.7版本的核心思想,jdk1.8在降低锁粒度方面更加的“过分”)。
虽然是盗来的图,但是这张图确实画的好。放眼望去,N把锁,每个锁由一个Segment数组和多个HashEntry组成,主干还是Segment数组。
final Segment<K,V>[] segments;
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;//额外说一下,这个volatile作用很大!!!
......
}
通过分段锁的思想,已经将整个table切分加锁。和HashMap的数据存储结构类似,ConcurrentHashMap的每一个Segment元素存的都是HashEntry数组+链表,而HashEntry是它最小的逻辑处理单元了。
通过上面这张图,我们可以清晰得知,在ConcurrentHashMap中,一个Segment可以理解为就是一个子哈希表,Segment里维护了一个HashEntry数组。并且Segment继承了ReentrantLock,是一种可重入锁(ReentrantLock)。所以,并发环境下,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑(默认16个线程并发执行,why are you so diao?)。
都说Segment可以理解成是自哈希表了,那么它跟HashMap肯定就有一些神似之处。
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;//加载因子
this.threshold = threshold;//阈值
this.table = tab;//主干数组即HashEntry数组
}
好好观察下Segment的构造方法,loadFactor、threshold是不是看着很熟悉?
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//MAX_SEGMENTS 为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;
//ssize 为segments数组长度,根据concurrentLevel计算得出
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
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;
}
构造方法有3个参数,分别为initialCapacity(数组大小)、loadFactor(加载因子,扩容相关)和concurrencyLevel(并发度,默认16个线程并发执行)。其中,segments数组长度ssize和concurrentLevel有关,ssize总是>=concurrentLevel的最小2次幂。
如果觉得这么描述不好理解,那么举个栗子就清楚了。比如concurrentLevel=17,2的几次幂刚好>=17呢?是32对吧!所以ssize=32。
更重要的是,Segment的数组大小之所以一定是2的次幂,就是为了方便通过按位与的散列算法来定位Segment的index位置。
put操作的步骤:
1.对value判空
2.hash(key)对key的hashcode重新散列
3.对Segment定位并确保已初始化
4.Segment为空,调用ensureSegment()方法;否则,直接调用查询到的Segment的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 // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//如果获取到的Segment为空,就调用ensureSegment(j)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
关于对Segment定位那块,hash值无符号右移segmentShift位与段掩码进行位运算,最终得到Segment位置。其中,SegmentMask(段掩码)是为了确保散列的均匀性(计算方式为segments数组长度-1);segmentShift计算方式为segmentShift=32-sshift,其中2的sshift次方等于ssize。比如segments数组长度16,2的n次方=16,segmentShift=32-n。
另外,值得注意的是,put方法首先也会通过hash算法定位到对应的Segment,此时,如果获取到的Segment为空,则调用ensureSegment()方法;否则,直接调用查询到的Segment的put方法插入值,注意此处并没有用getObjectVolatile()方法读,而是在ensureSegment()中再用volatile读操作,这样可以在查询segments不为空的时候避免使用volatile读,提高效率。在ensureSegment()方法中,首先使用getObjectVolatile()读取对应Segment,如果还是为空,则以segments[0]为原型创建一个Segment对象,并将这个对象设置为对应的Segment值并返回。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);//tryLock不成功时会遍历定位到的HashEnry位置的链表(遍历主要是为了使CPU缓存链表),若找不到,则创建HashEntry。tryLock一定次数后(MAX_SCAN_RETRIES变量决定),则lock。若遍历过程中,由于其他线程的操作导致链表头结点变化,则需要重新遍历。
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;//定位HashEntry,可以看到,这个hash值在定位Segment时和在Segment中定位HashEntry都会用到,只不过定位Segment时只用到高几位。
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;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//若c超出阈值threshold,需要扩容并rehash。扩容后的容量是当前容量的2倍。这样可以最大程度避免之前散列好的entry重新散列。扩容并rehash的这个过程是比较消耗资源的。
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
在Segment的put方法中,首先需要调用tryLock()方法获取锁,然后通过hash算法定位到对应的HashEntry,然后遍历整个链表,如果查到key值,则直接插入元素即可;而如果没有查询到对应的key,则需要调用rehash()方法对Segment中保存的table进行扩容,扩容为原来的2倍,并在扩容之后插入对应的元素。插入一个key/value对后,需要将统计Segment中元素个数的count属性加1。最后,插入成功之后,需要使用unLock()释放锁。
get操作的步骤:
1.hash(key)对key的hashcode重新散列(既然已经存进来了,k、v肯定不为null,所以不用再判断了)
2.定位Segment
3.定位HashEntry
4.通过getObjectVolatile()方法获取指定偏移量上的HashEntry
5.通过循环遍历链表获取对应值
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
//hash(key)对key的hashcode重新散列
int h = hash(key);
//先定位Segment
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//再定位HashEntry-->(((tab.length - 1) & h)) << TSHIFT) + TBASE;-->getObjectVolatile()方法获取指定偏移量上的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;
}
在jdk1.7中ConcurrentHashMap的get操作并没有加锁,因为在每个Segment中定义的HashEntry数组和在每个HashEntry中定义的value和next HashEntry节点都是volatile类型的,volatile类型的变量可以保证其在多线程之间的可见性,因此可以被多个线程同时读。
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j) ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
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;
} }
if (sum == last) break;
last = sum; } }
finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
针对并发插入数据时计算ConcurrentHashMap的大小,有两种方式:
方式一:不加锁而多次计算size,若前后两次结果一致则表示准确(最多3次)
方式二:如果两次结果不同,就对所有的Segment加锁来计算size