JUC之ConcurrentHashMap(八)
一、Hash表
1. 什么是Hash表
hash函数就是根据key计算出应该存储地址的位置,而哈希表是基于哈希函数建立的一种查找表
2. hash函数设计的考虑因素
- 计算散列地址所需要的时间(即hash函数本身不要太复杂)
- 关键字的长度
- 表长
- 关键字分布是否均匀,是否有规律可循
- 设计的hash函数在满足以上条件的情况下尽量减少冲突
3.哈希冲突的解决方案
不管hash函数设计的如何巧妙,总会有特殊的key导致hash冲突,特别是对动态查找表来说。hash函数解决冲突的方法有以下几个常用的方法
A.开放定制法(线性探索)
B.链地址法(HashMap)
C.公共溢出区法建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。
D.再散列法(布隆过滤器)准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个……
开放定址法
当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。基本公式为:hash(key) = (hash(key)+di)mod TableSize。其中di为增量序列,TableSize为表长。根据di的不同我们又可以分为线性探测,平方(二次)探测,双散列探测。
1)线性探测
以增量序列 1,2,……,(TableSize -1)循环试探下一个存储地址,即di = i。如果table[index+di]为空则进行插入,反之试探下一个增量。但是线性探测也有弊端,就是会造成元素聚集现象,降低查找效率。具体例子如下图:

特别对于开放定址法的删除操作,不能简单的进行物理删除,因为对于同义词来说,这个地址可能在其查找路径上,若物理删除的话,会中断查找路径,故只能设置删除标志。
//插入函数,利用线性探测法
bool Insert_Linear_Probing(int num){
//哈希表已经被装满,则不在填入
if(this->size == this->length){
return false;
}
int index = this->hash(num);
if(this->data[index] == MAX){
this->data[index] = num;
}else{
int i = 1;
//寻找合适位置
while(this->data[(index+i)%this->length] != MAX){
i++;
}
index = (index+i)%this->length;
this->data[index] = num;
}
if(this->delete_flag[index] == 1){//之前设置为删除
this->delete_flag[index] = 0;
}
this->size++;
return true;
}
链地址法
HashMap即是采用了链地址法,也就是数组+链表的方式,HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。
//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类。代码如下
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
所以,HashMap的整体结构如下

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
二.ConcurrentHashMap
ConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,ConcurrentHashMap在并发编程的场景中使用频率非常之高,下面我们来分析下ConcurrentHashMap的实现原理,并对其实现原理进行分析 。
众所周知,哈希表是种非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。
HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(多线程扩容时可能造成),导致get操作时,cpu空转, 所以,在并发环境中使用HashMap是非常危险的。
HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据比喻[11],这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。java1.7后的CHM中把每个数组叫Segment,每个segment下面存的是默认16段的Hashhenery,Hashhenery解决充突是在Hashhenery下面挂载链表,我们就画图说明下分段锁

ConcurrentHashMap初始化时,计算出Segment数组的大小ssize和每个Segment中HashEntry数组的大小cap,并初始化Segment数组的第一个元素;其中ssize大小为2的幂次方,默认为16,cap大小也是2的幂次方,最小值为2,最终结果根据初始化容量initialCapacity进行计算,计算过程如下
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
因为Segment继承了ReentrantLock,所有segment是线程安全的,但是在1.8中放弃了Segment分段锁的设计,使用的是Node+CAS+Synchronized来保证线程安全性,而且这样设计的好处是层级降低了,锁的粒度更小了,可以说是一种优化,比喻锁的是2,那么他锁的就只是发生冲突的2下面的链表,而不像1.7样,是锁整个HashEntry;而且1.8中对链表的长度进行了优化,在1.7的链表中链表查询的复杂度是O(n),但是在1.8中为了解决这问题引入了红黑树,在1.8中当我们链表长度大于8时并且数组长度大于64时,就会发生一个链表的转换,会把单向链表转换成红黑树。

put操作
在1.7 中当执行put方法插入数据的时候,根据key的hash值,在Segment数组中找到对应的位置如果当前位置没有值,则通过CAS进行赋值,接着执行Segment的put方法通过加锁机制插入数据;假如有线程AB同时执行相同Segment的put方法
线程A 执行tryLock方法成功获取锁,然后把HashEntry对象插入到相应位置 线程B 尝试获取锁失败,则执行scanAndLockForPut()方法,通过重复执行tryLock()方法尝试获取锁 在多处理器环境重复64次,单处理器环境重复1次,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B 当线程A执行完插入操作时,会通过unlock方法施放锁,接着唤醒线程B继续执行
但在1.8 中执行put方法插入数据的时候,根据key的hash值在Node数组中找到相应的位置如果当前位置的 Node还没有初始化,则通过CAS插入数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果当前位置的`Node`还没有初始化,则通过CAS插入数据
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
如果当前位置的Node已经有值,则对该节点加synchronized锁,然后从该节点开始遍历,直到插入新的节点或者更新新的节点
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
如果当前节点是TreeBin类型,说明该节点下的链表已经进化成红黑树结构,则通过putTreeVal方法向红黑树中插入新的节点
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的节点个数达到了8个,则通过treeifyBin方法将链表转化为红黑树
size操作
在1.7中统计每个segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果不一定准确,因为在计算后面的segment的元素个数时,前面计算过了的segment可能有数据的新增或删除;他的计算方式是:先采用不加锁的方式,连续计算两次;如果两次结果相等,说明计算结果准确,如果两次结果不相等,说明计算过程中出现了并发新增或者删除操作于是给每个segment加锁,然后再次计算
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();
}
}
但是在1.8中使用一个volatile类型的变量baseCount记录元素的个数,当新增或者删除节点的时候会调用,addCount()更新baseCount
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
// 有多线程竞争
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
//还没有初始化
if (as == null || (m = as.length - 1) < 0 ||
//获取下标
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
初始化时counterCells为空,在并发量很高时,如果存在两个线程同时执行CAS修改baseCount值,则失败的线程会继续执行方法体中的逻辑,使用CounterCell记录元素个数的变化,如果CounterCell数组counterCells为空,调用fullAddCount()方法进行初始化,并插入对应的记录数,通过CAS设置cellsBusy字段,只有设置成功的线程才能初始化CounterCell数组
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
//初始化数组长度
CounterCell[] rs = new CounterCell[2];
//表示元素个数
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
因为初始长度就只有两个,如果线程竞争很激烈的话,长度不够就要扩容,下面就是扩容的代码
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
如果通过CAS设置cellsBusy字段失败的话,则继续尝试通过CAS修改baseCount字段,如果修改baseCount字段成功的话,就退出循环
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break;
所以在1.8中的size实现比1.7简单多,因为元素个数保存baseCount中,部分元素的变化个数保存在CounterCell数组中,通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数。实现如下:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
三.transfer扩容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
//rc值等于扩容阈值,第一次一定不小于 if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
Integer.numberOfLeadingZeros 这个方法是返回无符号整数 n 最高位非 0 位前面的 0 的个数;
比如 10 的二进制是 0000 0000 0000 0000 0000 0000 0000 1010
那么这个方法返回的值就是 28

transfer
上面的解析完后下面我们走进正真的扩容代码;扩容是 ConcurrentHashMap 的精华之一,扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下,
扩容过程图解
ConcurrentHashMap 支持并发扩容,实现方式是,把 Node 数组进行拆分,让每个线程处理自己的区域,假设 table 数组总长度是 64,默认情况下,那么每个线程可以分到 16 个 bucket。然后每个线程处理的范围,按照倒序来做迁移通过 for 自循环处理每个槽位中的链表元素,默认 advace 为真,通过 CAS 设置 transferIndex属性值,并初始化 i 和 bound 值,i 指当前处理的槽位序号,bound 指需要处理的槽位边界,先处理槽位 31 的节点; (bound,i) =(16,31) 从 31 的位置往前推动。
sizeCtl 扩容退出机制
在扩容操作 transfer 的第 2414 行,代码如下
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
synchronized (f) {//对数组该节点位置加锁,开始处理数组该位置的迁移工作
if (tabAt(tab, i) == f) {//再做一次校验
Node<K,V> ln, hn;//ln 表示低位, hn 表示高位;接下来这段代码的作用
是把链表拆分成两部分,0 在低位,1 在高位
if (fh >= 0) {//下面部分代码原理点击这里
int runBit = fh & n;
Node<K,V> lastRun = f;
//遍历当前 bucket 的链表,目的是尽量重用 Node 链表尾部的一部分
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {如果最后更新的 runBit 是 0,设置低位节点
ln = lastRun;
hn = null;
}
else {//否则,设置高位节点
hn = lastRun;
ln = null;
}
//构造高位以及低位的链表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);//将低位的链表放在 i 位置也就是不动
setTabAt(nextTab, i + n, hn);//将高位链表放在 i+n 位置
高低位原理分析
ConcurrentHashMap 在做链表迁移时,会用高低位来实现,这里有两个问题要分析一下
第 14 个槽位插入新节点之后,链表元素个数已经达到了 8,且数组长度为 16,优先通过扩容来缓解链表过长的问题,扩容这块的图解稍后再分析,先分析高低位扩容的原理假如当前线程正在处理槽位为 14 的节点,它是一个链表结构,在代码中,首先定义两个变量节点 ln 和 hn,实际就是 lowNode 和 HighNode,分别保存 hash 值的第 x 位为 0 和不等于0 的节点通过 fn&n 可以把这个链表中的元素分为两类,A 类是 hash 值的第 X 位为 0,B 类是 hash 值的第 x 位为不等于 0(至于为什么要这么区分,稍后分析),并且通过 lastRun 记录最后要处理的节点。最终要达到的目的是,A 类的链表保持位置不动,B 类的链表为 14+16(扩容增加的长度)=30我们把 14 槽位的链表单独伶出来,我们用蓝色表示 fn&n=0 的节点,假如链表的分类是这样

for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
} }
通过上面这段代码遍历,会记录 runBit 以及 lastRun,按照上面这个结构,那么 runBit 应该是蓝色节点,lastRun 应该是第 6 个节点;接着,再通过这段代码进行遍历,生成 ln 链以及 hn 链
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}

接着,通过 CAS 操作,把 hn 链放在 i+n 也就是 14+16 的位置,ln 链保持原来的位置不动。并且设置当前节点为 fwd,表示已经被当前线程迁移完了
setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd);
在上面设计中分高低位的原因要从ConcurrentHashMap 的根据下标获取对象的算法来看,在 putVal 方法中 1018 行 ,通过(n-1) & hash 来获得在 table 中的数组下标来获取节点数据,【&运算是二进制运算符,1& 1=1,其他都为 0】
(f = tabAt(tab, i = (n - 1) & hash)) == null
假设我们的 table 长度是 16, 二进制是【0001 0000】,减一以后的二进制是 【0000 1111】假如某个 key 的 hash 值=9,对应的二进制是【0000 1001】,那么按照(n-1) & hash 的算法0000 1111 & 0000 1001 =0000 1001 , 运算结果是 9当我们扩容以后,16 变成了 32,那么(n-1)的二进制是 【0001 1111】仍然以 hash 值=9 的二进制计算为例0001 1111 & 0000 1001 =0000 1001 ,运算结果仍然是 9;我们换一个数字,假如某个 key 的 hash 值是 20,对应的二进制是【0001 0100】,仍然按照(n-1) & hash算法,分别在 16 为长度和 32 位长度下的计算结果

浙公网安备 33010602011771号