ConcurrentHashMap1.7深度解析
1.ConcurrentHashMap原型图
1.1 总结
1.1.1 分段锁定
从原型图我们可以看出concurrentHashMap1.7是采用的分段锁定,也就是对每个segment进行上锁,简单来说,ConcurrentHashMap的锁的颗粒度会在每个分段(segment)上。
不同的Segment:读读不需要互斥,读写不需要互斥
相同的Segment:读读不需要互斥,读写则需要互斥
1.1.2 那如何计算Segment数量和HashEntry的大小呢?
从这张图可以看出Segment的数量等于2^X >= concurrencyLevel,concurrencyLevel并发级别,也就是说Segment的数量是最接近concurrencyLevel的2的幂次方的值
每个Segment中应该有多少个HashEntry呢?图中也给出了答案:
HashEntry[size=2^X >= (initalCapacity/concurrencyLevel)],也就是最接近(初始化容量/并发级别)的2的幂次方的值
从图中我们也可以看到ConcurrentHashMap1.7的数据结构,他的Entry数组是分布在每个Segment中的
计算Demo
ConcurrentHashMap map = new ConcurrentHashMap(64, 0.75f, 10)
Segment array size = 2^x > concurrencyLevel => 24 ≥ 10 => 16
HashEntry[] array size = 2^x ≥ ( initialCapacity / concurrencyLevel ) => 23 ≥ (64 / 10)=> 8
1.1.3 存储大致过程
首先计算要存放在哪个Segment的下标
找到对应的Segment后,计算存储该Segment中的table[index]的位置
完成相应更新或者插入的操作
2.作者对ConcurrentHashMap的阐述
/**
* 一个哈希表,支持全并发的检索和更新。
*可调整更新的预期并发性。这个类服从于
* 与{@link java.util.Hashtable}的功能规范相同,并且
* 包括了对应于以下每个方法的版本
* <tt>Hashtable</tt>。然而,尽管所有操作都是
* 线程安全的,但检索操作不需要锁定。
<em>没有</em>对锁定整个表的任何支持
* 的方式来阻止所有的访问。 这个类是完全
* 在依赖其线程安全但不依赖其同步的程序中,该类完全可以与<tt>Hashtable</tt>互操作。
* 线程安全,但不依赖其同步细节。
*
* <p> 检索操作(包括<tt>get</tt>)通常不
* 块,所以可能与更新操作(包括
<tt>put</tt>和<tt>remove</tt>)。检索反映的是
* 最近的<em>完成的</em>更新操作的结果。
*在他们开始时。 对于像<tt>putAll</tt>这样的聚合操作
* 和<tt>clear</tt>,同时进行的检索可能反映了插入或
* 只删除一些条目。 同样地,迭代器和
* 枚举返回反映哈希表状态的元素
* 在迭代器/枚举创建时或创建后的某个点。
* 他们不<em>不</em>抛出{@link ConcurrentModificationException}。
* 然而,迭代器被设计为一次只能由一个线程使用。
*
* <p> 更新操作中允许的并发性是由以下因素指导的
* 可选的<tt>concurrencyLevel</tt>构造函数参数
* (默认<tt>16</tt>),它被用作内部大小的提示。 该
* 表是内部分区的,以尝试允许指定的
* 表的内部分区,以尝试允许指定数量的并发更新而不发生争执。因为放置
* 在哈希表中的位置基本上是随机的,实际的并发性会有所不同
* 变化。 理想情况下,你应该选择一个值来容纳尽可能多的
* 线程同时修改该表。使用一个
* 显著高于你需要的值会浪费空间和时间。
* 显著较低的值会导致线程争用。但是
* 在一个数量级内的高估和低估通常不会有太大的影响。
* 通常不会有什么明显的影响。一个1的值是
* 当我们知道只有一个线程会修改,而其他线程只会读取时,1的值是合适的。
* 所有其他的线程都只读。此外,调整这个或任何其他类型的
* 哈希表是一个相对较慢的操作,所以,在可能的情况下,最好提供一个估计值。
* 在构造函数中提供预期表大小的估计是一个好主意。
* 构造函数中提供预期的表大小。
*
<p>这个类和它的视图和迭代器实现了所有的
* <em>optional</em>方法的{@link Map}和{@link Iterator}。
* 接口。
*
* <p> 与{@link Hashtable}一样,但与{@link HashMap}不同,这个类
<em>不</em>允许<tt>null</tt>作为键或值。
*
* <p>这个类是一个
* <a href="{@docRoot}/.../technotes/guides/collections/index.html">的成员。
* Java集合框架</a>。
*
* @自1.5以来
* @作者 Doug Lea
* @param <K> 这个地图所维护的键的类型
* @param <V> 映射的值的类型
*/
总结
在上面作者提到concurrentHashMap是线程安全的,只有在写的时候加锁,读的时候是不加锁的
这里还提到的了这个concurrencyLevel,并发等级默认是16,也就是并发数,使用一个显著高于你需要的值会浪费空间和时间。显著较低的值会导致线程争用。但是在一个数量级内的高估和低估通常不会有太大的影响。
3.ConcurrentHashMap中定义的常量
/**
* 该表的默认初始容量。
* 当构造函数中没有另外指定时使用。
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 该表的默认负载因子,在构造函数中没有指定时使用。
* 另外在构造函数中指定时使用。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 该表的默认并发级别,在构造函数中没有指定时使用。
* 另外在构造函数中指定时使用。
*/
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 最大容量,如果更高的值被隐含在构造函数中,则使用该值。
* 构建器的参数指定了最大容量。 必须是
* 必须是2的幂<=1<<30,以确保条目可以被索引。
*使用ints。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 每段表的最小容量。 必须是二的幂
* 的幂,至少是2,以避免下次使用时立即调整大小。
*在懒惰的构造之后。
*/
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
/**
* 允许的最大段数;用于绑定
*构造函数参数。必须是小于1<<24的2次方。
*/
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
/**
* 大小和containsValue中非同步重试的次数
* 方法的非同步重试次数,然后再进行锁定。这是用来避免
* 如果表被不断地修改,这将导致无法获得准确的结果。
* 这将使我们无法获得一个准确的结果。
*/
static final int RETRIES_BEFORE_LOCK = 2;
/**
* 启用字符串键的替代散列?
*
<p>与其他哈希图的实现不同,我们没有实现一个
* 阈值来调节是否对字符串键使用替代散列。
* 字符串键。替代散列法要么对所有实例启用
* 或者对所有实例禁用。
*/
static final boolean ALTERNATIVE_HASHING;
/**
* 用于索引到段的掩码值。一个键的哈希码的上位数被用来选择段。
* 键的哈希代码用来选择段。
*/
final int segmentMask;
/**
*用于段内索引的移位值。
*/
final int segmentShift;
/**
*段,每个段都是一个专门的哈希表。
*/
final Segment<K,V>[] segments;
这里我们可以看到MIN_SEGMENT_TABLE_CAPACITY=2,作者也作了解释,避免下次使用时立即调整大小,因为扩容消耗性能,所以这个细节作者也告诉了我们
4.我们认识一下HashEntry
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
/**
* Sets next field with volatile write semantics. (See above
* about use of putOrderedObject.)
*/
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}
// Unsafe mechanics
static final sun.misc.Unsafe UNSAFE;
static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
5.构造方法
5.1 无参构造
/**
*创建一个新的、空的地图,具有默认的初始容量(16)。
* 负载因子(0.75)和并发级别(16)。
*/
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
initialCapacity=16
loadFactor=0.75
concurrencyLevel=16
/**
*创建一个新的、空的地图,并指定初始
*容量、负载系数和并发水平。
*
* @param initialCapacity 初始容量。该实现
* 执行内部尺寸以容纳这么多的元素。
* @param loadFactor 负载系数阈值,用于控制大小调整。
* 当每一个元素的平均数量超过这个阈值时,就会进行调整大小。
* bin超过这个阈值时,可以进行调整。
* @param concurrencyLevel 并发的估计数量
* 更新的线程数。该实现会执行内部大小调整
* 以尝试容纳这么多线程。
*如果初始容量是负的,或者负载系数或并发数是负的,则抛出IllegalArgumentException。
* 负数,或者负载因子或并发级别是
* 非正数。
*/
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;
//就是Segment数组的大小
int ssize = 1;
//这一步是保证并发级别concurrencyLevel是2的幂次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//假设concurrencyLevel=16
//sshift=4
//ssize=16
//segmentShift=28
//segmentMask=15
this.segmentShift = 32 - sshift;//偏移量
this.segmentMask = ssize - 1;//掩码
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//初始化容量/segment数组的大小=平均每个segment中的表格数
//c = 16/16 = 1
int c = initialCapacity / ssize;
//这里为的是向上取整,比如initialCapacity传进来的是17那17/16=1,很明显有一个容量被 //漏掉了这很不合理,所以才会有下面这个操作,1*16<17 所以c=c+1=2,保证每个segment中 //的表格数够用
if (c * ssize < initialCapacity)
++c;
//当前容量=MIN_SEGMENT_TABLE_CAPACITY=2,这里就说了,每个segment中最小表格容量 //为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
//必须保证每个segment中的表格容量是2的幂次方
while (cap < c)
cap <<= 1;
// create segments and segments[0]
//创建Segments[0]位置上的Segment对象,其实就是创建Segments数组上首位的Segment
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
//这里我们可以看到,只要Segment数组初始化后,不可以在扩容,一经创建,不可更改
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//有序的写入Segment数组中,并且是s[0]位置
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
//给全局的segments赋值
this.segments = ss;
}
从这里我们可以得到几个重要的信息
默认情况下concurrencyLevel=16
默认情况下Segment数组的大小为16
默认情况下每个Segment中table大小容量为2,所以Segments.size=16,tableTotal=2*16=32
在初始化ConcurrentHashMap时,Segments[0]位置的Segment对象也同时被创建了出来,至于为什么要把首位置的Segment创建出来我们往下看
Segment数组的大小必须是2的幂次方
每个Segment中table容量的大小也必须是2的幂次方
计算Demo
public static void main(String[] args) {
int concurrencyLevel = 16;
int sshift = 0;
int ssize = 1;
//这一步是保证并发级别concurrencyLevel是2的幂次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
int segmentShift = 32 - sshift;
int segmentMask = ssize - 1;
System.out.println("sshift="+sshift);
System.out.println("ssize="+ssize);
System.out.println("segmentShift="+segmentShift);
System.out.println("segmentMask="+segmentMask);
}
sshift=4
ssize=16
segmentShift=28
segmentMask=15
public static void main(String[] args) {
int concurrencyLevel = 15;
int sshift = 0;
int ssize = 1;
//这一步是保证并发级别concurrencyLevel是2的幂次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
int segmentShift = 32 - sshift;
int segmentMask = ssize - 1;
System.out.println("sshift="+sshift);
System.out.println("ssize="+ssize);
System.out.println("segmentShift="+segmentShift);
System.out.println("segmentMask="+segmentMask);
}
sshift=4
ssize=16
segmentShift=28
segmentMask=15
可以看到这里的四个结果并没有因为concurrencyLevel=15而改变,就是因为在while循环那里的操作就是为了ssize必须是2的幂次方的值
6.我们认识一下Segment对象
/**
* 段落是哈希表的专门版本。 这
* 机会性地从ReentrantLock子类化,只是为了
* 简化了一些锁,避免了单独的构造。
*/
static final class Segment<K,V> extends ReentrantLock implements Serializable {
/*
* 分段维护一个条目列表表,该表总是
* 保持一致的状态,因此可以读取(通过易失性的
*段和表的易失性读取),而无需锁定。 这
* 需要在必要的时候在表上复制节点
* 调整大小,所以旧的列表可以被读者遍历
*仍然使用旧版本的表。
*
* 这个类只定义了需要锁定的突变方法。
* 除了注意到的以外,这个类的方法执行的是
* ConcurrentHashMap方法的每段版本。 (其他
* 方法被直接集成到ConcurrentHashMap
* 方法)。这些突变的方法使用了一种控制的形式
* 通过方法scanAndLock和
* scanAndLockForPut。这些方法穿插了tryLocks和
* 遍历来定位节点。 其主要好处是吸收
* 缓存缺失(这对哈希表来说是非常常见的),同时
* 获得锁,这样一旦获得锁,就可以更快地进行遍历。
* 获得锁。我们实际上并不使用找到的节点,因为它们
* 反正必须在锁下重新获得,以确保更新的顺序性
* 更新的一致性(而且在任何情况下都可能是无法察觉的
* 陈旧),但它们通常会更快地被重新定位。
* 另外,scanAndLockForPut会推测地创建一个新的节点
* 如果没有找到节点,就会在put中使用。
*/
private static final long serialVersionUID = 2249069246763182397L;
/**
* 在预扫描中尝试锁定的最大次数。
* 可能会在获取时阻塞,以准备进行锁定的
* 段的操作。在多处理器上,使用一个有约束的
* 重试的次数可以保持定位时获得的高速缓存。
* 节点。
* 多核:64
* 单核:1
*/
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
/**
* 每段表。元素通过以下方式访问
* entryAt/setEntryAt提供不稳定的语义。
*/
transient volatile HashEntry<K,V>[] table;
/**
* 元素的数量。只能在锁内访问
* 或在其他保持可见性的易失性读取中。
*/
transient int count;
/**
* 本段中变异操作的总数。
* 尽管这可能会溢出32位,但它提供了
* 为CHM isEmpty()和size()方法的稳定性检查提供足够的准确性。
* 和size()方法的稳定性检查。 只在锁内或
* 在其他保持可见性的易失性读取中。
*/
transient int modCount;
/**
* 当表的大小超过这个阈值时,就会被重新洗牌。
* (这个字段的值总是<tt>(int)(capacity *)
* loadFactor)</tt>)。
*/
transient int threshold;
/**
* 哈希表的负载系数。 尽管这个值
* 对所有段都是一样的,但它被复制以避免需要
* 与外部对象的链接。
* @serial
*/
final float loadFactor;
/**
* lf:加载因子
* threshold:临界值
* table:数组
*/
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
/**
* put方法 concurrentHashMap.put()最终调用的就是Segment.put()
* key:键
* hash:hash值
* value:值
* onlyIfAbsent:是否更新值的开关
*/
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//这里使用的是tryLock()是非阻塞的加锁,lock()是阻塞加锁
//这一步就是确保拿到锁而且是堵塞加锁
//在阻塞加锁的时间内,作者还设计了一个巧妙while循环,在自旋获取锁的时间里做一下 //几点预热操作,因为无论怎样锁肯定会拿到的,那既然我们知道结果会拿到锁,我们拿到 //锁之后的一些操作能不能在获取锁的时间里提前获取到呢?答案肯定是能的,所以作者在 //while中做了以下几点:
//1.判断table[index]节点上是否已经存在该元素,如果存在,获取到锁后做更新操作就 //可以了,如果不存在,就node = new HashEntry,返回该node,然后获取到锁后直接插 //入即可
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
//旧的值
V oldValue;
try {
//当前的table数组
HashEntry<K,V>[] tab = table;
//计算存储的位置index
int index = (tab.length - 1) & hash;
//定位到第index位置上的HashEntry
HashEntry<K,V> first = entryAt(tab, index);
//遍历当前链表
for (HashEntry<K,V> e = first;;) {
//如果e!=null,就比较key是否相同
if (e != null) {
K k;
//key相同就做更新操作
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
//修改次数+1
++modCount;
}
//结束遍历
break;
}
e = e.next;
}
else {
//如果在获取锁之前,该node节点已经创建好了,直接插入到table中
if (node != null)
node.setNext(first);
else
// 初始化一个node,并放在first链表的头部
node = new HashEntry<K,V>(hash, key, value, first);
//table中的数量+1
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//因为count是对一个segment中的HashEntry节点个数的统计,
// 如果hash冲突严重,键值对只添加到HashEntry[]中某几个 //HashEntry链表中,
//就造成HashEntry[]有空闲位置,也会造成无味的扩容,内存利用 //率持续下降
//count 超过了阈值,默认是HashEntry<K,V>[]初始容量*0.75
// 扩容
rehash(node);
else
//插入到table中去
setEntryAt(tab, index, node);
//修改次数增加
++modCount;
//全局的容量更改
count = c;
oldValue = null;
break;
}
}
} finally {
//解锁
unlock();
}
//返回旧值
return oldValue;
}
/**
* 将表的大小增加一倍,并重新打包条目,同时在新表中加入
* 给定的节点到新表中
*/
@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
/*
*将每个列表中的节点重新分类到新表中。 因为我们
* 使用的是2次方扩展,所以每个bin中的元素
* 的元素必须保持在相同的索引上,或者以二的幂数移动。
* 二次幂偏移。我们消除了不必要的节点
* 我们通过捕捉旧节点可以被重用的情况来消除不必要的节点创建。
* 因为他们的下一个字段不会改变。
* 根据统计,在默认的阈值下,只有大约
* 六分之一的节点需要克隆,当一个表
* 倍增。它们所替代的节点将是垃圾
* 一旦它们不再被任何读者线程引用,就可以被收集。
* 任何读者线程可能正处于
* 并发地遍历表。条目访问使用普通
* 数组索引,因为它们后面是挥发性的
* 表的写入。
*/
//旧表格
HashEntry<K,V>[] oldTable = table;
//旧容量
int oldCapacity = oldTable.length;
//newCapacity = oldCapacity * 2^1,即为扩容为原来的2倍
int newCapacity = oldCapacity << 1;
//重新计算临界值
threshold = (int)(newCapacity * loadFactor);
//创建新的table容量为newCapacity=2*oldCapacity
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//掩码
int sizeMask = newCapacity - 1;
//我们需要将旧数组的元素转移到新数组中去
for (int i = 0; i < oldCapacity ; i++) {
//获取i位置上的链表元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
//next节点
HashEntry<K,V> next = e.next;
// 计算e在新数组中的位置
// 对newCapacity - 1 做&运算
int idx = e.hash & sizeMask;
//如果是单节点
if (next == null) // Single node on list
//next=null 说明是链表的最后一个节点了,直接赋值
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
//不是链表的最后一个节点,则需要寻找链表的最后一个元素
/** 从旧数组复制迁移节点到新数组。
这里又有一个追求极致性能的点,
按道理旧数组中的节点都需要重新哈希然后映射到新数组,
但是,作者做了一个小优化,
找到每条链表中最后一个与前一个节点哈希映射新数组下标不同的点,
称之为lastRun,这样就把一个链表截成了两半,
lastRun之后节点的哈希映射结果和lastRun相同,
所以只需要复制迁移lastRun节点即可,其后的节点可以顺带过去;
而lastRun前的节点则还需要一个个重新和新数组做哈希映射并复制*/
HashEntry<K,V> lastRun = e;
//最后一个索引位置
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//计算存储位置
int k = last.hash & sizeMask;
//如果当前的索引位置和前面的不同,说明这是该链表的一个分界点
if (k != lastIdx) {
//我们把最后一个index赋值给k
lastIdx = k;
//最后一个节点给lastRun
lastRun = last;
}
}
//将lastRun放在 newTable[lastIdx]位置
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//lastRun前面的节点重新计算新的存储位置,转移到新数组中去
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);
}
}
}
}
//将新元素添加到新数组中去
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 将新链表赋值给table, copy on write
// 只能保证最终一致性,不能保证实时一致性
table = newTable;
}
/**
* 扫描包含给定键的节点,同时试图
* 获取锁,如果没有找到,则创建并返回一个。如果没有找到,则返回。
* 返回时,保证锁被持有。与大多数
* 方法,对方法等价物的调用是不被筛选的。因为
* 遍历速度并不重要,我们还可以帮助预热
* 我们也可以帮助预热相关的代码和访问。
*
*如果没有找到键,则返回一个新的节点,否则为空。
*/
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//获取当前segment中要存储table位置的链表的首个HashEntry元素
//first=table[index]
HashEntry<K,V> first = entryForHash(this, hash);
//将该节点元素赋值给e
HashEntry<K,V> e = first;
//定义一个node节点
HashEntry<K,V> node = null;
//重试次数
int retries = -1; // negative while locating node
//这里采用的是自旋获取锁,在没有获取锁之前,我们可以先做一些事情,所以这里用了非 //阻塞枷锁的tryLock(),我们看到在没有获取锁之前循环体一直再尝试做一些事情,为了 //就是后期获取到锁后可以直接使用该数据
while (!tryLock()) {
//总是从链表的头部检索
HashEntry<K,V> f; // to recheck first below
//这里把retries当作了一个开关
if (retries < 0) {
//如果e=null说明table[index]位置是空的
if (e == null) {
//这里就创建table[index]位置的node元素 == 判断元素是否已经存 //在,不存在就创建
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
//table[index]位置的元素已经创建好了,就没必要重复创建了,所以 //retries=0,开关关闭
retries = 0;
}
//e=table[index]位置是有值的,那我们就判断key是否相同
else if (key.equals(e.key))
//如果key是相同的,那么就没必要重新查询了,关闭该开关
retries = 0;
else
//遍历该链表上下一个元素,继续判断该元素是否已经存在在该table中
e = e.next;
}
//充实次数已经达到上限,我们就使用阻塞加锁,必须保证获取到锁,获取到锁后结束 //该循环
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
// (retries & 1) == 0 偶数次检查头节点是否有修改
// 若first节点被修改了,则重置自旋重试机制,
// 为什么链表的第一个节点会变呢,是因为,新增元素时在头节点的位置添加。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
//返回该节点,可能为null,也可能不是,是新new的节点
return node;
}
}
entryForHash
/**
* 获取给定段和哈希值的表项
* 因为Segment已经找到,但是元素真正存放的位置是Segment的table中,这里就是获取要存储到 * table的哪个位置下面,返回该脚标链表的首个HashEntry元素
*/
static final <K,V> HashEntry<K,V> entryForHash(Segment<K,V> seg, int h) {
HashEntry<K,V>[] tab;
return (seg == null || (tab = seg.table) == null) ? null :
(HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
}
entryAt
/**
* 获取给定表的第i个元素(如果不是空的),并采用volatile
* 读取语义。注意:这被手动整合到一些
* 的方法中,以减少调用开销。
*/
@SuppressWarnings("unchecked")
static final <K,V> HashEntry<K,V> entryAt(HashEntry<K,V>[] tab, int i) {
return (tab == null) ? null :
(HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)i << TSHIFT) + TBASE);
}
7.ConcurrentHashMap.put()解析
/**
* 将指定的键映射到这个表中的指定值。
* 键和值都不能是空的。
*
<p>值可以通过调用<tt>get</tt>方法来获取。
* 用一个等于原始键的键来检索。
*
* @param key 密钥,指定的值将与之相关联。
* @param value 要与指定的键关联的值
* @return the previous value associated with <tt>key</tt>, or
* 如果没有<tt>key</tt>的映射,则为<tt>null</tt>。
* 如果指定的键或值为空,则抛出NullPointerException。
*/
public V put(K key, V value) {
//定义segment
Segment<K,V> s;
//键和值不允许为空
if (value == null)
throw new NullPointerException();
//计算key的哈希值
int hash = hash(key);
// 计算的hash向右移28位在与segmentMask作与操作,即以hash值的最高4位映射计算对应的Segment数组下标j
// 找到对应Segment的位置j,如果该位置的segment还未设置则需要先初始化
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//CAS 初始化segment
s = ensureSegment(j);
// segment的put内部加锁,所以是分段锁
return s.put(key, hash, value, false);
}
从注释中我们可以看出ConcurrentHashMap不允许键和值为空,否则会抛出NullPointerException异常
hash(key)
/**
* 在给定的hashCode上应用一个补充的哈希函数,这可以防止质量差的哈希函数。
* 防范质量差的哈希函数。 这一点至关重要
* 因为ConcurrentHashMap使用2次方长度的哈希表。
* 否则就会遇到哈希码的碰撞,而这些哈希码并没有
* 的哈希码的碰撞。
*/
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
这里作者重写了hash计算方法目的就是减少hash碰撞,让数据分布的更加均匀
添加元素时,首先对key进行哈希运算,
hash()
会对非字符串的key做一个补充哈希的处理(Wang/Jenkins hash
变体),使得哈希值的高位和低位不相同,减少哈希冲突。
总结
put的大致操作步骤如下
添加元素时,首先对key进行哈希运算,
hash()
会对非字符串的key做一个补充哈希的处理(Wang/Jenkins hash
变体),使得哈希值的高位和低位不相同,减少哈希冲突。然后对key的哈希值进行映射计算(
(hash >>> segmentShift) & segmentMask
)找到Segment
数组对应下标。key的哈希值是一个32位的数值,右移segmentShift位剩余高位和掩码segmentMask做&运算,这个过程相当于模运算((hash >>> segmentShift) % ssize),计算结果分布在[0,segmentMask]。
为什么还要对hash映射的下标做(j << SSHIFT) + SBASE))运算呢?
因为通过
UNSAFE.getObject
可以从主内存中获取最新的Segment,而这个方法需要知道Segment
在内存中的偏移量。同样计算主内存偏移量的方式在后续获取HashEntry
时也会用到。映射
Segment
数组下标的过程运用了与运算以及利用2的整数次方数值减1就是掩码的特性,而位运算要比通俗意义上的加减乘除的性能要高,这也是为什么Segment
数组的长度必须是2的整数次方的原因。可想而知作者在细枝末节上的极致性能追求。若找到的
segment
是空的,则先进行cas
初始化ensureSegment
,可以看出segments
数组是懒加载的
ensureSegment
/**
* 返回给定索引的段,创建它并记录在段表中(通过CAS)。
* 如果尚未存在,则记录在段表中(通过CAS)。
*
* @param k 指数
* @return the segment
*/
private Segment<K,V> ensureSegment(int k) {
//获取当前segment数组
final Segment<K,V>[] ss = this.segments;
//算出内存偏移量
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//如果当前的segment未被初始化
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 获取的seg为null则 开始初始化,获取0位置的segment,取出基本属性cap、lf、threshold
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
//table的容量
int cap = proto.table.length;
//加载因子
float lf = proto.loadFactor;
//临界值
int threshold = (int)(cap * lf);
//新建一个HashEntry数组,也就是table
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//重新检查,防止并发情况下,该segment为null
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//新建Segment对象
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//自旋cas设置segment,保证线程安全
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
Segment.put真正的put操作
/**
* put方法 concurrentHashMap.put()最终调用的就是Segment.put()
* key:键
* hash:hash值
* value:值
* onlyIfAbsent:是否更新值的开关
*/
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//这里使用的是tryLock()是非阻塞的加锁,lock()是阻塞加锁
//这一步就是确保拿到锁而且是堵塞加锁
//在阻塞加锁的时间内,作者还设计了一个巧妙while循环,在自旋获取锁的时间里做一下 //几点预热操作,因为无论怎样锁肯定会拿到的,那既然我们知道结果会拿到锁,我们拿到 //锁之后的一些操作能不能在获取锁的时间里提前获取到呢?答案肯定是能的,所以作者在 //while中做了以下几点:
//1.判断table[index]节点上是否已经存在该元素,如果存在,获取到锁后做更新操作就 //可以了,如果不存在,就node = new HashEntry,返回该node,然后获取到锁后直接插 //入即可
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
//旧的值
V oldValue;
try {
//当前的table数组
HashEntry<K,V>[] tab = table;
//计算存储的位置index
int index = (tab.length - 1) & hash;
//定位到第index位置上的HashEntry
HashEntry<K,V> first = entryAt(tab, index);
//遍历当前链表
for (HashEntry<K,V> e = first;;) {
//如果e!=null,就比较key是否相同
if (e != null) {
K k;
//key相同就做更新操作
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
//修改次数+1
++modCount;
}
//结束遍历
break;
}
e = e.next;
}
else {
//如果在获取锁之前,该node节点已经创建好了,直接插入到table中
if (node != null)
node.setNext(first);
else
// 初始化一个node,并放在first链表的头部
node = new HashEntry<K,V>(hash, key, value, first);
//table中的数量+1
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//因为count是对一个segment中的HashEntry节点个数的统计,
// 如果hash冲突严重,键值对只添加到HashEntry[]中某几个 //HashEntry链表中,
//就造成HashEntry[]有空闲位置,也会造成无味的扩容,内存利用 //率持续下降
//count 超过了阈值,默认是HashEntry<K,V>[]初始容量*0.75
// 扩容
rehash(node);
else
//插入到table中去
setEntryAt(tab, index, node);
//修改次数增加
++modCount;
//全局的容量更改
count = c;
oldValue = null;
break;
}
}
} finally {
//解锁
unlock();
}
//返回旧值
return oldValue;
}
scanAndLockForPut
/**
* 扫描包含给定键的节点,同时试图
* 获取锁,如果没有找到,则创建并返回一个。如果没有找到,则返回。
* 返回时,保证锁被持有。与大多数
* 方法,对方法等价物的调用是不被筛选的。因为
* 遍历速度并不重要,我们还可以帮助预热
* 我们也可以帮助预热相关的代码和访问。
*
*如果没有找到键,则返回一个新的节点,否则为空。
*/
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//获取当前segment中要存储table位置的链表的首个HashEntry元素
//first=table[index]
HashEntry<K,V> first = entryForHash(this, hash);
//将该节点元素赋值给e
HashEntry<K,V> e = first;
//定义一个node节点
HashEntry<K,V> node = null;
//重试次数
int retries = -1; // negative while locating node
//这里采用的是自旋获取锁,在没有获取锁之前,我们可以先做一些事情,所以这里用了非 //阻塞枷锁的tryLock(),我们看到在没有获取锁之前循环体一直再尝试做一些事情,为了 //就是后期获取到锁后可以直接使用该数据
while (!tryLock()) {
//总是从链表的头部检索
HashEntry<K,V> f; // to recheck first below
//这里把retries当作了一个开关
if (retries < 0) {
//如果e=null说明table[index]位置是空的
if (e == null) {
//这里就创建table[index]位置的node元素 == 判断元素是否已经存 //在,不存在就创建
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
//table[index]位置的元素已经创建好了,就没必要重复创建了,所以 //retries=0,开关关闭
retries = 0;
}
//e=table[index]位置是有值的,那我们就判断key是否相同
else if (key.equals(e.key))
//如果key是相同的,那么就没必要重新查询了,关闭该开关
retries = 0;
else
//遍历该链表上下一个元素,继续判断该元素是否已经存在在该table中
e = e.next;
}
//充实次数已经达到上限,我们就使用阻塞加锁,必须保证获取到锁,获取到锁后结束 //该循环
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
// (retries & 1) == 0 偶数次检查头节点是否有修改
// 若first节点被修改了,则重置自旋重试机制,
// 为什么链表的第一个节点会变呢,是因为,新增元素时在头节点的位置添加。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
//返回该节点,可能为null,也可能不是,是新new的节点
return node;
}
从这里我们可以得出,Segment本身就是一把锁,并且是为了确保put元素的过程中线程安全,它必须先尝试获取锁,这里作者用了tryLock()非阻塞加锁的方式,如果获取锁失败是不会阻塞在这里的,而是巧妙的用了一个三元表达式去执行了
scanAndLockForPut
操作,自旋重试一定的次数,并且在这个过程中不只是单纯的自旋,还会初始化添加元素需要的节点,为后续获取锁后节省时间,这又是一处体现作者追求极致性能的地方。注释很清晰判断table[index]节点上是否已经存在该元素,如果存在,获取到锁后做更新操作就 可以了,如果不存在,就node = new HashEntry,返回该node,然后获取到锁后直接插 入即可
entryAt
static final <K,V> HashEntry<K,V> entryAt(HashEntry<K,V>[] tab, int i) {
return (tab == null) ? null :
(HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)i << TSHIFT) + TBASE);
}
获取锁之后,hash映射
HashEntry
数组的下标(int index= (tab.length - 1) & hash
),并获取主内存中的first节点(entryAt(tab, index)
)。
和映射
Segment
数组下标不同的是,这里并没有对hash做移位操作,也就是映射HashEntry数组下标用了hash值的低位,映射Segment数组下标用了hash值的高位,这样做的目的也是为了使得元素分布均匀,减少哈希冲突(hash()
补充哈希使得哈希值高位和低位不同)。
找到的first节点不为空,则发生了哈希冲突,需要遍历链表,看看是否有key和hash相同的节点,有则判断是否需要替换,不论是否需要替换,都不需要加入新节点,则结束本次put操作。若遍历到末尾依然找不到相同的节点,则需要将新节点加到链表头部(头插法)。
元素个数
count+1
,若HashEntry
数组元素count
超出阈值且长度未达到最大值,则扩容rehash(node)
。
将新增的节点更新到主内存对应位置(
setEntryAt
)
setEntryAt
static final <K,V> void setEntryAt(HashEntry<K,V>[] tab, int i,
HashEntry<K,V> e) {
UNSAFE.putOrderedObject(tab, ((long)i << TSHIFT) + TBASE, e);
}
总结
segment
本身是一把锁,且为了确保put元素的过程是线程安全的,必须先尝试获取锁。若tryLock()
获取锁失败,不会立即阻塞,而是执行scanAndLockForPut
,自旋重试一定次数,并且在这个过程中不只是单纯的自旋,还会初始化添加元素需要的节点,为后续获取锁后节省时间,这又是一处体现作者追求极致性能的地方。获取锁之后,hash映射
HashEntry
数组的下标(int index= (tab.length - 1) & hash
),并获取主内存中的first节点(entryAt(tab, index)
)。找到的first节点不为空,则发生了哈希冲突,需要遍历链表,看看是否有key和hash相同的节点,有则判断是否需要替换,不论是否需要替换,都不需要加入新节点,则结束本次put操作。若遍历到末尾依然找不到相同的节点,则需要将新节点加到链表头部(头插法)。
元素个数
count+1
,若HashEntry
数组元素count
超出阈值且长度未达到最大值,则扩容rehash(node)
。将新增的节点更新到主内存对应位置(
setEntryAt
):
rehash扩容
/**
* 将表的大小增加一倍,并重新打包条目,同时在新表中加入
* 给定的节点到新表中
*/
@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
/*
*将每个列表中的节点重新分类到新表中。 因为我们
* 使用的是2次方扩展,所以每个bin中的元素
* 的元素必须保持在相同的索引上,或者以二的幂数移动。
* 二次幂偏移。我们消除了不必要的节点
* 我们通过捕捉旧节点可以被重用的情况来消除不必要的节点创建。
* 因为他们的下一个字段不会改变。
* 根据统计,在默认的阈值下,只有大约
* 六分之一的节点需要克隆,当一个表
* 倍增。它们所替代的节点将是垃圾
* 一旦它们不再被任何读者线程引用,就可以被收集。
* 任何读者线程可能正处于
* 并发地遍历表。条目访问使用普通
* 数组索引,因为它们后面是挥发性的
* 表的写入。
*/
//旧表格
HashEntry<K,V>[] oldTable = table;
//旧容量
int oldCapacity = oldTable.length;
//newCapacity = oldCapacity * 2^1,即为扩容为原来的2倍
int newCapacity = oldCapacity << 1;
//重新计算临界值
threshold = (int)(newCapacity * loadFactor);
//创建新的table容量为newCapacity=2*oldCapacity
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//掩码
int sizeMask = newCapacity - 1;
//我们需要将旧数组的元素转移到新数组中去
for (int i = 0; i < oldCapacity ; i++) {
//获取i位置上的链表元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
//next节点
HashEntry<K,V> next = e.next;
// 计算e在新数组中的位置
// 对newCapacity - 1 做&运算
int idx = e.hash & sizeMask;
//如果是单节点
if (next == null) // Single node on list
//next=null 说明是链表的最后一个节点了,直接赋值
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
//不是链表的最后一个节点,则需要寻找链表的最后一个元素
/** 从旧数组复制迁移节点到新数组。
这里又有一个追求极致性能的点,
按道理旧数组中的节点都需要重新哈希然后映射到新数组,
但是,作者做了一个小优化,
找到每条链表中最后一个与前一个节点哈希映射新数组下标不同的点,
称之为lastRun,这样就把一个链表截成了两半,
lastRun之后节点的哈希映射结果和lastRun相同,
所以只需要复制迁移lastRun节点即可,其后的节点可以顺带过去;
而lastRun前的节点则还需要一个个重新和新数组做哈希映射并复制*/
HashEntry<K,V> lastRun = e;
//最后一个索引位置
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//计算存储位置
int k = last.hash & sizeMask;
//如果当前的索引位置和前面的不同,说明这是该链表的一个分界点
if (k != lastIdx) {
//我们把最后一个index赋值给k
lastIdx = k;
//最后一个节点给lastRun
lastRun = last;
}
}
//将lastRun放在 newTable[lastIdx]位置
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//lastRun前面的节点重新计算新的存储位置,转移到新数组中去
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);
}
}
}
}
//将新元素添加到新数组中去
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 将新链表赋值给table, copy on write
// 只能保证最终一致性,不能保证实时一致性
table = newTable;
}
默认情况下当一个
Segment
中HashEntry
数组的元素个数大于初始容量的3/4且小于最大长度时触发扩容,从函数命名rehash()
也可以看出是一个再哈希的过程
总结
扩容的大致步骤
首先新建一个数组,长度为旧数组的2倍(
oldCapacity << 1
)。按新数组的长度重新计算扩容阈值(
threshold = (int)(newCapacity * loadFactor)
)。从旧数组复制迁移节点到新数组。这里又有一个追求极致性能的点,按道理旧数组中的节点都需要重新哈希然后映射到新数组,但是,作者做了一个小优化,找到每条链表中最后一个与前一个节点哈希映射新数组下标不同的点,称之为
lastRun
,这样就把一个链表截成了两半,lastRun
之后节点的哈希映射结果和lastRun
相同,所以只需要复制迁移lastRun
节点即可,其后的节点可以顺带过去;而lastRun
前的节点则还需要一个个重新和新数组做哈希映射并复制。将新元素添加到新数组对应位置中。
新数组赋值给旧数组(
table = newTable
),copy on write
思想,所以只能保证最终一致性,不能保证实时一致性,在扩容的过程中也不会影响get的使用。
扩容迁移数据图解
8.ConcurrentHashMap.get()解析
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
// 计算segment[]索引的同时计算内存偏移量
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 第一次hash计算找到segment,
// getObjectVolatile 加上volatile语义,强制从主存中获取属性值。
// 这个方法要求被使用的属性被volatile or final(具有happen-before的修饰符)修饰,否则功能和getObject方法相同。
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 第二次hash计算找到HashEntry
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
// key地址相等 or (hash相等&& key值相等)
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
总结
首先通过hash计算
segment[]
下标同时计算内存偏移量。
getObjectVolatile
从主内存中获取对应Segment
。第二次hash计算找到
HashEntry
。遍历链表找到key相同的节点返回即可。
8.1 可以看到get没有加锁,为什么可以不加锁呢?
put
添加元素时更改的是链表的头节点不会影响get的遍历,且put
和remove
修改的是HashEntry
的next
指针,next
被volatile
修饰,replace修改的是HashEntry
的value
,value
被volatile
修饰,都是利用volatile
语义(写happen-before读),使得修改后立即刷新主内存,并且通知其他线程获取到最新值。put操作中若发生扩容,其利用了
copy on write
思想,在扩容没有完前,get获取的数据都是一份独立的旧数据;又因为Segment
中HashEntry
数组被volatile
修饰,扩容完成后重新赋值table
会立即刷新主内存,通知其他线程获取最新值。
9.ConcurrentHashMap.remove()方法
删除元素的方法有两个:
remove(Object key)
,删除键为key的元素。
remove(Object key, Object value)
,删除键为key,值为value的元素。
public V remove(Object key) {
int hash = hash(key);
Segment<K,V> s = segmentForHash(hash);
return s == null ? null : s.remove(key, hash, null);
}
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;
}
segmentForHash
private Segment<K,V> segmentForHash(int h) {
// hash 映射 segments数组下标的同时 计算出 该segment在内存中的偏移量
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// segments数组是没有被volatile修饰的,使用getObjectVolatile,
// 可为segments增加volatile语义
return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);
}
代码基本相同,都是先找到对应的
Segment
,然后调用Segment
的remove
方法。
Segment.remove
final V remove(Object key, int hash, Object value) {
if (!tryLock())
// 1.获取锁失败,会自旋重试一段时间,如果还没获取锁则阻塞
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
// 找到 对应的hashentry
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;
if (value == null || value == v || value.equals(v)) {
if (pred == null)
// 被删节点的前驱为空,则说明删除的节点是头节点,
// 则被删节点的next节点作为index位置的头节点。
setEntryAt(tab, index, next);
else
// 若pred不为null,则是从链表中间位置删除的节点,
// 可将删除节点的pred与被删除节点的next相连
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
scanAndLock
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))
// e为null or key存在则置retries=0,开始自旋计数
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
// 超过最大自旋次数,则阻塞
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
// 偶数次检查头节点被修改,则 retries=-1 重置重试机制
e = first = f;
retries = -1;
}
}
}
scanAndLock
和scanAndLockForPut
的目的基本一样,scanAndLock
少了初始化节点操作。
总结
删除的大致步骤
首先尝试获取锁,获取失败会自旋重试一定次数后依然没有获取锁则阻塞。
获取锁后,通过哈希映射找到对应的
HashEntry
节点e,若为空则说明没必要删除,若不为空则开始遍历链表。找到
key
相同的节点,若有传递value
,还需判断该节点的value
是否相同。删除节点时需分两种情况,若被删除节点的前驱为空,则说明是从链表头部删除,被删除的节点的
next
节点作为index
位置的新头节点;若被删除节点的前驱不为空,则说明是从链表中间删除,将被删除节点的next
节点链接到其前驱的next
指针上。最后释放锁。
10.ConcurrentHashMap.size()方法
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;
// 统计所有segment中元素的个数
int size;
// size的长度超过了32位,代表溢出了,设置为true
boolean overflow; // true if size overflows 32 bits
// 统计所有segment中修改的次数,后续可判断segments是否有修改
long sum; // sum of modCounts
// 最近的一次sum
long last = 0L; // previous sum
// 重试次数
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// RETRIES_BEFORE_LOCK = 2
// 重试三次,乐观的认为在size的时候,segments内部没有变动
// 注意retries++
if (retries++ == RETRIES_BEFORE_LOCK) {
// 超过三次重试次数,遍历segments数组,分别获取锁,
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
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
// 统计总修改次数sum
sum += seg.modCount;
// 统计元素个数,并判断是否有溢出
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 统计的修改总次数sum和上次记录的相同则停止重试
if (sum == last)
break;
last = sum;
}
} finally {
// 若重试超过了三次,说明分别获取过锁,则需要遍历释放锁
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
// 返回size,若size溢出则为Integer.MAX_VALUE
return overflow ? Integer.MAX_VALUE : size;
}
一个
ConcurrentHashMap
被分成了多个Segment
,那获取元素的个数就是所有Segment
中元素个数之和。而size
的过程中有可能在put
、remove
等影响每个Segment
内部元素个数操作,所以需要一个个获取锁来统计。但是这样一个不太重要的size
操作把整个Segment
数组锁住岂不是非常影响写性能。
所以作者用了一种乐观锁的方式,假设在结算
size
的过程Segment
内部没有发生修改操作,如果发生了修改则重试重新计算。
判断Segment内部没有发生修改的方式是比对最近两次总的修改次数是否一致。而重试也不是无限重试,而是重试2次,加上初始的一次就是3次。
重试3次之后依然检查到
Segment
内部有修改,则遍历segments
数组加锁统计。假设第3次重试和第4次加锁统计的修改总次数sum相等则结束统计返回size,若不相等则第5次统计(重试次数不等2了,则不会发生锁重入)。
11.ConcurrentHashMap1.7总结
ConcurrentHashMap
中segment
数组的长度以及HashEntry
数组的长度之所以要保持为2的整数次方就是为了利用2的整数次方数值减1就是掩码的特性,哈希值与掩码做与运算相当于模运算来快速映射计算数组的下标。
ConcurrentHashMap
使用分段锁的方式,每一个segment
相等于一把锁,在修改map时首先会先获取锁。而get也不需要加锁的原因是HashEntry
中next
指针,value
都是volatile
修饰的,可以很好的利用其写happen-before读的语义,以及修改后立即刷新到主内存并通知其他线程获取最新值。哈希冲突的体现不只是在链表中,还是在第一次哈希映射
segment
数组时,多个元素到同一个segment
也算是哈希冲突。而解决哈希冲突的方式是链表法。哈希映射
segment
数组下标时用了哈希值的高位,而哈希映射HashEntry
数组下标时用的哈希值低位,为的是尽可能利用哈希值,使得元素节点分布均匀减少冲突。扩容是容量扩大为原来的2倍,然后遍历每个元素重新哈希映射到新数组中,但是作者有一个小优化就是把一个链表截成两半,以
lastRun
为界,lastRun
后面的节点因为和lastRun
哈希映射新数组的结果一样,所以可以跟随lastRun
一起复制到新数组,而lastRun
之前的节点则需要一个个重新哈希映射。size统计所有
segment
的元素个数,以乐观重试的方式判断segment
内部是否有修改,最近两次修改次数一致则返回统计的size。
segment
数组一旦初始化后期不可扩容。