一、与JDK1.7的区别
ConcurrentHashMap的实现与1.7版本有很大的差别,放弃了段锁的概念,借鉴了HashMap的数据结构:数组+链表+红黑树。ConcurrentHashMap不接受nullkey和nullvalue。
1.1 数据结构
数组+链表+红黑树
1.2 并发原理
cas乐观锁+synchronized锁
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
看完你就明白的锁系列之自旋锁
我们常说的 CAS 自旋锁是什么
加锁对象:数组每个位置的头节点。
1.3 放弃了分段锁
JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
...
}
二、ConcurrentHashMap结构
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
ConcurrentHashMap的基本属性:
// node数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30 ;
// 默认初始值,必须是2的幂数
private static final int DEFAULT_CAPACITY = 16 ;
//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8 ;
//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8 ;
//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;
//最小转换成树,hash桶最少需要64
static final int MIN_TREEIFY_CAPACITY = 64;
//扩容线程每次最少要迁移16个hash桶
private static final int MIN_TRANSFER_STRIDE = 16;
//在sizeCtl中记录大小戳的位移位
private static int RESIZE_STAMP_BITS = 16;
// 2^15-1,help resize的最大线程数
private static final int MAX_RESIZERS = ( 1 << ( 32 - RESIZE_STAMP_BITS)) - 1;
// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32-RESIZE_STAMP_BITS;
// forwarding nodes的hash值
static final int MOVED =-1;
// 树根节点的hash值
static final int TREEBIN= -2;
// ReservationNode的hash值
static final int RESERVED = -3;
// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
//存放node的数组
transient volatile Node<K,V>[] table;
//扩容时,将table中的元素迁移至nextTable;只有在调整大小时才非空
private transient volatile Node<K,V>[] nextTable;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
*当为0时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小
*/
private transient volatile int sizeCtl;
//当resizing的时候下一个tab下标索引值(当前值+1)
private transient volatile int transferIndex;
基本属性定义了ConcurrentHashMap的一些边界以及操作时的一些控制,下面看一些内部的一些结构组成,这些是整个ConcurrentHashMap整个数据结构的核心。
2.1 Node
Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据,源代码如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
//不允许更新value
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/**
* Virtualized support for map.get(); overridden in subclasses.
*/
//用于map中的get()方法,子类重写
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
Node数据结构很简单,从上可知,就是一个链表,但是只允许对数据进行查找,不允许进行修改。
2.2 TreeNode
TreeNode继承与Node,但是数据结构换成了二叉树结构,它是红黑树的数据的存储结构,用于红黑树中存储数据,当链表的节点数大于8时会转换成红黑树的结构,他就是通过TreeNode作为存储结构代替Node来转换成黑红树源代码如下:
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
//标志红黑树的红节点
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
Node<K,V> find(int h, Object k) {
return findTreeNode(h, k, null);
}
/**
* Returns the TreeNode (or null if not found) for the given key
* starting at given root.
*/
//根据key查找 从根节点开始找出相应的TreeNode,
final TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
if (k != null) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk; TreeNode<K,V> q;
TreeNode<K,V> pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
2.3 TreeBin
TreeBin从字面含义中可以理解为存储树形结构的容器,而树形结构就是指TreeNode,所以TreeBin就是封装TreeNode的容器,它提供转换黑红树的一些条件和锁的控制,部分源码结构如下:
static final class TreeBin<K,V> extends Node<K,V> {
//指向TreeNode列表和根节点
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// 读写锁状态
static final int WRITER = 1 ; // 获取写锁的状态
static final int WAITER = 2 ; // 等待写锁的状态
static final int READER = 4 ; // 增加数据时读锁的状态
/**
* 初始化红黑树
*/
TreeBin(TreeNode<K,V> b) {
super(TREEBIN, null, null, null);
this.first = b;
TreeNode<K,V> r = null;
for (TreeNode<K,V> x = b, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (r == null) {
x.parent = null;
x.red = false;
r = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = r;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
r = balanceInsertion(r, x);
break;
}
}
}
}
this.root = r;
assert checkInvariants(root);
}
......
}
2.4 ForwardingNode
ForwardingNode在转移的时候放在头部的节点,是一个空节点
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
2.5 ConcurrentHashMap无参构造方法
下面来看一下ConcurrentHashMap的无参构造方法:
public ConcurrentHashMap() {
}
由上你会发现ConcurrentHashMap的初始化其实是一个空实现,并没有做任何事,这里后面会讲到,这也是和其他的集合类有区别的地方,初始化操作并不是在构造函数实现的,而是在put操作中实现,当然ConcurrentHashMap还提供了其他的构造函数,有指定容量大小或者指定负载因子,跟HashMap一样,这里就不做介绍了。
2.6 ConcurrentHashMap几个重要方法
/*
* 用来返回节点数组的指定位置的节点的原子操作
*/
@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
/*
* cas原子操作,在指定位置设定值
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
/*
* 原子操作,在指定位置设定值
*/
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
三、put方法
/*
* 单纯的额调用putVal方法,并且putVal的第三个参数设置为false
* 当设置为false的时候表示这个value一定会设置
* true的时候,只有当这个key的value为空的时候才会设置
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//分散Hash;扰动函数同HashMap相同,但又增加了一次与操作
int hash = spread(key.hashCode());
//用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
int binCount = 0;
//这是一个死循环,可能的出口下面有标识:@1,@2,@3,@4
// 死循环,直到插入成功再跳出,因为如果其他线程正在修改tab,那么尝试就会失败,所以这边要加一个for循环,不断的尝试
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
//第一次put的时候table没有初始化,则初始化table
tab = initTable();
//数组的第一个元素为空,则赋值
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)))
//@1:可能的出口1
break; // no lock when adding to empty bin
}
/*
* 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
* 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的 性能损失
*/
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
/*
* 如果在这个位置有元素的话,就采用synchronized的方式加锁,
* 如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
* 如果找到了key和key的hash值都一样的节点,则把它的值替换到
* 如果没找到的话,则添加在链表的最后面
* 否则,是树的话,则调用putTreeVal方法添加到树中去
*
* 在添加完之后,会对该节点上关联的的数目进行判断,
* 如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
*/
V oldVal = null;
//synchronized锁。这里要注意,不会出现
//桶正在resize的过程中执行插入,因为桶resize的时候
//也请求了synchronized锁。即如果该桶正在resize,这里会发生锁等待
synchronized (f) {
//如果是链表的首个节点
if (tabAt(tab, i) == f) {
//取出来的元素的hash值大于0,当转换为树之后,hash值为-2
if (fh >= 0) {
binCount = 1;
//遍历这个链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
//找到相等的元素更新其value
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
//当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
if (!onlyIfAbsent)
e.val = value;
//@2:可能的出口2
break;
}
Node<K,V> pred = e;
//否则添加到链表尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
//@3:可能的出口3
break;
}
}
}
//表示已经转化成红黑树类型了
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//调用putTreeVal方法,将该元素添加到树中去
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
//@4:可能的出口4
break;
}
}
}
//计数
addCount(1L, binCount);
return null;
}
putValue过程描述:
- 根据key的hash值定位到桶的位置;
- 判断if(table==null),先初始化table;
- 死循环。判断if(table[i]==null),cas添加元素。成功则跳出循环,失败则进入下一轮for循环。
- 判断是否有其他线程在扩容table(取出来的节点的hash值是MOVED(-1)的话,则表示当前正在对这个数组进行扩容),有则帮忙扩容,扩容完成再添加元素。进入真正的put步骤
- 真正的put步骤。桶的位置不为空,遍历该桶的链表或者红黑树,若key已存在,则覆盖;不存在则将key插入到链表或红黑树的尾部。具体措施参见6
-
如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作: 判断当前取出的节点位置存放的是链表还是树: ——如果是链表的话,则遍历整个链表,直到取出来的节点的key来个要放的key进行比较,如果key相等,并且key的hash值也相等的话,则说明是同一个key,则覆盖掉value,否则的话则添加到链表的末尾; ——如果是树的话,则调用putTreeVal方法把这个元素添加到树中去
- 最后在添加完成之后,会判断在该节点处共有多少个节点(注意是添加前的个数),如果达到8个以上了的话,则调用treeifyBin方法来尝试将处的链表转为树,或者扩容数组。
并发问题:假如put操作时正好有别的线程正在对table数组(map)扩容怎么办?
—— 答:暂停put操作,先帮助其他线程对map扩容。
三、ConcurrentHashMap的扩容详解(太难啃了,目前还没有啃得动,有机会再补)
3.1 helpTransfer方法
在上述put方法中,如果检测到某个节点的hash值是MOVED(-1),会调用helpTransfer扩容方法,源码如下:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//tab为空时,则说明扩容已经完成
//hash for forwarding nodes,说明这个为了移动节点而准备的常量。
/*如果 table 不是空 且 node 节点是转移类型,数据检验
*且 node 节点的 nextTable(新 table) 不是空,同样也是数据校验,
*尝试帮助扩容
*/
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
// 根据 length 得到一个标识符号
int rs = resizeStamp(tab.length);
// 如果 nextTab 没有被并发修改 且 tab 也没有被并发修改
// 且 sizeCtl < 0 (说明还在扩容)
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
// 如果 sizeCtl 无符号右移 16 不等于 rs ( sc前 16 位如果不等于标识符,则标识符变化了)
// 或者 sizeCtl == rs + 1 (扩容结束了,不再有线程进行扩容)(默认第一个线程设置 sc ==rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1)
// 或者 sizeCtl == rs + 65535 (如果达到最大帮助线程的数量,即 65535)
// 或者转移下标正在调整 (扩容结束)
// 结束循环,返回 table
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 如果以上都不是, 将 sizeCtl + 1, (表示增加了一个线程帮助其扩容)
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 进行转移
transfer(tab, nextTab);
// 结束循环
break;
}
}
return nextTab;
}
return table;
}
- 这里解释一下为什么nextTable为空,说明扩容已经完成。
——table是原数组的地址,如果为空,说明成员变量table已经已经被新的数组替代,而老数组会被清空。 - resizeStamp方法下面会有介绍。
- if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0);这就代码的作用: 判断扩容是否结束或者并发扩容线程数是否已达最大值,如果是的话直接结束while循环。具体看上面注释
在 Node 的子类 ForwardingNode 的构造方法中,可以看到这个变量作为 hash 值进行了初始化。
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
疑问:
1.nextTab属性按我的理解是新hash桶数组,f是当前key经过hash后在原hash桶中的位置,为什么要将f.nextTable的值赋给nextTab?说不通啊,一个是节点的属性,一个是存放节点的数组。
3.2 resizeStamp方法
在helpTransfer方法中会调用resizeStamp方法生成rs。resizeStamp方法源码如下
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
numberOfLeadingZeros(n)返回的是n的二进制标识的从高位开始到第一个非0的数字的之间0的个数,比如numberOfLeadingZeros(8)返回的就是28 ,因为0000 0000 0000 0000 0000 0000 0000 1000在1前面有28个0
RESIZE_STAMP_BITS 的值是16,1 << (RESIZE_STAMP_BITS - 1)就是将1左移位15位,0000 0000 0000 0000 1000 0000 0000 0000
然后将两个数字再按位或,将相当于 将移位后的 两个数相加。
举个例子 :
8的二进制表示是: 0000 0000 0000 0000 0000 0000 0000 1000 = 8
7的二进制表示是: 0000 0000 0000 0000 0000 0000 0000 0111 = 7
按位或的结果就是:0000 0000 0000 0000 0000 0000 0000 1111 =15
相当于 8 + 7 =15
为什么会出现这种效果呢?因为8是2的整数次幂,也就是说8的二进制表示只会在某个高位上是1,其余地位都是0,所以在按位或的时候,低位表示的全是7的位值,所以出现了这种效果。
3.3 transfer方法
//复制元素到nextTab
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//stride是步长,transfer会依据stride,把table分为若干部分,依次处理,好让多线程能协助transfer
int n = tab.length, stride;
//NCPU为CPU核心数,每个核心均分复制任务,如果均分小于16个
//那么以16为步长分给处理器:例如0-15号给处理器1,16-32号分给处理器2。处理器3就不用接任务了。
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
/*
* 如果复制的目标nextTab为null的话,则初始化一个table两倍长的nextTab
* 此时nextTable被设置值了(在初始情况下是为null的)
* 因为如果有一个线程开始了表的扩张的时候,其他线程也会进来帮忙扩张,
* 而只是第一个开始扩张的线程需要初始化下目标数组
*/
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 = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
/*
* 创建一个fwd节点,这个是用来控制并发的,当一个节点为空或已经被转移之后,就设置为fwd节点
* 这是一个空的标志节点
*/
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//是否继续向前查找的标志位
boolean advance = true;
// to ensure sweep(清扫) before committing nextTab,在完成之前重新在扫描一遍数组,看看有没完成的没
boolean finishing = false; // to ensure sweep before committing nextTab
//开始转移各个槽
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//STEP1 判断是否可以进入下一个stride 确认i和bound
//通过stride领取一部分的transfer任务,while循环就是确认边界
while (advance) {
int nextIndex, nextBound;
//认领的部分已经被执行完(一个stride执行完)
if (--i >= bound || finishing)
advance = false;
//transfer任务被认领完
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//认领一个stride的任务
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
/**
* i < 0 说明要转移的桶 都已经处理过了
*
*
* 以上条件已经说明 transfer已经完成了
*/
//transfer 结束
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//如果完成整个 transfer的过程 清空nextTable 让table等于扩容后的数组
if (finishing) {
nextTable = null;
table = nextTab;
//0.75f * n 重新计算下次扩容的阈值
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//一个线程完成了transfer
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//如果还有其他线程在transfer ,,就不能设置finish为true,先返回
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//说明这是最后一个在transfer的线程 因此finish标志被置为 true
finishing = advance = true;
i = n; // recheck before commit
}
}
//如果该节点为null,则对该节点的迁移立马完成,设置成forwardNode
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else { //开始迁移该节点
synchronized (f) {
if (tabAt(tab, i) == f) { //double-check
//ln是扩容后依旧保留在原index上的node链表;hn是移到index + n 上的node链表
Node<K,V> ln, hn;
//普通链表
if (fh >= 0) {
/*
* 因为n的值为数组的长度,且是power(2,x)的,所以,在&操作的结果只可能是0或者n
* 根据这个规则
* 0--> 放在新表的相同位置
* n--> 放在新表的(n+原来位置)
*/
int runBit = fh & n;
Node<K,V> lastRun = f;
//这一次遍历的目的是找到最后一个一个节点,其后的节点hash & N 都不发生改变
//例如 有A->B->C->D,其hash & n 为 0,1,1,1 那就是找到B点
//这样做的目的是之后对链表进行拆分时 C和D不需要单独处理 维持和B的关系 B移动到新的tab[i]或tab[i+cap]上即可
//还有不理解的可以参考另一作者测试代码:https://github.com/insaneXs/all-mess/blob/master/src/main/java/com/insanexs/mess/collection/TestConHashMapSeq.java
/*
* lastRun 表示的是需要复制的最后一个节点
* 每当新节点的hash&n -> b 发生变化的时候,就把runBit设置为这个结果b
* 这样for循环之后,runBit的值就是最后不变的hash&n的值
* 而lastRun的值就是最后一次导致hash&n 发生变化的节点(假设为p节点)
* 为什么要这么做呢?因为p节点后面的节点的hash&n 值跟p节点是一样的,
* 所以在复制到新的table的时候,它肯定还是跟p节点在同一个位置
* 在复制完p节点之后,p节点的next节点还是指向它原来的节点,就不需要进行复制了,自己就被带过去了
* 这也就导致了一个问题就是复制后的链表的顺序并不一定是原来的倒序
*/
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;//n的值为扩张前的数组的长度
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//如果runBit == 0 说明之前找到的节点应该在tab[i]
if (runBit == 0) {
ln = lastRun;
hn = null;
}
//否则说明之前的节点在tab[i+cap]
else {
hn = lastRun;
ln = null;
}
//上面分析了链表的拆分只用遍历到lastRun的前一节点 因为lastRun及之后的节点已经移动好了
/*
* 构造两个链表,顺序大部分和原来是反的
* 分别放到原来的位置和新增加的长度的相同位置(i/n+i)
*/
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
//这里不再继续使用尾插法而是改用了头插法 因此链表的顺序可能会发生颠倒(lastRun及之后的节点不受影响)
if ((ph & n) == 0)
/*
* 假设runBit的值为0,
* 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同为0的节点)设置到旧的table的第一个hash计算后为0的节点下一个节点
* 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
*/
ln = new Node<K,V>(ph, pk, pv, ln);
/*
* 假设runBit的值不为0,
* 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同不为0的节点)设置到旧的table的第一个hash计算后不为0的节点下一个节点
* 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
*/
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//将新的链表移动到nextTab的对应坐标中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//tab上对应坐标的节点变为ForwardingNode
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;
}
}
/*
* 在复制完树节点之后,判断该节点处构成的树还有几个节点,
* 如果≤6个的话,就转回为一个链表
*/
//是否需要退化成链表
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;
}
}
}
}
}
}
到这里,ConcurrentHashMap的put操作和扩容都介绍的差不多了。
下面的两点一定要注意:
- 复制之后的新链表不是旧链表的绝对倒序。
- 在扩容的时候每个线程都有处理的步长,最少为16,在这个步长范围内的数组节点只有自己一个线程来处理
扩容过程:
transfer的代码比较长,我们也一部分一部分的分析各段代码的作用。
1.首先,最先发起扩容的线程需要对数组进行翻倍,然后将翻倍后得到的新数组通过nextTable变量保存。并且启用了transferIndex变量,初始值为旧数组的容量n,这个变量会被用来标记已经被认领的桶的下标。
扩容过程是从后往前的,因此transferIndex的初始值才是n。并且整个扩容过程依据步长stride,被拆分成个部分,线程从后往前依次领取一个部分,所以每次有线程领取任务,transferIndex总是要被减去一个stride。
2.当线程认领的一个步长的任务完成后,继续去认领下一个步长,直到transferIndex < 0,说明所有数据都被认领完。
3.当参与扩容的线程发现没有其他任务能被认领,那么就会更新sizeCtl为 sizeCtl-1 (说明有一条线程退出扩容)。最后一条线程完成了任务,发现sizeCtl == (resizeStamp(n) << RESIZE_STAMP_SHIFT + 2) ,那么说明所有的线程都完成了扩容任务,此时需要将nextTable替换为table,重置transferIndex,并计算新的sizeCtl表示下一次扩容的阈值。上面介绍了线程每次认领一个步长的桶数负责rehash,这里介绍下针对每个桶的rehash过程。
1.首先,如果桶上没有元素或是桶上的元素是ForwardingNode,说明不用处理该桶,继续处理上一个桶。
2.对于桶上存放正常的节点而言,为了线程安全,需要对桶的头节点进行上锁,然后以链表为例,需要将链表拆为两个部分,这两部分存放的位置是很有规律的,如果旧数组容量为oldCap,且节点之前在旧数组的下标为i,那么rehash链表中的所有节点将放在nextTable[i]或者nextTable[i+oldCap]的桶上(这一点可以从之前哈希值中比n最高位还靠前的一位来考虑,当前一位为0时,就落在nextTable[i]上,而前一位为1时,就落在nextTable[i+oldCap])。
同理红黑树也会被rehash()成两部分,如果新的红黑树不满足成树条件,将会被退化成链表。
3.当一个桶的元素被transfer完成后,旧数组相关位置上会被放上ForwardingNode的特殊节点表示该桶已经被迁移过。且ForwardingNode会指向nextTable。
3.4 tryPresize方法
/**
* 扩容表为指可以容纳指定个数的大小(总是2的N次方)
* 假设原来的数组长度为16,则在调用tryPresize的时候,size参数的值为16<<1(32),此时sizeCtl的值为12
* 计算出来c的值为64,则要扩容到sizeCtl≥为止
* 第一次扩容之后 数组长:32 sizeCtl:24
* 第二次扩容之后 数组长:64 sizeCtl:48
* 第二次扩容之后 数组长:128 sizeCtl:94 --> 这个时候才会退出扩容
*/
private final void tryPresize(int size) {
/*
* MAXIMUM_CAPACITY = 1 << 30
* 如果给定的大小大于等于数组容量的一半,则直接使用最大容量,
* 否则使用tableSizeFor算出来
* 后面table一直要扩容到这个值小于等于sizeCtrl(数组长度的3/4)才退出扩容
*/
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
/*
* 如果数组table还没有被初始化,则初始化一个大小为sizeCtrl和刚刚算出来的c中较大的一个大小的数组
* 初始化的时候,设置sizeCtrl为-1,初始化完成之后把sizeCtrl设置为数组长度的3/4
* 为什么要在扩张的地方来初始化数组呢?这是因为如果第一次put的时候不是put单个元素,
* 而是调用putAll方法直接put一个map的话,在putALl方法中没有调用initTable方法去初始化table,
* 而是直接调用了tryPresize方法,所以这里需要做一个是不是需要初始化table的判断
*/
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
//初始化tab的时候,把sizeCtl设为-1
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
/*
* 一直扩容到的c小于等于sizeCtl或者数组长度大于最大长度的时候,则退出
* 所以在一次扩容之后,不是原来长度的两倍,而是2的n次方倍
*/
else if (c <= sc || n >= MAXIMUM_CAPACITY)
//退出扩张
break;
else if (tab == table) {
int rs = resizeStamp(n);
/*
* 如果正在扩容Table的话,则帮助扩容
* 否则的话,开始新的扩容
* 在transfer操作,将第一个参数的table中的元素,移动到第二个元素的table中去,
* 虽然此时第二个参数设置的是null,但是,在transfer方法中,当第二个参数为null的时候,
* 会创建一个两倍大小的table
*/
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
/*
* transfer的线程数加一,该线程将进行transfer的帮忙
* 在transfer的时候,sc表示在transfer工作的线程数
*/
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);
}
}
}
疑问:下图的 if (table == tab)处判断是否有意义?tab=table难道不是引用传递吗,即使table被其他线程改变了,tab应该恒==于table的呀。
tryPresize方法的代码还没有完全理解,后续在慢慢去理解。。。
四、扩容方法(接上)
什么情况会导致扩容?
1.链表转换为红黑树时(链表节点个数达到8个可能会转换为红黑树)。如果转换时map长度小于64则直接扩容一倍,不转化为红黑树。如果此时map长度大于64,则不会扩容,直接进行链表转红黑树的操作。
2.map中总节点数大于阈值(即大于map长度的0.75倍)时会进行扩容。
如何扩容?
1.创建一个新的map,是原先map的两倍。注意此过程是单线程创建的
2.复制旧的map到新的map中。注意此过程是多线程并发完成。(将map按照线程数量平均划分成多个相等区域,每个线程负责一块区域的复制任务)
扩容的具体过程:
答:
注:扩容操作是hashmap最复杂难懂的地方,博主也是看了很久才看懂个大概。一两句话真的很难说清楚,建议有时间还是看源码比较好。网上很少有人使用通俗易懂语言来描述扩容的机制。所以这里我尝试用自己的语言做一个简要的概括,描述一下大体的流程,供大家参考,如果觉得不错,可以点个赞,表示对博主的支持,谢谢。
整体思路:扩容是并发扩容,也就是多个线程共同协作,把旧table中的链表一个个复制到新table中。
1.给多个线程划分各自负责的区域。分配时是从后向前分配。假设table原先长度是64,有四个线程,则第一个到达的线程负责48-63这块内容的复制,第二个线程负责32-47,第三个负责16-31,第四个负责0-15。
2.每个线程负责各自区域,复制时是一个个从后向前复制的。如第一个线程先复制下标为63的桶的复制。63复制完了接下来复制62,一直向前,直到完成自己负责区域的所有复制。
3.完成自己区域的任务之后,还没有结束,这时还会判断一下其他线程负责区域有没有完成所有复制任务,如果没有完成,则可能还会去帮助其它线程复制。比如线程1先完成了,这时它看到线程2才做了一半,这时它会帮助线程2去做剩下一半任务。
4.那么复制到底是怎么完成的呢?线程之间相互帮忙会导致混乱吗?
5.首先回答上面第一个问题,我们知道,每个数组的每个桶存放的是一个链表(红黑树也可能,这里只讨论是链表情况)。复制的时候,先将链表拆分成两个链表。拆分的依据是链表中的每个节点的hash值和未扩容前数组长度n进行与运算。运算结果可能为0和1,所以结果为0的组成一个新链表,结果为1的组成一个新链表。为0的链表放在新table的 i 位置,为1的链表放在 新table的 i+n处。扩容后新table是原先table的两倍,即长度是2n。
6.接着回答上面第二个问题,线程之间相互帮忙不会造成混乱。因为线程已完成复制的位置会标记该位置已完成,其他线程看到标记则会直接跳过。而对于正在执行的复制任务的位置,则会直接锁住该桶,表示这个桶我来负责,其他线程不要插手。这样,就不会有并发问题了。
7.什么时候结束呢?每个线程参加复制前会将标记位sizeCtl加1,同样退出时会将sizeCtl减1,这样每个线程退出时,只要检查一下sizeCtl是否等于进入前的状态就知道是否全都退出了。最后一个退出的线程,则将就table的地址更新指向新table的地址,这样后面的操作就是新table的操作了。
总结:上面的一字一句都是自己看完源码手敲出来的,为了简单易懂,可能会将一些细节忽略,但是其中最重要的思想都还包含在上面。如果有疑问或者有错误的地方,欢迎在评论区留言。
五、get方法
//不用担心get的过程中发生resize,get可能遇到两种情况
//1.桶未resize(无论是没达到阈值还是resize已经开始但是还未处理该桶),遍历链表
//2.在桶的链表遍历的过程中resize,上面的resize分析可以看出并未破坏原tab的桶的节点关系,遍历仍可以继续
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
get方法:
根据key的hash值定位,遍历链表或者红黑树,获取节点。
具体一点:
- 根据key的hash值定位到桶位置。
- map是否初始化,没有初始化则返回null。否则进入3
- 定位到的桶位置是否有头结点,没有返回nul,否则进入4
- 是否有其他线程在扩容,有的话调用find方法查找。所以这里可以看出,扩容操作和get操作不冲突,扩容map的同时可以get操作。
- 若没有其他线程在扩容,则遍历桶对应的链表或者红黑树,使用equals方法进行比较。key相同则返回value,不存在则返回null.
并发问题:假如此时正好有别的线程正在对数组扩容怎么办?
答:没关系,扩容的时候不会破坏原来的table,遍历任然可以继续,不需要加锁。
源码:
//不用担心get的过程中发生resize,get可能遇到两种情况
//1.桶未resize(无论是没达到阈值还是resize已经开始但是还未处理该桶),遍历链表
//2.在桶的链表遍历的过程中resize,上面的resize分析可以看出并未破坏原tab的桶的节点关系,遍历仍可以继续
六、addCount()方法
在putVal()方法的最后会调用addCount()方法,开始分析。
addCount()更新元素的容器个数
当ConcurrentHashMap添加了元素之后,需要通过addCount()更新元素的个数。并且如果发现元素的个数达到了扩容阈值(sizeCtl),那么将进行resize()操作。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//更新size
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();
}
//resize
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//不断CAS重试
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {//需要resize
//为每个size生成一个独特的stamp 这个stamp的第16为必为1 后15位针对每个n都是一个特定的值 表示n最高位的1前面有几个零
int rs = resizeStamp(n);
//sc会在库容时变成 rs << RESIZE_STAMP_SHIFT + 2;上面说了rs的第16位为1 因此在左移16位后 该位的1会到达符号位 因此在扩容是sc会成为一个负数
//而后16位用来记录参与扩容的线程数
//此时sc < 0 说明正在扩
if (sc < 0) {
/**
* 分别对五个条件进行说明
* sc >>> RESIZE_STAMP_SHIFT != rs 取sc的高16位 如果!=rs 则说明HashMap底层数据的n已经发生了变化
* sc == rs + 1 此处可能有问题 我先按自己的理解 觉得应该是 sc == rs << RESIZE_STAMP_SHIFT + 1; 因为开始transfer时 sc = rs << RESIZE_STAMP_SHIFT + 2(一条线程在扩容,且之后有新线程参与扩容sc均会加1,而一条线程完成后sc - 1)说明是参与transfer的线程已经完成了transfer
* 同理sc == rs + MAX_RESIZERS 这个应该也改为 sc = rs << RESIZE_STAMP_SHIFT + MAX_RESIZERS 表示参与迁移的线程已经到达最大数量 本线程可以不用参与
* (nt = nextTable) == null 首先nextTable是在扩容中间状态才使用的数组(这一点和redis的渐进式扩容方式很像) 当nextTable 重新为null时 说明transfer 已经finish
* transferIndex <= 0 也是同理
* 遇上以上这些情况 说明此线程都不需要参与transfer的工作
* PS: 翻了下JDK16的代码 这部分已经改掉了 rs = resizeStamp(n) << RESIZE_STAMP_SHIFT 证明我们的猜想应该是正确的
*/
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//否则该线程需要一起transfer
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//说明没有其他线程正在扩容 该线程会将sizeCtl设置为负数 表示正在扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
如上文所说,这个方法有两个作用,一是更新元素个数,二是判断是否需要resize()。
更新size()
我们可以单独看addCount中更新size的部分
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();
}
1.首先判断countCells是否已经被初始化,如果没有被初始化,那么将尝试在size的更新操作放在baseCount上。如果此时没有冲突,那么CAS修改baseCount就能成功,size的更新就落在了baseCount上。
2.如果此时已经有countCells了,那么会根据线程的探针随机落到countCells的某个下标上。对size的更新就是更新对应CountCells的value值。
3.如果还是不行,将会进入fullAddCount
方法中,自旋重试直到更新成功。这里不对fullAddCount
展开介绍,具体操作也类似,size的变化要么累加在对应的CountCell上,要么累加在baseCount上。这里说一下我个人对ConcurrentHashMap采用这么复杂的方式进行计数的理解。因为ConcurrenthHashMap是出于吞吐量最大的目的设计的,因此,如果单纯的用一个size直接记录元素的个数,那么每次增删操作都需要同步size,这会让ConcurrentHashMap的吞吐量大大降低。
因为,将size分散成多个部分,每次修改只需要对其中的一部分进行修改,可以有效的减少竞争,从而增加吞吐量。
resize()
对于resize()过程,我其实在代码的注释中说明的比较详细了。
1.首先,是一个while()循环,其中的条件是元素的size(由上一步计算而来)已经大于等于sizeCtl(说明到达了扩容条件,需要进行resize),这是用来配合CAS操作的。
2.接着,是根据当前数组的容量计算了resizeStamp(该函数会根据不同的容量得到一个确定的数)。得到的这个数会在之后的扩容过程中被使用。
3.然后是比较sizeCtl,如果sizeCtl小于0,说明此时已经有线程正在扩容,排除了几种不需要参与扩容的情况(例如,扩容已经完成,或是参与的扩容线程数已经到最大值,具体情况代码上的注解已经给出了分析),剩下的情况当前线程会帮助其他线程一起扩容,扩容前需要修改CAS修改sizeCtl(因为在扩容时,sizeCtl的后16位表示参与扩容的线程数,每当有一个线程参与扩容,需要对sizeCtl加1,当该线程完成时,对sizeCtl减1,这样比对sizeCtl就可以知道是否所有线程都完成了扩容)。
另外如果sizeCtl大于0,说明还没有线程参与扩容,此时需要CAS修改sizeCtl为rs << RESIZE_STAMP_SHIFT + 2(其中rs是有resizeStamp(n)得到的),这是一个负数,上文也说了这个数的后16位表示参与扩容的线程,当所有线程都完成了扩容时,sizeCtl应该为rs << RESIZE_STAMP_SHIFT + 1。这是我们结束扩容的条件,会在后文看到。
七、initTable方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//如果table为null或者长度为0, //则一直循环试图初始化table(如果某一时刻别的线程将table初始化好了,那table不为null,
该//线程就结束while循环)。
while ((tab = table) == null || tab.length == 0) {
//如果sizeCtl小于0,
//即有其他线程正在初始化或者扩容,执行Thread.yield()将当前线程挂起,让出CPU时间,
//该线程从运行态转成就绪态。
//如果该线程从就绪态转成运行态了,此时table可能已被别的线程初始化完成,table不为
//null,该线程结束while循环。
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//如果此时sizeCtl不小于0,即没有别的线程在做table初始化和扩容操作,
//那么该线程就会调用Unsafe的CAS操作compareAndSwapInt尝试将sizeCtl的值修改成
//-1(sizeCtl=-1表示table正在初始化,别的线程如果也进入了initTable方法则会执行
//Thread.yield()将它的线程挂起 让出CPU时间),
//如果compareAndSwapInt将sizeCtl=-1设置成功 则进入if里面,否则继续while循环。
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//再次确认当前table为null即还未初始化,这个判断不能少。
if ((tab = table) == null || tab.length == 0) {
//如果sc(sizeCtl)大于0,则n=sc,否则n=默认的容量大
小16,
//这里的sc=sizeCtl=0,即如果在构造函数没有指定容量
大小,
//否则使用了有参数的构造函数,sc=sizeCtl=指定的容量大小。
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//创建指定容量的Node数组(table)。
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//计算阈值,n - (n >>> 2) = 0.75n当ConcurrentHashMap储存的键值对数量
//大于这个阈值,就会发生扩容。
//这里的0.75相当于HashMap的默认负载因子,可以发现HashMap、Hashtable如果
//使用传入了负载因子的构造函数初始化的话,那么每次扩容,新阈值都是=新容
//量 * 负载因子,而ConcurrentHashMap不管使用的哪一种构造函数初始化,
//新阈值都是=新容量 * 0.75。
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
简单来说就是:
- 多线程使用cas乐观锁竞争tab数组初始化的权力。
- 线程竞争成功,则初始化tab数组。
- 竞争失败的线程则让出cpu(从运行态到就绪态)。等再次得到cpu时,发现tab!=null,即已经有线程初始化tab数组了,则退出即可。
八、remove方法
public V remove(Object key) {
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
//计算需要移除的键key的哈希地址。
int hash = spread(key.hashCode());
//遍历table。
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//table为空,或者键key所在的bucket为空,则跳出循环返回。
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
//如果当前table正在扩容,则调用helpTransfer方法,去协助扩容。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
//将键key所在的bucket加锁。
synchronized (f) {
if (tabAt(tab, i) == f) {
//bucket头节点的哈希地址大于等于0,为链表。
if (fh >= 0) {
validated = true;
//遍历链表。
for (Node<K,V> e = f, pred = null;;) {
K ek;
//找到哈希地址、键key相同的节点,进行移除。
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
//如果bucket的头节点小于0,即为红黑树。
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
//找到节点,并且移除。
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
//调用addCount方法,将当前ConcurrentHashMap存储的键值对数量-1。
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
九、总结
1.扩容完成后做了什么?
nextTable=null //新数组的引用置为null
tab=nextTab //旧数组的引用指向新数组
sizeCtl=0.75n //扩容阈值重新设置,数组元素个数超过这个阈值就会触发扩容
2.concurrentHashMap中设置为volatile的变量有哪些?
Node,nextTable,baseCount,sizeCtl
3.单线程初始化,多线程扩容
4.什么时候触发扩容?
1.链表转换为红黑树时(链表节点个数达到8个可能会转换为红黑树),table数组长度小于64。
2.数组中总节点数大于阈值(数组长度的0.75倍)
5.如何保证初始化nextTable时是单线程的?
所有调用transfer的方法(例如helperTransfer、addCount)几乎都预先判断了nextTab!=null,而nextTab只会在transfer方法中初始化,保证了第一个进来的线程初始化之后其他线程才能进入。
6.get操作时扩容怎么办?
7.put操作扩容时怎么办?
8.如何hash定位?
答:h^(h>>>16)&0x7fffffff,即先将hashCode的高16位和低16位异或运算,这个做目的是为了让hash值更加随机。和0x7fffffff相与运算是为了得到正数,因为负数的hash有特殊用途,如-1表示forwardingNode(上面说的表示该位置正在扩容),-2表示是一颗红黑树。
9.forwardingNode有什么内容?
nextTable //扩容时执向新table的引用
hash=moved //moved是常量-1,正在扩容的标记
10.扩容前链表和扩容后链表顺序问题
语言描述很难解释,直接看图,hn指向最后同一类的第一个节点,hn->6->7,此时ln->null,接着从头开始遍历链表;
第一个节点:由于1的hash&n==1,所以应该放到hn指向的链表,采用头插法。hn->1->6->7
第二个节点:同样,hn->2->1->6->7
第三个节点:hash&n==0,所以应该插入到ln链表,采用头插法,ln->3
.....
最后:
ln->5->3 //复制到新table的i位置处
hn->2->1->6->7 //复制到新table的i+n位置处
可以看到ln中所有元素都是后来一个个插入进来的,所以都是逆序
而hn中6->7是初始赋予的所以顺序,而其1,2是后来插入的,所以逆序。
总结:有部分顺序,有部分逆序。看情况
参考文章:
https://www.cnblogs.com/ylspace/p/12726672.html
https://www.cnblogs.com/zerotomax/p/8687425.html
https://www.cnblogs.com/insaneXs/p/13586928.html