上文我们提到了HashMap1.7和1.8的一些关键知识点以及不同点,最后面我们提到了在我们并发编程时候可以使用下面三种方式来代替HashMap:
使用Collections.synchronizedMap(Map)创建线程安全的map集合;
Hashtable
ConcurrentHashMap
不过ConcurrentHashMap的并发度会比前两种更加高,本文我们就来聊聊ConcurrentHashMap1.7是怎么保证线程安全的。
我们还得从它他的数据结构说起,如图
从图中我们可以ConcurrentHashMap1.7是由两层数组嵌套成的,相比HashMap多了一层segment数组,外层是segment[],然后每个segement里面是一个HashEntry
//Segment继承了ReentrantLock,所以每当一个线程占用锁访问一个 Segment 时,
//不会影响到其他的 Segment,这也是他并发度比较高的原因
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
// 记得快速失败(fail—fast)么?
transient int modCount;
// 大小
transient int threshold;
// 负载因子
final float loadFactor;
}
接下来我们看看ConcurrentHashMap的构造方法
我们最常用的就是无参构造方法
/**
* ConcurrentHashMap
* @param initialCapacity 初始化容量
* @param loadFactor 负载因子
* @param concurrencyLevel 并发级别,也就是segments数组的长度
*/
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计算出ssize为segments数组的长度
//如果我们传入的concurrencyLevel不是2的n次幂,计算出的size是大于等于我们传入的数n次幂
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;
// 计算每个segment中table的容量
int c = initialCapacity / ssize;
//判断如果传入initialCapacity不是2的n次幂,所做的操作相当于向上取整
if (c * ssize < initialCapacity)
++c;
// HashEntry[]最小容量为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
// 保证cap是2^n
while (cap < c)
cap <<= 1;
// create segments and segments[0]
// 创建segments并初始化第一个segment数组,其余的segment延迟初始化
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); // 这里是unsafe操作,并发安全的为数组的第0个位置赋值
this.segments = ss;
}
<<构造函数>>:默认Segment也就是=并发级别(concurrentylevel)=16;
最大容量2的30次方 最大并发2的16次放
*Entry个数* = initialCapacity / concurrentylevel;
ssize =1
while(ssize<concurrentylevel){
++shift;//后面算segmentshift用来计算Entry索引
ssize<<=1; //默认1<16 一直走变成ssize=16
}
cap=1;
c=cap;
while(C*ssize<initialCapcatity){ //默认c*16<16不走
++c;
}
int cap = minCap默认2 //所以默认最少Entry为2个小hashmap
while(cap<c){
cap<<=1;
}
第二次假设initialCapcatity=17;则多一个entryc为3,cap=2,cap=2<3则cap=2的次方
1、首先确定段的位置,
调用Segment中的put方法:
2、加锁
3、检查当前Segment数组中包含的HashEntry节点的个数,如果超过阈值就重新hash只是对 Segments对象中的Hashentry数组进行重哈希
4、然后再次hash确定放的链表。
5、在对应的链表中查找是否相同节点,如果有直接覆盖,如果没有将其放置链表尾部
j=hash>>>segmentshift & segmentMask
前面W有计算shift,segmentshift =32-shift;//例如容量16的时候28=32-4;
异或的时候就是后移28位就会保留高4位作为异或条件,同理容量翻倍,就保留高5位,和hashMap差不多,只是计算方式变了。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();//这就是为啥他不可以put null值的原因
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
//判断segements[]数组下标为j的位置是否为空,如果为空,就在这个位置初始化一个
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j); //下图有此方法
return s.put(key, hash, value, false);
}
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // 计算原始偏移量(在Unsafe操作要用到),相当于在segments数组的位置
Segment<K,V> seg;
// 一开始我们先判断当前初始化的位置是不是为空,如果不为空就没必要初始化了。在多线程的情况下,有可能别的线程已经在你前面初始化了
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // 获取第一个segment,这就是我们前面提到的原型设计模式,用cap和loadFactoe 为模板
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化ss[k] 内部的table数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 我们再次检查这个ss[k]有没有被初始化原因也是跟上面的一样
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 这里用自旋+CAS来保证把segments数组的u位置设置为s
// 万一有多线程执行到这一步,只有一个成功,break
// getObjectVolatile 保证了读的可见性,所以一旦有一个线程初始化了,那么就结束自旋
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
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
HashEntry<K,V>[] tab = table;
//算出数组下标
int index = (tab.length - 1) & hash;
//取这个数组index下标的值
HashEntry<K,V> first = entryAt(tab, index);
//开始遍历first为头结点的链表
for (HashEntry<K,V> e = first;;) {
if (e != null) {//<1>
//遍历链表
K k;
//key相同就覆盖value
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
//记下旧的会返回
oldValue = e.value;
//key存在就不更新
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
//遍历下一个节点
e = e.next;
}
else {//<2>
//e==null
// 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 小hashMap个数
//判断是否扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//扩容完毕后,还会将这个node插入到新的数组中。
rehash(node);
else
//把生成的数组放入,头插法赋值上,用unsafe改了内存里面的值
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
//两倍扩容
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//新数组容量-1
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;
//新数组的索引,没有重新hash,只有两种情况
int idx = e.hash & sizeMask;
if (next == null) // Single node on list 只有单个节点
newTable[idx] = e;
else {
//记录了索引一样的最后几位,转移第一个,下面也会跟着移
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) {//统一相等得索引存在一起lastRun
lastIdx = k;
lastRun = last;
}
}
//一次性转移lastRun
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//剩的下就转移,头插法
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, p.value, n);
}
}
}
}
//添加新的node
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
get方法就是获取内存里最新的值,一样通过hash算索引,通过unsafe.getObjectVpolatile方式从数组里拿对应的segment对象,然后拿对应segment对象的Entry索引,然后去内部数组遍历链表去拿对应的数组。
remove方法找对应hash的segment对象,一样用unsafe找,也是先加锁,得到锁就去移除对应的数组里的链表的索引的数据了。
size()方法起初值为-1 等于2时候才对每个segment对象加锁,所以它这里遍历两次segment对象,修改次数+·和统计count,第一次遍历,把存sum给last,第二次再遍历的时候,sum会恢复0,然后继续存sum,拿sum和last比较,如果相同就返回了,拿到size。如果统计不成功就加锁统计。就相当于普瞟了两眼对不对。
JDK1.7 ConcurrentHashMap主要利用了Unsafe操作、ReentrantLock以及分段锁的思想,分段思想是为了提高并发量,我们可以通过concurrencyLevel参数指定并发级别,但是segment的数量初始化了就不能修改。ConcurrentHashMap的Segment就表示每一个段。