初了解&说明
- ConcurrentHashMap是java并发工具包的其中一个类,该类本身并发安全,可以用于并发环境之中。
- 全文若没有特殊说明,ConcurrentHashMap默认指代1.7版本。
前置知识
- 建议先学习HashMap的1.8,1.7版本。
- 建议先学习ConcurrentHashMap的1.8版本,1.8机制与1.7不太相同,且复杂的多,学完1.8再学1.7会比较轻松。
问题和解答
- 可以在这个章节笼统的了解一下ConcurrentHashMap。
一、ConcurrentHashMap的java7版本和java8版本有什么区别?
- 1.8版本后出于1.7,理论上应该是对1.7版本的进一步改进,实际上也是这个样子,1.8版本的并发和性能优于1.7,相对的代码也更复杂。
- 1.8版本数据结构是哈希表+链表+红黑树结构,1.7 版本只用了哈希表+链表结构。
- 1.8版本的同步使用的是Synchronize+CAS进行同步,粒度小,可以最大程度的并发;1.7版本使用的是分段所,对一个片段进行加锁,粒度相对大,逻辑相对简单一些。
原理
键值存储
- ConcurrentHashMap的键值存储和HashMap的1.7版本相似,都是计算哈希,映射到索引,数组中该索引槽位为空则放入,若不为空(发生冲突)则按链式结构存储。
并发控制
ConcurrentHashMap的并发控制用到的是片段锁。ConcurrentHashMap拥有一个片段数组,这个片段数组的长度决定了并发的最大数量。每个片段槽位维护着一个独立的哈希表,当要存键值对时,先映射到指定的片段槽位,再存进该槽位的哈希表中。
由此可知,ConcurrentHashMap维护着多个哈希表,而哈希表的数量则是片段数组的长度,而上锁也是对某个哈希表上锁,对其他哈希表没有影响。类似通过拆分哈希表的方式降低锁的粒度,但代价就是要维护多个哈希表。
源码解析
核心内部类讲解
- HashEntry 基本的键值存储单位,同时包含了单向链式结构的属性。
- Segment 片段数组的基本单位,该类维护了独立的哈希表,并负责了锁的操作。
HashEntry<K,V>
/**
* 保存了键值
* 本身是链式结构
*/
static final class HashEntry<K,V> {
final int hash; // 存储哈希,避免总是重复计算
final K key; // 键
volatile V value; // 值
volatile HashEntry<K,V> next; // 链式结构,下一个节点
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
final void setNext(HashEntry<K,V> n) {
// 通过Unsafe类的指针设置next属性,
// 确保了其他线程的可见性
UNSAFE.putOrderedObject(this, nextOffset, n);
}
static final sun.misc.Unsafe UNSAFE;
static final long nextOffset; // next属性的指针
/**
* 这里都是注册Unsafe类,指针初始化的操作
*/
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
Segment<K,V>
/**
* Segment是ConcurrentHashMap并发的核心类。
* 每个Segment类管理着对应的哈希表,集成了对哈希表的存取值方法和扩容操作。
*
* ConcurrentHashMap通过把哈希表分散到多个Segment中,达到并发的效果,
* 每个Segment可独立独写,互不影响。
* Segment的数量决定了最高并发的数量,Segment的数量一旦初始化后面不再改变。
*/
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
/**
* 获取锁的最大自旋阈值
* 判断cpu是否支持并发,若不支持则关闭自旋。
* Runtime.getRuntime().availableProcessors() 该方法用于获取cpu支持的线程数
*/
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
/**
* 每个片段维护的哈希表
*/
transient volatile HashEntry<K,V>[] table;
/**
* 哈希表中存储的元素数量
*/
transient int count;
/**
* 统计哈希表更改次数。
* 主要用于统计元素操作,在操作前后分别取值比较,
* 可判断操作过程中哈希表是否被修改过。
*/
transient int modCount;
/**
* 数组扩容的阈值
* 当本片段的哈希表元素达到该值,则扩容该哈希表。
* 该值大小通过 哈希表的长度 * loadFactor 得到。
* loadFactor在下面介绍。
*/
transient int threshold;
/**
* 扩容因子
* 该值用于计算上面的属性threshold。
* 该值通常情况等于0.75.
*/
final float loadFactor;
// 构造函数
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
/**
* 存键值操作
* 对当前片段上锁,存值。
*/
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; // 获取该片段对应的哈希表
int index = (tab.length - 1) & hash; // 计算映射得索引
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; // 修改次数+1
}
break;
}
e = e.next; // 链式遍历
}
// 来到这表示插入的键原本不存在
// 需要新增节点
else {
// 这里的node是尝试上锁失败时创建的
// 可以最大程度减少上锁的时间
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; // 返回旧值,没有则为空
}
/**
* 扫描哈希表并上锁
* 该函数是在对哈希表上锁失败后做的一些预先操作,
* 可以提前执行上锁后要进行的操作,从而减少上锁的时间。
*
* 该方法在允许自旋的阈值下,判断插入的键是否存在,
* 若存在则提前找出来,并开始自旋获取锁;
* 若不存在则创建新节点,然后开始自旋获取锁。
*
* @return node
* 若该键存在,返回值必为null
* 若键不存在,则返回值有可能不为null,完整的扫描了整个哈希表;
* 也可能为null,扫描到一半获取到了锁。
*/
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// entryForHash是通过Unsafe类获取到当前片段对应的哈希表
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
// retires为-1时,则是扫描哈希表,找到或创建键值节点
// retires大于等于0时,则开始自旋获取锁。
int retries = -1;
while (!tryLock()) {
HashEntry<K,V> f;
// 扫描哈希表
if (retries < 0) {
// 表示遍历到链表尾部了
if (e == null) {
// 表示该键节点不存在,创建新的节点
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
// 表示找到对应的键
else if (key.equals(e.key))
retries = 0;
// 链式遍历
else
e = e.next;
}
// 这里开始自旋获取锁
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
// 这里判断哈希表是否被更改了
// 若发生重组,哈希表会被修改。
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1; // 重新遍历
}
}
return node;
}
/**
* 扩容当前片段的哈希表
* 被调用时已经处于上锁状态,不用考虑当前片段的并发问题。
*
* 由于无论是旧的数组容量还是新的数组容量都一定是2的幂次方,
* 则容量值-1的值用二进制显示是低位全1的掩码,
* 该掩码可以直接和哈希值做与操作获取到对应的索引。
* 且新数组的掩码和旧数组的掩码只相差了一个位,
* 如16-1:00000000000000000000000000001111
* 32-1:00000000000000000000000000011111
* 因此旧哈希表同一索引槽位的节点重新计算索引,
* 得到的新索引只可能是两个值。
* 用上面的例子就是第五位是1还是0,
* 0则是保持原来的位置
* 1则去到一个新的索引位置。
*/
@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
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; // 计算新数组掩码。
// 遍历哈希表每个槽位
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
int idx = e.hash & sizeMask; // 计算在新数组的索引
if (next == null)
newTable[idx] = e;
else {
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
// 获取最后一段不变的节点段
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 把最后一段节点放到对应的位置
newTable[lastIdx] = lastRun;
// 从头遍历节点,放到指定的槽位
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);
}
}
}
}
int nodeIndex = node.hash & sizeMask; // 插入新节点
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable; // 更改默认的哈希表
}
/**
* 移除值
* 只有当key和value都匹配时才移除
* 若value为null,则key匹配就移除
*/
final V remove(Object key, int hash, Object value) {
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;
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
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
/**
* 自旋获取锁
* 跟scanAndLockForPut方法很像
* 个人觉得它的扫描哈希表没啥用。
* 注释说不管找没找到都要上锁确保更新的顺序一致性,
* 但它的tryLock是非公平锁。。。
* 我觉得这个方法很迷。也可能是我很迷。
*/
private void scanAndLock(Object key, int hash) {
// entryForHash是通过Unsafe类获取到当前片段对应的哈希表
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
// retires为-1时,则是扫描哈希表,找到或创建键值节点
// retires大于等于0时,则开始自旋获取锁。
int retries = -1;
// 每次循环都尝试获取锁
while (!tryLock()) {
HashEntry<K,V> f;
if (retries < 0) {
// 表示找到了对应节点
if (e == null || key.equals(e.key))
retries = 0;
else
e = e.next;
}
// 若超出自旋阈值则阻塞
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
// 判断默认的哈希表是否被改变。
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1; // 重新扫描新哈希表
}
}
}
/**
* 替换值
*/
final boolean replace(K key, int hash, V oldValue, V newValue) {
// 逻辑与上面相似,不展示。
}
/**
*
*/
final V replace(K key, int hash, V value) {
// 逻辑与上面相似,不展示。
}
/**
* 清除所有元素
*/
final void clear() {
// 逻辑很简单,不展示。
}
}
ConcurrentHashMap控制属性
/**
* 默认初始化的哈希表大小
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 默认最高并发大小
*/
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 最大数组容量
* int类型大小是32位,因此最多左移31位。
* 但最高位是符号控制位,1<<31得到得是负数。
*
* 容量必须是2得幂次方,因此不能低31位全1。
* 所以最大值只能是1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* Segment中哈希表最小容量阈值
*/
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
/**
* 最大Segment数量阈值
*/
static final int MAX_SEGMENTS = 1 << 16;
/**
* 重试次数阈值。
* 统计元素数量时,若2次计算结果一致,则认为统计得值是正确的
* 否则则一直重试统计,若超过该值则上锁统计。
*/
static final int RETRIES_BEFORE_LOCK = 2;
ConcurrentHashMap一般属性及初始化
/**
* Segment的掩码,由Segment数组的容量-1得出。
*/
final int segmentMask;
/**
* 位移数
* 通过Segment数量得出。
* 由于计算键值节点所属的Segment是通过该节点哈希值的高位计算得出,
* 因此需要把该哈希值右移到掩码的范围,才能进行与操作。
* 该值就是保存要右移的位数。
*/
final int segmentShift;
/**
* Segment数组,数组大小既是Segment的数量。
*/
final Segment<K,V>[] segments;
/*
构造器
这里只展示了核心和较常用的构造器
*/
/**
* @param initialCapacity 哈希表大小
* @param loadFactor 加载因子
* @param concurrencyLevel 并发等级
*/
@SuppressWarnings("unchecked")
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;
/* 初始化Segment属性,数量等 */
int sshift = 0; // 并发大小在二进制中的有效位数
int ssize = 1; // 数组大小
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
/* 计算数组的容量 */
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize; //初始容量除以并发数量
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
// 从最低数组大小慢慢往上两倍扩大
while (cap < c)
cap <<= 1;
// 只初始化Segment数组的第一个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;
}
/**
* 理论初始化后:
* Segment数组长度:16
* 每个哈希表大小:2
* 扩容因子:0.75
*
* 实际上只会初始索引为0的Segment,其他的索引暂不初始化。
* 其他Segment初始化时,哈希表的大小根据第0个Segment来设置,
* 而不是按初始值设置。
*/
public ConcurrentHashMap() {
// this(16, 0.75, 16)
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
通用方法
/**
* 计算哈希
*/
private int hash(Object k) {
int h = hashSeed;
// 对字符串进一步哈希处理
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
ConcurrentHashMap存取机制
- put()
- get(Object key)
put方法
/**
* 存储键值
*/
@SuppressWarnings("unchecked")
public V put(K key, V value) {
Segment<K,V> s;
// 值不能为null
if (value == null)
throw new NullPointerException();
int hash = hash(key); // 计算哈希
// 计算该键值对应的Segment
int j = (hash >>> segmentShift) & segmentMask;
// 判断该Segment是否初始化
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j); // 初始化指定索引Segment
// 核心上锁、存值在Segment内部实现。(上面内部类已介绍)
return s.put(key, hash, value, false);
}
/**
* 初始化给定索引的Segment并返回
*
* @param k 索引
*/
@SuppressWarnings("unchecked")
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
// 计算内存中指定索引Segment在数组的偏移量
long u = (k << SSHIFT) + SBASE;
Segment<K,V> seg;
// 判断该Segment有没有初始化
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用数组第0个Segment作为原型,包括哈希表大小,扩容因子
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 再次确认为空
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
// 通过CAS初始化。
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
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里的哈希表
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; // 找不到返回空
}
计数机制
size方法
/**
* 统计元素数量
* 在没有上锁的情况下统计两次,若结果相同则认为正确
* 否则上锁统计。
*/
public int size() {
final Segment<K,V>[] segments = this.segments;
int size; // 元素数量
boolean overflow; // 是否溢出
long sum; // 对所有Segment修改的次数
long last = 0L; // 最近一次的统计结果
int retries = -1; // 统计次数
try {
for (;;) {
// 判断是否达到重试比较的次数,若是则上锁统计
if (retries++ == RETRIES_BEFORE_LOCK) {
// 对所有Segment上锁
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock();
}
sum = 0L;
size = 0;
overflow = false;
// 遍历所有Segment,累加元素数量和修改次数
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) {
// 所有segment解锁
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
总结
分段锁
分段锁就是把一个哈希表拆分成多个哈希表,每个锁管理一个哈希表,从而增加并发量。且每个分段锁独立维护一个哈希表,分段锁内哈希表的扩容也是独立扩容,不会影响其他分段的哈希表。
上锁的时候使用了ReentrantLock尝试上锁,上锁失败会自旋上锁,自旋次数在可并发的CPU中是64次,不可并发时则为1,若在自旋次数中没能获取到锁,则阻塞当前线程。
分段数组一开始只初始化第0个分段,后面的分段等到要用的时候才初始化,且初始化时,哈希表大小和扩容因子以第0个分段的当前值作为原型进行初始化。
存值
存值先通过哈希高位映射出对应的分段,再通过低位映射出对应哈希表的索引,再通过链式处理哈希冲突,从而放值。ConcurrentHashMap不支持存储value为空的值。
取值
ConcurrentHashMap取值不会阻塞,先计算分段,再计算索引,最后取值。
统计数量
在指定阈值范围内不上锁统计,若其中连续两次统计结果一致,则结束返回,若一直不相同且达到了阈值,则全部分段上锁,统计,解锁,返回。