在进行深入剖析源码前,我们要知道,ConcurrentHashMap的数据结构同HashMap一样也为:数组+链表+红黑树。
1、接下来我们来思考,如何正确理解ConcurrentHashMap安全性的概念。
假设有这样一段代码:
//实现功能 计数count
public class ConcurrentHashMapTest {
public static ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
public static void main(String[] args) {
Integer count = map.get("count");
if(count==null){
//第一次添加键值对令值为1
map.put("count",1);
}
else {
//当键存在有值时 对值进行+1操作
map.put("count",count+1);
}
}
}
若此时有两个线程A、B,两者都执行map.get方法,得到的均为null,则线程A、B均进入if中执行map.put("count",1)方法,即A执行一遍后count值为1,B根本不管A有没有执行,它也执行一遍count值为1,这样问题就来了,线程A、B明明都执行了方法,但是最后count的值却只加了一次。
原因:ConcurrentHashMap可以实现线程安全,但要理解,ConcurrentHashMap并不是像加了synchronized锁一样,ConcurrentHashMap只能保证map内的数据不被破坏,在并发线程中有读写操作时并不能保证安全,主要就是key一样时会有值覆盖问题。
解决方法(不加同步锁的前提):
//实现功能 计数count
public class ConcurrentHashMapTest {
public static ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
public static void main(String[] args) {
while(true){
Integer count = map.get("count");
if(count==null){
//将数据存储到map中后,会返回null,表示存储成功
//putIfAbsent方法表示若存在该key则不进行操作,若key不存在则进入操作
if(map.putIfAbsent("count",1)==null){
break;
}
}else {
//replace方法执行成功会返回true
//replace(key,oldValue,newValue)
if(map.replace("count",count,count+1)){
break;
}
}
}
}
}
此时,A、B两个线程的过程再来一遍:线程A、B进入while循环后都执行get方法,A先执行得到null,进入if语句,B也得到null也进入if语句。由于此时不存在“count”的这个key,因而这时线程A执行putIfAbsent方法,成功存储键值对后返回null,则break循环。线程B此时也想执行putIfAbsent方法,但因已经存在“count”这个key,因而不执行,由于死循环继续得到count(此时为1)不为null,线程B会进入replace方法,执行成功会返回true给if而后break循环。
2、Put方法
进入put方法可以发现其核心方法依然是putVal方法
因而我们先来分段分析一下这个核心方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key、value不允许为空
if (key == null || value == null) throw new NullPointerException();
//基于key计算出Hash值 一会细讲
int hash = spread(key.hashCode());
//声明了一个标识
int binCount = 0;
//死循环,并声明了很多tab,table赋值给了tab,为当前ConcurrentHashMap的数组
for (Node<K,V>[] tab = table;;) {
//声明了一堆变量 开发者写的
//tab--数组 n--数组长度 i--数据要存储的索引位置 f--当前索引位置的数据
Node<K,V> f; int n, i, fh; K fk; V fv;
//判断若数组为null或者数组的长度为0
if (tab == null || (n = tab.length) == 0)
//则初始化数组
tab = initTable();
//到else if说明数组已经初始化完成 需要将数据插入到map中
//下面的tabAt方法表示:获取数组中某一个索引位置的数据即得到tab[i]
//casTabAt—>以CAS的方式,将数组tab中i位置的数据从null修改为new Node<K,V>(hash, key, value)
//将数组长度-1 & key的hash值 作为索引i 配合tabAt方法获取这个索引位置的值
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//若当前索引位置的值为null,则需要将数据插入到这个位置,采用CAS方法
//插入成功返回true,否则返回false再从for循环再来
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
//成功则break循环
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent && fh == hash && // check first node
((fk = f.key) == key || fk != null && key.equals(fk)) &&
(fv = f.val) != null)
return fv;
//哈希冲突存储
else {
//说明出现了hash冲突,需要将数据挂到链表上或者添加到红黑树中
V oldVal = null;
//锁住当前桶的位置
synchronized (f) {
//拿到i索引位置的数据,判断跟锁的数据是不是同一个
//避免并发操作时还未追加数据就被另一个线程修改了数据
if (tabAt(tab, i) == f) {
//fh就是当前桶位置的哈希值
//哈希值大于等于0时说明当前桶下是链表存储或者桶是空
//以下操作把数据挂到链表上 不是挂末尾就是替换
if (fh >= 0) {
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)))) {
//获取当前位置的value值
oldVal = e.val;
//onlyIfAbsent若为false,则表示key值相同时同意修改原值
//否则直接break
if (!onlyIfAbsent)
e.val = value;
break;
}
//追加操作 没有找到一样的 就找末尾
Node<K,V> pred = e;
if ((e = e.next) == null) {
//next指向null就直接插入数据
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
//表明桶下是颗树
//以下操作把数据挂到红黑树上
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;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
//如果binCount >= 8就需要将链表转为红黑树(前提数组长度>64)
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
jdk1.7中是segment锁,是一个位置可能会锁住多个桶,jdk1.8如图,只锁住当前桶的位置。
在源码中,我们注意到其hash值得计算是用的spread(key.hashCode())方法,接下来来深入了解此方法。
3、spread方法(散列算法、尽量避免哈希冲突)
进入spread源码
//h即key.hashCode() 直接用哈希值冲突比较大
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
//进入HASH_BITS可以看到
static final int HASH_BITS = 0x7fffffff;
//其在二进制下表示 01111111 11111111 11111111 11111111
由putVal源码中得知:索引下标是由(n-1)&hash计算出的
1、为什么n要减1?
( n - 1 ) & hash(此hash为经过spread方法计算出的hash)
假设存在一个int类型的hash,32位如下(随意写)
00000110 00011000 00111010 00001110 ---hash(假设该hash没有经过spread方法)
假设n的长度为16(数组长度 随意假设)
00000000 00000000 00000000 00010000 ---n=16
若此时n&hash可以发现大多数哈希不为0的地方&之后都为0、都没有用,仅与n(数组长度)有关,则发生哈希冲突很严重,因而采取n-1令&计算得到的结果尽可能不同
00000000 00000000 00000000 00001111 ---n-1=15
2、为什么hash要进行一系列右移、异或、与运算(即spread方法)?
此时可以发现若要让hash的高位起作用,则n-1必须非常大才可以。因而我们对hash先进行了无符号右移16位,高位补0,即
00000110 00011000 00111010 00001110 ---原hash
00000000 00000000 00000110 00011000 --- 进行无符号右移16位
再对二者进行异或运算,异或后结果与(n-1)进行与运算 可以发现原来参与不到运算的高位,现在可以参与进来,尽可能的避免哈希冲突
3、为什么(h ^ (h >>> 16)) & HASH_BITS最后要&上这个值HASH_BITS?
可以保证key的hashcode值一定是一个正数!
因为负数有特殊含义(如图),因而必须得是正数。
小结:将key的hashCode值的高16位与低16位进行异或运算,将结果与数组的长度-1进行&运算,得到当前的数据即为要添加数据的索引值。这样做的目的是尽可能打散数据,避免哈希冲突。
4、initTable方法
为了更好的理解这个方法,先对方法中的参数、属性做了解
sizeCtl:
-1:代表map数组正在初始化
小于-1:代表正在扩容
0:表示还没有初始化
正数:若没有初始化,代表要初始化的长度;若已经初始化了,代表扩容的阈值 即临界值
//初始化数组
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//判断数组是否已经初始化
while ((tab = table) == null || tab.length == 0) {
//sizeCtl赋值给sc,并判断是否小于0
if ((sc = sizeCtl) < 0)
//提出释放CPU时间片的请求
Thread.yield();
//若sizeCtl大于等于0,则以CAS的方式将sizeCtl设置为-1 而-1代表正在初始化
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
//要开始初始化了 再次判断(单例模式懒汉的DCL)
if ((tab = table) == null || tab.length == 0) {
//获取数组初始化的长度,如果sc>0,以sc作为长度;如果sc为0,就以默认长度16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
//nt就是new出来的数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
//table初始化完毕
table = tab = nt;
//临界值即得到下次扩容的阈值并赋值给sc
sc = n - (n >>> 2);
}
} finally {
//将sc赋值给sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
假设多线程A、B进入,线程A刚进入,
还没有初始化数组,但此时sizeCtl已经是-1了,因而此时线程B可以进入到while循环并进入if语句, B会提出释放CPU时间片,因而线程A会继续正常初始化,而后线程B会回到while循环条件再一次判断,便直接退出了,因为此时数组已经初始化完毕,可以防止多线程重复初始化操作。
为什么要再次判断数组是否为空或者数组长度是否为0?
假设线程A执行到
,并且最后finally中会将sc赋值给sizeCtl,此时sc大于0,若有另一个线程B正好执行到
那线程B也会进入else if语句,再次对数组初始化,这明显是不行的,所以要再次判断数组是否为空即双重锁定检查。
5、TreeifyBin方法中tryPresize方法
首先,为什么要有红黑树?主要是为了提升查询效率。
//转红黑树 传的参数是数组table 以及索引i
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n;
if (tab != null) {
//获取数组长度 并判断是否小于64
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//小于64则尝试扩容
//n左移一位 相当于乘2
tryPresize(n << 1);
//转红黑树
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
//这个方法是有可能并发操作的 从别的地方进来可能会先扩容
//则再进入判断扩容时会发现扩容长度已经小于扩容阈值
private final void tryPresize(int size) {
//对扩容数组长度做判断并赋值给c 若大于最大值 则c就取最大值MAXIMUM_CAPACITY
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
//否则c就为这个->size + (size >>> 1) + 1,并传入tableSizeFor方法
//该方法就是将数c转为2的n次幂(若传入的不是2的n次幂)保证数组长度是2的n次幂
tableSizeFor(size + (size >>> 1) + 1);
//以上主要就是得到要扩容的数组长度是多少
int sc;
//拿到sizeCtl,是否大于0
while ((sc = sizeCtl) >= 0) {
//则有两种可能 1、没初始化数组(可能从concurrentHashMap的putAll中直接进入这个方法)
// 2、数组已经初始化
Node<K,V>[] tab = table; int n;
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSetInt(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;
}
}
}
//若要扩容的长度已经小于扩容阈值 代表扩容已经完成了
//或者数组长度已经大于等于最大长度 那么不用再管 跳出循环
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
//得到一个扩容戳(扩容戳是长度32位的数值,其高16位做扩容标识,低16位做扩容线程数!)
//高16位与数组长度有关系 低16位默认就是2 且为2的时候 代表有一个线程正在扩容
int rs = resizeStamp(n);
//代表没有线程扩容 先设置sizeCtl标记 开始扩容
if (U.compareAndSetInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
通过以上对putVal方法源码的解读,相信大家一定有了更深刻的认识。