一、简介
java8中ConcurrentHashMap的结构是:数组+链表+红黑树。
因为在hash冲突严重的情况下,链表的查询效率是O(n),所以jdk8中改成了单个链表的个数大于8时,数组长度小于64就扩容,数组长度大于等于64,则链表会转换为红黑树,这样以空间换时间,查询效率会变为O(nlogn)。
红黑树在Node数组内部存储的不是一个TreeNode对象,而是一个TreeBin对象,TreeBin内部维持着一个红黑树。
使用CAS+synchronized+volatile 来保证并发安全
这里要说一下,希望大家搞清楚保证线程安全一定要满足原子性、可见性、有序性。
不要像我前两天遇到的面试官,识少少,扮代表。知道个CAS就死活给我说,ConcurrentHashMap就用CAS就能实现线程安全的,我给他说,CAS只能保证原子性,他还觉得是我不懂。
二、源码
1.成员变量
1.sizeCtl
private transient volatile int sizeCtl;
该字段控制table(也被称作hash桶数组)的初始化和扩容。
sizeCtl为负数的时候,表示table初始化或者扩容。
sizeCtl = -1 表示已经初始化。
sizeCtl = -(1+正在扩容的线程数)
2. MAXIMUM_CAPACITY
private static final int MAXIMUM_CAPACITY = 1 << 30;
table最大容量是2的30次方
<<是移位运算符,表示在二进制的情况下,1后面加几个0。在十进制的时候表示1*2的30次方,及1乘以30个2
3.DEFAULT_CAPACITY
private static final int DEFAULT_CAPACITY = 16;
table默认初始化容量16。扩容总是2的n次方。
4.LOAD_FACTOR
private static final float LOAD_FACTOR = 0.75f;
table的负载因子。当前已使用容量 >= 负载因子*总容量的时候,进行resize扩容
5.table
transient volatile Node<K,V>[] table;
整个hash表的结构。也被称作hash桶数组
6.TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
当桶内链表长度>=8时,会将链表转成红黑树
7.UNTREEIFY_THRESHOLD
static final int UNTREEIFY_THRESHOLD = 6;
当桶内node小于6时,红黑树会转成链表。
8.MIN_TREEIFY_CAPACITY
static final int MIN_TREEIFY_CAPACITY = 64;
table的总容量,要大于64,桶内链表才转换为树形结构,否则当桶内链表长度>=8时会扩容。
2.内部类
1.Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
......
}
这是链表的Node,也是其他Node的基类。
成员:
1.hash值(-1为ForwardingNode表示正在扩容,-2为TreeBin表示桶内为红黑树,大于0表示桶内为链表。)、
2.key
3.value、
3.下一个Node。
整个table的结构就是Node数组。用valatile保证value和nextNode在被修改时的可见性和有序性。
2.TreeNode
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;
}
红黑树Node,继承了Node
成员:父节点、左孩子节点、右孩子节点、前一个节点(删除链表的非头节点的节点,需要知道它的前一个节点)、颜色
3.TreeBin
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
}
成员:
树的包装类Node。
root:红黑色的根节点。
first:链表头节点。
waiter:最近一个设置waiter标识的线程
lockState:锁状态标识
锁状态可能的值,可以多个读
writer=1 写锁
waiter=2 等待写锁
reader=4 读锁
4.ForwardingNode
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
}
在扩容时使用,实现了扩容时新表和旧表的连接。
当数组槽为空或已经完成数组槽的扩容后插入数组槽中告知其他线程。如果旧数组的一个hash桶中全部的节点都迁移到新数组中,旧数组就在这个hash桶中放置一个ForwardingNode。读操作或者迭代读时碰到ForwardingNode时,将操作转发到扩容后的新的table数组上去执行,写操作碰见它时,则尝试帮助扩容。
3.构造函数
1.无参构造函数,new一个默认table容量为16的ConcurrentHashMap
public ConcurrentHashMap() {
}
2.int类型的入参是table容量
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;
}
这里并不是把入参当作容量,table的容量必须是2的N次方。
做了3件事:
1.如果入参<0,抛出异常。
2.如果入参大于最大容量,则使用最大容量(是2的30次方)
3.tableSizeFor方法得到一个大于负载因子入参且最接近2的N次方的数作为容量
4.设置sizeCtl的值等于初始化容量。未对table进行初始化,table的初始化要在第一次put的时候进行。
3.入参是个map,会直接调用pullAll把入参全部放入new出来的这个ConcurrentHashMap中
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
4.入参float类型的是负载因子。当前已使用容量 >= 负载因子*总容量的时候,进行resize扩容
调用下面那个构造函数
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
5.这里的concurrencyLevel只是为了兼容1.7,并不是实际的并发等级
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;
}
4.对table进行初始化
对table进行初始化是在第一次put的时候,调用的初始化方法是:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 如果table为空,进入while准备开始初始化。
while ((tab = table) == null || tab.length == 0) {
// 将sizeCtl赋值给sc。如果sizeCtl<0,线程等待
if ((sc = sizeCtl) < 0)
// 线程等待
Thread.yield(); // lost initialization race; just spin
// 如果能将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")
// 生成一个长度为16的Node数组,把引用给table
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 重新设置sizeCtl=数组长度 - (数组长度 >>>2)
sc = n - (n >>> 2);
}
} finally {
// 重新设置sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
做了6件事:
1.如果table为空,进入while准备开始初始化。
2.将sizeCtl赋值给sc。如果sizeCtl<0,线程等待,小于零时表示有其他线程在执行初始化。
3.如果能将sizeCtl设置为-1,则开始进行初始化操作
4.用户有指定初始化容量,就用用户指定的,否则用默认的16.
5.生成一个长度为16的Node数组,把引用给table。
6.重新设置sizeCtl=数组长度 - (数组长度 >>>2) 这是忽略符号的移位运算符,可以理解成 n - (n / 2)。
三、写操作 put
源码:
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) {
// 如果key或者value为空,抛出异常
if (key == null || value == null) throw new NullPointerException();
// 得到hash值
int hash = spread(key.hashCode());
// 用来记录所在table数组中的桶的中链表的个数,后面会用于判断是否链表过长需要转红黑树
int binCount = 0;
// for循环,直到put成功插入数据才会跳出
for (Node<K,V>[] tab = table;;) {
// f=桶头节点 n=table的长度 i=在数组中的哪个下标 fh=头节点的hash值
Node<K,V> f; int n, i, fh;
// 如果table没有初始化
if (tab == null || (n = tab.length) == 0)
// 初始化table
tab = initTable();
// 根据数组长度减1再对hash值取余得到在node数组中位于哪个下标
// 用tabAt获取数组中该下标的元素
// 如果该元素为空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 直接将put的值包装成Node用tabAt方法放入数组内这个下标的位置中
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果头结点hash值为-1,则为ForwardingNode结点,说明正再扩容,
else if ((fh = f.hash) == MOVED)
// 调用hlepTransfer帮助扩容
tab = helpTransfer(tab, f);
// 否则锁住槽的头节点
else {
V oldVal = null;
// 锁桶的头节点
synchronized (f) {
// 双重锁检测,看在加锁之前,该桶的头节点是不是被改过了
if (tabAt(tab, i) == f) {
// 如果桶的头节点的hash值大于0
if (fh >= 0) {
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果遇到节点hash值相同,key相同,看是否需要更新value
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) {
// 就生成Node挂在链表尾部,该Node成为一个新的链尾。
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果桶的头节点是个TreeBin
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 用红黑树的形式添加节点或者更新相同hash、key的值。
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 如果链表长度>需要树化的阈值
if (binCount >= TREEIFY_THRESHOLD)
//调用treeifyBin方法将链表转换为红黑树,而这个方法中会判断数组值是否大于64,如果没有大于64则只扩容
treeifyBin(tab, i);
if (oldVal != null)
// 如果是修改,不是新增,则返回被修改的原值
return oldVal;
break;
}
}
}
// 计数器加1,完成新增后,table扩容,就是这里面触发
addCount(1L, binCount);
// 新增后,返回Null
return null;
}
put方法实际上是调用的putVal方法,
该方法做了5件事:
1.如果key或者value为空,抛出异常。
2.spread(key.hashCode()) 得到key的hash值
3.binCount 用来记录所在table数组中的桶的中链表的个数,后面会用于判断是否链表过长需要转红黑树
4. for循环,执行新增/修改逻辑。直到put成功插入数据才会跳出。
4.1 如果table没有初始化,则初始化table。第一次调用put的时候就是没有初始化的。
4.2 根据数组长度减1再对hash值取余得到在node数组中位于哪个下标,用tabAt获取数组中该下标的元素,如果该元素为空,直接将put的值包装成Node用tabAt方法放入数组内这个下标的位置中,这个时候它是这个桶中链表的头节点。这里的tabAt获取元素和casTabAt写入元素都是使用的CAS保证原子性。
4.3 如果头结点hash值为-1,则为ForwardingNode结点,说明正再扩容, 调用hlepTransfer帮助扩容。
4.4 synchronized锁住该下标的元素,即是锁住该桶的头节点,这样其他写操作会等待该资源。双重锁检测,看在加锁之前,该桶的头节点是不是被改过了。
4.4.1 如果桶表头的hash值>=0,遍历链表,如果遇到节点hash值相同,key相同,看是否需要更新value。如果到链表尾部都没有遇到相同的,就生成Node挂在链表尾部,该Node成为一个新的链尾。
4.4.2 如果桶的头节点是个TreeBin,调用putTreeVal方法用红黑树的形式添加节点或者更新相同hash、key的值。
4.5 如果链表长度>需要树化的阈值(默认是8),调用treeifyBin方法将链表转换为红黑树(而这个方法中会判断数组值是否大于64,如果没有大于64则只扩容)。
4.6 如果是修改,不是新增,则返回被修改的原值
5. addCount方法计数器加1,完成新增后,table扩容,就是这里面触发
3.1 扩容
入口:
1.每次添加完后,调用的addCount中有调用transfer扩容
2.桶中链表大于8调用treeifyBin方法转红黑树的方法的时候,在该方法中会判断table当前总容量是否大于64,如果table当前总容量小于64,不会转红黑树,而是调用tryPresize方法尝试扩容,tryPresize方法中会调用transfer扩容
而这两个方法都有一个相同的部分,判断是直接扩容还是帮助其他正在进行扩容的线程扩容
相同部分源码以addCount为例:
Node<K,V>[] tab, nt; int n, sc;
// 如果当前容量>=总容量*负载因子 并且table不为空 并且table的长度小于最大容量
// 则扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
// 如果sc<0 表示当前有其他线程在扩容
if (sc < 0) {
// 满足这些条件之一则不帮助扩容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//帮助扩容,设置sc+1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 设置sc的值,成为第一个执行扩容的线程
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 扩容
transfer(tab, null);
s = sumCount();
}
做了2件事:
1.判断如果已使用容量>=负载因子*总容量 ,则调用transfer方法扩容。
2.如果是第一个扩容的就开始,如果有别的线程在扩容就帮助别个线程一起扩容
而真正的扩容方法是transfer
transfer源码:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//每个做扩容的线程至少处理16个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//如果nextTab为空,新建一个是原来2倍长度的nextab
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;
//初始化ForwardingNode节点,其中保存了新数组nextTable的引用,在处理完每个槽位的节点之后当做占位节点,表示该槽位已经处理过了
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//当前线程是否需要继续寻找下一个可处理的节点
boolean advance = true;
//所有桶是否都已迁移完成
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//这个while是给当前线程分配迁移任务,即它负责迁移哪几个桶,它要处理的桶的下标范围
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//用CAS设置transfer减去已分配的桶,并发扩容保证线程安全,每个扩容的线程根据这个字段扩容自己分配到区间的桶,各不干扰。
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//确定当前线程每次分配的待迁移桶的范围为[bound, nextIndex)
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//所有线程已干完活,最后才走这里。
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//addCount()处给sizeCtl赋过的初值,相等时说明没有线程在参与扩容了,置finishing=advance=true,为保险让i=n再检查一次。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//如果i处是ForwardingNode表示第i个桶已经有线程在负责迁移了。
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) {
Node<K,V> ln, hn;
//如果是链表结点
if (fh >= 0) {
//由于n是2的幂次方(所有二进制位中只有一个1),如n=16(0001 0000),第4位为1,那么hash&n后的值第4位只能为0或1。所以可以根据hash&n的结果将所有结点分为两部分
int runBit = fh & n;
Node<K,V> lastRun = f;
//找出最后一段完整的fh&n不变的链表,这样最后这一段链表就不用重新创建新结点了。
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;
}
//lastRun之前的结点因为fh&n不确定,所以全部需要重新迁移。
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);
}
//低位链表放在i处
setTabAt(nextTab, i, ln);
//高位链表放在i+n处
setTabAt(nextTab, i + n, hn);
//在原table中设置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;
}
}
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;
}
}
}
}
}
}
扩容的步骤看上面的注释,这里主要记录一下扩容是怎么保证线程安全的
1.多个线程都做扩容的时候,由字段transferIndex表示当前已分配的桶到什么下标了,对transferIndex字段的修改是用的CAS,每个线程先获取自己处理哪个区间的桶,每个线程自己迁移自己的桶,互不打扰。一个线程最少处理16个桶。
比如,现在数组长度为32,线程A迁移0-15的桶,线程B迁移16-31的桶。当前哪些区间的桶被分配的的临界值是transferIndex表示,对它的修改是CAS的,所以多线程扩容线程安全
2.如果有线程去写concurrenthashmap,发现现在正在扩容,则去帮组扩容。如果有线程去读,发现正在扩容,则通过桶上的forwdingNode去新的map中去读。
3.2 treeifyBin链表转红黑树
源码:
private final void treeifyBin(Node<K,V>[] tab, int index) {
// b表示需要转换为红黑树的那个桶在数组中的下标
Node<K,V> b; int n, sc;
// 如果table不为空
if (tab != null) {
// 如果table长小于64,调用tryPresize扩容,而不是转换为红黑树
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 调用tryPresize扩容
tryPresize(n << 1);
// 开始进行转换为红黑树
// 得到要转换为红黑树的链表的头节点,如果头节点不为空,并且头节点的hash >= 0
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 锁住头节点
synchronized (b) {
// 双重锁检查,以防在锁之前又被其他线程改变了该桶头节点的内容
if (tabAt(tab, index) == b) {
// hd表示红黑树的根节点
// tl表示preNode
TreeNode<K,V> hd = null, tl = null;
// 遍历链表
for (Node<K,V> e = b; e != null; e = e.next) {
// 把链表中的每个Node包装为TreeNode
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
// 确定红黑树的根节点
hd = p;
else
// 还是要维护next指针
tl.next = p;
tl = p;
}
//用TreeBin<K,V>包装红黑树的根节点,并放入到数组的桶中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
做了3件事:
1.如果table为空,什么都不做
2.如果table长小于64,调用tryPresize扩容,而不是转换为红黑树。
3.进行将链表转换为红黑树的操作:
3.1 得到要转换为红黑树的链表的头节点,如果头节点不为空,并且头节点的hash >= 0 ,synchronized 锁住桶中头节点,双重锁检查,以防在锁之前又被其他线程改变了该桶头节点的内容。
3.2 遍历链表,把链表中的每个Node包装为TreeNode,确定红黑树的根节点,还是要要维护next指针,TreeNodo不光是红黑树用,其实还维护了pre和next指针,可以看成它又是一个双向链表。用TreeBin<K,V>包装红黑树的根节点,并用CAS放入到数组的桶中。注意,这个时候并没有设置parent指针,没有设置红黑颜色。
3.3 putTreeVal方法往红黑树中添加一个元素
putTreeVal是TreeBin中的一个方法,不是TreeNode中的。
源码:
final TreeNode<K,V> putTreeVal(int h, K k, V v) {
Class<?> kc = null;
//标识是否已经遍历过一次树,未必是从根节点遍历的,但是遍历路径上一定已经包含了后续需要比对的所有节点。
boolean searched = false;
// 从根节点开始遍历整个红黑树
for (TreeNode<K,V> p = root;;) {
// dir是方向,负往左走,正往右走
// ph当前节点的哈希值
// pk 当前节点的key
int dir, ph; K pk;
// 如果根节点是空的
if (p == null) {
// 设置头节点=根节点=新增的这个节点
first = root = new TreeNode<K,V>(h, k, v, null, null);
break;
}
// 如果当前节点的哈希值大于要新增的节点的哈希值
else if ((ph = p.hash) > h)
// 表示等会要往当前节点的左子节点走
dir = -1;
// 如果当前节点的哈希值小于要新增的节点的哈希值
else if (ph < h)
// 表示等会儿要往当前节点的右子节点走
dir = 1;
// 如果当前节点的key等于要新增节点的key,表示已经存在
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
// 直接返回当前节点
return p;
// 当前节点的hash值和要新增的key的hash值是相等的,但是equals不等
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// searched 标识是否已经对比过当前节点的左右子节点了
// 如果还没有遍历过,那么就递归遍历对比,看是否能够得到与要新增的key equals相等的的节点
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.findTreeNode(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.findTreeNode(h, k, kc)) != null))
// 如果得到了与要新增的key equals相等的的节点就返回
return q;
}
// 走到这里就说明,遍历了所有子节点也没有找到和要新增的key equals相等的节点
// 再比较一下当前节点key和要新增的key的大小
dir = tieBreakOrder(k, pk);
}
// 定义xp指向当前节点
TreeNode<K,V> xp = p;
// 如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
// 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
// 而当前节点指针p已经变到了它的左/右孩子节点
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 把原来的头节点赋值给f
TreeNode<K,V> x, f = first;
// 生成新的要添加的TreeNode节点,并且赋值给头节点first和x
first = x = new TreeNode<K,V>(h, k, v, f, xp);
// 如果原来的头节点不为空,把头节点挂到新增的节点后面
if (f != null)
f.prev = x;
// dir < = 0 ,把新增节点挂到它父节点(当前节点)的左孩子节点
if (dir <= 0)
xp.left = x;
else
// 否则把新增节点挂到它父节点(当前节点)的右孩子节点
xp.right = x;
// 如果新增节点的父节点是黑色,则新增节点为红色
if (!xp.red)
x.red = true;
// 如果新增节点的父节点是红色,则需要重新维护红黑树的红黑平衡。
else {
// 锁
lockRoot();
try {
// 维护红黑平衡。
root = balanceInsertion(root, x);
} finally {
// 解锁
unlockRoot();
}
}
break;
}
}
assert checkInvariants(root);
return null;
}
做了2件事:
从根节点开始遍历整个红黑树:
1.如果根节点是空的,设置头节点和根节点都为这个新增的这个节点。
2.如果根节点不是空的:有相同的则返回,没有相同的则找到正确的位置插入
2.1当前节点的哈希值大于要新增的节点的哈希值,设置dir=-1,表示等会要往当前节点的左子节点走
2.2 当前节点的哈希值小于要新增的节点的哈希值,设置dir=1,表示等会儿要往当前节点的右子节点走
2.3 如果当前节点的key等于要新增节点的key,表示已经存在,直接返回当前节点
2.4 当前节点的哈希值和要新增的key的哈希值是相等的,但是equals不等的情况下,如果还没有遍历过,那么就递归遍历对比,看是否能够得到与要新增的key equals相等的的节点,如果得到了与要新增的key equals相等的的节点就返回。
2.5 如果找不到相等的,则新增:
2.5.1如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
2.5.2 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
2.5.3 如果需要下一轮比较,此时已经将当前节点指针p变到了它的左/右孩子节点
2.5.4 新增操作
2.5.4.1 生成新的要添加的TreeNode节点,并且设置链表头节点为新添加的节点,如果原来的头节点不为空,把原来的头节点挂到新增的节点后面
2.5.4.2 dir < = 0 ,把新增节点挂到它父节点(当前节点)的左孩子节点。否则把新增节点挂到它父节点(当前节点)的右孩子节点
2.5.4.3 如果新增节点的父节点是黑色,则新增节点为红色,即默认新增节点为红色。如果新增节点的父节点是红色,则需要重新维护红黑树的红黑平衡:
2.5.4.3.1 加锁。lockRoot();
2.5.4.3.2 维护红黑平衡。 balanceInsertion(root, x);
2.5.4.3.3 解锁.unlockRoot();
从这里我们可以知道,ConcurrentHashMap桶中的链表部分:
如果只是链表Node,put方法中,新增是挂在链表的尾部。
如果是红黑树TreeNode维护的链表,同时把原来的单链表结构变成了双链表结构,putTreeVal方法中,链表部分的维护,新增是挂在链表的头部(First指针)。
四、读操作 get
get操作是无锁的。即使TreeBin的find函数有可能会加TreeBin的内部读锁,但也是非阻塞的。
源码:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 得到key的哈希值
int h = spread(key.hashCode());
// 如果tabele不为空,并且tab.length大于0,得到桶的头节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 桶的头节点的哈希值等于要get的key的哈希值
if ((eh = e.hash) == h) {
//桶的头节点的key等于要get的key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
//那么桶的头节点就是我们要get的节点,直接返回头节点的value
return e.val;
}
// 桶的头节点的哈希值小于0,表示在红黑树上或者正在扩容
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 这里表示在桶的链表上
// 遍历该桶的链表找到get的节点,返回节点的value
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
这里可以看到get方法是没有加锁的。Node中的value和nextNode定义的时候用了volatile来保证可见性和有序性。至于红黑树内的情况,稍后分析。
做了4件事:
1.得到key的哈希值
2.如果桶的头节点的哈希值等于要get的key的哈希值,直接返回头节点的value
3.桶的头节点的哈希值小于0则有两种情况:-1是ForwardingNode,则用ForwardingNode的find方法到nextTable上查找。 -2是TreeBin,调用TreeBin的find函数。根据自身读写锁情况,判断是用红黑树方式查找,还是用链表方式查找。
4.如果在链表上,遍历该桶的链表找到get的节点,返回节点的value
4.1 TreeBin的find方法在红黑树上读
源码:
在红黑树中找到一个节点,有两种方式:
1.用链表的方式查找
2.用红黑树的方式查找
final Node<K,V> find(int h, Object k) {
// key不为空开始查找,否则直接返回null
if (k != null) {
// 遍历链表
for (Node<K,V> e = first; e != null; ) {
int s; K ek;
//当锁状态为等待或写的状态时,以链表的方式的方式查找结点
//当为写状态的时候,读则采取遍历链表的方式,这样虽然查找复杂度提高,但读写不阻塞
//当为等待状态的时候,不继续加写锁,能让被阻塞的写线程尽快恢复运行,或者刚好让某个写线程不被阻塞
if (((s = lockState) & (WAITER|WRITER)) != 0) {
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
e = e.next;
}
//否则是读读操作,设置TreeBin的LOCKSTATE读锁状态增加
else if (U.compareAndSwapInt(this, LOCKSTATE, s,
s + READER)) {
TreeNode<K,V> r, p;
try {
// 利用红黑树来查找,速度很快
p = ((r = root) == null ? null :
r.findTreeNode(h, k, null));
} finally {
Thread w;
//如果是最后一个读线程,并且有写线程因为读锁而阻塞,要告诉写线程可以尝试获取写锁了
if (U.getAndAddInt(this, LOCKSTATE, -READER) ==
(READER|WAITER) && (w = waiter) != null)
LockSupport.unpark(w);
}
return p;
}
}
}
return null;
}
做了2件事:
1.key不为空开始查找,否则直接返回null
2.遍历链表:
2.1 读写并行,当锁状态为等待或写的状态时,以链表的方式的方式查找结点
2.1.1 当为写状态的时候,读则采取遍历链表的方式,这样虽然查找复杂度提高,但读写不阻塞
2.1.2 当为等待状态的时候,不继续加写锁,让被阻塞的写线程尽快恢复运行,或者刚好让某个写线程不被阻塞2.2 否则就是读读并行,用CAS设置TreeBin的lockState字段增加,用红黑树查找,这里用CAS设置LockState字段,不要去理解成成读读互斥了,并不是一个线程读完了才能让另一个线程读,是只有把lockState字段增加这个操作本身互斥而已。如果是最后一个读线程,并且有写线程因为读锁而阻塞,要告诉写线程可以尝试获取写锁了。
五、ConcurrentHashMap的同步机制
核心:
1.读读不互斥
2.读写不互斥
3.写写互斥
5.1 读读不互斥
可以看到整个get方法是没有锁的,无论是synchronized所还是JUC包中的那些Lock,都没有。
读方法细分:
1.桶内只是链表,直接遍历链表读了。无任何锁性质的东西。
2.桶内有红黑树,在TreeBin的find方法中操作,是读读同时进行的时候,用红黑树查找。这里用CAS设置LockState字段,不要去理解成成读读互斥了,并不是一个线程读完了才能让另一个线程读,是只有把lockState字段增加这个操作本身互斥而已。
举个例子:
两个线程同时读,A线程用CAS设置了LockState字段为读后,A开始真的做读操作。B线程并不需要等A读完才能读,B线程只需要等A设置完LockState字段后,自己就能去设置LockState字段了然后开始读了。
5.2 读写不互斥
虽然写方法put会用synchronized去锁桶内头节点/红黑树的根节点,
但是:读方法get没有任何锁性质的东西,不需要获取桶内头节点的synchronized锁
读方法细分:
1.桶内只是链表,直接遍历链表读了。无任何锁性质的东西。
2.桶内有红黑树,在TreeBin的find方法中操作,是读写同时进行的时候,用链表方式查找。
5.3 写写互斥
这个没得办法了,写和写并发的时候肯定是互斥的,一个线程在写的时候需要用synchronized对桶内头节点/红黑树的根节点加锁,另外一个线程要写同一个桶,也要用synchronized获取锁,此时只有等待,等正在写的线程写完后释放锁,再去竞争资源。