继承了AbstractMap,实现了ConcurrentMap接口和Serializable序列化接口。
说这个之前,先来说说HashTable,HashTable也是线程安全的集合,实现原理是HashTable对方法实现synchronize重量级锁,锁住了整张表,这样的话所有的操作都是串行化的,效率非常低。
ConcurrentHashMap的底层数据结构是:数组+数组+链表
正是得益于这种数据结构,可以实现高并发的功能。ConcurrentHashMap为了解决这个问题,对锁粒度进行了优化,将整个表进行了分段,即分段锁技术,将整张表分成了多个数组(Segment),然后每个数组元素又是一个HashMap(hash表),当需要并发时,锁住的是每个Segment,其他Segment还是可以操作的,这样不同Segment之间就可以实现并发,大大提高效率
ConcurrentHashMap:
上面的Segment数组是ConcurrentHashMap的一个属性,数组名是segments,这就是将整张表分段处理的关键,segments中的每一个元素之间都是可以并发的,所以segments数组长度是多少,也就说明当前ConcurrentHashMap最多可以支持多少个并发级别。
Segment是一个内部类,看一下Segment的属性
Segment:
上面是Segment的所有概述,c表示继承结构,绿色的菱形表示属性,蓝色表示方法,我们主要看其中的table属性,如下图
从上图可以看到table是一个HashEntry数组,HashEntry是真正存储数据的节点,可以类比HashMap中的Entry属性,可以理解为这里的一个Segment就是一个HashMap(可以这样理解,但不完全是这样),并且这里的table属性使用了volatile修饰,对所有线程可见。
下面来看看HashEntry:
HashEntry同样是一个内部类,从上图的总体结构和属性名大概都可以知道这是一个怎样的类,HashEntry是一个链表的节点,具体来看看:
hash表示节点的hash值,这里的hash有两个用处,一个是决定了该节点属于哪个Segment,另外一个是决定属于table中的哪个链表
key表示存储键值对中的键
value表示键值对中的值,可以看到value用volatile修饰,这块就开始考虑多线程的修改了,表示对所有的线程都是可见的,这也是保证并发的必要条件。
next表示链表的下一个节点,因为每一个table都是数组加链表的结构,所以为了解决hash冲突,用拉链法,让同一映射到该位置的多个元素以链表的形式串起来。
现在就理解了 数组+数组+链表 这种数据结构在源码中是如何实现的了,画一张图来帮助理解
/**
* The default initial capacity for this table,
* used when not otherwise specified in a constructor.
*/
//默认初始容量:16,这里指的是table数组的默认大小
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* The default load factor for this table, used when not
* otherwise specified in a constructor.
*/
//默认加载因子,可以类比HashMap中的加载因子,用于扩容,因为segments数组是
//用来并发的,一旦确定就不能扩容,所以这个值会传给每个Segment,Segment对象
//对table数组进行扩容。这个属性代表table数组中已经用的占比标准,默认为0.75,
//如果table数组中非null占比大于0.75,就该扩容了。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The default concurrency level for this table, used when not
* otherwise specified in a constructor.
*/
//默认并发级别,代表可以同时并发的最大数,也就是segments数组的容量的大小
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* The maximum capacity, used if a higher value is implicitly
* specified by either of the constructors with arguments. MUST
* be a power of two <= 1<<30 to ensure that entries are indexable
* using ints.
*/
//最大容量,ConcurrentHashMap的最大容量,最大扩容为2^30,到这就不再扩容
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The minimum capacity for per-segment tables. Must be a power
* of two, at least two to avoid immediate resizing on next use
* after lazy construction.
*/
// 最小容量,table数组的最小容量:2
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
/**
* The maximum number of segments to allow; used to bound
* constructor arguments. Must be power of two less than 1 << 24.
*/
//最大segments容量,也就是说最大并发量为2^16即65536
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
//该变量在size方法和containsValue方法中用到,表示尝试锁的次数
static final int RETRIES_BEFORE_LOCK = 2;
ConcurrentHashMap的构造函数
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//防御性检查
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//传参传入的并发级别(segments数组的最大值)最大不能超过上面定义的常量,也就是2^16
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
//sshift是ssize左移的次数,ssize是大于concurrencylevel的最小的2的整数倍
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//segmentshift和segmentMask用来定位节点属于segments数组中哪个元素,也就是定位到table
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
//如果传入的初始容量大于最大容量,则赋值为最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//c为每张table数组的大小,这里获得c采用的是进一法,不是去尾法,体现在if,++c中。
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
//重新确定table数组的大小为cap,值为大于c的最小的2的整数倍
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//初始化s0,其实就是segments[0],传入加载因子,阈值和新创建的HashEntry数组(即table数组)
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
//创建segments数组并初始化并发量的大小ssize
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//安全的将segment0赋值到segments[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
上面是最重要的构造函数,大体意思通过注释已经表现出来了
首先会对传入的参数进行防御性检查,然后对传入的参数进行了一些处理,这些处理包括concurrencyLevel会变成大于它的最小的2的倍数,通过这些变量计算了segmentShift ,segmentMask ,这两个用来确定一个节点是哪个Segment,确定table数组的大小cap,把cap,loadFactor,计算的阈值传入构造创建一个Segment,创建ss数组,把ss数组的第1项初始化为刚才创建的Segment对象。
这里为什么是第一项呢?
其实是在put过程中进行用到进行初始化,关于这种思想以及深究在文末给出了详细剖析
点我到文末
其他ConcurrentHashMap构造函数
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY),
DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
putAll(m);
}
剩下的这几种都是第一种构造函数的重载,根据用户传入参数的不同稍微做了修改,如果用户没有传入哪些参数,则使用默认参数,最后调用第一种构造函数进行初始化。
Segment的构造函数
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
可以看到Segment的构造函数非常简单,就是初始化加载因子,阈值和table数组。
调用put方法时,首先会调用ConcurrentHashMap的put方法,先来看一下:
public V put(K key, V value) {
Segment<K,V> s;
//值不能为null
if (value == null)
throw new NullPointerException();
//hash函数,调用hash算法,返回一个让节点分布更为均匀的hash值
int hash = hash(key);
//通过segmentShift,segmentMask和hash值确定Segment,也就是在segments中定位元素
int j = (hash >>> segmentShift) & segmentMask;
//如果获取到的Segment为空,那么进入函数ensureSegment创建一个Segment
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
//确定好Segment,代理给Segment进行put操作
return s.put(key, hash, value, false);
}
重申注释中需要注意的几点:
紧接着我们来看Segment中的put操作
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//trylock如果成功则获取到锁
//不成功则调用scanAndLockForPut方法
//scanAndLockForPut仍然会继续的trylock,lock,确保这里要获取到锁,方法详见往下看
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
//定位具体在数组中哪个链表,还是用hash定位
int index = (tab.length - 1) & hash;
//得到该链表的头结点
HashEntry<K,V> first = entryAt(tab, index);
//遍历链表
for (HashEntry<K,V> e = first;;) {
//如果不为空,则比对,如果key相同,hash相同,说明已经有了该key
//将该节点的value修改为新的value,返回旧的oldValue、
//如果不相同,链表指针往后移动,遍历下一个节点
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;
}
//如果为空,则证明已经到了链表的末尾,到了末尾还没找到,则证明没有该key
//创建一个新的节点存储,存储完成后容量加1判断一下是否需要扩容
//如果需要扩容,则需要重哈希(rehash)
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//扩容,扩容在后面详细讲解
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//执行完成,释放锁
unlock();
}
return oldValue;
}
代码的大体意思看注释,下面说一说重要的几点:
下面看一下能确保加锁的函数scanAndLockForPut:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//根据this(Segment)和hash确定table中的索引,拿到key所在链表的第一个节点
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//retries表示尝试获取锁的次数
int retries = -1;
//获取锁失败时一直循环,除非tryLock成功或者达到自旋次数,直接Lock,退出
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//第一阶段(retries < 0):没有找到key相同的节点或者没有遍历完该链表
if (retries < 0) {
//如果当前索引链表为空,或者循环到链表的最后
if (e == null) {
//判断node是否被初始化过,如果没有则初始化,置retries为0,进入第二阶段
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//key匹配上了,所以就找到了节点,置retries为0,进入第二阶段
else if (key.equals(e.key))
retries = 0;
//指针移动,往后遍历
else
e = e.next;
}
//第二阶段(retries >= 0):如果尝试次数大于最大获取锁的次数,则进行Lock操作,退出循环
//MAX_SCAN_RETRIES取决于cpu,在下面有讲解
else if (++retries > MAX_SCAN_RETRIES) {
//lock操作,获取不到锁就阻塞,直到获取到锁跳出循环
lock();
break;
}
//第二阶段(retries >= 0):因为插入元素是头插法,所以这块是为了判断首个预期值是否等于现在的值
//如果不等于,则证明其他线程已经插入了元素,retries=-1,进入第一阶段重新开始
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
可以从代码的注释中知道,我把这个函数分为两个阶段,第一个阶段为retries<0,第二个阶段为retries>=0,在代码的第一阶段,就是遍历该条链表该key是否已经存在,如果存在进入第二阶段,最后返回null,覆盖新值交给外部的put去做,如果不存在就创建该节点进行返回,所以第一阶段就是创建这个新节点
第二阶段分为两个步骤,首先去判断自旋次数是否已经超过标准,如果超过了标准就直接Lock,退出循环,如果没有超过标准,就继续让它自旋,不过在下一次自旋之前需要先判断线程安全的问题,即是否有其他线程修改过这一条链表,如果修改过,那么直接进入第一阶段(因为有可能其他线程插入了一个key相同的节点),一切重新开始。
整个过程可以用一张图来表示:
来看看这个最大值,静态变量MAX_SCAN_RETRIES
//cpu的核数
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
判断cpu的可用核数,如果大于1,那么trylock64次才lock,如果不是,则trylock一次就去lock。
trylock和lock的区别就是,trylock只是尝试的去获取锁,获取不到就返回,lock是如果不获取到锁就阻塞当前线程。
所以结合上面的过程就可以知道自旋获取锁只会在cpu比较空闲的情况下才会进行,如果cpu利用率比较高,那么就阻塞掉该线程,等待锁。这就是一个优化。
同样,remove方法也是代理给了Segment进行实现,来分析一下源码
final V remove(Object key, int hash, Object value) {
//如果尝试获取锁失败,就调用方法确保获取到锁
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
//拿到table数组,算出位置,通过这两个信息拿到对应链表的第一个结点
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;
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)
//前一个节点为空,则删除的是第一个节点,直接把下一个节点设为链表头
setEntryAt(tab, index, next);
else
//前一个节点不为空,那么把前一个节点的next设为下一个节点
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
//定位键为key的对象在segments数组中的位置,也就是确定Segment
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//for循环初始化部分就进行定位HashEntry在table中的位置,然后遍历链表,寻找
//节点,找到后返回对应的value,找不到返回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是没有加锁的,那么如何保证get操作的安全性呢?
首先,get是一个读的过程,读的过程并不会修改数据,所以读和读也就是get和get之间是线程安全的。
但是同时读和写就会产生不同,如果一个线程在put,一个线程在get,如何保证线程安全
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) {
for (int j = 0; j < segments.length; ++j)
//锁住所有Segment,也就是锁住了segments数组
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;
}
size方法是对segments中所有table的统计,所以是需要对所有Segment对象加锁,加锁后再进行统计,该方法相对于HashMap效率是比较低的。
public boolean containsValue(Object value) {
// Same idea as size()
if (value == null)
throw new NullPointerException();
final Segment<K,V>[] segments = this.segments;
boolean found = false;
long last = 0;
int retries = -1;
try {
//对所有Segment加锁
outer: for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
long hashSum = 0L;
int sum = 0;
for (int j = 0; j < segments.length; ++j) {
HashEntry<K,V>[] tab;
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null && (tab = seg.table) != null) {
for (int i = 0 ; i < tab.length; i++) {
HashEntry<K,V> e;
for (e = entryAt(tab, i); e != null; e = e.next) {
V v = e.value;
if (v != null && value.equals(v)) {
found = true;
break outer;
}
}
}
sum += seg.modCount;
}
}
if (retries > 0 && sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return found;
}
这样做主要是为了避免在统计或者查找过程中,其他线程在其他Segment(分段锁)进行了操作。
刚才在put中提到了扩容,整个过程也就是rehash函数,下面看看这个函数
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
//2倍扩容
int newCapacity = oldCapacity << 1;
//计算阈值
threshold = (int)(newCapacity * loadFactor);
//创建新的table
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//该值用于后面的hash运算与,保证运算后的值落到数组的范围内
int sizeMask = newCapacity - 1;
//遍历旧表中的每一个头结点
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
//判断该位置头结点是否为空,为空就没必要遍历该条链表
if (e != null) {
//第一个数据节点
HashEntry<K,V> next = e.next;
//e节点的新的位置,保证idx落到数组的范围内,这块其实相当于重新hash,sizeMask是newCapacity得来的
int idx = e.hash & sizeMask;
//如果只有一个数据节点,那么直接挪到相应的位置
if (next == null) // Single node on list
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
//该条链表rehash后,最后几个节点保持一致的分界点,下面画图举例解释,请细看下面第三点
HashEntry<K,V> lastRun = e;
//记录lastRun节点的位置
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
//如果k=lastIdx,就不更新lastIdx和lastRun
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//将lastRun后面所有链表一同挪过去
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//从头到lastRun一个一个的重新hash,放入该放的位置,方式为头插
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);
}
}
}
}
//把新的节点重哈希,添加到新table中
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
这是一种设计思想:为了尽可能的节省空间,只有在用到的时候才会进行初始化,分配空间。在jdk中很多地方都用到了这种思想。
首先我们来看一下ConcurrentHashMap的构造函数
框中有三句话
可以明显的看到问题,这里只初始化了第一个数组元素,后面的数组元素都是null,只有在put操作的时候,用到才会进行初始化,那么不禁有一个问题,那为什么要创建这第一个数组元素呢,一个都不创建用的时候再创建不是更符合上面的设计思想吗?
这是因为在ConcurrentHashMap的构造函数中,已经可以确定每一个Segment中的加载因子,阈值和table数组的开辟大小,所以利用这些值就先创建一个作为例子,后续在创建其他Segment对象时,就根据这第一个标准进行创建,具体来看一下代码来理解这段话:
ConcurrentHashMap的put方法:
可以看到框中所表达的意思:如果根据hash原子的获得对应位置的Segment为null,则会调用下面的那个函数,来看一下ensureSegment函数
和我们分析的一样,取出了第一个Segment数组元素,拿到该元素的加载因子,阈值,还有table数组的长度,用这三个值初始化了Segment对象进行了返回。
所以,除过第一个Segment数组元素,其他的都是在这里进行初始化的。
回到刚才位置