我对ConcurrentHashMap一无所知
上期讲到HashMap,很明显它里面的所有方法,都是不支持多线程抢占执行的,一旦在并发场景下,就可能会出现以下问题。
//环形链表问题
//在多个线程执行put时,假如同时触发了resize,而恰好一个线程停在了next = e.next位置,线程2此时来执行
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; // 线程一执行此处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
//线程2一股脑跑完了后,将链表的顺序倒置一遍(jdk1.7中是头插法),结果就导致了线程1中的next与e相互引用,从而导致进入死循环。
那么并发下有什么好的替换品呢?当然有,Hashtable与ConcurrentHashMap,这两个互为竞品,但显然hashtable效率不如ConcurrentHashMap,点进去一看,hashtable里面的方法基本是hashmap的方法加了synchronized关键字修饰,那么意味着凡是对hashtable进行修改的方法,都需要获取到锁,这里锁的粒度比较大,故而效率低下。
下面开始本篇重点内容ConcurrentHashMap 1.7源码解读。
成员属性
// node数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认初始值,必须是2的幕数
private static final int DEFAULT_CAPACITY = 16;
//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
private static int RESIZE_STAMP_BITS = 16;
// 2^15-1,help resize的最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// forwarding nodes的hash值
static final int MOVED = -1;
// 树根节点的hash值
static final int TREEBIN = -2;
// ReservationNode的hash值
static final int RESERVED = -3;
// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
//存放node的数组
transient volatile Node<K,V>[] table;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
*当为0时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小
*/
private transient volatile int sizeCtl;
构造方法
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//老规矩,上来先检查参数的合法性
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 找到比concurrencyLevel大且最接近它的2的N次幂
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;//调整到2的n次方
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;//这里相当于计算hash时&操作的值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//---------上面算出的是segment的长度-----
//---------下面要算每一个segment里面的hashEntry数量
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity) // 向上取整
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0] 这一步是精髓,在创建开始时,直接先创建一个segment[0] 方便后面创建segment的时候直接获取到segment里面的属性,如cap、size等
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]//
this.segments = ss;
}
scanAndLockForPut方法
将cpu的性能压榨到极致!
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
//如果尝试加锁失败,那么就对segment[hash]对应的链表进行遍历找到需要put的这个entry所在的链表中的位置,
//这里之所以进行一次遍历找到坑位,主要是为了通过遍历过程将遍历过的entry全部放到CPU高速缓存中,
//这样在获取到锁了之后,再次进行定位的时候速度会十分快,这是在线程无法获取到锁前并等待的过程中的一种预热方式。
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//获取锁失败,初始时retries=-1必然开始先进入第一个if
if (retries < 0) {//<1>
if (e == null) { //<1.1>
//e=null代表两种意思,第一种就是遍历链表到了最后,仍然没有发现指定key的entry;
//第二种情况是刚开始时确实太过entryForHash找到的HashEntry就是空的,即通过hash找到的table中对应位置链表为空
//当然这里之所以还需要对node==null进行判断,是因为有可能在第一次给node赋值完毕后,然后预热准备工作已经搞定,
//然后进行循环尝试获取锁,在循环次数还未达到<2>以前,某一次在条件<3>判断时发现有其它线程对这个segment进行了修改,
//那么retries被重置为-1,从而再一次进入到<1>条件内,此时如果再次遍历到链表最后时,因为上一次遍历时已经给node赋值过了,
//所以这里判断node是否为空,从而避免第二次创建对象给node重复赋值。
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))//<1.2> 遍历过程发现链表中找到了我们需要的key的坑位
retries = 0;
else//<1.3> 当前位置对应的key不是我们需要的,遍历下一个
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {//<2>
// 尝试获取锁次数超过设置的最大值,直接进入阻塞等待,这就是所谓的有限制的自旋获取锁,
//之所以这样是因为如果持有锁的线程要过很久才释放锁,这期间如果一直无限制的自旋其实是对系统性能有消耗的,
//这样无限制的自旋是不利的,所以加入最大自旋次数,超过这个次数则进入阻塞状态等待对方释放锁并获取锁。
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {//<3>
// 遍历过程中,有可能其它线程改变了遍历的链表,这时就需要重新进行遍历了。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
put方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//先尝试对segment加锁,如果直接加锁成功,那么node=null;如果加锁失败,则会调用scanAndLockForPut方法去获取锁,
//在这个方法中,获取锁后会返回对应HashEntry(要么原来就有要么新建一个)
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//这里是一个优化点,由于table自身是被volatile修饰的,然而put这一块代码本身是加锁了的,所以同一时间内只会有一个线程操作这部分内容,
//所以不再需要对这一块内的变量做任何volatile修饰,因为变量加了volatile修饰后,变量无法进行编译优化等,会对性能有一定的影响
//故将table赋值给put方法中的一个局部变量,从而使得能够减少volatile带来的不必要消耗。
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
//这里有一个问题:为什么不直接使用数组下标获取HashEntry,而要用entryAt来获取链表?
//这里结合网上内容个人理解是:由于Segment继承的是ReentrantLock,所以它是一个可重入锁,那么是否存在某种场景下,
//会导致同一个线程连续两次进入put方法,而由于put最终使用的putOrderedObject只是禁止了写写重排序无法保证内存可见性,
//所以这种情况下第二次put在获取链表时必须用entryAt中的volatile语义的get来获取链表,因为这种情况下下标获取的不一定是最新数据。
HashEntry<K,V> first = entryAt(tab, index);//先获取需要put的<k,v>对在当前这个segment中对应的链表的表头结点。
for (HashEntry<K,V> e = first;;) {//开始遍历first为头结点的链表
if (e != null) {//<1>
//e不为空,说明当前键值对需要存储的位置有hash冲突,直接遍历当前链表,如果链表中找到一个节点对应的key相同,
//依据onlyIfAbsent来判断是否覆盖已有的value值。
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
//进入这个条件内说明需要put的<k,y>对应的key节点已经存在,直接判断是否更新并最后break退出循环。
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;//未进入上面的if条件中,说明当前e节点对应的key不是需要的,直接遍历下一个节点。
}
else {//<2>
//进入到这个else分支,说明e为空,对应有两种情况下e可能会为空,即:
// 1>. <1>中进行循环遍历,遍历到了链表的表尾仍然没有满足条件的节点。
// 2>. e=first一开始就是null(可以理解为即一开始就遍历到了尾节点)
if (node != null) //这里有可能获取到锁是通过scanAndLockForPut方法内自旋获取到的,这种情况下依据找好或者说是新建好了对应节点,node不为空
node.setNext(first);
else// 当然也有可能是这里直接第一次tryLock就获取到了锁,从而node没有分配对应节点,即需要给依据插入的k,v来创建一个新节点
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1; //总数+1 在这里依据获取到了锁,即是线程安全的!对应了上述对count变量的使用规范说明。
if (c > threshold && tab.length < MAXIMUM_CAPACITY)//判断是否需要进行扩容
//扩容是直接重新new一个新的HashEntry数组,这个数组的容量是老数组的两倍,
//新数组创建好后再依次将老的table中的HashEntry插入新数组中,所以这个过程是十分费时的,应尽量避免。
//扩容完毕后,还会将这个node插入到新的数组中。
rehash(node);
else
//数组无需扩容,那么就直接插入node到指定index位置,这个方法里用的是UNSAFE.putOrderedObject
//网上查阅到的资料关于使用这个方法的原因都是说因为它使用的是StoreStore屏障,而不是十分耗时的StoreLoad屏障
//给我个人感觉就是putObjectVolatile是对写入对象的写入赋予了volatile语义,但是代价是用了StoreLoad屏障
//而putOrderedObject则是使用了StoreStore屏障保证了写入顺序的禁止重排序,但是未实现volatile语义导致更新后的不可见性,
//当然这里由于是加锁了,所以在释放锁前会将所有变化从线程自身的工作内存更新到主存中。
//这一块对于putOrderedObject和putObjectVolatile的区别有点混乱,不是完全理解,网上也没找到详细解答,查看了C源码也是不大确定。
//希望有理解的人看到能指点一下,后续如果弄明白了再更新这一块。
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
ensureSegment
这个方法也是一个很关键的方法,用来确保每次创建segment不会冲突。
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // 以初始化时创建的第一个坑位的ss[0]作为模版进行创建
int cap = proto.table.length; // 直接就获取到了segment里面hashEntry的大小以及计算好的loadFactor,细节!
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//通过unsafe类直接获取到ss里地址偏移量u位置的segement
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 第一次检查是否有其它线程创建了这个Segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 第二次检查是否有线程创建了这个segment
//这里通过自旋的CAS方式对segments数组中偏移量为u位置设置值为s,这是一种不加锁的方式,
//万一有多个线程同时执行这一步,那么只会有一个成功,而其它线程在看到第一个执行成功的线程结果后
//会获取到最新的数据从而发现需要更新的坑位已经不为空了,那么就跳出while循环并返回最新的seg
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
总结
源码看起来真累啊,顶不住了,先就到这吧,再往下深挖就需要把整个设计细节都弄明白,想想还是算了。核心部分的代码过了一遍,不得不再次感叹大哥李那惊为天人的实力!下一篇源码,咱们尝试一下reentretlock以及AQS源码。