文章目录
大家可以去他那里学习,传送门:敖丙
概述
HashMap 并不是一个线程安全的集合,那么可以使用 HashTable,但是 HashTable 的话不管是存储还是读取元素,都是加入了 synchronized 关键字,因此,它的效率并不是特别的高,而使用集合工具类 Collections,生成的也是一个 SynchronizedMap,其实和 HashTable 类似,锁住的是整张表。
JDK1.7 中 ConcurrentHashMap 采用的方案,被叫做锁分段技术,每个部分就是一个 Segment(段),但是,在 JDK1.8 中,完全重构了,采用的是 Synchronized + CAS ,把锁的粒度进一步降低,而放弃了 Segment 分段。
ConcurrentHashMap1.7
存储结构
在 JDK1.7 中,本质上还是采用 链表+数组 的形式存储键值对的;为了提高并发,把原来的整个 table 划分为 n 个 Segment,从整体来看,它是一个由 Segment 组成的数组,每个 Segment 里边是由 HashEntry 组成的数组,每个 HashEntry之间又可以形成链表,可以把每个 Segment 看成是一个小的 HashMap,其内部结构和 HashMap 是一模一样的;
当对某个 Segment 加锁时,如图中 Segment2,并不会影响到其他 Segment 的读写。每个 Segment 内部自己操作自己的数据;这样一来,我们要做的就是尽可能的让元素均匀的分布在不同的 Segment中。最理想的状态是,所有执行的线程操作的元素都是不同的 Segment,这样就可以降低锁的竞争。
Segment 结构
//Segment 对象,继承自 ReentrantLock 可重入锁。
//其内部的属性和方法和 HashMap 神似,只是多了一些拓展功能。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//这是在 scanAndLockForPut 方法中用到的一个参数,用于计算最大重试次数
//获取当前可用的处理器的数量,若大于1,则返回64,否则返回1。
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
//用于表示每个Segment中的 table,是一个用HashEntry组成的数组
transient volatile HashEntry<K,V>[] table;
//Segment中的元素个数,每个Segment单独计数(下边的几个参数同样的都是单独计数)
transient int count;
//每次 table 结构修改时,如put,remove等,此变量都会自增
transient int modCount;
//当前Segment扩容的阈值,同HashMap计算方法一样也是容量乘以加载因子
//需要知道的是,每个Segment都是单独处理扩容的,互相之间不会产生影响
transient int threshold;
//加载因子
final float loadFactor;
//Segment构造函数
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
...
// put(),remove(),rehash() 方法都在此类定义
}
HashEntry
// HashEntry,存在于每个Segment中,它就类似于HashMap中的Node,用于存储键值对的具体数据和维护单向链表的关系
static final class HashEntry<K,V> {
//每个key通过哈希运算后的结果,用的是 Wang/Jenkins hash 的变种算法,此处不细讲,感兴趣的可自行查阅相关资料
final int hash;
final K key;
//value和next都用 volatile 修饰,用于保证内存可见性和禁止指令重排序
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;
}
}
常用属性
//默认初始化容量,这个和 HashMap中的容量是一个概念,表示的是整个 Map 的容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认的并发级别,这个参数决定了 Segment 数组的长度
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//每个Segment中table数组(HashEntry数组)的最小长度为2,且必须是2的n次幂。
//由于每个Segment是懒加载的,用的时候才会初始化,因此为了避免使用时立即调整大小,设定了最小容量2
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//用于限制Segment数量的最大值,必须是2的n次幂
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//在size方法和containsValue方法,会优先采用乐观的方式不加锁,直到重试次数达到2,才会对所有Segment加锁
//这个值的设定,是为了避免无限次的重试。后边size方法会详讲怎么实现乐观机制的。
static final int RETRIES_BEFORE_LOCK = 2;
//segment掩码值,用于根据元素的hash值定位所在的 Segment 下标
final int segmentMask;
//和 segmentMask 配合使用来定位 Segment 的数组下标
final int segmentShift;
// Segment 组成的数组,每一个 Segment 都可以看做是一个特殊的 HashMap
final Segment<K,V>[] segments;
构造函数
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//检验参数是否合法。值得说的是,并发级别一定要大于0,否则就没办法实现分段锁了
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
//偏移量,是为了对hash值做位移操作,计算元素所在的Segment下标
int sshift = 0;
//用于设定最终Segment数组的长度,必须是2的n次幂
int ssize = 1;
//这里就是计算 sshift 和 ssize 值的过程 (1)
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
//Segment的掩码,此时为15
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//c用于辅助计算cap(Segment的容量)的值 (2)
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// cap 用于确定某个Segment的容量,即Segment中HashEntry数组的长度
int cap = MIN_SEGMENT_TABLE_CAPACITY;
//(3)
while (cap < c)
cap <<= 1;
// create segments and segments[0]
//这里用 loadFactor做为加载因子,cap乘以加载因子作为扩容阈值,创建长度为cap的HashEntry数组,
//三个参数,创建一个Segment对象,保存到S0对象中。后边在 ensureSegment 方法会用到S0作为原型对象去创建对应的Segment。
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
//创建出长度为 ssize 的一个 Segment数组
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//把S0存到Segment数组中去。在这里,我们就可以发现,此时只是创建了一个Segment数组,
//但是并没有把数组中的每个Segment对象创建出来,仅仅创建了一个Segment用来作为原型对象。
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
上述代码中标注的(1),(2),(3):
假设调用了默认构造,都用的是默认参数,即 initialCapacity 和 concurrencyLevel 都是16,loadFactor 是 0.75;
- sshift 和 ssize 值的计算过程为:每次循环,都会把 sshift 自增1,并且 ssize 左移一位,即乘以 2,直到 ssize 的值大于等于 concurrencyLevel 的值 16(此时 sshift=4,ssize=16);sshfit 是为了辅助计算 segmentShift 值,ssize 是为了确定 Segment 数组长度;
- 计算 c 的值,c = 16/16 = 1,判断 c * 16 < 16 是否为真,真的话 c 自增 1,此处为 false,因此 c 的值为 1 不变;
- 此时,由于 c 为 1, cap 为 2,因此判断 cap < c 为 false,最终 cap 为 2;
总结
- 确定 segmentShift,这个用于后边计算 hash 值的偏移量,此处即为 32-4=28;
- 确认 segmentMark,此时为 15;
- 确定 ssize,必须是一个大于等于 concurrencyLevel 的一个 2 的 n 次幂值,此处为 16;
- 确定 cap,必须是一个大于等于 2 的一个 2 的 n 次幂值,此处为 2;
put 方法
put 方法的总体流程:
- 先对第一次 hash 计算后的对应的 Segment 结点进行初始化;
- 先进行锁的获取;
- 通过哈希算法计算出当前 key 的 hash 值;
- 通过这个 hash 值找到它所对应的 Segment 数组的下标;
- 再通过 hash 值计算出它在对应 Segment 的 HashEntry 数组的下标;
- 找到合适的位置插入元素;
public V put(K key, V value) {
Segment<K,V> s;
//不支持 value 为空
if (value == null)
throw new NullPointerException();
//通过 Wang/Jenkins 算法的一个变种算法,计算出当前key对应的hash值
int hash = hash(key);
//上边计算出的segmentShift为28,因此hash值右移28位,说明此时用的是hash的高4位,
//然后把它和掩码15进行与运算,得到的值一定是一个 0000 ~ 1111 范围内的值,即0~15
int j = (hash >>> segmentShift) & segmentMask;
//这里是用Unsafe类的原子操作找到Segment数组中j下标的 Segment 对象
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//初始化j下标的Segment
s = ensureSegment(j);
//在此Segment中添加元素
return s.put(key, hash, value, false);
}
上边有一个这样的方法, UNSAFE.getObject (segments, (j << SSHIFT) + SBASE;它是为了通过 Unsafe 这个类,找到 j 最新的实际值,这个计算 (j << SSHIFT) + SBASE,在后边非常常见,我们只需要知道它代表的是 j 的一个偏移量,通过偏移量,就可以得到 j 的实际值。可以类比,AQS 中的 CAS 操作。Unsafe 中的操作,都需要一个偏移量,如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hAxxj8gj-1596270911171)(F:\我的复习\集合\ConcurrentHashMap.assets\image-20200715093203014.png)]
(j << SSHIFT) + SBASE 就相当于图中的 stateOffset 偏移量,只不过图中是 CAS 设置新值,而我们这里是取 j 的最新值;
s.put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//这里通过tryLock尝试加锁,如果加锁成功,返回null,否则执行scanAndLockForPut方法
//这里说明一下,tryLock和lock是 ReentrantLock中的方法,
//区别是tryLock不会阻塞,抢锁成功就返回true,失败就立马返回false,
//而lock方法是,抢锁成功则返回,失败则会进入同步队列,阻塞等待获取锁。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//获取Segment的table数组
HashEntry<K,V>[] tab = table;
//通过hash值,与tab数组长度-1取模,找到其所在HashEntry数组的下标
int index = (tab.length - 1) & hash;
//获取当下标位置的第一个HashEntry节点
HashEntry<K,V> first = entryAt(tab, index);
//遍历链表
for (HashEntry<K,V> e = first;;) {
//如果第一个节点不为空
if (e != null) {
K k;
//并且第一个节点,就是要插入的节点,则替换value值,否则继续向后查找
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
//替换旧值,并获取到旧值
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
//否则说明当前index位置不存在任何节点,此时first为null,
//或者当前index存在一条链表,并且已经遍历完了还没找到相等的key,此时first就是链表第一个元素
else {
//如果node不为空,则直接头插
if (node != null)
node.setNext(first);
else
//否则,创建一个新的node,并头插
node = new HashEntry<K,V>(hash, key, value, first);
//segment数量+1
int c = count + 1;
//如果当前Segment中的元素大于阈值,并且tab长度没有超过容量最大值,则扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
//否则,就把当前node设置为index下标位置新的头结点
setEntryAt(tab, index, node);
++modCount;
//更新count值
count = c;
//这种情况说明旧值肯定为空
oldValue = null;
break;
}
}
} finally {
//注意ReentrantLock必须手动解锁
unlock();
}
//返回旧值
return oldValue;
}
上述添加代码中,获取 HashEntry 数组下标是:(tab.length - 1) & hash;而计算 Segment 下标是:(hash >>> segmentShift) & segmentMask;计算 Segment 数组下标是用的 hash 值高几位(这里以高 4 位为例)和掩码做与运算,而计算 HashEntry 数组下标是直接用的 hash 值和数组长度减 1 做与运算;
这是为了尽量避免当前 hash 值计算出来的 Segment 数组下标和计算出来的 HashEntry 数组下标趋于相同;简单说,就是为了避免分配到同一个 Segment 中的元素扎堆现象,即避免它们都被分配到同一条链表上,导致链表过长,同时,也是为了减少并发;
ensureSegment()方法
第一个 put 方法中的 ensureSegment 方法;
判断 j 下标的 Segment 为空后,则需要调用此方法,初始化一个 Segment 对象,以确保拿到的对象一定是不为空的,否则无法执行 s.put 方法;
//k为 (hash >>> segmentShift) & segmentMask 算法计算出来的值
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
//u代表 k 的偏移量,用于通过 UNSAFE 获取主内存最新的实际 K 值
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//从内存中取到最新的下标位置的 Segment 对象,判断是否为空,(1)
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//之前构造函数说了,s0是作为一个原型对象,用于创建新的 Segment 对象
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
//获取容量
int cap = proto.table.length;
//获取加载因子
float lf = proto.loadFactor;
//扩容阈值
int threshold = (int)(cap * lf);
//把 Segment 对应的 HashEntry 数组先创建出来
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//再次检查 K 下标位置的 Segment 是否为空, (2)
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//此处把 Segment 对象创建出来,并赋值给 s,
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//循环检查 K 下标位置的 Segment 是否为空, (3)
//若不为空,则说明有其它线程抢先创建成功,并且已经成功同步到主内存中了,
//则把它取出来,并返回
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//CAS,若当前下标的Segment对象为空,就把它替换为最新创建出来的 s 对象
//若成功,就跳出循环,否则,就一直自旋直到成功,或者 seg 不为空(其他线程成功导致)
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
为什么标志的(1),(2),(3)处,每次都判断最新的 Segment 是否为空?
在多线程环境下,因为不确定是什么时候会有其它线程 CAS 成功,有可能发生在以上的任意时刻。所以,只要发现一旦内存中的对象已经存在了,则说明已经有其它线程把 Segment 对象创建好,并 CAS 成功同步到主内存了。此时,就可以直接返回,而不需要往下执行了。这样做,是为了代码执行效率考虑;
scanAndLockForPut()方法
put 方法第一步抢锁失败之后,就会执行此方法;
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//根据hash值定位到它对应的HashEntry数组的下标位置,并找到链表的第一个节点
//注意,这个操作会从主内存中获取到最新的状态,以确保获取到的first是最新值
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//重试次数,初始化为 -1
int retries = -1; // negative while locating node
//若抢锁失败,就一直循环,直到成功获取到锁。有三种情况
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//1.若 retries 小于0,
if (retries < 0) {
//若e节点和 node 都为空,则创建一个 node 节点。这里只是预测性的创建一个node节点
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//如当前遍历到的e节点不为空,则判断它的key是否等于传进来的key,若是则把retries设为0
else if (key.equals(e.key))
retries = 0;
//否则,继续向后遍历节点
else
e = e.next;
}
//2.若是重试次数超过了最大尝试次数,则调用lock方法加锁。表明不再重试,我下定决心了一定要获取到锁。
//要么当前线程可以获取到锁,要么获取不到就去排队等待获取锁。获取成功后,再 break。
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
//3.若 retries 的值为偶数,并且从内存中再次获取到最新的头节点,判断若不等于first
//则说明有其他线程修改了当前下标位置的头结点,于是需要更新头结点信息。
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//更新头结点信息,并把重试次数重置为 -1,继续下一次循环,从最新的头结点遍历当前链表。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
会一直循环尝试获取锁,若获取成功,则返回,否则的话,每次循环时,都会同时遍历当前链表;若遍历完了一次,还没找到和 key 相等的节点,就会预先创建一个节点;
注意:这里只是预测性的创建一个新节点,也有可能在这之前,就已经获取锁成功了;
同时,当重试次每偶数次时,就会检查一次当前最新的头结点是否被改变,因为若有变化的话,还需要从最新的头结点开始遍历链表;
还有一种情况,就是循环次数达到了最大限制,则停止循环,用阻塞的方式去获取锁;这时,也就停止了遍历链表的动作,当前线程也不会再做其他预热(warm up)的事情;
scanAndLockForPut 这个方法可以确保返回时,当前线程一定是获取到锁的状态;
关于为什么预测性的创建新节点的问题
因为遍历速度无所谓,所以,我们可以预先(warm up)做一些相关联代码的准备工作。这里相关联代码,指的就是循环中,在获取锁成功或者调用 lock 方法之前做的这些事情,当然也包括创建新节点;
在 put 方法中可以看到,有一句是判断 node 是否为空,若创建了,就直接头插。否则的话,它也会自己创建这个新节点;
rehash 方法
当 put 方法时,发现元素个数超过了阈值,则会扩容。需要注意的是,每个 Segment 只管它自己的扩容,互相之间并不影响。换句话说,可以出现这个 Segment 的长度为 2,另一个 Segment 的长度为 4 的情况(只要是2的n次幂);
//node为创建的新节点
private void rehash(HashEntry<K,V> node) {
//获取当前Segment中的旧表
HashEntry<K,V>[] oldTable = table;
//获取旧的容量
int oldCapacity = oldTable.length;
//新容量为旧容量的2倍
int newCapacity = oldCapacity << 1;
//更新新的阈值
threshold = (int)(newCapacity * loadFactor);
//用新的容量创建一个新的 HashEntry 数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//计算当前的segment掩码,用于计算节点在新数组中的下标
int sizeMask = newCapacity - 1;
//遍历旧表
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
//如果e不为空,说明当前链表不为空
if (e != null) {
HashEntry<K,V> next = e.next;
//计算hash值再新数组中的下标位置
int idx = e.hash & sizeMask;
//如果e不为空,且它的下一个节点为空,则说明这条链表只有一个节点,
//直接把这个节点放到新数组的对应下标位置即可
if (next == null) // Single node on list
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
//记录上一次遍历到的节点
HashEntry<K,V> lastRun = e;
//对应上一次遍历到的节点在新数组中的新下标
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//计算当前遍历到的节点的新下标
int k = last.hash & sizeMask;
//若 k 不等于 lastIdx,则说明此次遍历到的节点和上次遍历到的节点不在同一个下标位置
//需要把 lastRun 和 lastIdx 更新为当前遍历到的节点和下标值。
//若相同,则不处理,继续下一次 for 循环
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//把和lastRun节点的下标位置相同的链表最末尾的几个连续的节点放到新数组的对应下标位置
newTable[lastIdx] = lastRun;
//再把剩余的节点,复制到新数组
//从旧数组的头结点开始遍历,直到 lastRun 节点,因为 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];
//用的是复制节点信息的方式,并不是把原来的节点直接迁移,区别于lastRun处理方式
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
//所有节点都迁移完成之后,再处理传进来的新的node节点,把它头插到对应的下标位置
int nodeIndex = node.hash & sizeMask; // add the new node
//头插node节点
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
//更新当前Segment的table信息
table = newTable;
}
从头结点开始向后遍历,找到当前链表的最后几个下标相同的连续的节点。如下图,虽然开头出现了有两个节点的下标都是 k2, 但是中间出现一个不同的下标 k1,打断了下标连续相同,因此从下一个 k2,又重新开始算。好在后边三个连续的节点下标都是相同的,因此倒数第三个节点被标记为 lastRun,且变量无变化。
从 lastRun 节点到尾结点的这部分就可以整体迁移到新数组的对应下标位置了,因为它们的下标都是相同的,可以这样统一处理;
另外从头结点到 lastRun 之前的节点,无法统一处理,只能一个一个去复制了。且注意,这里不是直接迁移,而是复制节点到新的数组,旧的节点会在不久的将来,因为没有引用指向,被 JVM 垃圾回收处理掉。
ConcurrentHashMap1.8
在 1.8 CHM(ConcurrentHashMap) 中,底层存储结构和 1.8 的 HashMap 是一样的,都是 数组+链表+红黑树;1.8 之中采用的是 Synchronized + CAS;
由于1.8 的 CHM 和 HashMap 结构和基本属性变量,还有初始化逻辑都差不多,只是多了一些并发情况需要用到的参数和内部类
put 方法
- 根据 key 计算出 hashcode ;
- 判断是否需要进行初始化;
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功;
- 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容;
- 如果都不满足,则利用 synchronized 锁写入数据;
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树;
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key 和 value 都不能为空
if (key == null || value == null) throw new NullPointerException();
//计算hash值,与HashMap1.8有所不同
//(h ^ (h >>> 16)) & HASH_BITS; HASH_BITS = 0x7fffffff;
//所以,hash值除了做了高低位异或运算,还多了一步,保证最高位的 1 个 bit 位总是0
int hash = spread(key.hashCode());
//用来计算当前链表上的元素个数
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果表为空,则说明还未初始化
if (tab == null || (n = tab.length) == 0)
//初始化表,只有一个线程可以初始化成功
tab = initTable();
//若表已经初始化,则找到当前 key 所在的桶,并且判断是否为空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//若当前桶为空,则通过 CAS 原子操作,把新节点插入到此位置,
//这保证了只有一个线程可以 CAS 成功,其它线程都会失败。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//若所在桶不为空,则判断节点的 hash 值是否为 MOVED(值是-1)
else if ((fh = f.hash) == MOVED)
//若为-1,说明当前数组正在进行扩容,则需要当前线程帮忙迁移数据
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//这里用加同步锁的方式,来保证线程安全,给桶中第一个节点对象加锁
synchronized (f) {
//recheck 一下,保证当前桶的第一个节点无变化,后边很多这样类似的操作
if (tabAt(tab, i) == f) {
//如果hash值大于等于0,说明是正常的链表结构
if (fh >= 0) {
binCount = 1;
//从头结点开始遍历,每遍历一次,binCount计数加1
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果找到了和当前 key 相同的节点,则用新值替换旧值
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只是头结点对TreeNode的再封装
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;
}
}
}
}
//注意下,这个判断是在同步锁外部,因为treeifyBin内部也有同步锁,并不影响
if (binCount != 0) {
//如果节点个数大于等于8并且数组长度大于等于64,则转化为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
//把旧节点值返回
return oldVal;
break;
}
}
}
//给元素个数加 1,并有可能会触发扩容,比较复杂,稍后细讲
addCount(1L, binCount);
return null;
}
initTable() 方法
第一次添加元素时,数组为空然后进行初始化数组;
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//循环判断表是否为空,直到初始化成功为止
while ((tab = table) == null || tab.length == 0) {
//sizeCtl 这个值有很多情况,默认值为0,
//当为 -1 时,说明有其它线程正在对表进行初始化操作
//当表初始化成功后,又会把它设置为扩容阈值
//当为一个小于 -1 的负数,用来表示当前有几个线程正在帮助扩容
if ((sc = sizeCtl) < 0)
//若 sc 小于0,其实在这里就是 -1,因为此时表是空的,不会发生扩容,sc只能为正数或者-1
//因此,当前线程放弃 CPU 时间片,只是自旋
Thread.yield(); // lost initialization race; just spin
//通过CAS把 sc 的值设置为-1,表明当前线程正在进行表的初始化,其它失败的线程就会自旋
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//重新检查一下表是否为空
if ((tab = table) == null || tab.length == 0) {
//如果sc大于0,则为sc,否则返回默认容量 16
//当调用有参构造创建 Map 时,sc的值是大于0的
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//创建数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//n减去 1/4n ,即为 0.75n ,表示扩容阈值
sc = n - (n >>> 2);
}
} finally {
//更新 sizeCtl 为扩容阈值
sizeCtl = sc;
}
//若当前线程初始化表成功,则跳出循环。其它自旋的线程因为判断数组不为空,也会停止自旋
break;
}
}
return tab;
}
transfer 方法
为了方便,上边以原数组长度 8 为例。在元素迁移的时候,所有线程都遵循从后向前推进的规则,即如图A线程是第一个进来的线程,会从下标为7的位置,开始迁移数据。
而且当前线程迁移时会确定一个范围,限定它此次迁移的数据范围,如图 A 线程只能迁移 bound=6到 i=7 这两个数据。
此时,其它线程就不能迁移这部分数据了,只能继续向前推进,寻找其它可以迁移的数据范围,且每次推进的步长为固定值 stride(此处假设为2)。如图中 B线程发现 A 线程正在迁移6,7的数据,因此只能向前寻找,然后迁移 bound=4 到 i=5 的这两个数据。
当每个线程迁移完成它的范围内数据时,都会继续向前推进。那什么时候是个头呢?
这就需要维护一个全局的变量 transferIndex,来表示所有线程总共推进到的元素下标位置。如图,线程 A 第一次迁移成功后又向前推进,然后迁移2,3 的数据。此时,若没有其他线程在帮助迁移,则 transferIndex 即为 2。
剩余部分等待下一个线程来迁移,或者有任何的 A 和B线程已经迁移完成,也可以推进到这里帮助迁移。直到 transferIndex=0 。
//迁移数据
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//根据当前CPU核心数,确定每次推进的步长,最小值为16.(为了方便我们以2为例)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//从 addCount 方法,只会有一个线程跳转到这里,初始化新数组
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//新数组长度为原数组的两倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
//用 nextTable 指代新数组
nextTable = nextTab;
//这里就把推进的下标值初始化为原数组长度(以16为例)
transferIndex = n;
}
//新数组长度
int nextn = nextTab.length;
//创建一个标志类
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//是否向前推进的标志
boolean advance = true;
//是否所有线程都全部迁移完成的标志
boolean finishing = false; // to ensure sweep before committing nextTab
//i 代表当前线程正在迁移的桶的下标,bound代表它本次可以迁移的范围下限
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//需要向前推进
while (advance) {
int nextIndex, nextBound;
//(1) 先看 (3) 。i每次自减 1,直到 bound。若超过bound范围,或者finishing标志为true,则不用向前推进。
//若未全部完成迁移,且 i 并未走到 bound,则跳转到 (7),处理当前桶的元素迁移。
if (--i >= bound || finishing)
advance = false;
//(2) 每次执行,都会把 transferIndex 最新的值同步给 nextIndex
//若 transferIndex小于等于0,则说明原数组中的每个桶位置,都有线程在处理迁移了,
//于是,需要跳出while循环,并把 i设为 -1,以跳转到 ④判断在处理的线程是否已经全部完成。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//(3) 第一个线程会先走到这里,确定它的数据迁移范围。(2)处会更新 nextIndex为 transferIndex 的最新值
//因此第一次 nextIndex=n=16,nextBound代表当次迁移的数据范围下限,减去步长即可,
//所以,第一次时,nextIndex=16,nextBound=16-2=14。后续,每次都会间隔一个步长。
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//bound代表当次数据迁移下限
bound = nextBound;
//第一次的i为15,因为长度16的数组,最后一个元素的下标为15
i = nextIndex - 1;
//表明不需要向前推进,只有当把当前范围内的数据全部迁移完成后,才可以向前推进
advance = false;
}
}
//(4)
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//若全部线程迁移完成
if (finishing) {
nextTable = null;
//更新table为新表
table = nextTab;
//扩容阈值改为原来数组长度的 3/2 ,即新长度的 3/4,也就是新数组长度的0.75倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//到这,说明当前线程已经完成了自己的所有迁移(无论参与了几次迁移),
//则把 sc 减1,表明参与扩容的线程数减少 1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//在 addCount 方法最后,我们强调,迁移开始时,会设置 sc=(rs << RESIZE_STAMP_SHIFT) + 2
//每当有一个线程参与迁移,sc 就会加 1,每当有一个线程完成迁移,sc 就会减 1。
//因此,这里就是去校验当前 sc 是否和初始值是否相等。相等,则说明全部线程迁移完成。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//只有此处,才会把finishing 设置为true。
finishing = advance = true;
//这里非常有意思,会把 i 从 -1 修改为16,
//目的就是,让 i 再从后向前扫描一遍数组,检查是否所有的桶都已被迁移完成,参看 (6)
i = n; // recheck before commit
}
}
//(5) 若i的位置元素为空,则说明当前桶的元素已经被迁移完成,就把头结点设置为fwd标志。
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//(6) 若当前桶的头结点是 ForwardingNode ,说明迁移完成,则向前推进
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
//(7) 处理当前桶的数据迁移。
else {
synchronized (f) { //给头结点加锁
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//若hash值大于等于0,则说明是普通链表节点
if (fh >= 0) {
int runBit = fh & n;
//这里是 1.7 的 CHM 的 rehash 方法和 1.8 HashMap的 resize 方法的结合体。
//会分成两条链表,一条链表和原来的下标相同,另一条链表是原来的下标加数组长度的位置
//然后找到 lastRun 节点,从它到尾结点整体迁移。
//lastRun前边的节点则单个迁移,但是需要注意的是,这里是头插法。
//另外还有一点和1.7不同,1.7 lastRun前边的节点是复制过去的,而这里是直接迁移的,没有复制操作。
//所以,最后会有两条链表,一条链表从 lastRun到尾结点是正序的,而lastRun之前的元素是倒序的,
//另外一条链表,从头结点开始就是倒叙的。看下图
Node<K,V> lastRun = f;
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) {
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);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
//树节点
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
迁移后的新数组链表方向示意图,以 runBit =0 为例: