三万字的基于JDK1.8的ConcurrentHashMap的主要方法的实现原理分析,包含初始化、扩容等逻辑的源码的详细分析!最后给出了JDK1.7和JDK1.8两个版本的ConcurrentHashMap的对比。
public class ConcurrentHashMap
extends AbstractMap
implements ConcurrentMap, Serializable
我们常用的Map集合是HashMap,但是HashMap是不安全的,它的所有操作都没有同步控制,在JDK1.7的时候由于采用头插法,容易造成循环链表,虽然在JDK1.8作出了改进采用尾插法,不会造成死循环,但是并发情况下仍然会造成数据的丢失。
在JDK1.5之前,在并发环境下想要使用Map集合存放K-V类型的键值对,需要使用HashTable,因为Hashtable是线程安全的,但是Hashtable的安全策略过于简单粗暴,将所有的方法都加上了synchronized修饰符,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
在JDK1.5的时候,增加了并发包JUC,里面有许多的并发容器,其中就有ConcurrentHashMap,在并发情况下保证了线程安全,同时提供了更高的并发效率。
同Hashtable,ConcurrentHashMap也不允许null的key或者value。
ConcurrentHashMap实现了ConcurrentMap,表示它是一个线程安全、支持并发的的集合。
ConcurrentHashMap没有实现Cloneable接口,不支持克隆。实现了Serializable接口,支持序列化。
ConcurrentHashMap在JDK1.8之前采用锁分段技术和JDK1.8采用的Synchronized和CAS技术都可以有效提升并发访问率,但是JDK1.8的结构使得并发效率更高,本文主要讲解JDK1.8的ConcurrentHashMap的原理,最后与JDK1.7的原理做了比较。
阅读本文建议先了解Jdk1.8的HashMap的原理,这样方便理解:Java集合—四万字的HashMap的源码深度解析与应用。
ConcurrentHashMap在JDK1.8的实现中是直接用与JDK1.8的HashMap相同的的Node数组+链表+红黑树的数据结构来实现。
并发控制(加锁)使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,并没有使用什么特殊的结构加以控制,这样的话,我们只要学习了JDK1.8的HashMap,那么再去学习ConcurrentHashMap就非常容易理解。
但是JDK1.8的ConcurrentHashMap中具有超过六千行代码和多达五十多个内部类,想要完全分析所有的代码需要耗费大量的时间和精力,这一次我们主要分析一些常用的关键的代码。
下面只列举出了主要类属性,部分属性的注释可能比较生涩,在后面将源码的时候会仔细讲解,在此不必过于深究。
/**
* node数组最大容量:2^30=1073741824
*/
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认初始容量,也可以指定,必须是2的幂次方
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 并发级别,这是JDK1.7遗留下来的,为兼容以前的版本
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 加载因子,默认0.75
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 链表树形化阈值,即链表转成红黑树的阈值,在存储数据时,当存储数据之后,当链表长度 大于8 时,则将链表转换成红黑树。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树还原为链表的阈值,当在扩容时,resize()方法的split()方法中使用到该字段
* 在重新计算红黑树的节点存储位置后,当拆分成的红黑树链表内节点数量 小于等于6 时,则将红黑树节点链表转换成普通节点链表。
*
* 该字段仅仅在split()方法中使用到,在真正的remove删除节点的方法中时没有用到的,实际上在remove方法中,
* 判断是否需要还原为普通链表的个数不是固定为6的,即有可能即使节点数量小于6个,也不会转换为链表,因此不能使用该变量!
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 哈希表树形化的最小容量阈值,即当哈希表中的容量 大于等于64 时,才允许树形化链表,否则不进行树形化,而是扩容。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 用在transfer方法中,transfer可以并发,每个CPU(线程)所需要处理的连续的桶的个数,最少16。
*/
private static final int MIN_TRANSFER_STRIDE = 16;
/**
* 用于辅助生成扩容版本唯一标记,最小是6。这里是一个非final的变量,但是也没有提供修改的方法
* 每次扩容都会有一个唯一的标记,一次扩容完毕之后,才会进行下一次扩容
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* 扩容的最大线程数, 2^15-1
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
/**
* 扩容版本标记移位之后会保存到sizeCtl中当作扩容线程的基数,然后在反向移位可以获取到扩容版本标记
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
/*一些特殊节点的哈希值*/
/**
* ForwardingNode的hash值,一种临时节点,用于扩容时辅助扩容,相当于标志节点,不存储数据
*/
static final int MOVED = -1;
/**
* TreeBin结点的hash值,用于代理红黑树根节点,会存储数据
* 红黑树添加删除节点时,树结构可能发生改变,因此额外维护了一个读写锁
*/
static final int TREEBIN = -2;
/**
* ReservationNode的hash值,也相当于标志节点,不存储数据
* 也是相当于占位符,在JDK1.8才出现的新属性,用于computeIfAbsent、compute方法,一般用不到
*/
static final int RESERVED = -3;
/**
* 可用CPU数量
*/
static final int NCPU = Runtime.getRuntime().availableProcessors();
/**
* 存放node的数组, hash表的初始化是在插入第一个元素时进行的。
* 在put操作时,如果检测到table为空或其长度为0时,则会调用initTable()方法对table进行初始化操作。
*/
transient volatile Node<K, V>[] table;
/**
* 扩容后的新的table数组,只有在扩容时才会用到(才会非null)
*/
private transient volatile ConcurrentHashMap.Node<K, V>[] nextTable;
/**
* JDK1.8的新属性
* 控制标识符,用来控制table的出于初始化、扩容等操作,不同的值有不同的含义:
* 当为0时:代表当时的table还没有被初始化
* 当为负数时:
* -1代表线程正在初始化哈希表;
* 其他负数,表示正在进行扩容操作,此时sizeCtl=(rs << RESIZE_STAMP_SHIFT )+ n + 1,即此时的sizeCtl由 版本号rs左移16位 + 并发扩容的线程数n +1 组成,并不是由所谓的-(n+1)简单组成!
* 当为正数时:表示初始化容量或者下一次进行扩容的阈值,即如果hash表的实际大小>=sizeCtl,则进行扩容,阈值是当前ConcurrentHashMap容量的0.75倍,不能改变
*/
private transient volatile int sizeCtl;
/**
* CAS的标志位。在初始化或者counterCells数组扩容的时候会用到
*/
private transient volatile int cellsBusy;
/**
* 元素个数基本计数器,只会记录CAS更新成功的数值,可能不准确
*/
private transient volatile long baseCount;
/**
* 添加/删除元素时如果如果使用baseCountCAS计算失败
* 那么使用CounterCell[]数组保存CAS失败的个数
* 最后size()方法统计出来的大小是baseCount和counterCells数组的总和
*/
private transient volatile CounterCell[] counterCells;
/**
* transfer方法用于扩容或者协助扩容,允许多个线程同时操作,但是为了防止重复操作,ConcurrentHashMap将数组一段连续的桶位分给一条线程进行操作
* 下一条线程进来帮助扩容的时候需要知道上一条线程是操作了哪些桶位,这里的transferIndex就是记录了下一个将要执行transfer任务的线程的起始数组下标索引+1
* transfer分配桶位的方式是从最后的索引向前分配,直到0索引位置,每次一条新线程分配了桶位,transferIndex都需要更新,
* 因此如果一条线程想要帮助扩容那么需要判断transferIndex <= 0,如果成立,那么表示所有的桶位都被分配完了,不需要新来的线程帮助了
*/
private transient volatile int transferIndex;
下面只列举出了主要内部类,部分内部类的方法没有列举出,在后面讲源码的时候会仔细讲解,在此不必过于深究。
Node内部类代表着普通节点。它与普通HashMap中的Node的区别是value和next属性增加了volatile修饰,并且它不允许调用setValue方法直接改变Node的value值,并增加了find方法辅助map.get()方法。
static class Node<K, V> implements Map.Entry<K, V> {
//链表的数据结构
final int hash;
final K key;
//val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序
//以及在多线程环境下线程A修改结点的val或者新增结点的时候是对线程B可见的。
volatile V val;
volatile Node<K, V> next;
//……省略部分方法
//不允许更新value,不允许直接改变value的值
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
/**
* 辅助map.get()方法,从当前节点开始查找指定k的节点
* @param h hash
* @param k key
* @return 找到的Node或者null
*/
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;
}
}
由于JDK1.8的ConcurrentHashMap采用了和HashMap相同的数据结构,因此也具有红黑树节点类TreeNode。但是与HashMap不相同的是,TreeNode并不直接参与对于红黑树节点的操作,而是由TreeBin代理来完成。
另外TreeNode在ConcurrentHashMap直接继承Node类,而并非HashMap中的继承自LinkedHashMap.Entry
/**
* 红黑树节点类
*/
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;
//节点的颜色,默认是红色
boolean red;
//…………
}
TreeBin并不保存key、value的信息,而是用于包装TreeNode结点,它代替了TreeNode的根结点进行的各种操作,它的hash值固定为-2。也就是说在实际的ConcurrentHashMap数组桶位中,存放的是TreeBin对象,而不是TreeNode对象。
另外由于红黑树的结构可能会因为节点的插入删除而发生变化,对于读的线程有很大影响,因此这个类还带有一个简易的读写锁,这也是增加了TreeNode结点的原因。
从属性可以看出来,和HasmMap的区别还在于:ConcurrentHashMap中的红黑树的头结点不一定是链表的根节点(使用两个属性分别保存),而HashMap中的红黑树的头结点一定是链表的根节点。
/**
* 代理红黑树根节点的类
* @param
* @param
*/
static final class TreeBin<K,V> extends Node<K,V> {
//红黑树的真正根节点
TreeNode<K, V> root;
//链表头节点,红黑树实际上还维护了一个双端链表
volatile TreeNode<K, V> first;
//最近设置WRITER状态的线程
volatile Thread waiter;
//锁状态,实际上是将十进制值转换为二进制来区分的
volatile int lockState;
// 锁状态单位
//写锁,写锁是独占锁 二进制是 001
static final int WRITER = 1; // set while holding write lock
//等待获取写锁 二进制是 010
static final int WAITER = 2; // set when waiting for write lock
//读锁单位,读锁了是共享锁 二进制是 100
static final int READER = 4; // increment value for setting read lock
//……
}
ForwardingNode是在扩容转移数据时出现的临时节点,本身并不存储业务数据,hash值固定为-1,但是会存储下一个哈希表的引用。在旧数组某个桶位的结点转移到新数组中之后,旧数组的桶为上会放置一个ForwardingNode。
当读操作遇到ForwardingNode时,会被转发到新数组的上去继续读;而当写操作遇到ForwardingNode时,表示正在扩容,那么写线程加入到扩容操作中去,提升扩容效率。
/**
* 在扩容转移数据时的临时节点
*/
static final class ForwardingNode<K,V> extends Node<K,V> {
//新数组的引用
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
//hash值固定为MOVED,即-1
super(MOVED, null, null, null);
this.nextTable = tab;
}
/**
* 查找操作直接转移到新数组中
* @param h
* @param k
* @return
*/
Node<K,V> find(int h, Object k) {
// …………
}
}
和HashMap一样,指定容量并不一定是真正的初始容量,真正的初始容量一定是2的幂次方,最大为MAXIMUM_CAPACITY= 1073741824,即int范围内的最大2的幂次方。
构造器中也没有进行哈希表的初始化工作,哈希表初始化在添加元素的时候进行的,并且只有第一次put才会调用initTable()初始化。
在无参构造器中,什么也没干,sizeCtl等于默认值0,就是表示调用的无参构造器,之后在put方法真正初始化哈希表时会使用默认容量16;在后面三个有参参构造器中,最终使用sizeCtl记录真正初始容量的大小,之后在put方法真正初始化哈希表时会使用sizeCtl的值进行初始化。
另外JDK1.8的ConcurrentHashMap中所谓的loadFactor、concurrencyLevel 实际没啥太大作用,仅用于计算初始容量,仅仅是为了兼容旧版本,loadFactor固定为0.75。
创建一个带有默认初始容量 (16)、加载因子 (0.75)的空Map。
/**
* 创建一个带有默认初始容量 (16)、加载因子 (0.75)的空Map。
*/
public ConcurrentHashMap() {
//什么也不做,初始化参数采用默认值。
}
创建一个带有指定初始容量、默认加载因子 (0.75)的空Map。
注意这里的指定容量不一定是真正的初始容量,真正的容量是 大于指定容量的最小2的幂次方,最大值是MAXIMUM_CAPACITY。
/**
* 创建一个带有指定初始容量、默认加载因子 (0.75)的空Map。
* 注意这里的指定容量不一定是真正的初始容量,真正的容量是 大于指定容量的最小2的幂次方,最大值是MAXIMUM_CAPACITY
*
* @param initialCapacity 指定容量
* @throws IllegalArgumentException 指定容量为负数
*/
public ConcurrentHashMap(int initialCapacity) {
//如果指定容量为负数,那么抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException();
//获取真正的初始容量
/*如果指定容量大于等于MAXIMUM_CAPACITY/2,那么初始容量就是MAXIMUM_CAPACITY
* 否则,初始容量为 大于指定容量的最小2的幂次方*/
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
//计算大于initialCapacity的最小2的幂次方
//这里是和HashMap的不一样的地方,HashMap计算的是 大于等于 指定容量的最小2的幂次方
//tableSizeFor方法的原理我们在HashMap的源码分析中已经深入讲解了
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//初始容量赋值给sizeCtl变量,这里的sizeCtl就表示初始容量的大小
this.sizeCtl = cap;
}
创建一个带有指定初始容量、加载因子的空Map。
/**
* 创建一个带有指定初始容量、加载因子的空Map。
* @param initialCapacity 指定初始容量,并不一定是实际初始容量
* @param loadFactor 指定加载因子
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
//内部调用三个参数的构造器
this(initialCapacity, loadFactor, 1);
}
创建一个带有指定初始容量、加载因子和并发级别的空Map。这里的加载因子和并发级别仅仅是对初始容量有控制作用,并没有其他意义,加上这两个参数只是为了兼容JDK1.7的版本。
和第二个构造器不同的是,第三第四个构造器,最终计算出的真正初始容量和这三个参数都有关系,最终容量可能会比指定容量更小,比如initialCapacity设为16,loadFactor设为3,那么计算的size为6,那么tableSizeFor((int)size),会得到8,小于16。
/**
* 创建一个带有指定初始容量、加载因子和并发级别的空Map。
*
* @param initialCapacity 指定初始容量,并不一定是实际初始容量
* @param loadFactor 加载因子,并不是实际的加载因子,这里只是为了兼容JDK1.7的版本
* @param concurrencyLevel 并发级别,并不是实际的加载因子,这里只是为了兼容JDK1.7的版本
* @throws IllegalArgumentException 指定容量为负数,或者指定加载因子非正数,或者并发级别非正数
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//如果指定容量为负数,或者指定加载因子非正数,或者并发级别非正数,那么抛出异常
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
/*这里也能看出来,加载因子和并发级别仅仅是对初始容量有控制作用,并没有其他意义,加上这两个参数只是为了兼容JDK1.7的版本*/
//如果初始容量小于并发级别
if (initialCapacity < concurrencyLevel) // Use at least as many bins
//那么初始容量指定为并发级别
initialCapacity = concurrencyLevel; // as estimated threads
//计算新size。从这里也能看出来,后两个构造器最终算出来的初始容量可能会比指定容量更小,
//比如initialCapacity设为16,loadFactor设为3,那么计算的size为6,那么tableSizeFor((int)size),会得到8,小于16
long size = (long) (1.0 + (long) initialCapacity / loadFactor);
/*
* 如果扩容阈值大于等于MAXIMUM_CAPACITY,那么初始容量就是MAXIMUM_CAPACITY
* 否则,初始容量就是 大于等于size的最小2的幂次方
* */
int cap = (size >= (long) MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int) size);
//初始容量赋值给sizeCtl变量,这里的sizeCtl就表示初始容量的大小
this.sizeCtl = cap;
}
构造一个与给定Map具有相同映射关系的新Map。
/**
* 构造一个与给定Map具有相同映射关系的新Map。
*
* @param m 指定Map
*/
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
//默认容量赋值给sizeCtl,这里的sizeCtl就表示初始容量的大小
this.sizeCtl = DEFAULT_CAPACITY;
//直接调用putAll方法,在putAll方法中初始化哈希表
putAll(m);
}
put方法用于插入指定键值对,大概原理和HashMap的原理差不多,只是保证了线程安全,返回以前与 key 相关联的值,如果 key 没有映射关系,则返回 null。
如果指定key或者value为 null,那么抛出NullPointerException。
put方法内部直接调用putVal方法。
/**
1. 开放给外部调用的put方法
2. @param key k
3. @param value v
4. @return 返回以前与 key 相关联的值,如果 key 没有映射关系,则返回 null。
*/
public V put(K key, V value) {
//内部直接调用putVal方法,相比于HasmMap,少了一些参数
return putVal(key, value, false);
}
put方法的主要逻辑都在putVal方法中,和HashMap的同名putVal方法非常相似,但是多了很多同步的操作,大概步骤为:
/**
* 按照指定onlyIfAbsent规则尝试插入键值对
*
* @param key k
* @param value v
* @param onlyIfAbsent 在JDK1.8的新方法putIfAbsent中传递true;put和putAll方法中传递false
* 如果为true,并且传入的key已经存在,那么不进行value替换,返回旧的value。如果不存在key,就添加key和value,返回null。
* 如果为false,并且传入的key已经存在,那么进行value替换,并返回旧的value。如果不存在key,就添加key和value,返回null;
* @return 旧值,如果没有则返回null
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
/*1 首先是null检测,ConcurrentHashMap 不允许null k和null v*/
if (key == null || value == null) throw new NullPointerException();
/*2 spread方法计算key的hash值,为非负数*/
int hash = spread(key.hashCode());
//用于记录是在什么位置操作了哈希表
//0 表示在一个空位置插入了新节点
//1 表示链表中操作
//2 表示在红黑树中操作
int binCount = 0;
/*3 死循环,尝试插入数据*/
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
/*3.1 如果table数组为null或者长度为0,那么调用initTable初始化*/
if (tab == null || (n = tab.length) == 0)
tab = initTable();
/*3.2 否则,调用tabAt方法volatile的获取计算出的桶位的最新元素,赋值给f
* 如果元素为null,那么表示这个位置没有哈希冲突,那么可以直接在该位置存放元素
* 这里计算元素存储位置的方法和HashMap中是一样的:(n - 1) & hash n为length,这样保证计算出的值位于[0,n-1]之间
* */
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//调用casTabAt直接尝试CAS的在该位置存放一个新Node节点
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
//如果存入成功,那么表示插入数据成功了,结束循环;否则进行下一次循环,直到插入成功
//这里能够看出来,如果是没有哈希冲突的情况下,直接使用的CAS插入新节点,并没有使用锁
break; // no lock when adding to empty bin
}
/*3.3 否则,表示计算出来的桶位存在元素,即表示存在哈希冲突
* 如果该已存在元素的hash值为MOVED,即-1,那么表示是ForwardingNode节点,即表示正在扩容
* 由于put是写操作,那么当前线程先被“征用了”,先协助扩容完成再说吧!
* */
else if ((fh = f.hash) == MOVED)
/*调用helpTransfer方法协助扩容*/
tab = helpTransfer(tab, f);
/*3.4 否则,开始处理哈希冲突*/
else {
V oldVal = null;
/*对链表头结点或者红黑树根节点,加上synchronized锁*/
synchronized (f) {
//如果加锁节点仍然是处于原来的位置,表示加锁成功
if (tabAt(tab, i) == f) {
/*如果hash大于等于0,那么表示该位置是链表,因为红黑树的头结点的TreeBin结点,hash为-2*/
if (fh >= 0) {
//binCount改为1,表示操作了链表
binCount = 1;
/*循环链表,每一次循环如果没有跳出那么binCount自增一*/
for (Node<K, V> e = f; ; ++binCount) {
K ek;
//如果找到了key相等节点,那么替换value
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
//保存旧值
oldVal = e.val;
//如果onlyIfAbsent为true,则不替换
if (!onlyIfAbsent)
e.val = value;
//结束链表的循环
break;
}
Node<K, V> pred = e;
//如果某个节点e的后继为null,表示没有找到key相等节点
//那么尾插法插入新节点
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
//结束链表的循环
break;
}
}
}
/*否则,f属于TreeBin,表示该位置是红黑树*/
else if (f instanceof TreeBin) {
Node<K, V> p;
//binCount改为2,表示操作了红黑树,同时addCount一定会进行扩容判断
binCount = 2;
//调用putTreeVal添加红黑树节点,将会返回Nopd类型的节点变量p
//如果p为null,表示在红黑树中找到key相同的节点,插入了新节点
//否则,表示找到了key相等的节点,并返回了该节点
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
value)) != null) {
//保存旧值
oldVal = p.val;
//如果onlyIfAbsent为true,则不替换
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
/*3.5 如果binCount不为0,表示操作了链表或者红黑树*/
if (binCount != 0) {
//如果binCount大于等于8,表示此时链表节点个数肯定超过了8个,将转换链表为红黑树
if (binCount >= TREEIFY_THRESHOLD)
//treeifyBin方法将链表转换为红黑树,这里的调整逻辑和HashMap的treeifyBin方法是一致的,他会先判断数组长度是否小于最小树形化长度64
//如果是,那么进行扩容,则不是树形化;否则才进行树形化,树形化时同样对于链表头结点加上synchronized锁
treeifyBin(tab, i);
//如果旧值不为null,说明是替换,并没有插入,此时直接返回旧值,方法结束
if (oldVal != null)
return oldVal;
//到这一步,说明是新加了节点,结束大循环,进入最后的步骤addCount
break;
}
}
}
/*4 在节点数量改变之后 统计节点数量,兼职扩容或者帮助扩容
* addCount方法在很多方法中都被调用,在putVal的调用中,binCount只会是大于等于0的值
* */
addCount(1L, binCount);
return null;
}
spread方法和HashMap的hash方法非常相似,都是采用相同的扰动算法减少冲突,区别是spread方法最终通过& HASH_BITS一定会得到非负数的hash值,这么做的原因是方便后续的判断,因为那些特殊节点的hash都是负数,必须要区分开来。
/**
* hash运算时使用的常量,保证最终hash值不是负数
*/
static final int HASH_BITS = 0x7fffffff;
/**
* 计算key的hash值,计算出来的值是正数
*/
static final int spread(int h) {
//&操作 前半部分和hashMap的hash(key)函数差不多,都是采用了扰动算法,得到hash值
//然后使用hash & HASH_BITS(0x7fffffff,即2147483647——int类型的最大值)
//这样可以让最终的hash变成非负数,方便后续的判断(因为那些特殊节点的hash都是负数,必须要区分开来)
//Hashtable中也是采用了类似的算法:
// int hash = key.hashCode();
// int index = (hash & 0x7FFFFFFF) % tab.length;
return (h ^ (h >>> 16)) & HASH_BITS;
}
在put方法中,会检测如果哈希表数组为null或者长度为0,那么调用initTable方法初始化哈希表。比较简单,主要是使用CAS操作保证只有一条线程能够成功进行初始化操作,其他线程都将失败,并没有使用锁。
该方法中,如果初始化成功,那么使用sizeCtl记录下一次扩容阈值的大小。我们还能知道ConcurrenthashMap的加载因子固定是0.75,这也证明了构造器中的加载因子参数是没啥用的,仅仅是兼容以前的版本。
/**
* 使用sizeCtl记录的大小或者默认容量初始化哈希表
* 和HashMap不同的是,HashMap的初始化和扩容都是用一个resize方法
*
* @return 新的数组
*/
private final Node<K, V>[] initTable() {
//sc用来保存当前的sizeCtl
Node<K, V>[] tab;
int sc;
/*死循环,如果table不为null 或者 长度不为0 那么表示已经被某个线程初始化完毕了,此时跳出循环*/
while ((tab = table) == null || tab.length == 0) {
/*如果sizeCtl值小于0,则无法初始化,此时让对应线程通过yield让出CPU执行权*/
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if
/*否则,尝试CAS将sizeCtl的值从sc改为-1,表示有线程正在进行初始化操作*/
(U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
/*CAS成功,CAS操作只有一条线程能够成功,这里保证了初始化的线程安全*/
try {
/*再次检查是否初始化*/
if ((tab = table) == null || tab.length == 0) {
//sc是否大于0,如果不大于0,可能是无参构造器,那么使用默认容量16初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
//新建数组,长度为n
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n];
//新数组赋值给table
table = tab = nt;
//下面的计算等于 n - n/4 等于 0.75n,即加载因子固定为0.75,计算出下一次扩容阈值sc
sc = n - (n >>> 2);
}
} finally {
//最终将下一次扩容时的阈值赋值给sizeCtl,这里的sizeCtl就表示扩容阈值的大小
sizeCtl = sc;
}
//初始化完毕,结束循环
break;
}
//到这里说明该线程没能初始化哈希表成功,但是有可能其他线程初始化成功了,因此继续循环判断,如果满足条件那么该线程也退出initTable方法
}
return tab;
}
TabAt方法是ConcurrentHashMap中的一系列用于读、写单个table数组元素的方法,有三个:
这三个方法是ConcurrentHashMap中用的非常多的方法,同样用于保证数据的即时性和正确性。
/**
* volatile的读取数组的指定索引的元素
* 保证能够读取最新的数据
*
* @param tab 数组
* @param i 索引
* @return 对应索引的元素
*/
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的更新数组的指定索引的元素,即Node链表的头节点,或者红黑树代理根节点TreeBin
* 保证了复合操作的原子性,并刷新缓存
*
* @param tab 数组
* @param i 索引
* @param c 预期值
* @param v 新值
* @return true 成功 false 失败
*/
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);
}
/**
1. volatile的写入数组的指定索引的元素
2. @param tab 数组
3. @param i 索引
4. @param 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);
}
当添加新节点之后的链表长度大于8,那么将该链表转换为红黑树,使用的就是treeifyBin方法。注意:在该方法里面还会判断当哈希表中的容量大于等于MIN_TREEIFY_CAPACITY,即64 时,才允许树形化链表,否则不进行树形化,而是扩容。treeifyBin方法可以分为以下几步:
/**
* 当添加新节点之后的链表长度大于8,那么将该链表转换为红黑树。
* 该方法和HashMap的同名方法非常相似,只是多了同步处理
*
* @param tab 数组
* @param index 需要树形化的数组元素的索引位置
*/
private final void treeifyBin(Node<K, V>[] tab, int index) {
Node<K, V> b;
int n, sc;
//如果tab不为null
if (tab != null) {
/*如果容量小于MIN_TREEIFY_CAPACITY,即小于64,那么进行数组扩容*/
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//调用tryPresize方法扩容,传入2倍于旧容量的新容量
tryPresize(n << 1);
/*否则,如果该位置的结点不为null并且hash大于0,那么可以进行树形化*/
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
/*对于链表头节结点加synchronized锁,保证该桶位同步*/
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K, V> hd = null, tl = null;
/*遍历整个链表,首先将普通链表转换为红黑树双链表,然后将双链表头部传入到TreeBin结点的构造器中
* 在TreeBin构造器中会将红黑树双链表转换为红黑树,因此这里的这个构造器类似于HashMap的treeify方法,逻辑都是一样的
* */
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;
}
//该桶位的结点是一个TreeBin节点,包装另外红黑树
setTabAt(tab, index, new TreeBin<K, V>(hd));
}
}
}
}
}
尝试调整哈希表的容量或者初始化哈希表以达到最佳容量。
在新增结点之后,所在的链表的元素个数大于8,则会调用treeifyBin把链表转换为红黑树,在转换结构之前,若tab的长度小于MIN_TREEIFY_CAPACITY(64),则会即调用tryPresize方法,传入参数为原数组容量的2倍,即尝试将数组长度扩大到原来的两倍。
在putAll方法中,同样会调用该方法,传入参数为指定map的size,即将哈希表容量调整到能够容纳指定map的全部节点。
/**
* 尝试调整哈希表的容量或者初始化哈希表以达到最佳容量。
* 在新增结点之后,所在的链表的元素个数大于8,则会调用treeifyBin把链表转换为红黑树,在转换结构之前,若tab的长度小于MIN_TREEIFY_CAPACITY(64),则会
* 即调用tryPresize方法 传入参数为原数组容量的2倍,即尝试将数组长度扩大到原来的两倍。
* 在putAll方法中,同样会调用该方法,传入参数为指定map的size,即将哈希表容量调整到能够容纳指定map的全部节点
*
* @param size 元素数量
*/
private final void tryPresize(int size) {
/*根据size,计算真正需要的容量c,必须是2的幂次方*/
//如果传入的新size大于等于 最大容量/2,那么c=MAXIMUM_CAPACITY;否则c= 大于size的最小2的幂次方
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
/*
* 开启一个循环sc = sizeCtl,>=0表示没有处于扩容以及初始化状态
* 如果sc>=0
* sc=0 表示没有初始化
* sc>0 表示没有初始化,或者已经初始化了但没有扩容
*
* 在循环中初始化哈希表或者扩容哈希表,直到达到最佳容量
* */
while ((sc = sizeCtl) >= 0) {
//获取此时的哈希表table赋值给tab
Node<K, V>[] tab = table;
int n;
//如tab为null或者 长度为0 表示没有初始化哈希表
if (tab == null || (n = tab.length) == 0) {
//此时的sc保存的是我们通过指定参数构造器计算出来的初始化容量,比较sc和c的大小,取最大的最为初始容量赋值给n
n = (sc > c) ? sc : c;
/*尝试CAS设置sizeCtl状态为01,表示正在初始化哈希表*/
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
/*CAS成功之后,开始初始化哈希表*/
try {
//如果此时table还是原来的
if (table == tab) {
@SuppressWarnings("unchecked")
//新建table nt,容量为n
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n];
//nt赋值给table成员变量
table = nt;
//sc设置为新容量的0.75倍,即扩容阈值
sc = n - (n >>> 2);
}
} finally {
//sizeCtl设置为sc
sizeCtl = sc;
}
}
}
/*
* 否则,表示已经初始化了哈希表,此时的sc表示下一次的扩容阈值
* 如果c小于等于sc,即需要的容量小于等于扩容阈值,此时表明以达到最佳容量
* 或者 此时数组容量大于等于最大数祖容量,表明不能继续扩容
* 这里是在循环中跳出循环的唯一逻辑
* */
else if (c <= sc || n >= MAXIMUM_CAPACITY)
//那么不用管了,也不需要调整容量了
break;
/*
* 否则,表示需要调整容量,下面就是addCount方法中的扩容或者帮助扩容的逻辑,我们前面已经讲过了
*/
else if (tab == table) {
//
int rs = resizeStamp(n);
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;
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);
/*扩容或者协助扩容完毕之后继续循环,判断新的哈希表是否符合要求,如果达到要求,
* 即c <= sc || n >= MAXIMUM_CAPACITY 为真,或者如果处于扩容状态,那么退出循环,tryPresize方法结束
* */
}
}
}
addCount用于在哈希表节点数量改变之后重新统计元素数量,同时检查是否需要扩容或者需要协助扩容,并调用相应的transfer方法,大概步骤如下:
在addCount方法中,如果需要扩容,在第一个线程进行扩容时,会将sizeCtl尝试CAS变成:此次扩容版本号<<16 + 2的数,这个数是一个很大的负数。因此sizeCtl如果是小于-1的负数,那么表示的是扩容的状态,并且sizeCtl的值不是常说的由-(n+1)组成这么简单,而是sizeCtl=rs<<16+2组成,+2表示有一条线程在进行扩容,后续每加进来一条线程,sizeCtl都会自增1,因此sizeCtl= rs<<16+n+1,rs表示某次扩容唯一版本号,n表示有n条线程在一起扩容。而sizeCtl>>>16,即可反解出某次扩容的版本号,即rs= sizeCtl>>>16。
/**
* addCount用于哈希表节点数量变化之后的节点数量统计
* 同时检查 是否需要扩容或者需要协助扩容
*
* @param x 需要添加的计数,可能为负数(删除节点)
* @param check 判断是否需要检查->是否需要扩容或者需要协助扩容,小于0表示一定不需要检查
*/
private final void addCount(long x, int check) {
CounterCell[] as;
long b, s;
/*1 如果counterCells不为null或者counterCells为null但是CAS更新baseCount失败时,那么更新counterCells;否则表示baseCount计数成功,进行第二步*/
/*这里更新counterCells的思想类似于JUC中的LongAdder,使用了“热点数据分离”的基本思想,采用一个数组来计数
不同的线程大概率可以更新数组的不同位置的值,避免了多线程竞争更新同一个值,而最终的值会计算数组全部位置值的总和
size()方法则会计算(baseCount + CounterCell数组总值)的和
*/
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a;
long v;
int m;
boolean uncontended = true;
//如果counterCells为null
//否则 如果counterCells长度为0
//否则 如果获取当前线程在数组中的探针哈希位置a的元素为空
//否则 如果CAS更新a位置的元素的值失败
//以上条件满足一项,就进入if方法体中调用fullAddCount方法
if (as == null || (m = as.length - 1) < 0 ||
//ThreadLocalRandom.getProbe()用于尽量将不同的线程分散到不同的数组位置,这样进一步减少不同线程对相同位置的竞争
//ThreadLocalRandom是一个比Random在多线程下性能更强的伪随机数生成器,使用ThreadLocal的原理,大大的提升了并发条件下的伪随机数生成效率
//JDK的很多类中都使用了ThreadLocalRandom,比如LongAdder,比如跳表ConcurrentSkipListMap,比如阻塞工具LockSupport
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
//fullAddCount 用于并记录个数,并兼容counterCells的初始化or扩容。
fullAddCount(x, uncontended);
return;
}
//到这一步,说明counterCells更新成功了
//如果check小于等于1,那么直接return,表示不需要扩容或者协助扩容
//在外面的putVal方法中,如果原链表长度就为1,如果此时添加了新节点,那么binCount还是为1(因为直接跳出了循环),此时明明已经添加了新节点,但是这种情况再配合addCount的情况,此时就不会判断是否需要扩容或者协助扩容;
//另外如果直接在空桶位添加了新节点之后,binCount为0,此时也不会判断是否需要扩容或者协助扩容。
//难道是因为baseCount有线程竞争,并且此时该桶位元素较少,所以直接不需要判断?
if (check <= 1)
return;
//否则,表示check大于1,计算元素数量的近似值s,然后判断是否需要扩容,sumCount()方法在size()方法中也被调用
s = sumCount();
}
/*到这一步,说明counterCells为null,并且CAS更新baseCount成功 或者 counterCells不为null,但是CAS更新baseCount失败,并且CAS更新counterCells成功,并且check>1*/
/*2 判断是否需要扩容或者协助进行扩容操作
* 如果check>=0,表示不是删除元素,那么需要检查是否需要扩容或者协助扩容
* */
if (check >= 0) {
Node<K, V>[] tab, nt;
int n, sc;
//如果s(数量)大于等于sizeCtl 并且 此时的table不为null 并且 table长度小于最大容量 那么开始扩容
while (s >= (long) (sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//根据leagth获取此次扩容版本的唯一版本号rs 范围是[32770,32798]
int rs = resizeStamp(n);
//如果sc小于0,表明正在扩容,则加入其中协助扩容
if (sc < 0) {
//1 如果 sc无符号右移RESIZE_STAMP_SHIFT(16)位不等于此次扩容版本的唯一标志rs,如果是在同一个版本的扩容过程中应该是相等的,如果不等那说明不是同一个版本,不能协助
//2 否则 如果sizeCtl等于此次扩容版本的唯一标志rs+1,表示扩容结束了,不需要协助,因为第一次调用扩容方法之前sizeCtl == rs + 2
//3 否则 如果sizeCtl等于此次扩容版本的唯一标志rs+MAX_RESIZERS,表示协助扩容的线程数量达到了最大值MAX_RESIZERS,不需要协助
//4 否则 如果记录的新数组等于null,表示扩容结束了,不需要协助
//5 否则 如果transferIndex <= 0,表示数组桶位分配完了,不需要协助
//以上五种情况成立一种,就表示不需要协助,那么结束循环,addCount方法结束
//第2、3个条件,实际上是一个JDK1.8的BUG,因为我们会发现,在我们的JDK版本中这两个判断永远都会返回false,正确的判断应该是: sc == (rs<<16) + 1 和 sc == (rs<<16) + MAX_RESIZERS 或者 (sc >>> RESIZE_STAMP_SHIFT) == rs + 1 || (sc >>> RESIZE_STAMP_SHIFT) == rs + MAX_RESIZERS
//另外,在helpTransfer方法中也存在类似的BUG,BUG地址:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427,该BUG已在高版本的JDK中修复
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
/*到这一步,说明需要帮忙扩容*/
/*尝试CAS设置sc的值为sc+1,即每多一条线程来帮忙,sc的值都会+1*/
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
//如果CAS成功,那么调用transfer方法协助扩容,传递旧数组tab,新数组nt
transfer(tab, nt);
}
/*否则,将版本号rs左移RESIZE_STAMP_SHIFT(16)位,然后加2之后得到的值CAS赋值给sizeCtl
* 这个操作必须保证新的sizeCtl为负数,即正在扩容的状态,怎么保证呢?
* rs[32770,32798] 的第16位(最高位)二进制是1,左移16位之后,将低16位都移动到了高16位,刚好最高位的1再符号位上,此时变成一个超大的负数,再加上2,范围是 [-2147352574,-2145517566],这里加2表示有一条线程在扩容
* 即,在扩容的时候,sizeCtl肯定是负数,还是一个比较大的负数
* 在此后其他线程进来协助扩容的时候,再使用sizeCtl无符号右移16位的结果就是唯一版本号rs
* 因此如果sizeCtl为比-1更小的负数,那么此时sizeCtl=(rs<
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//如果CAS成功,则调用transfer进行扩容,传递旧数组tab,新数组null(第一次扩容,将会建立新数组)
transfer(tab, null);
//重新计算节点数量,判断是否还需要扩容
s = sumCount();
}
}
}
在addCount方法中,我们知道了ConcurrentHashMap统计元素数量的方式和HashMap不一样,HashMap采用一个size变量就行了,而ConcurrentHashMap采用了baseCount和一个counterCells数组一起统计。
baseCount是countCells为null时会尝试CAS自动累加的值,而如果countCells数组部位null或者CAS操作baseCount失败,也就是出现并发的情况下,就会使用countCells数组来计数,这个数组类似于LongAdder,数组内部有多个位置,尽量使不同的县城功更新不同的位置,这样减少了线程之间的竞争。
为什么不采用一个size变量然后循环CAS更新呢? 因为可能会有很多线程同时更新计数值,这样可能会导致很多线程不停的自旋,浪费CPU。为什么不采用单独一个数组呢,因为如果没有竞争的话,此时还初始化一个数组然后更新数组就没有单独更新一个变量更快了。因此,综合考虑下ConcurrentHashMap采用一个变量baseCount和一个数组counterCells一起来计数,没有竞争的时候使用baseCount,出现了竞争就使用counterCells。
最终统计总数的时候,会将baseCount的值和counterCells数组每个位置的值相加,并且这个值也只能是近似值,因为在统计的时候哈希表可能正在增删数据。size()方法内部就是主要调用的sumCount()方法。
/**
1. 统计节点数量近似值
2. 3. @return 总数近似值
*/
final long sumCount() {
CounterCell[] as = counterCells;
CounterCell a;
//sum初始化等于baseCount
long sum = baseCount;
if (as != null) {
//遍历counterCells数组,将让sun和每一个位置的数据相加
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
//返回sum
return sum;
}
transfer方法用于扩容或者协助扩容以及转移数据操作,在增删改的方法内部都可能会被调用到,接收两个参数,一个是旧的数组tab,一个是新的数组nextTab,如果nextTab为null那么表示第一条线程进入transfer,需要初始化新数组和一些参数。大概步骤为:
从transfer方法能够看出来,只有某桶位转移完毕之后,才会在该位置设置一个ForwardingNode节点,因此在扩容时对于还没有转移的桶位还是能够进行并发的增删操作的。而读操作则完全没有影响,遇到了ForwardingNode节点会通过nextTable转发到新数组中继续读。
/**
* 获取CPU数量,实际上是获取的可用线程数目,现在很多高级cpu都可以超线程(即一个核心模拟出多个线程)
* 我的笔记本只有一个单CPU:i7-8750h,但是具有6核12线程,所以获取的值就是12
*/
static final int NCPU = Runtime.getRuntime().availableProcessors();
/**
* 扩容的方法,或者协助扩容的方法
*
* @param tab 旧数组
* @param nextTab 新数组,不过为null那么表示扩容,不为null表示协助扩容
*/
private final void transfer(Node<K, V>[] tab, Node<K, V>[] nextTab) {
//获取旧数组长度
int n = tab.length, stride;
//NCPU表示服务器世纪可用线程数量
/*1 计算每个CPU线程需要处理的桶数量 -> (n >>> 3) / NCPU ,尽量让每条线程分配到均匀的桶数量(任务)
如果结果小于16,表示桶位比较少,那么就直接取16,即每条线程最少16桶位任务*/
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
/*2 如果nextTab为null,表示第一条线程进入该轮次扩容的transfer方法,此时nextTab为null,需要初始化新table,nextTable以及transferIndex*/
if (nextTab == null) {
// initiating
try {
@SuppressWarnings("unchecked")
//新建table,容量为n << 1,即旧数组长度的2倍
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1];
//nextTab变量记录新数组
nextTab = nt;
} catch (Throwable ex) {
// try to cope with OOME
//如果扩容失败,可能是由于内存不足导致的OOM,那么sizeCtl设置为Integer.MAX_VALUE,之后再也不会扩容了
sizeCtl = Integer.MAX_VALUE;
//返回结束扩容操作
return;
}
//这里说明新数组建好了,此时将新数组复制给nextTable全局变量,此时nextTable不为null
nextTable = nextTab;
//transferIndex表示下一次要分配桶任务的线程的起始桶索引+1,这里直接等于n即旧数组长度,表示桶任务是从后向前分配的
transferIndex = n;
}
//执行到这里,可能是其他线程协助扩容 或者 第一条线程将新数组以及相关变量被初始化好了
//获取新数组长度
int nextn = nextTab.length;
//这里出现了ForwardingNode转发节点
//新建ForwardingNode节点,节点的nextTable属性就是设置为新数组nextTab
ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab);
//推进transferIndex值的标志位,如果等于true,说明可能需要向前推进值;如果是 false,那么就不能推进,需要将当前的位置处理完毕才能继续推进
//只有线程完成了自己的任务并且还剩余有其他任务的时候,才能尝试继续推进。
boolean advance = true;
//表示扩容完成的状态,如果为true,表示扩容完毕。
boolean finishing = false; // to ensure sweep before committing nextTab
//开启一个死循环,i 表示当前线程当前处理的桶位索引,bound 表示当前线程可处理的最小桶位索引,即当前线程可处理的桶位索引是[bound,i]
//i和bound的值在内部初始化
for (int i = 0, bound = 0; ; ) {
Node<K, V> f;
int fh;
/*
* 第一次进来是,尝试为每个线程分配任务
* 后续进来时表示当前桶位任务做完之后,获取下一个任务,以及判断是否可以推出了
*/
//第一次进来,advance默认为true,表示transferIndex值可以向前推进
while (advance) {
int nextIndex, nextBound;
/*如果当前线程当前处理的桶位索引自减1之后,大于等于当前线程可处理的最小桶位索引,表示分配给它的任务还没干完
或者扩容完成的状态为true,表示已经扩容完成了,这两种情况下都不能推进
这里的--i也相当于获取下一个任务,当分配的所有任务做完之后,--i >= bound将返回false,第一次进来的时候也会返回false,然后就会走下面的
*/
if (--i >= bound || finishing)
//advance置为null,表示不能推进了
advance = false;
/*否则,如果transferIndex小于等于0,表示桶位都被分配完毕了,此时不能推进*/
else if ((nextIndex = transferIndex) <= 0) {
//i改为-1
i = -1;
//advance置为null,表示不能推进了
advance = false;
/*否则,CAS的推进transferIndex的值 将transferIndex从当前值改为:nextIndex > stride ?nextIndex - stride : 0)
* 即如果当前transferIndex的值nextIndex大于每一线程应该分配的桶位值stride,那么transferIndex=nextIndex-stride
* 否则,transferIndex=0,这种情况就表示桶任务被分配完毕了。
* 如果CAS成功,那么当前线程被分配到了一片连续索引位置的任务,最多为stride个最小为1个。
* 这里能够明白所谓“向前推进”,就是CAS的更新(减少)transferIndex的值,直到0,由于是CAS因此同时只有一条线程能够成功,失败的线程,将会继续下一次循环
* */
} else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//这里表示“推进”transferIndex的值成功,该线程分配到了任务
//更新bound值为nextBound,即当前transferIndex的值,表示能够处理的最小数组桶位索引
bound = nextBound;
//更新i的值为nextIndex减一,即原来的transferIndex的值-1,表示当前处理的数组桶位索引
//这里也能看出来更新后的nextBound索引位置的任务是由本线程处理的。
i = nextIndex - 1;
//advance置为false,表示不能推进了,因为分配了任务,需要完成任务再来判断
advance = false;
}
}
/*
* 如果i小于0,表明扩容已经结束了或者至少任务被分配完毕了,那么当前线程需要退出transfer方法
* 否则 如果i大于等于旧数组长度n,表明扩容的轮次不一致,有可能是当前线程误判了第一轮扩容n=2,但是实际上是第三轮n=8,此时i可能大于n,那么需要退出方法
* 否则 如果i+n大于等于新数组长度nextn,如果前两个为false,那么0
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//如果扩容完成的状态为true
if (finishing) {
//nextTable变量置为null
nextTable = null;
//table置为nextTab
table = nextTab;
//扩容阈值设置为原来容量的1.5倍,即相当于现在容量的0.75倍
sizeCtl = (n << 1) - (n >>> 1);
//方法结束
return;
}
/*CAS尝试将sizeCtl的值减一,即尝试把正在执行扩容的线程数减1,表明自己要退出扩容*/
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//因为第一个线程设置的sizeCtl=resizeStamp(n) << RESIZE_STAMP_SHIFT +2,因此这里尝试反解
//如果此时sc - 2不等于rs<
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//到这里,表示当前线程是本轮扩容中的最后一个线程,那么要最一些后续处理
//finishing和advance置为true
//同时将i变成n,需要重新检查一次旧数组所有的桶位,看是否都迁移到新数组中去了
//如果所有桶位都处理完了,那么旧数组都是ForwardingNode节点
//如果有线程因为扩容轮次不一致或者其他问题,那么他领取的任务会作废,此时由最后的线程将这些作废的任务统一处理了
finishing = advance = true;
i = n; // recheck before commit
}
}
/*否则,表示扩容没有结束,开始处理
* 如果该桶位是null,即没有节点,那么直接CAS放入一个ForwardingNode节点fwd
* */
else if ((f = tabAt(tab, i)) == null)
//advance置为CAS的结果,可能是true表示成功,或者false表示失败
advance = casTabAt(tab, i, null, fwd);
/*否则,该桶位不是null
* 但是如果是ForwardingNode节点,表明该位置已经被处理了,此时直接advance置为true,进行下一轮循环
* */
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
/*否则,表示该桶位是链表或者红黑树,那么转移节点数据
* 扩容时转移节点数据和HashMap的resize方法中的数据转移非常相似,但是这里会首先对链表头节点或者红黑树根节点加synchronized锁,保证线程安全
* 它们都是根据转移的规律将原来桶位索引k的结点拆分成不移动索引位置和需要移动索引位置的两条结点链表,
* 然后将这两条链表分别转移到新数组的k和k+oldCap索引位置处,如果原来是链表,那么这里就结束了,
* 如果原来是红黑树,在这个过程中需要判断两条链表的长度小于等于6就调用“树还原为链表”的方法untreeify存储到新位置,
* 否则进行“树形化”形成新的红黑树存储到新位置,这里的树形化方法在TreeBin节点构造器中实现的,而不是HashMap的treeify方法
* 关于这个规律,在HashMap的愿按摩文章中已经分析的很清楚了,在此不再赘述!
* 另外红黑树部分更多的涉及到数据结构的知识,建议看看单独学习红黑树以及HashMap的红黑树,再来看看这里的红黑树,实际上都差不多。
* */
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K, V> ln, hn;
/*如果是链表*/
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);
}
//使用规律将原结点链表拆分出的两条链表转移到新数组i或者i + n索引位置
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//原桶位节点设置为ForwardingNode节点fwd
setTabAt(tab, i, fwd);
//advance设置为true
advance = true;
}
/*否则,如果是红黑树*/
else if (f instanceof TreeBin) {
TreeBin<K, V> t = (TreeBin<K, V>) f;
ConcurrentHashMap.TreeNode<K, V> lo = null, loTail = null;
ConcurrentHashMap.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;
ConcurrentHashMap.TreeNode<K, V> p = new ConcurrentHashMap.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;
//使用规律将原结点链表拆分出的数据转移到新数组i或者i + n索引位置
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//原桶位节点设置为ForwardingNode节点fwd
setTabAt(tab, i, fwd);
//advance设置为true
advance = true;
}
}
}
}
}
}
除了在更新统计数量addCount方法中会协助扩容之外,在写数组的过程中(比如put、clear、remove),如果发现查找的数组位置的节点的hash为-1(即ForwardingNode节点),则说明此时哈希表的这个桶位正在扩容,那么当前线程先停下工作,同样加入到扩容的工作中去,协助扩容,这有助于提升效率。
helpTransfer和addCount中的协助扩容逻辑非常相似。如果看懂了addCount方法的原理,那么这个方法也很简单了,这也是将addCount源码解析放到前面的原因。
/**
* 在写数组的过程中,如果发现正在扩容,那么当前线程先停下工作,加入到扩容的工作中去,协助扩容,这有助于提升效率
*
* @param tab 旧数组
* @param f 旧数组中找到的桶位的第一个元素
* @return 返回新数组
*/
final Node<K, V>[] helpTransfer(Node<K, V>[] tab, Node<K, V> f) {
Node<K, V>[] nextTab;
int sc;
/**
* 如果 旧数组tab不为null,这里的tab是我们传入的本地变量,按常理永远不会为null
* 并且 当前桶位的元素为类型为ForwardingNode ,这里的f也是我们传入的本地变量,按常理永远不会为null
* 并且 f节点记录的新数组不等于null,那么说明可以继续判断,这里的nextTable没有清理的方法,按常理永远不会为null
*/
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K, V>) f).nextTable) != null) {
//根据旧数组长度计算本次扩容版本号
int rs = resizeStamp(tab.length);
/*
* 开启一个循环
* 如果新数组等于成员变量nextTable记录的新数组(不为null,相当于addcount中的条件4)
* 并且 旧数组 还等于成员变量table记录的数组 并且 sizeCtl的值还是小于0的,那么说明还在扩容
*/
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
//1 如果 sc无符号右移RESIZE_STAMP_SHIFT(16)位不等于此次扩容版本的唯一标志rs,如果是在同一个版本的扩容过程中应该是相等的,如果不等那说明不是同一个版本,不能协助
//2 否则 如果sizeCtl等于此次扩容版本的唯一标志rs+1,表示扩容结束了,不需要协助,因为第一次调用扩容方法之前sizeCtl == rs + 2
//3 否则 如果sizeCtl等于此次扩容版本的唯一标志rs+MAX_RESIZERS,表示协助扩容的线程数量达到了最大值MAX_RESIZERS,不需要协助
//5 否则 如果transferIndex <= 0,表示数组桶位分配完了,不需要协助
//以上五种情况成立一种,就表示不需要协助,那么结束循环
//第2、3个条件,实际上是一个JDK1.8的BUG,因为我们会发现,在我们的JDK版本中这两个判断永远都会返回false,正确的判断应该是: sc == (rs<<16) + 1 和 sc == (rs<<16) + MAX_RESIZERS 或者 (sc >>> RESIZE_STAMP_SHIFT) == rs + 1 || (sc >>> RESIZE_STAMP_SHIFT) == rs + MAX_RESIZERS
//另外,在helpTransfer方法中也存在类似的BUG,BUG地址:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427,该BUG已在高版本的JDK中修复
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
//跳出循环
break;
/*
*到这一步,说明需要协助扩容
*尝试CAS设置sc的值为sc+1,即每多一条线程来帮忙,sc的值都会+1
*/
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//如果CAS成功,那么调用transfer方法协助扩容,传递旧数组tab,新数组nextTab
transfer(tab, nextTab);
//协助扩容完毕,那么跳出循环
break;
}
}
//到这里说明扩容完毕,那么返回新数组
return nextTab;
}
//此时,说明扩容已经彻底结束了,table已经是最新的数组,直接返回table就行
return table;
}
把remove和replace方法放到一起,是因为它们公用一个内部方法replaceNode实现。
在remove删除节点时,如果成功删除了节点,同样需要调用addCount统计节点总数,但是check参数传递-1,表示不需要检查扩容或者协助扩容。
remove、replace和put方法都相当于写哈希表的操作,在过程中如果某个桶位的结点是ForwardingNode类型,那么表示正在扩容,同put的逻辑,此时当前正在remove或者replace的线程先被“征用了”,先helpTransfer协助扩容,完成扩容之后在继续自己的逻辑。
remove或者replace处理某个桶位时,同样需要对该桶位的节点(链表头节点胡总和红黑树根节点)加synchronized锁,保证该桶位的线程安全。
上面说的“匹配”,实际上就是看key或者value与指定的key或者value是否相等,ConcurrentHashMap和HashMap的匹配规则是一样的,对于key来说,判断相等需要两个key的hash相等,并且(两key使用==比较返回true或者equals比较返回true),使用表达式就是:
e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))
对于Value来说,判断相等只需要两value使用 == 比较返回true 或者equals返回true就行了,使用表达式就是:
value == (pv = p.val) || (pv != null && value.equals(pv));
其他方法涉及到的查找方法的匹配key或者value相等的逻辑都是上面的逻辑,比如get、contains等方法。
public V remove(Object key)
删除与指定key相匹配的键值对,大概原理和HashMap的原理差不多,只是保证了线程安全。
返回以前与key相关联的value,如果没找到匹配的key,则返回null。
如果指定key为null,则抛出NullPointerException。
/**
* 删除与指定key相匹配的键值对
*
* @param key key
* @return 返回以前与key相关联的value,如果没找到匹配的key,则返回null。
* @throws NullPointerException 如果指定key为null
*/
public V remove(Object key) {
//返回replaceNode方法的返回值,传入key、null、null
return replaceNode(key, null, null);
}
public boolean remove(Object key, Object value)
删除与指定key和value都相匹配的键值对,大概原理和HashMap的原理差不多,只是保证了线程安全。
如果都匹配并移除成功,则返回true;否则如果value为null或者没有匹配成功,则返回 false。
如果指定key为null,则抛出NullPointerException。
/**
* 删除与指定key、value相匹配的键值对
*
* @param key 指定key
* @param value 指定value
* @return 如果都匹配并移除成功,则返回true;否则如果value为null或者没有匹配成功,则返回 false。
* @throws NullPointerException 如果指定key为null
*/
public boolean remove(Object key, Object value) {
//如果key为null,抛出异常
if (key == null)
throw new NullPointerException();
//如果value不为null,并且replaceNode的返回值不为null,那么返回true;否则返回false
//replaceNode传入key、null、value
return value != null && replaceNode(key, null, value) != null;
}
public V replace(K key, V value)
如果存在与指定key相匹配的键值对,那么使用指定value替换旧值。
返回被替换的旧值,如果没有匹配成功则返回null。
如果指定key或者value为null,则抛出NullPointerException。
/**
* 如果存在与指定key相匹配的键值对,那么使用指定value替换旧值。
*
* @param key 指定key
* @param value 指定新value
* @return 返回被替换的旧值,如果没有匹配成功则返回null。
* @throws NullPointerException 如果指定key或者value为null
*/
public V replace(K key, V value) {
//如果指定key或者value为null,则抛出NullPointerException。
if (key == null || value == null)
throw new NullPointerException();
//返回replaceNode方法的返回值,传入key、value、null
return replaceNode(key, value, null);
}
public boolean replace(K key, V oldValue, V newValue)
如果存在与指定key和oldValue都相匹配的键值对,那么使用newValue替换旧值。
如果都匹配并替换成功返回true,否则返回false。
如果key或者oldValue或者newValue为null,则抛出NullPointerException。
/**
1. 如果存在与指定key和oldValue都相匹配的键值对,那么使用newValue替换旧值。
2. 3. @param key 指定key
4. @param oldValue 指定value
5. @param newValue 指定新value
6. @return 如果都匹配并替换成功返回true,否则返回false。
7. @throws NullPointerException 如果key或者oldValue或者newValue为null
*/
public boolean replace(K key, V oldValue, V newValue) {
//如果key或者oldValue或者newValue为null,则抛出NullPointerException。
if (key == null || oldValue == null || newValue == null)
throw new NullPointerException();
//传入key、newValue、oldValue
//replaceNode的返回值不为null,那么返回true;否则返回false
return replaceNode(key, newValue, oldValue) != null;
}
replaceNode是remove和replace方法的公共内部实现,比较简单,大概步骤为:
/**
* remove、replace方法的内部公共实现方法
*
* @param key 需要匹配的key
* @param value 新value,如果不为null,表示替换;如果为null表示删除
* @param cv 旧value,如果不为null,表示需要key和value都匹配才能进行删除或者替换;如果为null表示只需要匹配key
* @return 原value,如果为null表示删除、替换失败
*/
final V replaceNode(Object key, V value, Object cv) {
//获取key的hash值
int hash = spread(key.hashCode());
/*开启一个死循环*/
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
//如果 哈希表为null
//否则 如果哈希表长度为0
//否则 如果key对应桶位的是null
//以上三点满足一点即直接break跳出死循环,返回null
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
/*
* 否则,如果该桶位的结点是ForwardingNode类型,表示正在扩容
* 那么当前线程加入到扩容中去,先协助扩容!
* */
else if ((fh = f.hash) == MOVED)
//helpTransfer方法帮助扩容
tab = helpTransfer(tab, f);
/*否则,说明该桶位存在结点并且此时没有扩容,那么走自己的逻辑*/
else {
//oldVal用于记录旧值
V oldVal = null;
//如果成功加锁并尝试匹配过节点(无论是否匹配成功),那么为true;否则为false,默认false
//validated标志也作为是否可以退出循环的标志
boolean validated = false;
/*
*对该桶位的结点加synchronized锁,然后删除
*这里的逻辑就和HashMap的remove方法逻辑非常相似了
*
*/
synchronized (f) {
//再次校验
if (tabAt(tab, i) == f) {
/*如果结点的hash大于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)))) {
//如果找到了key相等的结点e
//使用ev变量记录找到e的value
V ev = e.val;
/*
* 如果传入的旧value为null,说明不需要匹配旧value
* 否则 如果传入的旧的value等于e的value,表示匹配成功
* 否则 如果e的value不为null(常理来说肯定是不为null的)并且如果旧value和e的value通过equals比较返回true,同样表示匹配成功
*
* 如果上面的三个条件有一个满足即表示匹配成功(包括不需要匹配)
* */
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
//旧值赋值为ev(e的value)
oldVal = ev;
//如果新value不为null,表示替换
if (value != null)
//那么替换e的value为新value
e.val = value;
/*否则,表示删除,如果pred不为null,表示不是链表头结点*/
else if (pred != null)
//更新next引用关系,将结点e移除链表
pred.next = e.next;
/*否则,表示删除,并且删除的结点e是头结点*/
else
/*重新设置头结点为e.next*/
setTabAt(tab, i, e.next);
/*上面的删除操作,都没有将e的next置为null,因此不会影响读取链表的数据,即对于链表,读写可以并发*/
}
break;
}
pred = e;
//如果遍历到末尾了,还没有匹配成功,那么break跳出链表循环
if ((e = e.next) == null)
break;
}
}
/*否则,表示红黑树*/
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K, V> t = (TreeBin<K, V>) f;
TreeNode<K, V> r, p;
//如果红黑树不为null并且匹配到的结点p不为null,表示成功过匹配到洛克节点
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
//pv记录p的value
V pv = p.val;
/*
* 如果传入的旧value为null,说明不需要匹配旧value
* 否则 如果传入的旧的value等于p的value,表示匹配成功
* 否则 如果p的value不为null(常理来说肯定是不为null的,这里是流程)并且如果旧value和p的value通过equals比较返回true,同样表示匹配成功
*
* 如果上面的三个条件有一个满足即表示匹配成功(包括不需要匹配)
* */
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
//旧值赋值为pv(p的value)
oldVal = pv;
//如果新value不为null,表示替换
if (value != null)
//那么替换p的value为新value
p.val = value;
//否则表示删除,调用removeTreeNode方法删除p结点
else if (t.removeTreeNode(p))
//removeTreeNode如果返回true,表示红黑树结点比较少并且结构符合要求,此时调用untreeify将红黑树还原为链表,并存入原来的桶位处
//HashMap中的removeTreeNode方法就相当于ConcurrentHashMap中的removeTreeNode和untreeify方法的结合
// 因此直接查看HashMap文章中的removeTreeNode方法的源码即可理解这两个方法的原理
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
/*
* 如果validated为true,表示成功加锁并尝试匹配过节点,下面继续看成功与否,并退出循环
* 因此这里的validated如果为true,那么表示可以退出循环了
*/
if (validated) {
//如果oldVal不为null,表示替换或者删除成功
if (oldVal != null) {
//如果value,即传入的新value参数为null,那么表示删除
//由于删除操作是一个“写”操作,那么调用addCount方法更新节点数量
if (value == null)
//调用addCount 传入 -1 -1
//表示节点数量是减少的,同时不需要判断是否扩容或者需要协助扩容,因为这里只是删除
addCount(-1L, -1);
//直接,返回旧值,方法结束
return oldVal;
}
//如果oldVal为null,表示没有删除或者替换成功,这里直接break跳出最外层的for死循环,最终返回null
break;
}
}
}
//返回null
return null;
}
public V get(Object key)
尝试获取与指定key相匹配的键值对的value。
返回与指定key相匹配的键值对的value,如果没有匹配到,则返回null。
如果指定key为null,则抛出NullPointerException。
同JDK1.7的版本,get没有加锁,因为Node的属性val和指针next是用volatile修饰的,在多线程环境下可以保证可见性,即保证获取到最新的数据。大概步骤为:
/**
1. 尝试获取与指定key相匹配的键值对的value。
2. 3. @param key 指定key
4. @return 返回与指定key相匹配的键值对的value,如果没有匹配到,则返回null。
5. @throws NullPointerException 如果指定key为null
*/
public V get(Object key) {
Node<K, V>[] tab;
Node<K, V> e, p;
int n, eh;
K ek;
//计算key的hash值
int h = spread(key.hashCode());
/*如果哈希表不为null,并且key对应的桶位的结点e不为null,那么开始查找*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
/*首先,如果e.hash==h,那么尝试直接匹配该节点e*/
if ((eh = e.hash) == h) {
/*如果key匹配相等*/
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
//直接返回e的value
return e.val;
}
/*否则,如果e的hash小于0,表示是该桶位正在扩容,或者该桶位是红黑树*/
else if (eh < 0)
//调用e的find方法继续查找
//如果是正在扩容,那么e就是ForwardingNode结点,那么ForwardingNode.find方法会将get请求转发到扩容的新数组中区查找
//如果是红黑树,那么e就是TreeBin结点,那么TreeBin.find方法会查找这一颗红黑树
//最终如果find的返回值p不为null,说明匹配到了结点,返回p的value ;否则返回null
return (p = e.find(h, key)) != null ? p.val : null;
/*
* 到这一步,说明上面的if、 else if都不成立,表示该位置是一个链表
* 那么循环查找链表即可,比较简单
*/
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
/*
* 到这一步,说明:
* 哈希表为null
* 否则 key对应的桶位的结点e为null
* 否则 链表没有匹配到相等的key
* 以上情况满足一种,均返回null
*/
return null;
}
在get方法中我们说过,如果e的hash小于0,那么该桶位可能表示正在扩容或者是红黑树,那么会调用相应类型的find方法继续查找,我们先来看看ForwardingNode中的find方法。
ForwardingNode中的find方法用于将读请求转换到最新的哈希表中去,让读线程去最新哈希表中查找,大概步骤为:
/**
1. ForwardingNode节点类中重写的find方法
2. 将读取的请求转发到新哈希表中去
3. 4. @param h key的hash值
5. @param k 需要匹配的key
6. @return 匹配到key则返回对应节点,否则返回null
*/
Node<K, V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer:
/*
* 因为ForwardingNode节点的nextTable属性保存了新的哈希表,因此获取新哈希表赋值给tab
* 开启一个死循环,
* 注意这里的循环并不是循环整个新哈希表,而是先计算出key在新哈希表的桶位索引之后,在内部在开启一个for循环
* 在内部for循环中匹配该桶位的数据,无论成功还是失败都会结束所有循环,除非
*/
for (Node<K, V>[] tab = nextTable; ; ) {
Node<K, V> e;
int n;
/*
* 首先是一系列检查,这种检查看起来有些是不必要的,但是为了程序的健壮性建议加上,在JUC的源码中这种多余的检查随处可见
* 如果key为null
* 否则 新数组tab为null
* 否则 key在新数组中对应的桶位的结点e为null
* 以上情况满足一种,就直接返回null
*/
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
/*检查通过,开启一个死循环,看起来没有结束条件,实际上结束条件写在了循环体中
* 如果在该桶位匹配到了key,那么直接return对应的结点,结束该方法
* 如果没有找到key,那么直接return null,结束该方法
* 如果该位置的结点还是ForwardingNode类型,那么结束内层循环,结束外层大循环,获取新的tab,继续下一次大循环
*/
for (; ; ) {
int eh;
K ek;
/*匹配该节点e*/
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
//如果e的key匹配相等,那么直接返回e
return e;
/*如果e的hash小于0,表示是该桶位正在扩容,或者该桶位是红黑树*/
if (eh < 0) {
/*如果是ForwardingNode,说明“新哈希表”在扩容,即此时该线程落后了太多了,那么需要继续转发到下一个更新的哈希表继续匹配
* 如果一个读请求在获取某个table之后,由于长时间未能获得CPU的时间片而阻塞,并且在此期间哈希表扩容了超过一次
* 那么再重新获取CPU时间片之后就有可能发生这种情况
* */
if (e instanceof ForwardingNode) {
//tab赋值为e的nextTable,即更新的哈希表,
tab = ((ForwardingNode<K, V>) e).nextTable;
//直接结束内层for循环,同时跳过本次最外层大循环,继续下一次大循环,注意此时tab已经变最新哈希表了
//如果落后了很多轮次的扩容,那么e instanceof ForwardingNode条件将一直为true
//最终将会转发到最新的哈希表中,此时这个e instanceof ForwardingNode条件将返回false
continue outer;
}
/*否则,表示该桶位是红黑树,那么调用红黑树的find,尝试匹配key*/
else
//返回find的结果
return e.find(h, k);
}
/*
* 到这一步,说明e不匹配,e的hash也没有小于0,表示就是链表,那么e赋值为e.next
* 如果e为null,表示到了链表尾部还没有匹配到key,那么返回null
* 如果e不为,那么继续下一次循环,
*/
if ((e = e.next) == null)
return null;
}
}
}
TreeBin中的find用于在该桶位的红黑树中匹配指定的key,在HashMap的源码中我们就说过里面的红黑树实际上还是一个双端链表,ConcurrenthashMap也是如此。
这里可能有一个读写锁,根据自身读写锁情况,如果存在写锁或者存在获取写锁的情况,那么使用链表方式查找;否则,获取读锁并使用红黑树的方式查找。
ConcurrentHashMap对于红黑树的操作使用读写锁的原因是:红黑树的结构比如父级、子级会因为增、删而发生巨大的变化,如果采用红黑树的方式读取,那么可能导致读取的时候不能读取全部数据。而不用独占锁是因为读线程是可以并发的,使用读写锁可以提升并发效率。
这里的读写锁不需要考虑 写-写 竞争的情况,因为在写操作的最外层都加了synchronized锁,保证一次只有一条写线程,因此只需要考虑读-写竞争。存在读锁的时候写线程必须阻塞,而存在写锁的时候则不影响读取,因为还可以采用链表的方式读,因为红黑树本身也维护了链表的关系,只不过此时效率比较低,但是相比于读线程阻塞会好的多。
另外我们说过ConcurrentHashMap和HashMap一样,红黑树还维护了一张链表,具体原理在HashMap章节中。
大概步骤为:
/**
* TreeBin节点类中重写的find方法
* 在该桶位的红黑树中匹配指定的key,在HashMap的源码中我们就说过,里面的红黑树实际上还是一个双端链表,ConcurrenthashMap也是如此
* 这里可能有一个读写锁,根据自身读写锁情况,如果存在写锁,那么使用链表方式查找,安全,效率低;否则,使用红黑树的方式查找,效率更高
*
* @param h 指定key的hash值
* @param k 指定的key
* @return 匹配到key则返回对应节点,否则返回null
*/
final Node<K, V> find(int h, Object k) {
/*key检验*/
if (k != null) {
/*
* 一个for循环,首先e保存first
* 这里的first是该TreeBin节点的属性,保存的就是这个红黑树的链表头节点
* 我们说过ConcurrentHashMap的红黑树还维护了一张链表,链表节点实际上就是红黑树的节点
* 如果e为null,那么退出循环。
*/
for (Node<K, V> e = first; e != null; ) {
int s;
K ek;
/*
* WAITER | WRITER固定为,2+1=3
* 已知读线程获取读锁时会将lockState加4。
* 如果lockState & 3 不等于0,那么lockState的最低两位一定都是0,
* 那么说明此时存在写线程获取了写锁(会将lockState设置为1),或者存在写线程在等待获取写锁(会将lockState加2)
*
* 如果存在线程持有该桶位的写锁,因为获取了写锁就不能获取读锁,为了不阻塞读线程,此时使用链表方式查找,效率较低,但是相比于阻塞读线程更快
* 如果存在线程在等待获取该桶位的写锁,为了让已经被阻塞的写线程尽快恢复运行,此时同样使用链表方式查找,因为查找可以不需要获取读锁,而写的话必须要获取写锁
*/
if (((s = lockState) & (WAITER | WRITER)) != 0) {
/*使用链表方式查找*/
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
//找到了就直接返回e,方法结束
return e;
//否则查找下一个
e = e.next;
}
/*
* 否则,尝试获取读锁
* 尝试CAS的lockState增加READER(读线程增量,4),即读线程数量加1
* CAS成功表示获取到了读锁,失败则继续下一次循环
*/
else if (U.compareAndSwapInt(this, LOCKSTATE, s,
s + READER)) {
/*如果CAS成功,说明获取到了读锁*/
TreeNode<K, V> r, p;
try {
//调用findTreeNode使用红黑树的方式二分查找
p = ((r = root) == null ? null :
r.findTreeNode(h, k, null));
} finally {
/*查找结束之后,需要释放读锁,可能尝试唤醒等待的写线程*/
Thread w;
/*
* 这一个if的表达式中有多个步骤,实际上是一个 ( a == b ) && c 的操作
* &&左边的表达式的含义是:
* 1 首先尝试CAS的将lockState值减少READER,即减少4,表示释放了一个读锁,然后返回减少4之前的lockState值。getAndAddInt内部具有自旋操作,保证最终成功
* 2 计算(READER | WAITER)的值,即READER+WAITER,固定为6
* 3 判断之前的lockState值是否也等于6,如果是那么第一个表达式为true,有什么含义呢?
* 在TreeBin的处理中:如果有写线程在等待获取写锁,那么lockState会加上2;如果读锁都释放完毕了还存在等待获取写锁的线程,那么lockState将变成2
* 如果第一个表达式返回true,这表示在步骤1中释放的读锁是最后一个读锁,并且此时还存在等待获取写锁的线程
*
* &&右边的表达式的含义是:
* 当前TreeBin节点记录的被阻塞的线程变量waiter不为null,
*
* 如果两边的表达式都为true,说明这是释放最后一个读锁的读线程,并且有写线程因为获取不到写锁而阻塞,此时可以尝试唤醒被阻塞的写线程
*
*/
if (U.getAndAddInt(this, LOCKSTATE, -READER) ==
(READER | WAITER) && (w = waiter) != null)
//调用阻塞工具LockSupport直接唤醒被阻塞的w写线程
LockSupport.unpark(w);
}
//尝试查找过key,那么直接返回查找结果p,方法结束
return p;
}
}
}
/*
* 到这一步,说明:
* key为null
* 否则 如果按照链表方式没有查找到
* 以上两个条件满足一个即到此返回null
*/
return null;
}
在最开始“主要内部类”部分,我们见识到了TreeBin的结构,在上面的“TreeBin的find查找红黑树”部分,我们见识到了ConcurrenthashMap对于红黑树结构加上读写锁的原理,以及读锁获取、释放的原理,这里我们来看看写锁的获取、释放的原理。
写锁肯定是类似于调用pru、remove方法的线程才能获取的,我们在putVal中能找到写锁的获取时机和逻辑,实际上是在插入节点完毕之后的调整平衡的方法之前会获取写锁,之后会释放写锁,而插入红黑树节点的扩及和HasmMap差不多,我们在HashMap文章部分已经分析过了。
因此我们着重分析lockRoot和unlockRoot方法!
&emsp**;lockRoot就是获取某个桶位的红黑树的写锁的入口方法,大概步骤为:**
从步骤中能看出来,如果lockState标志从0变成1,那就表示获取到了写锁。并且如果调用该方法并且能够返回的写线程,一定是获取到了写锁的线程。
/**
1. 位于TreeBin节点类中的方法
2. 获取该桶位的写锁,当线程从该方法返回时,一定是获取到了该桶位的写锁的线程
3. Acquires write lock for tree restructuring.
*/
private final void lockRoot() {
//首先是直接尝试CAS的将lockState,从0变成WRITER状态,即从没有锁变成获取了写锁的状态
//WRITER=1,即使用lockState=1表示获取了写锁
if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER))
//如果CAS失败,那么调用contendedLock方法,继续获取直到成功才返回
contendedLock(); // offload to separate method
//如果CAS成功,或者contendedLock方法返回,那么表示获取写锁成功,写锁是独占锁
}
在lockRoot中获取尝试获取写锁失败之后,进入contendedLock继续获取或者被阻塞,大概步骤为:
contendedLock方法虽然代码看起来简单,但是涉及到大量的位运算规律,比较考验计算机基本功底。另外park方法是不会释放之前的获取的synchronized锁的:JUC—LockSupport以及park、unpark方法底层源码深度解析。
/**
* 位于TreeBin节点类中的方法
* 如果尝试CAS获取写锁失败,那么调用该方法
*/
private final void contendedLock() {
//waiting标志位,如果为true表示可以阻塞自己(写线程);否则不能阻塞自己(写线程)
boolean waiting = false;
//开启一个死循环,尝试获取锁,获取到锁就结束循环
for (int s; ; ) {
/*
* 这里的 ~WAITER,即~2,即表示 -3 是一个固定值
* 如果lockState & -3 等于0,那么表示这个lockState一定是0或者2,怎么得出这个结果的?
* -3的二进制补码我们知道是 11111111111111111111111111111101
* 因此 lockState为0(二进制数全是0)或者2(二进制数为10)时,结果为0
* 而lockState为0时,表示没有任何线程获取任何锁;
* lockState为2时,表示只有一个写线程在等待获取锁,这也就是前面讲的find方法中,最后一个读线程释放了读锁并且还有写线程等待获取写锁的情况,实际上就是该线程
* 综合起来,这个判断如果为true则表示:没有任何线程获取任何锁,或者只有一个写线程在等待获取锁(就是当前线程被唤醒之后的逻辑)。
*/
if (((s = lockState) & ~WAITER) == 0) {
/*此时当前获取写锁的线程可以继续尝试获取写锁*/
if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) {
//如果获取成功并且如果waiting标志位为true
if (waiting)
//那么将waiter清空,因为waiter是waiting为true时设置的,表示此时没有写线程在等待写锁
waiter = null;
//获取到了锁contendedLock方法结束
return;
}
/*
* 否则,判断 s & WAITER==0
* WAITER固定为2
* 如果s & WAITER为0,即需要s & 2 =0,那么s(lockState)必须为1或者大于2的数,比如4、8等等
* 由于不存在写并发(外面对写操作加上了synchronized锁),因此lockState一定属于大于2的数,比如4、8等等
* 这表示有线程获取到了读锁,此时写线程应该等待
*
* */
} else if ((s & WAITER) == 0) {
//尝试将lockState设置为s | WAITER ,这里的s|WAITER就相当于s+WAITER,即将此时的lockState加上2,表示有写线程在等待获取写锁
if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) {
//如果CAS成功,那么waiting置为true
waiting = true;
//waiter设置为当前线程
waiter = Thread.currentThread();
}
}
/*
* 否则,根据前面的判断此时的lockState一定是6、10、14等数
* 判断是否需要阻塞自己,如果waiting=true,表示需要阻塞
*/
else if (waiting)
//调用park方法阻塞自己,此时写线程不再继续执行代码,而是等待被唤醒
//这里的park不会释放之前获取到的synchronized锁,因为park或者unpark方法根本就与“锁”无关
//如果被唤醒,那么可能是因为最后一个读锁也被释放了,或者是因为被中断,那么继续循环获取锁
//该循环的唯一出口就是获取到了写锁
LockSupport.park(this);
}
}
unlockRoot用于释放写锁。很简单,直接将lockState设置为0,这里不需要CAS,因为读锁时独占锁,并且存在写锁的时候肯定不存在读锁,此时能保证线程安全。
/**
* 释放写锁
*/
private final void unlockRoot() {
//直接将lockState设置为0
lockState = 0;
}
clear方法用于清除遍历到的所有节点数据,由于存在并发clear方法结束不代表Map真的变空了,有可能一边清理另一边又添加了数据。大概步骤为:
通过clear方法,我们知道sunCount方法可能返回负数值。因为上面的循环清除可能将比如put方法刚刚加进去的结点给清理掉,而如果刚加进去的结点的线程还没有来得及调用addCount方法更新统计计数,即加1,但是这时的clear方法,先一步调用addCount,那么肯定会多减1,在此期间使用sunCount方法统计节点总数,肯定会得到负数的结果!
在下面的在size、isEmpty、mappingCount等计数方法中都会处理sunCount方法返回负数情况!
/**
* 清除此Map中的所有键值对
*/
public void clear() {
//删除计数,每删除一个元素节点,delta自减1
long delta = 0L; // negative number of deletions
//初始化i为0,这里i相当于桶位索引
int i = 0;
Node<K, V>[] tab = table;
//开以一个循环
// 如果tab不为null并且容量大于i(第一次为0),那么继续循环
while (tab != null && i < tab.length) {
int fh;
//获取i桶位的结点f
Node<K, V> f = tabAt(tab, i);
/*如果f为null,那么++i,继续下一次循环*/
if (f == null)
++i;
/*否则,如果f是ForwardingNode节点,那么表示正在扩容,那么先协助扩容*/
else if ((fh = f.hash) == MOVED) {
//协助扩容,返回新的扩容之后的哈希表
tab = helpTransfer(tab, f);
//扩容结束之后i重置为0,从新开始遍历-清除新的哈希表tab
i = 0; // restart
}
/*否则,那么走删除逻辑*/
else {
/*删除同样需要对f加上synchronized锁*/
synchronized (f) {
if (tabAt(tab, i) == f) {
//设置变量p
//如果f的hash值大于等于0,那么p=f
//否则 如果f桶位是红黑树,那么p=first,即红黑树链表头节点
//否则 p=null
Node<K, V> p = (fh >= 0 ? f :
(f instanceof TreeBin) ?
((TreeBin<K, V>) f).first : null);
//如果p不为null,那么循环清理链表(普通链表和红黑树链表)
//这里的清理实际上只是预清理,用于记录此桶位清理了多少元素
while (p != null) {
//delta自减1
--delta;
//p指向p.next,这样最终p会指向链表的尾节点,那时表示该桶位的链表或者红黑树删除完毕
p = p.next;
}
//该桶位置的结点设置为null,这样原来的链表或者红黑树没有引用,会被GC回收;同时i++,继续下一次循环
//注意,setTabAt方法才算是真正的清除元素的方法,并且是整个桶一起清除的,上面的while循环并没有真正的清理
setTabAt(tab, i++, null);
}
}
}
}
//到这里,表示tab为null或者遍历清除tab全部桶位完毕
//如果delta不为0,那么表示删除了元素
if (delta != 0L)
//此时调用addCount方法,重新对此时Map的元素进行计数
//这一步说明:sunCount方法可能返回负数的值
//因为上面的循环清除可能将比如put方法刚刚加进去的结点给清理掉,如果刚加进去的结点的线程还没有来得及调用addCount方法更新统计数据,即加1
//但是这时的clear方法,先一步调用addCount,那么肯定会多减1,在此期间使用sunCount方法统计节点总数,肯定会得到负数的结果!
//在size或者isEmpty或者mappingCount方法中都会处理sunCount方法返回负数情况
addCount(delta, -1);
}
public int size()
size方法用于获取Map集合的元素数量近似值。
由于Map的元素数量时可能超过int范围的,但是由于sinze方法返回int类型的数据,因此这里的结果更加不准确。
/**
* 获取Map集合的元素数量近似值
*
* @return 近似值,int类型
*/
public int size() {
//直接调用sunCount计算baseCount 和counterCells的总和,这个方法在前面已经讲过了,注意返回long类型的值
long n = sumCount();
//如果小于0,那么返回0
//如果大于Integer.MAX_VALUE,那么返回Integer.MAX_VALUE
//由于实际上Map的元素数量是可能超过int范围的,但是由于size只能返回int类型的值,因此这里返回的size更加不准确。
return ((n < 0L) ? 0 :
(n > (long) Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int) n);
}
public long mappingCount()
mappingCount方法用于获取Map集合的元素数量近似值。
由于size返回int类型的值,数据不准确。JDK1.8新加入了mappingCount方法用于获取更加准确的元素数量,返回long类型的值。
/**
* 获取Map集合的元素数量近似值
*
* @return 近似值,long类型
*/
public long mappingCount() {
//直接调用sunCount计算baseCount 和counterCells的总和,这个方法在前面已经讲过了,注意返回long类型的值
long n = sumCount();
//如果小于0,那么返回0;否则返回long类型的n,不会截断,相比于size方法更加准确。
return (n < 0L) ? 0L : n;
}
public boolean isEmpty()
判断此Map是否为空。同样只是近似值,因为判断的时候可能并发的清空了集合或者添加了数据。
/**
* 判断此Map是否为空。
* @return true 是 false 否
*/
public boolean isEmpty() {
//同样调用sumCount,如果结果小于等于0,那么返回true;否则返回false
return sumCount() <= 0L;
}
public boolean containsKey(Object key)
如果此map包含指定key,则返回 true。如果指定key为null,则抛出NullPointerException。
/**
* 判断此map是否包含指定key
* @param key 指定key
* @return 如果此map包含指定key,则返回 true。
*/
public boolean containsKey(Object key) {
//因为内部元素的key和value一定部位null,那么是直接调用get方法,如果不返回null说明包含,否则不包含
return get(key) != null;
}
public boolean containsValue(Object value)
如果此map包含指定value,则返回 true。如果指定value为null,则抛出NullPointerException。
由于这个方法查找的的是value,因此非常有可能对哈希表进行全部遍历,并且对于数组、链表还是红黑树都采用的是顺序遍历,效率较低。
由于读时并发的情况,存在读取到ForwardingNode节点的情况,因此需要将读操作转发到里面的nextTable中去继续读,如果又读取到ForwardingNode节点,那么继续转发到更里面的nextTable中去继续读,当最里面的nextTable查找完毕之后,退回到倒数第二层的nextTable,从转发的位置的下一个位置开始继续读去,这样才能保证containsValue方法能够遍历此Map的所有元素节点,这类似于Java中方法的递归操作或者说类似于栈空间对于方法的执行操作。ConcurrentHashMap将上面的操作封装到一个Traverser对象中去了,方便调用。
/**
* 判断此map是否包含指定value
* 由于这个方法查找的的是value,因此非常有可能对哈希表进行全部遍历
* 并且对于数组、链表还是红黑树都采用的是顺序遍历,效率较低
*
* @param value 指定value
* @return 如果此map包含指定value,则返回 true。
* @throws NullPointerException 如果value为null
*/
public boolean containsValue(Object value) {
//如果指定value为null,则抛出NullPointerException。
if (value == null)
throw new NullPointerException();
Node<K, V>[] t;
//如果哈希表不为null
if ((t = table) != null) {
//初始化一个遍历器,用于在并发情况下保证遍历全部元素节点
Traverser<K, V> it = new Traverser<K, V>(t, t.length, 0, t.length);
for (Node<K, V> p; (p = it.advance()) != null; ) {
V v;
//如果存在value相等的结点,那么返回true
if ((v = p.val) == value || (v != null && value.equals(v)))
return true;
}
}
//如果哈希表为null或者不存在value相等的结点,那么返回false
return false;
}
public boolean contains(Object value)
如果此map包含指定value,则返回 true。如果指定value为null,则抛出NullPointerException。
这是一个遗留的方法,由于Hashtable具有这个同名方法,并且它们都属于Map集合体系,因此这里只是为了兼容Hashtable做出的妥协,本质和containsValue是一样的!
/**
* 为了兼容Hashtable的方法,和containsValue的逻辑是一样的
*
* @param value 指定value
* @return 如果此map包含指定value,则返回 true。
* @throws NullPointerException 如果value为null
*/
public boolean contains(Object value) {
//内部直接调用containsValue方法
return containsValue(value);
}
putAll方法用于将指定的map中的全部数据添加到本map集合中来,相当于浅克隆。
/**
* putAll方法用于将指定的map中的数据添加到本map集合中来。
* 相当于浅克隆。
*
* @param m 指定map集合
*/
public void putAll(Map<? extends K, ? extends V> m) {
//内部直接调用tryPresize获取一个比较合理容量的的哈希表,可能是初始化也可能是扩容,在前面讲过次源码了
tryPresize(m.size());
//剩下的就很简单了,遍历此map的所有键值对,循环调用putVal方法添加
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putVal(e.getKey(), e.getValue(), false);
}
本文主要是讲了JDK1.8的ConcurrentHashMap的实现,并没有讲JDK1.7的ConcurrentHashMap的实现,其实这两个版本的实现差别还挺大的,这里只是列出了他们之间的主要区别:
区别 | JDK1.7 | JDK1.8 |
数据结构 | 由一个Segment数组以及每个索引位置上包含的一个HashEntry数组构成,HashEntry数组的桶位存放的是一个的HashEntry链表,HashEntry数组和JDK1.7的HashMap结构差不多。 | 由一个Node数组组成,Node数组的桶位存放的可能是一张Node链表,或者一颗Node红黑树。整体结构和JDK1.8的HashMap结构非常相似,还是一整张的哈希表,并且相关参数和HashMap差不多,不如链表树形化阈值为大于8等等。而Node类似于JDK1.7的ConcurrentHashMap中的HashEntry。 虽然还能看到Segment的数据结构,但是已经没有实际意义,只是为了兼容旧版本,并不参与任何结点的操作!构造器中指定的loadFactor以及concurrencyLevel参数同样只是为了兼容旧版本,以及初始化容量设定,并无其他意义。 |
同步机制 | Lock“分段锁”机制 + volatile。Segment继承了ReentrantLock,一个Segment位置持有一把锁,内部是一个HashEntry数组,相当于把一个大的哈希表拆分成了多段的小哈希表,每一段使用不同的Segment对象作为锁,每次锁住一小段的桶位,保证了同步并提升并发度。另外HashEntry中的val和next属性使用volatile修饰,保证了单个变量的单次操作的原子性和可见性。 | CAS+synchronized+volatile。对于每一个哈希表的桶为加synchronized锁,每次只锁住一个桶位,保证一批代码线程安全并进一步提升并发度。同时使用CAS来完成对于单个变量的读-比-写等复合操作,保证了线程安全的同时避免了加锁。另外Node中的val和next属性使用volatile修饰,保证了单个变量的读、写等单次操作的原子性和可见性。 采用和JDK1.8的HashMap相同的结构的的原因是:对于过长的链表,顺序遍历时间复杂度为O(n),会消耗大量时间,而对于长链表采用红黑树替换,可以降低时间复杂度至O(logn)。 另外,抛弃了“分段锁”机制,而是锁住每一个桶位,相比于“分段锁”机制的锁住一批桶位,可以说降低了锁的粒度,并且降低锁力度还减少了需要同步的代码的数量,这样就提升了更多的并发度。JDK1.7的ConcurrentHashMap并发度默认为16(Segment数组长度为16,因此只有16把锁),且初始化之后不可更改。 由于增加了CAS机制,因此很多代码不需要加锁即可实现同步,比如put()方法中初始化数组的代码,使用一个 sizeCtl 变量,如果CAS将这个变量置为-1,就表明table正在初始化,此时其他线程则自旋等待,如果初始化完毕那么都能安全的获得最新的初始化哈希表。 |
hash操作 | 定位结点需要经历两次hash操作,第一次hash操作获取key的hash值,然后再第二次hash操作,根据获取的hash值定位某个Segment桶位,最后在该桶位下根据hash值定位到HashEntry中的某个桶位,需要消耗更多的时间; | 由于结构的改变,取消了Segment数组,只需要一次hash操作即可定位到Node数组的某个桶位。 |
写操作 | 除了需要定位两次之外,由于数据结构比较简单,因此整体而言比较简单。每一次的写锁住一个Segment位,但是下面可能有多桶位,虽然最终只会写某一个桶位,却可能会影响其他桶位的写。但是所有桶位只需要获取一次Segment锁即可。 | 每一次只会锁住一个桶位,不影响其他桶位的写。另外,由于采用了更加复杂的红黑树结构,因此写操作需要考虑更多的可能性,比如链表转红黑树、比如树还原为链表,如果在写红黑树时除了获取基于该桶位的synchronized锁,还需要再获取一个基于该桶位的读写锁中的写锁,然后才能继续操作,更加复杂。 |
读操作 | 除了需要定位两次之外,不会加锁,因为内部的val和next属性使用volatile修饰,因此保证每次读取都能获取到最新的数据 | 只需要定位一次,也不需要加锁,因为内部的val和next属性使用volatile修饰,因此保证每次读取都能获取到最新的数据。 但是,不需要加锁不代表一定不会加锁,在读取的桶位是红黑树的时候,由于红黑树结构的特性,会因为节点增删而发生较大改变,因此如果在读红黑树读的时候,该桶位的写锁没被获取 或者 没有线程在等待获取该桶位的写锁,那么读线程会尝试获取该桶位的读锁,因为获取了读锁之后写线程就不能获取写锁,红黑树结构便不会发生较大改变,此时读线程就能使用红黑树的方式去遍历这个桶位,读的效率更高。ConcurrentHashMap中读写锁的具体关系和代码实现在上面的章节已经介绍了。 |
锁的选择 | 采用Lock锁(Segment继承ReentrantLock) | 采用synchronized锁,之所以在更高级JDK版本使用更加原始的锁机制,是因为现在synchronized的优化已经非常好了,比如锁升级优化,性能和Lock锁相差无几,并且Lock锁采用了Java实现(底层的唤醒、阻塞等仍然采用了JVM实现),会消耗更高的内存空间。 |
size操作 | 每一个Segment中具有一个count的volatile类型的变量,用于统计每一个Segment的元素数量,在调用size方法时,并不是简单的将所有Segment的count相加,那样可会导致得到的结果和真正的数量不一致,但也不是在统计的时候,停掉所有的写操作,那样会导致性能较低。 先尝试最多3次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,没有变化则直接返回,如果容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。判断count是否发生变化的方式是使用modCount变量,该变量用于记录ConcurrentHashMap内部的哈希表结构改变次数,每次put、remove等操作成功之后modCount自增一。 因此size方法至少统计两次。即使是在最后不得已使用了加锁的方式,最终返回的结果和此时的实际数量仍然可能不一致,因为是堆每一个Segment一次加锁统计,一个Segment统计完成之后就放开了去统计下一个Segment,此时写线程就可以去已经统计的Segment中获取锁并操作数据。 |
采用一个baseCount变量和一个counterCells数组来计数,当CAS更新baseCount变量出现线程竞争的时候,就会初始化一个counterCells数组来计数,这里的counterCells就相当于一个LongAdder,用于降低并发更新数值的时候发生冲突的概率。最终的size方法会将baseCount和counterCells数组元素的和相加,仍然获得一个近似值,因为JDK1.8的size操作根本就没有加锁。并且提供了一个更加精准的mappingCount方法用于处理总量超过int范围的情况,因此推荐使用mappingCount计数。相比于JDK1.7版本的繁琐的计数方式,JDK1.8的size方法的性能得到了极大的提升,但是也牺牲了准确度。 |
其他 | 类似于JDK1.8的HasmMap相对于JDK1.7的HashMap的优点。 比如扰动算法,JDK1.8的版本更加精简,比如尾插法,比如使用规律转移数据 |
JDK1.8的ConcurrentHashMap相比于JDK1.7的版本无论是数据结构还是代码都发生了较大的改变。如果没有特别多的时间,建议学习JDK1.8的版本,有时间的话两个版本的源码都看看也行。
在学习JDK1.8的ConcurrentHashMap的源码之前,我觉得可以先学习JDK1.8的HashMap的源码,因为它们的数据结构其实是一样的,但是ConcurrentHashMap多了并发的考虑和同步的处理,并且HashMap对于以前版本的优化基本上都用在JDK1.8的ConcurrentHashMap中了,比如尾插法、扰动算法的精简、数据转移的规律等等,先学习HashMap的源码能让我们在学习ConcurrentHashMap的源码时更加的轻松。本文中对于某些和HashMap同样原理的代码并没有深入分析,具体的分析在HashMap的文章中。
阅读HashMap的源码时不需要考虑多线程,而ConcurrentHashMap的某些代码在单线程下看起来是多余的,但是在并发环境下却是必须的,所以我们在阅读ConcurrentHashMap的时候,需要时刻的想象着多线程的环境会发生什么情况。
JDK1.8的ConcurrentHashMap总共有超过六千行代码(JDK1.7的ConcurrentHashMap只有一千多行代码),具有超过五十个内部类,可以想象其内部结构的复杂度,称其作为JUC中的collections部分的精华也不为过。本文所谓的深度分析不过是浅尝辄止,但是已经让我有点头晕目眩了,这也不得让人感叹并发编程大师Doug Lea的精妙思想真不是一般人能够理解呀,愿诸君共勉,即使是咸鱼,也要做最咸的那条!
相关文章:
HashMap:Java集合—四万字的HashMap的源码深度解析与应用。
LockSupport:JUC—LockSupport以及park、unpark方法底层源码深度解析。
AQS:JUC—五万字的AbstractQueuedSynchronizer(AQS)源码深度解析与应用案例。
volatile:Java中的volatile实现原理深度解析以及应用。
CAS:Java中的CAS实现原理解析与应用。
UNSAFE:JUC—Unsafe类的原理详解与使用案例。
如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!