持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
在多线程中,我们可能需要操作普通的集合对象,就会出现线程安全问题,例如HashMap在并发更新的情况下,进行Rehash时会造成元素之间会形成一个循环链表,导致死循环问题。尽管1.8对HashMap进行了优化,降低了死循环出现的概率,但是扩容的时候依然可能出现死循环问题。
这时候我们自然想到使用互斥锁来解决线程安全问题,但是这样锁的力度太大了,很可能会导致系统性能急剧下降.
这时候我们就想到能否细化锁,在集合结构上进行优化,从而实现高并发,所以就有了hashTable->hashMap->SynchronizedHashMap->ConcurrentHashMap这么一个优化的过程,其中ConcurrentHashMap从1.7到1.8又做了很大的改动,进一步优化了性能。我可以先简单讲一讲1.7时ConcurrentHashMap是如何实现扩容的,存在的问题出和优化点,然后引出1.8扩容的处理方案,并且详细说一下扩容、get、remove、put方法,还是说您有什么特别想问的内容呢?
1 使用
//创建容量为8的concurrentHashMap
ConcurrentHashMap<String,String> map = new ConcurrentHashMap<>(8);
//多线程并发操作,线程安全
new Thread(()->map.put("1","one")).start();
new Thread(()->map.put("2","two")).start();
2 构造函数
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
其中 cap是 Node 数组的长度,保持为2的整数次方,tableSizeFor(...) 是根据传入的初始容量,确定出一个合适的数组长度,例如1.5被的初始容量+1,再往上取最接近2的整数次方,作为数组长度 cap 的初始值。sizeCtl 用于控制在初始化或者并发扩容的时候的线程数,初值设为 cap,在初始化会详细介绍。
3 初始化
我们可以发现,ConcurrentHashMap在构造函数中仅仅只是设置了一些参数。初始化发生在插入的时候。
当多个线程都往里面添加元素的时候,在进行初始化,这里就存在一个问题,多个线程重复初始化。处理方案就是通过CAS修改sizeCtl
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//sizeCtl < 0说明另外一个线程执行CAS成功,正在进行初始化,当前线程挂起
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//说明该线程通过CAS获得了初始化的权利,则使用CAS将sizeCtl设置为-1,。表示本线程正在进行初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
//默认容量为16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//初始化
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//sizeCtl并非数组长度,所以初始化之后就不再等于数组长度,而是n-n/4=0.75n,表示下一次扩容的阈值
sc = n - (n >>> 2);
}
} finally {
//初始化完成,再将sizeCtl设回去
sizeCtl = sc;
}
break;
}
}
return tab;
}
多线程的竞争是通过对 sizeCtl进行 CAS 操作实现的。如果某个线程成功将 sizeCtl设置为-1,那么就拥有初始化的权利并进行初始化,初始化完成结束后,再把 sizeCtl设置回去;其他线程自旋等待直到数组不为null也就是初始化结束,退出整个函数。
因为初始化的工作量很小,所以此处并没有让其他线程帮助初始化,采用的策略是让其他线程一直等待。
sizeCtl是初始化的核心参数,默认为0,用来控制 table 的初始化和扩容操作
- 默认值为0,如果在构造函数中有参数传入,该值就是2的幂次方
- 如果线程获得初始化权限了,就会将该值设为-1,主要是防止其他线程进入,其他线程看见该值<0,表明有线程正在初始化,就自旋等待
- -N代表有N-1个线程在对HashMap进行并发扩容
- 最后完成初始化后将
sizeCtl设置为0.75n,表示扩容后的阈值
put
核心思想依然是HashMap那一套:根据hash值计算节点插入在table位置,如果该位置为空,则直接插入,否则插入到链表或者红黑树中。
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//不允许空值放入
if (key == null || value == null) throw new NullPointerException();
//计算哈希值
int hash = spread(key.hashCode());
//记录slot中元素的个数
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为空,则初始化
tab = initTable();
//如果节点slot i为空,,说明当前slot没有被放入key-value,那么将值封装成Node对象直接插入slot i中。因为对于空的slot,放入元素是不需要上锁的
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//进入这里说明节点slot i非空,通过MOVED标志查看数组是否正在扩容正在迁移,如果正在迁移,调用helpTransfer帮助完成迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
//进入这里说明slot i非空并且未进行扩容,就正常将元素插入到slot i中
//使用synchronized对该节点上锁
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//fh>=0说明是链表
if (fh >= 0) {
binCount = 1;
//遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果当前节点的hashCode与待放入节点的hashCode相同
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) { //比较equals
//保存旧值
oldVal = e.val;
//如果没有设置onlyIfAbsent,那么覆盖旧值
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;
}
}
}
//该节点是红黑树
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//如果返回值不为空,说明红黑树已经包含了key,这时候根据onlyIfAbsent看看能否覆盖原来的值
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//如果链表长度超过8,就需要把链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//size+1,看看是否需要扩容
addCount(1L, binCount);
return null;
}
put方法啊会进入四个分支:
- 判断数组桶是否为空,如果为空,则进行初始化 (分支一)
- 数组不为空,就判断slot i是否为空,如果为空,说明该元素是该槽第一个元素,直接将对象封装为Node对象插入slot i中 (分支二)
- 如果数组非空,那么通过MOVED标志查看是否正在迁移,如果迁移就调用helpTransfer辅助扩容 (分支三)
- 如果未迁移,并且通过fh>=0判断出节点为链表,则遍历链表,通过hash和equals比较每个节点,如果找到对应的节点,就根据onlyIfAbsent看是否替换原值;如果没有对应的节点,直接尾插法插入节点 (分支四)
- 如果判断节点为红黑树,也是像第四步一样判断 (分支四)
- 如果插入元素后链表长度达到临界值,就调用TreeifyBin吧链表转化为红黑树 (分支四)
- addCount做两件事情,第一件事情是size+1并且看看是否需要扩容
扩容
主要做两件事:
- 构建一个nextTable,其大小为原来大小的两倍,这个步骤是在单线程环境下完成的
- 将原来table里面的内容复制到nextTable中,这个步骤是允许多线程操作的,所以性能得到提升,减少了扩容的时间消耗
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 每核处理的量小于16,则强制赋值16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; //构建一个nextTable对象,其容量为原来容量的两倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
// 连接点指针,用于标志位(fwd的hash值为-1,fwd.nextTable=nextTab)
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 当advance == true时,表明该节点已经处理过了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 控制 --i ,遍历原hash表中的节点
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 用CAS计算得到的transferIndex
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 已经完成所有节点复制了
if (finishing) {
nextTable = null;
table = nextTab; // table 指向nextTable
sizeCtl = (n << 1) - (n >>> 1); // sizeCtl阈值为原来的1.5倍
return; // 跳出死循环,
}
// CAS 更扩容阈值,在这里面sizectl值减一,说明新加入一个线程参与到扩容操作
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
// 遍历的节点为null,则放入到ForwardingNode 指针节点,调用compareAndSwapObjectf
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// f.hash == -1 表示遍历到了ForwardingNode节点,意味着该节点已经处理过了
// 这里是控制并发扩容的核心
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 节点加锁
synchronized (f) {
// 节点复制工作
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// fh >= 0 ,表示为链表节点
if (fh >= 0) {
// 构造两个链表 一个是原链表 另一个是原链表的反序排列
int runBit = fh & n;
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);
}
// 在nextTable i 位置处插上链表,调用本地方法putObjectVolatile
setTabAt(nextTab, i, ln);
// 在nextTable i + n 位置处插上链表
setTabAt(nextTab, i + n, hn);
// 在table i 位置处插上ForwardingNode 表示该节点已经处理过了
setTabAt(tab, i, fwd);
// advance = true 可以执行--i动作,遍历节点
advance = true;
}
// 如果是TreeBin,则按照红黑树进行处理,处理逻辑与上面一致
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;
}
}
}
}
}
}
-
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1]; //构建一个nextTable对象,其容量为原来容量的两倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}单线程初始化nextTable,其中容量为原来HashMap的两倍 -
将节点从table移动到nextTable中
- 初始化ForwardingNode节点,每当一个槽完成迁移后,就把该节点放在槽中,表示该槽已经完成迁移,并且它指向新的ConcurrentHashMap的引用,这样,就能实现扩容时依然能调用get访问数据
- for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 控制 --i ,遍历原hash表中的节点
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 用CAS计算得到的transferIndex
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}一个for循环,通过CAS设置transferindex属性(代表整个数组扩容的进度),也就是为当前线程分配一个stride,CAS操作成功,线程拿到一个stride的迁移任务,初始化i和bound值,i表示当前处理的槽位序号,bound表示槽位边界,从后往前处理节点;没拿到九月继续while循环自旋,直到transferindex小于等于0,说明扩容结束退出循环 - // 遍历的节点为null,则放入到ForwardingNode 指针节点,调用compareAndSwapObjectf
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);当前槽位没有节点,则在table中的i位置放入fwd,这个过程是采用Unsafe.compareAndSwapObjectf方法实现的,很巧妙的实现了节点的并发移动。 - // f.hash == -1 表示遍历到了ForwardingNode节点,意味着该节点已经处理过了
// 这里是控制并发扩容的核心
else if ((fh = f.hash) == MOVED)
advance = true; // already processed意味着当前槽位的节点已经被处理过了,直接跳过,处理下一个节点 - // 节点加锁
synchronized (f) {
// 节点复制工作
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// fh >= 0 ,表示为链表节点
if (fh >= 0) {
// 构造两个链表 一个是原链表 另一个是原链表的反序排列
int runBit = fh & n;
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);
}
// 在nextTable i 位置处插上链表,调用本地方法putObjectVolatile
setTabAt(nextTab, i, ln);
// 在nextTable i + n 位置处插上链表
setTabAt(nextTab, i + n, hn);
// 在table i 位置处插上ForwardingNode 表示该节点已经处理过了
setTabAt(tab, i, fwd);
// advance = true 可以执行--i动作,遍历节点
advance = true;进入这个判断说明需要对它迁移,通过fh >= 0 判断是链表,那么就定义两个变量节点,ln(lowNode)和hn(highNode),分别保存哈希值第X位为0和1的节点。通过hashCode % tab.length(等价于hashCode &(tab.length-1)),这也就是说原先位于i位置的元素,在新的hash表的数组一定处于第i个或者第i+n个位置。然后使用setTabAt方法,将一部分链接到nextTab[i]位置,一部分链接到nextTab[i+n]位置。然后把tab[i]位置指向一个ForWardingNode节点
get
get方法是不用加锁的,是非阻塞的。
其中Node节点是重写过的,设置了volatile关键字修饰,致使它每次获取的都是最新设置的值
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算hash
int h = spread(key.hashCode());
//要找的slot非空,如果为空直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 通过hash值比较第一个节点
if ((eh = e.hash) == h) {
//通过地址和equals方法比较,如果是要找的key,直接返回value
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;
}
- 判断table是否为空,如果为空,直接返回null。
- 计算key的hash值,并获取指定table中指定位置的Node节点,通过遍历链表或则树结构找到对应的节点,返回value值。
实际选择
使用诸如Synchronized的互斥锁还是ConcurrentHashMap效率更高的本质就是在问CAS和Synchronized哪个效率高?
这是不一定的,需要看并发量和锁定后那段代码的执行时间。
有时候一个线程就应该用HashMap、LinkedList、ArrayList
高并发并且执行时间短就应该使用ConcurrentHashMap、ConcurrentQueue
而代码执行时间较长,线程并发量不是特别高,此时用Synchronized效率更高
所以要结合不同的情况,结合测试结果合理选择互斥锁还是并发容器,而不能看到HashMap,多线程这两个词无脑选择ConcurrentHashMap
参考
JDK源码
《java并发实现原理》
《深入理解java高并发编程》