一、出现背景
1、线程不安全的HashMap
先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成,具体原因自行百度google或查看源码分析,参考https://blog.csdn.net/diweikang/article/details/89198811),导致get操作时cpu空转,所以,在并发环境中使用HashMap是非常危险的。
2、效率低下的HashTable容器
HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
3、ConcurrentHashMap的锁分段技术
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁。那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术(分段锁)。首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。另外,ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,不用对整个ConcurrentHashMap加锁。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构。 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
二、ConcurrentHashMap类的定义
2.1、成员变量
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>, Serializable {
/**
* 在构造函数未指定初始大小时,默认使用的map大小
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 默认的扩容因子,当初始化构造器中未指定时使用。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 默认的并发度,这里所谓的并发度就是能同时操作ConcurrentHashMap(后文简称为chmap)的线程的最大数量,
* 由于chmap采用的存储是分段存储,即多个segement,加锁的单位为segment,所以一个cmap的并行度就是segments数组的长度,
* 故在构造函数里指定并发度时同时会影响到cmap的segments数组的长度,因为数组长度必须是大于或等于并行度的最小的2的幂。
*/
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 每个segment的HashEntry最小容量
*/
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
/**
* 分段最大的容量(最大的segment的数量)
*/
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
/**
* 默认自旋次数,超过这个次数直接加锁,防止在size方法中由于不停有线程在更新map
* 导致无限的进行自旋影响性能,当然这种会导致ConcurrentHashMap使用了这一规则的方法 如size、clear是弱一致性的。
*/
static final int RETRIES_BEFORE_LOCK = 2;
/**
* 用于索引segment的掩码值,key哈希码的高位用于选择segment
*/
final int segmentMask;
/**
* 用于索引segment偏移值
*/
final int segmentShift;
/**
* Segment数组
*/
final Segment<K, V>[] segments;
transient Set<K> keySet;
transient Set<Map.Entry<K, V>> entrySet;
transient Collection<V> values;
}
ConcurrentHashMap有两个重要的内部类Segment类和HashEntry类,下面我们就看看他们的源码:
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的大小造成影响的操作的数量(比如put或者remove操作)
*/
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; //为了确保读操作能够看到最新的值,将value设置成volatile
volatile HashEntry<K, V> next; //不再用final关键字,采用unsafe操作保证并发安全
}
可以看出来Segment是个锁对象,HashEntry是链表的一个结点,HashEntry.Key存的是每个键值对的键值,HashEntry.Value存的键值对的值。而且大多数变量都是final修饰的。
2.2、构造函数
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;
//ssize 为segments数组长度,根据concurrentLevel计算得出
int ssize = 1;
//依据给定的concurrencyLevel并行度,找到最适合的segments数组的长度,
// 为上文默认并行度参数说明的大于等于concurrencyLevel的最小的2的n次方
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; // table[]数组大小
//创建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); // ordered write of segments[0]
this.segments = ss;
}
整个ConcurrentHashMap的初始化方法还是非常简单的,先是根据concurrentLevel确定Segment数组大小,这里Segment的数量是不小于concurrentLevel的最小的2的指数,就是说Segment的数量永远是2的指数个,这样的好处是方便采用移位操作来进行hash,加快hash的过程。接下来就是根据intialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数,同样使为了加快hash的过程。
在初始化过程中涉及几个关键的量,我们先看构造函数的三个参数:
- initialCapacity:ConcurrentHashMap中键值对总个数,如果不指定时默认取16
- loadFactor:负载因子,用于确定table[]数组扩容阈值,就是当table[]数组长度大于(cap * loadFactor)时,进行扩容,未指定默认取0.75。
- concurrencyLevel:代表ConcurrentHashMap内部的segment[ ]数组的长度,concurrencyLevel在初始化后是不可改变的,也就是说segment[ ]数组长度是不可变得,所以扩容操作主要针对于table[ ]数组。这样的好处在于扩容是不需要对整个ConcurrentHashMap做rehash,只对某个table[ ]数组做rehash即可,未指定是默认取16。
构造函数主要功能就是创建了sagment[ ]数组和table[ ]数组,而重点就在于计算这两个数组的大小。
- ssize:sagment[ ]数组的真实大小,sszie是不大于concurrencyLevel的最大的2的指数,他的好处在于方便采用移位操作进行hash,加快速度。
- cap:table[ ]数组的大小,cap是不大于initialCapacity / ssize的最大的2的指数,好处同样也是加快hash。
另外还有两个重要的变量segmentShift和segmentMask:这两个量是用来定位segment桶在segments[]数组中位置用的,详见“3.1定位sagment桶”
- segmentShift
- segmentMask
三、ConcurrentHashMap类常用方法
3.1、定位Segment桶
在ConcurrentHashMap的remove()、replace()操作中有一个步骤至关重要,就是要先通过key值确定键值对究竟是存在segments[ ]数组中哪个位置。
private Segment<K,V> segmentForHash(int h) {
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);
}
参数h就是hash值,根据传入的hash值向右无符号右移segmentShift位,然后和segmentMask进行与操作,这里的segmentShift和segmentMask值是在构造函数中计算得到的,就可以得出以下结论:假设Segment的数量是2的n次方,根据元素的hash值的高n位就可以确定元素到底在哪一个Segment中。
关于segmentShift和segmentMask
segmentShift和segmentMask这两个全局变量的主要作用是用来定位Segment
- segmentMask:段掩码,假如segments数组长度为16,则段掩码为16-1=15;segments长度为32,段掩码为32-1=31。这样得到的所有bit位都为1,可以更好地保证散列的均匀性。
- segmentShift:2的sshift次方等于ssize,segmentShift=32-sshift。若segments长度为16,segmentShift=32-4=28;若segments长度为32,segmentShift=32-5=27。而计算得出的hash值最大为32位,无符号右移segmentShift,则意味着只保留高几位(其余位是没用的),然后与段掩码segmentMask位运算来定位Segment。
3.2、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 // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
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的主要逻辑也就两步:1.定位segment并确保定位的Segment已初始化 2.调用Segment的put方法。
首先调用tryLock,如果加锁失败,则进入scanAndLockForPut(key, hash, value),该方法实际上是先自旋等待其他线程解锁,直至指定的次数MAX_SCAN_RETRIES。若自旋过程中,其他线程释放了锁,导致本线程直接获得了锁,就避免了本线程进入等待锁的场景,提高了效率。若自旋一定次数后,仍未获取锁,则调用lock方法进入等待锁的场景。
采用这种自旋锁和独占锁结合的方法,在很多场景下能够提高Segment并发操作数据的效率。
每次插入一个新的数据,首先会进行一次hash计算,找到对应的segment,然后再进行一次hash计算,找到segment下HashEnty数组的下标,然后首先获取本segment的锁,再插入数据。最后的s.put放就调用了Segment类中的put方法。Segment中包含了扩容时的threshold,一旦当前HashEntry[]数量大于threshold,就需要进行扩容。所以,扩容时在segment内部进行的,扩容只扩容HashEntry数组,而不改变segment数量。
所以在使用ConcurrentHashMap时,特别是需要考虑高并发情况下,需要谨慎设置concurrencyLevel,因为一旦初始化segment数组的数量就不会改变,如果后续插入数据较多,且并发量较多,ConcurrentHashMap提供的并发能力就相对有限了。
有以下几点值得关注:
- 同步锁:多线程同步锁是在调用Segment类的put方式时使用的,此时其他线程不能访问当前segment桶,但是可以访问其他segment桶。
- 扩容:在添加新的键值对之前会判断当前segment桶大小是否超过阈值threshold ,如果超过就调用rehash()进行扩容,newCapacity = oldCapacity << 1; 将table[]大小扩容到原来的两倍。
3.3、get方法
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
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方法效率很高,没有加锁,只需要计算两次hash值,然后遍历一个单向链表(此链表长度平均小于2)。这是因为Segment中的HashEntry数组是volatile类型,且get时不修改任何数据。根据Java内存模型,volatile写必然发生在volatile读之前,且能够保证内存可见性。之所以不会读到过期的值,是因为根据Java内存模型的happen-before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。
3.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 (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
// 给所有的segment桶加锁
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();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。
因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
四、总结
ConcurrentHashMap是结合HashTable和HashMap得到的线程安全的保证高并发的Map实现类
- ConcurrentHashMap主要结构是一个Segment[]数组,每个Segment元素都有一个HashEntry[]数组,Segment相当于一个小的HashTable。
- Segment[]数组大小是不可变的,扩容操作是将Segment类中HashEntry[]扩容到原来的两倍。
- put( )和remove( )方法是要加锁的,而且只对单个segment桶加锁。
- size( )可能需要遍历整个ConcurrentHashMap,而且每个segment桶都要加锁。
参考:
https://www.cnblogs.com/chengxiao/p/6842045.html
https://blog.csdn.net/klordy_123/article/details/82933115