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;
}
1、Segment#put真正的操作元素
找到对应的segment
后,就继续对其内部的HashEntry
链表数组进行操作,这个过程中可能会产生哈希冲突,也可能需要扩容,而作者是如何解决实现的呢?
// java.util.concurrent.ConcurrentHashMap.Segment#put
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// (1)若获取锁,则node=null,没有获取锁,也会做一些事情scanAndLockForPut:
// 拿不到锁,不立即阻塞,而是先自旋,若自旋到一定次数仍未拿到锁,再调用lock()阻塞;
// 在自旋的过程中遍历链表,若发现没有重复的节点,则提前新建一个节点,为后面再插入节省时间。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
//(2)tab.length是2的整数次方,所以tab.length-1 的二进制就是若干1,对hash 做与运算,算出的index不会超出tab.length-1
int index = (tab.length - 1) & hash;
// 定位到第index个HashEntry
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;😉 {
if (e != null) {
// (3)e不为空则说明发生hash冲突,解决hash冲突的办法:链表法,新节点作为链表的头节点
// 如果hash值算的不好,经常发生hash冲突,就会造成某一个链表很长,性能就会很低。
K k;
// HashEntry key地址相等 or (hash值相等且key值相等)时
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// onlyIfAbsent 为false,则旧值替换为新值,然后break,否则直接break
e.value = value;
++modCount;
}
break;
// put key存在时会新值替换旧值
// putIfAbsent key存在时不替换。
}
// 在e不为null的情况下,向下遍历,直到找到HashEntry的末尾或者 一个key或者hash相等的HashEntry
e = e.next;
}
else {
if (node != null)
// node不为null,说明前面没有抢到锁时,顺便初始化了node
// 同时node放在了HashEntry单列表的头部
node.setNext(first);
else
// 初始化一个node,并放在first链表的头部
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//(4) 因为count是对一个segment中的HashEntry节点个数的统计,
// 如果hash冲突严重,键值对只添加到HashEntry[]中某几个HashEntry链表中,
// 就造成HashEntry[]有空闲位置,也会造成无味的扩容,内存利用率持续下降
// count 超过了阈值,默认是HashEntry<K,V>[]初始容量*0.75
// 扩容
rehash(node);
else
// (5)更新tab index位置的node
setEntryAt(tab, index, node);
// modCount++
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
(1)segment
本身是一把锁,且为了确保put元素的过程是线程安全的,必须先尝试获取锁。若tryLock()
获取锁失败,不会立即阻塞,而是执行scanAndLockForPut
,自旋重试一定次数,并且在这个过程中不只是单纯的自旋,还会初始化添加元素需要的节点,为后续获取锁后节省时间,这又是一处体现作者追求极致性能的地方。
/**
- @return a new node if key not found, else null
*/
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// 找到对应的HashEntry节点first
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,防止重复初始化
retries = 0;
}
else if (key.equals(e.key))
// 若 找到节点不为空,且 key和准备添加的key相等
retries = 0;
else
// e不为空且 key不相等,则向下遍历该链表,e设置为下一个节点,
// 直到找到下一个节点空的或者key相等的(可能是替换操作,无需初始化节点)
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
// 自旋一定次数后lock阻塞
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;
}
}
return node;
}
(2)获取锁之后,hash映射HashEntry
数组的下标(int index= (tab.length - 1) & hash
),并获取主内存中的first节点(entryAt(tab, index)
)。
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);
}
和映射Segment
数组下标不同的是,这里并没有对hash做移位操作,也就是映射HashEntry
数组下标用了hash值的低位,映射Segment
数组下标用了hash值的高位,这样做的目的也是为了使得元素分布均匀,减少哈希冲突(hash()
补充哈希使得哈希值高位和低位不同)。
(3)找到的first节点不为空,则发生了哈希冲突,需要遍历链表,看看是否有key和hash相同的节点,有则判断是否需要替换,不论是否需要替换,都不需要加入新节点,则结束本次put操作。若遍历到末尾依然找不到相同的节点,则需要将新节点加到链表头部(头插法)。
(4)元素个数count+1
,若HashEntry
数组元素count
超出阈值且长度未达到最大值,则扩容rehash(node)
。
(5)将新增的节点更新到主内存对应位置(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);
}
2、rehash扩容
扩容比较复杂重要,所以单独拎出来探讨,默认情况下当一个Segment
中HashEntry
数组的元素个数大于初始容量的3/4且小于最大长度时触发扩容,从函数命名rehash()
也可以看出是一个再哈希的过程:
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// newCapacity = oldCapacity * 2^1,即为扩容为原来的2倍
int newCapacity = oldCapacity << 1;
// 重新计算新的threshold
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
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
// 不是链表的最后一个节点,则需要寻找链表的最后一个元素
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) {
lastIdx = k;
lastRun = last;
}
}
// 找到lastRun节点,因为lastRun后面的节点的hash都和lastRun一样,
// 所以可以直接把lastRun连同后面的节点一起复制到新数组的对应位置。
newTable[lastIdx] = lastRun;
// Clone remaining nodes
// 而 lastRun前面的节点则需要重新计算在新数组中的位置,
// 并拷贝(new HashEntry)到新数组中
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;
}
(1)首先新建一个数组,长度为旧数组的2倍(oldCapacity << 1
)。
(2)按新数组的长度重新计算扩容阈值(threshold = (int)(newCapacity * loadFactor)
)。
(3)从旧数组复制迁移节点到新数组。这里又有一个追求极致性能的点,按道理旧数组中的节点都需要重新哈希然后映射到新数组,但是,作者做了一个小优化,找到每条链表中最后一个与前一个节点哈希映射新数组下标不同的点,称之为lastRun
,这样就把一个链表截成了两半,lastRun
之后节点的哈希映射结果和lastRun
相同,所以只需要复制迁移lastRun
节点即可,其后的节点可以顺带过去;而lastRun
前的节点则还需要一个个重新和新数组做哈希映射并复制。如图:
(4)将新元素添加到新数组对应位置中。
(5)新数组赋值给旧数组(table = newTable
),copy on write
思想,所以只能保证最终一致性,不能保证实时一致性,在扩容的过程中也不会影响get的使用。
删除元素的方法有两个:
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;
}
代码基本相同,都是先找到对应的Segment
,然后调用Segment
的remove
方法。
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
的remove
方法:
-
首先尝试获取锁,获取失败会自旋重试一定次数后依然没有获取锁则阻塞。
-
获取锁后,通过哈希映射找到对应的
HashEntry
节点e,若为空则说明没必要删除,若不为空则开始遍历链表。 -
找到
key
相同的节点,若有传递value
,还需判断该节点的value
是否相同。 -
删除节点时需分两种情况,若被删除节点的前驱为空,则说明是从链表头部删除,被删除的节点的
next
节点作为index
位置的新头节点;若被删除节点的前驱不为空,则说明是从链表中间删除,将被删除节点的next
节点链接到其前驱的next
指针上。 -
最后释放锁。
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
和scanAndLockForPut
的目的基本一样,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;
}
}
}
replace方法较为简单就不做分析了,来看看get方法:
-
首先通过hash计算
segment[]
下标同时计算内存偏移量。 -
getObjectVolatile
从主内存中获取对应Segment
。 -
第二次hash计算找到
HashEntry
。 -
遍历链表找到key相同的节点返回即可。
可以看到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
会立即刷新主内存,通知其他线程获取最新值。
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;
}
一个ConcurrentHashMap
被分成了多个Segment
,那获取元素的个数就是所有Segment
中元素个数之和。而size
的过程中有可能在put
、remove
等影响每个Segment
内部元素个数操作,所以需要一个个获取锁来统计。但是这样一个不太重要的size
操作把整个Segment
数组锁住岂不是非常影响写性能。
所以作者用了一种乐观锁的方式,假设在结算size
的过程Segment
内部没有发生修改操作,如果发生了修改则重试重新计算。
**判断Segment
内部没有发生修改的方式是比对最近两次总的修改次数是否一致。**而重试也不是无限重试,而是重试2次,加上初始的一次就是3次。
重试3次之后依然检查到Segment
内部有修改,则遍历segments
数组加锁统计。假设第3次重试和第4次加锁统计的修改总次数sum
相等则结束统计返回size,若不相等则第5次统计(重试次数不等2了,则不会发生锁重入)。
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;
}
看完java7的ConcurrentHashMap
源码,了解了其实现原理后,心里的疑云基本都解开了:
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
最后我们该如何学习?
1、看视频进行系统学习
这几年的Crud经历,让我明白自己真的算是菜鸡中的战斗机,也正因为Crud,导致自己技术比较零散,也不够深入不够系统,所以重新进行学习是很有必要的。我差的是系统知识,差的结构框架和思路,所以通过视频来学习,效果更好,也更全面。关于视频学习,个人可以推荐去B站进行学习,B站上有很多学习视频,唯一的缺点就是免费的容易过时。
另外,我自己也珍藏了好几套视频资料躺在网盘里,有需要的我也可以分享给你:
2、读源码,看实战笔记,学习大神思路
“编程语言是程序员的表达的方式,而架构是程序员对世界的认知”。所以,程序员要想快速认知并学习架构,读源码是必不可少的。阅读源码,是解决问题 + 理解事物,更重要的:看到源码背后的想法;程序员说:读万行源码,行万种实践。
Spring源码深度解析:
Mybatis 3源码深度解析:
Redis学习笔记:
Spring Boot核心技术-笔记:
3、面试前夕,刷题冲刺
面试的前一周时间内,就可以开始刷题冲刺了。请记住,刷题的时候,技术的优先,算法的看些基本的,比如排序等即可,而智力题,除非是校招,否则一般不怎么会问。
关于面试刷题,我个人也准备了一套系统的面试题,帮助你举一反三:
只有技术过硬,在哪儿都不愁就业,“万般带不去,唯有业随身”学习本来就不是在课堂那几年说了算,而是在人生的旅途中不间断的事情。
人生短暂,别稀里糊涂的活一辈子,不要将就。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
年的Crud经历,让我明白自己真的算是菜鸡中的战斗机,也正因为Crud,导致自己技术比较零散,也不够深入不够系统,所以重新进行学习是很有必要的。我差的是系统知识,差的结构框架和思路,所以通过视频来学习,效果更好,也更全面。关于视频学习,个人可以推荐去B站进行学习,B站上有很多学习视频,唯一的缺点就是免费的容易过时。
另外,我自己也珍藏了好几套视频资料躺在网盘里,有需要的我也可以分享给你:
[外链图片转存中…(img-9F6jMxyH-1712567068737)]
2、读源码,看实战笔记,学习大神思路
“编程语言是程序员的表达的方式,而架构是程序员对世界的认知”。所以,程序员要想快速认知并学习架构,读源码是必不可少的。阅读源码,是解决问题 + 理解事物,更重要的:看到源码背后的想法;程序员说:读万行源码,行万种实践。
Spring源码深度解析:
[外链图片转存中…(img-jGLnW3mD-1712567068737)]
Mybatis 3源码深度解析:
[外链图片转存中…(img-YfJV0Wz8-1712567068738)]
Redis学习笔记:
[外链图片转存中…(img-cipJBMQi-1712567068738)]
Spring Boot核心技术-笔记:
[外链图片转存中…(img-rh8TaQzf-1712567068738)]
3、面试前夕,刷题冲刺
面试的前一周时间内,就可以开始刷题冲刺了。请记住,刷题的时候,技术的优先,算法的看些基本的,比如排序等即可,而智力题,除非是校招,否则一般不怎么会问。
关于面试刷题,我个人也准备了一套系统的面试题,帮助你举一反三:
[外链图片转存中…(img-mr90I7KH-1712567068738)]
只有技术过硬,在哪儿都不愁就业,“万般带不去,唯有业随身”学习本来就不是在课堂那几年说了算,而是在人生的旅途中不间断的事情。
人生短暂,别稀里糊涂的活一辈子,不要将就。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!