点赞关注,不会迷路!
2021最新学习面试资料 点击一起学习 暗号:csdn 第一时间分享最新学习资料+简历优化资源
我们知道HashMap
是线程不安全的,因此在老版本JDK中提供了HashTable
来实现多线程级别的,改变之处重要有以下几点。
❝❞
HashTable
的put
,get
,remove
等方法是通过synchronized
来修饰保证其线程安全性的。
HashTable
是 不允许key跟value为null的。问题是
synchronized
是个关键字级别的重量锁,在get数据的时候任何写入操作都不允许。相对来说性能不好。因此目前主要用的ConcurrentHashMap
来保证线程安全性。
ConcurrentHashMap
主要分为JDK<=7跟JDK>=8的两个版本,ConcurrentHashMap
的空间利用率更低一般只有10%~20%,接下来分别介绍。
JDK7
先宏观说下JDK7中的大致组成,ConcurrentHashMap由Segment
数组结构和HashEntry
数组组成。Segment是一种可重入锁,是一种数组和链表的结构,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构。正是通过Segment分段锁,ConcurrentHashMap实现了高效率的并发。缺点是并发程度是有segment数组来决定的,并发度一旦初始化无法扩容。先绘制个ConcurrentHashMap
的形象直观图。
要想理解currentHashMap
,可以简单的理解为将数据「分表分库」。ConcurrentHashMap
是由 Segment
数组 结构和HashEntry
数组 结构组成。
❝❞
Segment 是一种可重入锁
ReentrantLock
的子类 ,在ConcurrentHashMap
里扮演锁的角色,HashEntry
则用于存储键值对数据。
ConcurrentHashMap
里包含一个Segment
数组来实现锁分离,Segment
的结构和HashMap
类似,一个Segment
里包含一个HashEntry
数组,每个HashEntry
是一个链表结构的元素, 每个Segment
守护者一个HashEntry
数组里的元素,当对HashEntry
数组的数据进行修改时,必须首先获得它对应的Segment
锁。
-
我们先看下segment类:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table; //包含一个HashMap 可以理解为
}
可以理解为我们的每个segment
都是实现了Lock
功能的HashMap
。如果我们同时有多个segment
形成了segment
数组那我们就可以实现并发咯。
我们看下currentHashMap
的构造函数,先总结几点。
-
-
每一个segment里面包含的table(HashEntry数组)初始化大小也一定是2的次幂
-
这里设置了若干个用于位计算的参数。
-
initialCapacity:初始容量大小 ,默认16。
-
loadFactor: 扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity* loadFactor时,该Segment会进行一次扩容。
-
concurrencyLevel:并发度,默认16。并发度可以理解为程序运行时能够「同时更新」ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
-
segment的数组大小最终一定是2的次幂
-
构造函数详解:
//initialCapacity 是我们保存所以KV数据的初始值
//loadFactor这个就是HashMap的负载因子
// 我们segment数组的初始化大小
@SuppressWarnings("unchecked")
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS) // 最大允许segment的个数,不能超过 1< 24
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0; // 类似扰动函数
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1; // 确保segment一定是2次幂
}
this.segmentShift = 32 - sshift;
//有点类似与扰动函数,跟下面的参数配合使用实现 当前元素落到那个segment上面。
this.segmentMask = ssize - 1; // 为了 取模 专用
if (initialCapacity > MAXIMUM_CAPACITY) //不能大于 1< 30
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize; //总的数组大小 被 segment 分散后 需要多少个table
if (c * ssize < initialCapacity)
++c; //确保向上取值
int cap = MIN_SEGMENT_TABLE_CAPACITY;
// 每个table初始化大小为2
while (cap < c) // 单独的一个segment[i] 对应的table 容量大小。
cap <<= 1;
// 将table的容量初始化为2的次幂
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]);
// 负载因子,阈值,每个segment的初始化大小。跟hashmap 初始值类似。
// 并且segment的初始化是懒加载模式,刚开始只有一个s0,其余的在需要的时候才会增加。
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
-
hash 不管是我们的get操作还是put操作要需要通过hash来对数据进行定位。
// 整体思想就是通过多次不同方式的位运算来努力将数据均匀的分不到目标table中,都是些扰动函数
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// 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);
}
-
get 相对来说比较简单,无非就是通过
hash
找到对应的segment
,继续通过hash
找到对应的table
,然后就是遍历这个链表看是否可以找到,并且要注意get
的时候是没有加锁的。
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key); // JDK7中标准的hash值获取算法
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // hash值如何映射到对应的segment上
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
// 无非就是获得hash值对应的segment 是否存在,
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
// 看下这个hash值对应的是segment(HashEntry)中的具体位置。然后遍历查询该链表
K k;
if ((k = e.key) key || (e.hash h && key.equals(k)))
return e.value;
}
}
return null;
}
-
put 相同的思路,先找到
hash
值对应的segment
位置,然后看该segment
位置是否初始化了(因为segment是懒加载模式)。选择性初始化,最终执行put操作。
@SuppressWarnings("unchecked")
public V put(K key, V value) {
Segment<K,V> s;
if (value null)
throw new NullPointerException();
int hash = hash(key);// 还是获得最终hash值
int j = (hash >>> segmentShift) & segmentMask; // hash值位操作对应的segment数组位置
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) null)
s = ensureSegment(j);
// 初始化时候因为只有第一个segment,如果落在了其余的segment中 则需要现初始化。
return s.put(key, hash, value, false);
// 直接在数据中执行put操作。
}
其中put
操作基本思路跟HashMap
几乎一样,只是在开始跟结束进行了加锁的操作tryLock and unlock
,然后JDK7中都是先扩容再添加数据的,并且获得不到锁也会进行自旋的tryLock或者lock阻塞排队进行等待(同时获得锁前提前new出新数据)。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁,获取失败尝试获取自旋锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
// segment 内部的数组
HashEntry<K,V>[] tab = table;
// 利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
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 {
// node 是不是 null,这个要看获取锁的过程。没获得锁的线程帮我们创建好了节点,直接头插法
// 如果不为 null,那就直接将它设置为链表表头;如果是 null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 将新的结点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}
如果加锁失败了调用scanAndLockForPut
,完成查找或新建节点的工作。当获取到锁后直接将该节点加入链表即可,「提升」了put操作的性能,这里涉及到自旋。大致过程:
❝❞
在我获取不到锁的时候我进行tryLock,准备好new的数据,同时还有一定的次数限制,还要考虑别的已经获得线程的节点修改该头节点。
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
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是