目录
JDK1.7和1.8下的ConcurrentHashMap区别
构造方法提问: 为什么默认设置并发度为16(concurrencyLevel)?
讲讲JDK1.7中ConcurrentHashMap底层原理
问:为啥ConcurrentHashMap不允许key,value是null?
与HashMap类似,由Segment,HashEntry组成,是数组+链表构成;
Segment是ConcurrentHashMap中的一个内部类:
原理:ConcurrentHashMap采用了分段锁技术,其中Segment(可以理解为一小段数组,里面含有n个HashEntry)继承ReentrantLock,但是put和get操作都会被同步处理——>每当一个线程占用锁访问Segment时,不会影响其他的Segment(可以理解为:有多把锁,一把锁锁住的临界区不会影响其他的地方)
volatile修饰了HashEntry的数据和value,从而保证了多线程环境下数据的可见性;
问:分段锁Segment怎么被定位的?
通过key进行定位,准确来说与HashMap类似,通过hash值&数组长度-1确定位置,value值是被volatile关键字修饰的;
我们在对应的Segment可以进行put、get操作:
1 public V put(K key, V value) { 2 Segment<K,V> s; 3 if (value == null) 4 throw new NullPointerException(); 5 int hash = hash(key); 6 int j = (hash >>> segmentShift) & segmentMask; 7 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck 8 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment 9 s = ensureSegment(j);10 return s.put(key, hash, value, false);11 }
因为value是被volatile关键字修饰的,所以并不能保证原子性——>所以put需要加锁;
扩容:
阈值是0.75,默认长度好似16,达到16*0.75=12,就会进行扩容;
注意:扩容以后,因为数组长度发生变化,所以元素的放入会发生改变->index=hash&length-1;
put流程:
Segment通过key的hash定位HashEntry,遍历HashEntry,判断传入的key与当前遍历的key是否相等,相等就覆盖旧的value,不为空就会创建一新的HashEntry加入到Segment中(并且会判断是否要扩容);
并发死链现象
桶下标index相同会产生链表——>才会产生如此现象,
我们首先先展示头插法,单线程下的扩容:
进行节点转移前,先吧数组长度进行扩容*2
节点(Entry)转移
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; //得到新数组的长度
for (Entry<K,V> e : table) { //第一步:遍历整个数组对应下标下的链表,e代表一个节点
while(null != e) { //当e==null时,则该链表遍历完了,继续遍历下一数组下标的链表
Entry<K,V> next = e.next; //第二步:先把e节点的下一节点存起来
if (rehash) { //得到新的hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //在新数组下得到新的数组下标
e.next = newTable[i]; //第三步:将e的next指针指向新数组下标的位置
newTable[i] = e; //第四步:将该数组下标的节点变为e节点
e = next; //第五步:遍历链表的下一节点
}
}
}
首先e遍历,得到第一个节点,保存下一个节点;
e指向的节点指向新数组下标:
该数组下标变为e节点
然后遍历到链表的下一个节点
按照以上步骤,我们可以将节点2和节点1都移到新的数组中,全部移到新数组后如图所示:
总结:会发现转移后元素的位置反了;
多线程下的扩容:
我这里假设有两个线程分别为t1和t2,而且两个线程同时读到了需要扩容,这时候,就会导致会创建两个新的数组,而且同时要去操作这个老的数组的节点1,2,3,假设这时候两个线程都执行了第一步和第二步,这时候的状态如图所示:
2. 假设这时候操作系统的时间片没有分到t2线程,只有t1线程在运行,那么按照单线程情况下转移的过程得到t1线程完成转移后如图所示:
3、接下来是t2线程运行了,那么此时移动的便是t1线程新创建的表中的节点1,2,3;现在来详细说明一下这些步骤;
-
1)上面讲了,t2线程已经完成了源码内容的第一步和第二步的操作,接下来进行第三步:将e2的next2指针指向t2线程创建的新数组下标的位置,如图所示:
2)接下来执行第四步操作:将该数组下标的节点变为e2节点,如图所示:
接下来执行第五步操作:遍历链表的下一节点,如图所示:
接下来执行第三步操作,结果如图所示:(注意这里next指向了3)
接下来执行第四步操作,结果如图所示:(e2指向2,占据3的位置)
接下来执行第五步操作遍历下一节点(重点来了),如图所示:(发现又到了3)
总结:接下来也不用演示了,节点2和节点3会一直循环下去,可以导致程序一直执行,问题就很严重了,电脑直接卡死,可能还会抛出OutOfMemoryException;而且你们有没有发现,节点1给丢弃了,导致丢值的现象。
JDK1.8的HashMap采用了尾插法避免了并发死链问题;
1.8下的ConcurrentHashMap
之前是头插法,后面是尾插;
结构与HashMap1.8下相同:数组+链表+红黑树
抛弃了原来的Segment分段锁,采用了CAS+synchronized
好处:synchronized有多种锁状态,进行优化,每个节点通过继承AQS同步器来保证同步支持;
1.8ConcurrentHashMap源码分析:
构造方法:
initalCapacity:初始化大小;loadFactor:阈值;
初始大小:1+初始大小/阈值
因为后面的hash算法需要是2的n次方,所以会经过tableSizeFor()将初始大小变为2的n次方;
——>所以我们看到的初始大小和我们设置的会不一样;
get方法:
put流程
对比与HashMap,不有null的key和value
spread:保证数是正整数
如果哈希表没有创建或者长度为0,就会调用initTable来创建哈希表(底层调用CAS操作保证哈希表创建的安全,保证原子性)
casTabAt(tab,i,null,new Node()):给链表头部添加节点,本质就是CAS将null->new Node(),如果CAS失败就进入下一次循环;(头节点没下标进入这个分支)
扩容情况:
tab=helpTransfer(tab,f):对哈希表进行扩容;
如果出现哈希冲突的时候进入最后一个场景,节点被synchronized上锁;
1.先判断有没有被移动节点:tabAt
2.判断链表长度是否>0,然后进行遍历,找链表中的key有没有一样的,如果不一样,就追加;
key一样会直接覆盖;
总得来说,就是先看链表长度,然后遍历链表,看key是否有一样的,有的话直接覆盖,(毕竟这里都是hash冲突的),所以如果说key不一样直接追加,如果链表长度>8,转为红黑树;
如果链表的长度>8(树化阈值),链表被转为红黑树;
initTable创建哈希表:
创建哈希表的状态是sc值,sc为-1表示创建成功(红框字体为创建哈希表)
本质也是CAS操作
其他线程在获取哈希表的时候,会进行判断sc的状态,如果已经创建了,就会进行Thread.yield(),当前线程谦让状态,但是并不会阻塞;
addCount:本质就是利用LongAddr的思想进行计数
利用Cells和base
size计算流程:
size的计算实际发生在put和remove改变集合元素的操作中;
没有竞争发生,向baseCount累加计数
有竞争发生,新建counterCells,向其中的一个cell累加计数
1.counterCells初始有两个cell;
2.如果计数竞争比较激烈,会创建新的cell来累加计数;
扩容流程transfer方法:
传入的旧新哈希表,有两种转移机制:根据hash值是否<0来判断->是根据普通的链表进行操作还是根据红黑树;
JDK7 ConcurrentHashMap
目的:用分段锁(类似多把锁)实现并发度,提高并发度(Segment分而治之)——>不同的线程用的是不一样的Segment(如果套上线程池,个人认为线程池中核心线程数也应该有要求);
每一个Segment有一个HashEntry(里面就跟Hash表一样:数组+链表)
缺点:Segments数组默认大小16,而且不是lazy,你一启动就会创建Segments,不是用到时才创建,而是直接创建,内存消耗较大;
JDK1.7和1.8下的ConcurrentHashMap区别
HashMap
在多线程高并发情况下会出现线程不安全的情况:
在jdk1.7,并发执行会出现并发死链现象和数据丢失现象;
jdk1.8会出现数据覆盖;
解决:使用HashTable保证线程安全,但是他是会给整个集合加锁,会导致同一时间其他线程阻塞;
ConcurrentHashMap:
对数据加了volatile关键字使所有数据在内存中都是可见的。
使用了Segment分段锁(里面是HashEntry)来进行 数组+链表。
即Segment+HashEntry+ReentrantLock
如果A线程Put,B线程Get也是可以的,相当于写读操作,是并发的;
Put:当放入元素时,我们不是对整个HashMap直接就加值,而是先通过hash值确定在哪个Segment,然后对这个分段进行加锁(ReentrantLock),这样大大提高了高并发环境下的处理能力;
但是,要计算两次hash值,第二次要求Segment中的具体位置;
构造方法提问: 为什么默认设置并发度为16(concurrencyLevel)?
因为如果并发度过小,会导致严重的锁竞争(线程较多时);
较大的话,Segment的范围较小了,容易查到不同的Segment,(类似10个靶子突然变成100个靶子),更难筛选命中了,导致性能降低;
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;
// 找的2的整数次幂
int sshift = 0; //ssize从1变成大于等于concurrencyLevel的2次幂需要左移的次数
int ssize = 1;
//依据给定的concurrencyLevel并行度,找到最适合的segments数组的长度,
// 该长度为大于concurrencyLevel的最小的2的n次方
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;
//如果使用默认参数,也就是initialCapacity是16,concurrencyLevel是16,那么ssize也就是16,c是1,下面c++这句就不会执行
if (c * ssize < initialCapacity)
++c;
//最后计算出来的c相当于initialCapacity / ssize向上取整
//cap是每个分段锁中HashEntry数组的长度
int cap = MIN_SEGMENT_TABLE_CAPACITY
while (cap < c)
cap <<= 1;
// 新建segments 数组,初始化 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;
}
get是加锁的吗?为啥不加?
其实就相当于ReentrantReadWriteLock中的读锁,所以是可以不用加锁的用一个Volatile就行保证读可见(用的Unsafe的getObjectVolatile()读取HashEntry)->具有Volatile的意义,保证多线程环境下获取最新的Segment;
讲讲put的流程
首先是通过key得到hash值,确定Segment(尝试获取片段锁,如果没获取到就自旋),拿到锁后,然后再通过hash值确定对应的HashEntry,对链表进行遍历,如果说key相同,则覆盖,没有的话,会进行cas操作——>将null设置为new Node();
讲讲JDK1.7中ConcurrentHashMap底层原理
首先他是有两层数组实现的:Segment[]+HashEntry[];——>HashEntry里面就是HashTable(数组+链表);
Put:首先是通过key得到hash值,确定Segment(尝试获取片段锁,如果没获取到就自旋),拿到锁后,然后再通过hash值确定对应的HashEntry,对链表进行遍历,如果说key相同,则覆盖,没有的话,会进行cas操作——>将null设置为new Node();
JDK1.7情况下如何保证线程安全的?
主要利用Unsafe中CAS和getObjectVolatile(get方法时有利用保证共享资源的读可见)
+ReentrantLock+分段思想(提高并发度);
ReentrantLock体现在分段锁Segment上,Segment继承ReentrantLock;
然后HashMap是允许key,value为null的,但是ConcurrentHashMap不允许;
问:为啥ConcurrentHashMap不允许key,value是null?
因为在应用多线程环境下的时候,null值可能是他没被映射,本身就是null,但是还可能是被其他线程修改为null,所以不能实null值,无法确定;