温故而知新,再探ConcurrentHashMap
这里说的还是1.7的ConcurrentHashMap,在1.8中,ConcurrentHashMap已经不是基于segments实现了。
之前也知道ConcurrentHashMap是通过把锁加载各个segment上从而实现分段式锁来达到增加并发效率的,但是时间久了容易忘,这次再看了一下源码,记录一下以免再忘。为方便起见,把ConcurrentHashMap简称为CHM
首先看构造函数,有三个参数:initialCapacity,loadFactor,concurrencyLevel,前两个和HashMap是一样的,就是指定初始容量和扩容的比例参数,后面一个concurrencyLevel,从名字就可以看出来,这是指定并发数的。
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;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
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<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;
}
根据concurencyLevel,CHM会设置一个SSize,代表着segment的数量,并且这个数量很有意思,它是一个不小于concurrencyLevel的最小的2的k次方。另外还有一个sshift,这个值就是k,通过它计算segmentMask=32-sshift,通过运算hash>>>segmentMask找到相对于的hash值的高sshift位,高sshift位所能表达的方位恰好是0到ssize-1,也就是segment的数量。所以用hash>>>segmentMask的来找到key对应的segment编号简直就是天才设计啊。通过看put函数,就会发现真的是这样:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
看第6行,j就是segment的编号,先通过Unsafe判断Segment存在与否,如果存在,就用ensureSegment(j)去获取这个segment。
再来看ensureSegment:
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]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
我们再看segments,它是final的,所以它是多线程可见的,但是它的元素却不是。数组(或对象)为volatile,只能保证它的引用值是可见的,但是数组元素(或对象属性)却不是多线程可见的,这是java的设计缺陷。为了弥补这个缺陷,在上面的第5行用了 UNSAFE.getObjectVolatile(ss, u) 来补坑。
final Segment<K,V>[] segments
找到了segment之后,接下来就是调用segment的put方法把数据插入到segment里面了。看segment的put函数,第一行居然有一个trylock(),而且根据获取锁的结果进行了不同的操作。因为Segment类继承了ReetrantLock,所以锁的就是它本身。如果tryLock成功获取到写入锁,node就设为Null,然后进入正常的流程。如果获取锁失败,那么就要先进入scanAndLockForPut操作。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
我们接下来看tryLock失败之后会发生什么。我们知道,CHM在读取操作是不加锁,但是在写入操作的时候是一定要加锁的,但是呢,真的是一开始就加锁吗?仔细观察scanAndLockForPut函数,发现不是这样的。scanAndLockForPut函数有个循环,以自旋锁的方式一直在尝试获取锁,如果不成功就循环下去。但是就这样一直循环下去吗?也不是,可以看下面,有个retries参数,每循环一次,这个reties参数就加1。从19行到22行可知,当达到MAX_SCAN_RETRIES次数之后,就会直接调用lock(),自旋锁变成了重量级锁。而且我们可以看到,在自旋的过程中,scanAndForput并不是什么都没干的,它总是在干一件事儿:给node赋值。如果node为null,就生成新对象。如果找到了key相等的节点,则指向找到的节点。并且在这个过程中,每隔一步((retries & 1) == 0,不明白为什么要在偶数次检查)检查一次table是否已经变了,如果变了,则需要重新找node(把制为retries = -1;,是的重新进入retries < 0逻辑),开始重新计算retries。也就是说如果有线程改变了当前segment,则继续等待MAX_SCAN_RETRIES次。
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
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {//当达到MAX_SCAN_RETRIES次数之后,就会直接调用lock(),自旋锁变成了重量级锁。
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
然后在回到put函数。put函数也很妙,我们直接以注解的方式来说明:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//先尝试获取锁,如果获取失败,则进入scanAndLockForPut函数,并在scanAndLockForPut函数中尝试以自旋的方式获取锁。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
//计算hash桶的位置
int index = (tab.length - 1) & hash;
//获取到第一个节点
HashEntry<K,V> first = entryAt(tab, index);
//遍历hash桶中的HashEntry
for (HashEntry<K,V> e = first;;) {
if (e != null) {//如果没有遍历完
K k;
//如果找到了相同的key,则根据onlyIfAbsent判断是替换值还是不做任何操作,并且结束遍历
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {//如果遍历到了头,则检查是否在scanAndLockForPut已经获取了node,如果是,则设置node的next为当前的first
if (node != null)
node.setNext(first);
else//如果scanAndLockForPut没有获取node,则新建node,注意,node的next还是first。
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//如果超过了阈值,则先进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else//最后,把node放入table中。可见,新插入的node总是位于链表的最前端的
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
可以看到,新加入的HashEntry总是位于链表的最前端。这是和HashMap的区别之一,HashMap新加入的节点是挂在链表的最后端的。
接下来看扩容,扩容位于函数rehash中,因为扩容函数比较复杂,所以我们依然用注解的方式逐条说明:
private void rehash(HashEntry<K,V> node) {
/*
* Reclassify nodes in each list to new table. Because we
* are using power-of-two expansion, the elements from
* each bin must either stay at same index, or move with a
* power of two offset. We eliminate unnecessary node
* creation by catching cases where old nodes can be
* reused because their next fields won't change.
* Statistically, at the default threshold, only about
* one-sixth of them need cloning when a table
* doubles. The nodes they replace will be garbage
* collectable as soon as they are no longer referenced by
* any reader thread that may be in the midst of
* concurrently traversing table. Entry accesses use plain
* array indexing because they are followed by volatile
* table write.
*/
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;
//重新计算扩容阈值
threshold = (int)(newCapacity * loadFactor);
//新建Table
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//从新计算掩膜,掩膜的作用就是计算索引值,利用table长度是2的整数次方(k)这一特性,直接区hash值的低k位就是索引值
int sizeMask = newCapacity - 1;
//遍历处理table上的每一个hash桶
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
//如果当前hash桶不为空,则需要处理
HashEntry<K,V> next = e.next;
//计算新桶在新table中的位置
int idx = e.hash & sizeMask;
//如果当前hash桶只有一个节点,直接把他放到新桶中就可以了
if (next == null) // Single node on list
newTable[idx] = e;
//否则的话,就需要遍历当前桶的链表
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
//找到第一个后续元素的新位置都不变的节点,然后把这个节点当成头结点,直接把后续的整个链表都放入新table中
//因为table的容量总是2的k次方,而且每次扩容都是容量乘以2,也就是segmentMask会增加1位,那么,节点的新桶在
// 新table中的位置要么还是老位置,要么增加了一个oldCapacity,具体要看新增的这一位上key的hash值是否为1. for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//直接把整个连续的位置不变的节点组成的链表加入到新桶中
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//把剩下的节点(都在搬迁链表的前端)一个个放入到新桶中。
//同样,每次都是加入到桶的最前端
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
//扩容完成之后,在新的table中插入要put的节点
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
扩容后的节点的桶位置要么在和原来一样,要么就是增加一个oldCapacity。容量选为2的k次方的优势在rehash中再次得到体现。
最后再来看remove操作。因为1.7中HashEntry的next已经不是final而是volatile的了,所以remove操作改变比较大。在之前,因为无法改变一个HashEntry的next指针,所以只能把欲删除的节点之前的节点全部new一遍,并且会发生转向。而1.7中,可以直接改变next的值,从而实现节点的删除。
显式看remove函数。很简单,就是找到对应的segment,然后调用segment的remove函数。
public boolean remove(Object key, Object value) {
int hash = hash(key);
Segment<K,V> s;
return value != null && (s = segmentForHash(hash)) != null &&
s.remove(key, hash, value) != null;
}
接下来看segment的remove函数,这个函数仍然是以注释的方式加以说明。
final V remove(Object key, int hash, Object value) {
if (!tryLock())
//自旋尝试获取锁
scanAndLock(key, hash);
V oldValue = null;
//获取锁之后
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
//取得头结点
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
//如果已经找到节点
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
//并且值相等,或者传入的值为null(说明是删除指定的key),则删除
if (value == null || value == v || value.equals(v)) {
//如果是找到的节点头结点,则将next节点存入table中
if (pred == null)
setEntryAt(tab, index, next);
else
//否则让pred节点指向next节点,使当前节点删除
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
//否则什么都不做,直接跳出循环
break;
}
//继续找下一个节点
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
发现remove函数里面也有一个类似于put函数中的scanAndLockForput函数的函数,叫做scanAndLock。但是很奇怪,这个函数只做自旋操作,却并不传递出任何只,其实完全可以在自旋的过程中找到prev和e的,而不是非要等到获取到锁之后再进行,不知道1.8中有没有改进(但是1.8已经没有segment了),有时间去看看1.8中怎么实现的。
/**
* Scans for a node containing the given key while trying to
* acquire lock for a remove or replace operation. Upon
* return, guarantees that lock is held. Note that we must
* lock even if the key is not found, to ensure sequential
* consistency of updates.
*/
private void scanAndLock(Object key, int hash) {
// similar to but simpler than scanAndLockForPut
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
int retries = -1;
while (!tryLock()) {
HashEntry<K,V> f;
if (retries < 0) {
if (e == null || key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1;
}
}
}
再看clear。clear函数很简单,先是通过segments获取所有的segments,然后迭代删除:
public void clear() {
final Segment<K,V>[] segments = this.segments;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> s = segmentAt(segments, j);
if (s != null)
s.clear();
}
}
每个segment的clear也很简单,就是获取table,然后让hash桶变成null:
final void clear() {
lock();
try {
HashEntry<K,V>[] tab = table;
for (int i = 0; i < tab.length ; i++)
setEntryAt(tab, i, null);
++modCount;
count = 0;
} finally {
unlock();
}
}
再看size函数和isEmpty函数,也很有意思。他们都通过多次遍历segments,并比较各趟遍历的modCount之和,看是否变化,一次判断是否在调用size和isEmpty的时候是否发生了更改。
isEmpty之需循环遍历两趟,sum先在第一趟遍历中累加segment.modCount,在第二趟遍历时减掉各modCount,如果最终等于0,则说明两趟之间各modCount都没有变化,说明是准确的。如果这两趟遍历中,有一个segment不是Empty的,就会返回false。如果没有一个segment不是空的,最终就会返回true。但是很明显,这种方式有个缺点,他不能检测ABA问题,比如有一个segment增加了一个节点,有删除了一个节点,这是它还是空的,但是modCount却发生了变化,此时isEmpty就会返回错误的结果。另外,采用同样方式的还有containsValue操作,但是containsKey操作不是这样的,所以containsKey是不可靠的,很奇怪为什么要这么处理。
public boolean isEmpty() {
/*
* Sum per-segment modCounts to avoid mis-reporting when
* elements are concurrently added and removed in one segment
* while checking another, in which case the table was never
* actually empty at any point. (The sum ensures accuracy up
* through at least 1<<31 per-segment modifications before
* recheck.) Methods size() and containsValue() use similar
* constructions for stability checks.
*/
long sum = 0L;
final Segment<K,V>[] segments = this.segments;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
if (seg.count != 0)
return false;
sum += seg.modCount;
}
}
if (sum != 0L) { // recheck unless no modifications
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
if (seg.count != 0)
return false;
sum -= seg.modCount;
}
}
if (sum != 0L)
return false;
}
return true;
}
size不是遍历两趟,而是多趟,如果有两趟之间modCount之和没有变化,则返回遍历的累加结果。否则继续遍历,当达到一定次数之后发现变化还是稳定不下来,就会对segment加锁遍历。
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
温故而知新,再探ConcurrentHashMap的更多相关文章
- 【再探backbone 02】集合-Collection
前言 昨天我们一起学习了backbone的model,我个人对backbone的熟悉程度提高了,但是也发现一个严重的问题!!! 我平时压根没有用到model这块的东西,事实上我只用到了view,所以昨 ...
- ViewPager+Fragment再探:和TAB滑动条一起三者结合
Fragment前篇: <Android Fragment初探:静态Fragment组成Activity> ViewPager前篇: <Android ViewPager初探:让页面 ...
- 再探jQuery
再探jQuery 前言:在使用jQuery的时候发现一些知识点记得并不牢固,因此希望通过总结知识点加深对jQuery的应用,也希望和各位博友共同分享. jQuery是一个JavaScript库,它极大 ...
- [老老实实学WCF] 第五篇 再探通信--ClientBase
老老实实学WCF 第五篇 再探通信--ClientBase 在上一篇中,我们抛开了服务引用和元数据交换,在客户端中手动添加了元数据代码,并利用通道工厂ChannelFactory<>类创 ...
- Spark Streaming揭秘 Day7 再探Job Scheduler
Spark Streaming揭秘 Day7 再探Job Scheduler 今天,我们对Job Scheduler再进一步深入一下,对一些更加细节的源码进行分析. Job Scheduler启动 在 ...
- 再探ASP.NET 5(转载)
就在最近一段时间,微软又有大动作了,在IDE方面除了给我们发布了Viausl Studio 2013 社区版还发布了全新的Visual Studio 2015 Preview. Visual Stud ...
- 再探java基础——break和continue的用法
再探java基础——break和continue的用法 break break可用于循环和switch...case...语句中. 用于switch...case中: 执行完满足case条件的内容内后 ...
- 第四节:SignalR灵魂所在Hub模型及再探聊天室样例
一. 整体介绍 本节:开始介绍SignalR另外一种通讯模型Hub(中心模型,或者叫集线器模型),它是一种RPC模式,允许客户端和服务器端各自自定义方法并且相互调用,对开发者来说相当友好. 该节包括的 ...
- 深入出不来nodejs源码-内置模块引入再探
我发现每次细看源码都能发现我之前写的一些东西是错误的,去改掉吧,又很不协调,不改吧,看着又脑阔疼…… 所以,这一节再探,是对之前一些说法的纠正,另外再缝缝补补一些新的内容. 错误在哪呢?在之前的初探中 ...
随机推荐
- C# 中使用using的三种方法
1.using指令 using+命名空间,这种方法基本学习过C#的都用过,好处在于,写代码的时候不需要指定详细的命名空间 using System.Windows.Media; using Syste ...
- laravel中chunk方法使用外部变量以及改变该变量
- 不同数据库表结构的转化,PowerDesigner的使用教程
通过学习PowerDesigner工具,学习概念模型,物理模型,面向对象模型,业务模型,以及不同数据库表结构的转化. 通过案例给大家分享,sql server 2008r2 数据库和oracle数据库 ...
- [译] 关于 SPA,你需要掌握的 4 层 (1)
此文已由作者张威授权网易云社区发布. 欢迎访问网易云社区,了解更多网易技术产品运营经验. 我们从头来构建一个 React 的应用程序,探究领域.存储.应用服务和视图这四层 每个成功的项目都需要一个清晰 ...
- C# LINQ(3)
我们还是接着讨论一下group by 这一章节讨论group的本质:分组. 分组之后进行存储或者查询. 这个时候就要用一个新的关键字:into 这个之后就group就不作为结尾了. 必须重写另起sel ...
- Hibernate学习第4天--HQL——QBC查询详解,抓取策略优化。
上次课回顾: l Hibernate的一对多 n 表与表之间关系 u 一对多关系 u 多对多关系 u 一对一关系 n Hibernate的一对多配置 u 搭建Hibernate基本环境 ...
- session的获取
Springmvc: RequestAttributes ra = RequestContextHolder.getRequestAttributes(); HttpServletRequest re ...
- Agreement has been updated--Edit Phone Number最便捷解决办法(不需要安全提示问题和双重认证)
2018年06月04日亲测有效: CSDN博客跳转网址:
- 分享记录一批免费VIP视频解析接口,不定时更新!
VIP视频接口的作用相信大家都懂,那么,由于接口的维护.开发具有不稳定性,失效率很高.这里收集一些目前可用的接口,如果不能用,请反馈给我删除,感谢大家! 电影<西虹市首富>优酷链接:htt ...
- sqoop常用语句
1,列出全部数据库 sqoop list-databases --connect jdbc:sqlserver://10.10.10.2 --username sa --password 1 2,导 ...