ConcurrentHashMap从入门到入土
ConcurrentHashMap的出现,是为了解决hashmap存在的一些问题,尤其是在多线程方面
HashMap存在的问题
Hashmap线程不安全
在多线程环境下,使用hashMap进行put操作可能会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。
Hashtable线程安全但效率低下
Hashtable容器使用Synchronized来保证线程安全,但在线程竞争激烈的情况下,效率低下。
因为当一个线程访问Hashtable的同步方法时,其他线程也访问时会进入阻塞或轮询状态。
ConcurrentHashMap解决方式
针对前面两种情况,其问题在于没有加锁导致不安全,加了Synchronized锁会导致效率低下。
所以这里采用分段锁:首先将数据分成一段一段的存储,然后给每一段数据分配一把锁。有些方法需要跨段(size、containsValue),他们需要锁定整个表的时候,需要按顺序锁定所有段,操作完毕后再按照顺序释放所有的段。
在其内部,段数组是final的,并且其成员变量也是final的,但是仅仅将数组声明为final的并不能保证数组成员也是final的,这需要实现上的保证,这可以确保不会出现死锁,因为获取锁的顺序是固定的。
数据结构
1.7
JDK1.7中,采用分段锁机制,实现并发的更新操作,底层采用数组(Segment)+链表(HashEntry)的存储结构。
- Segment继承ReentrantLock(重入锁)来充到锁的角色,每个Segment对象守护每个散列映射表的若干个桶
- HashEntry用来封装映射表的键值对
- 每个桶是由若干个HashEntry对象链接起来的链表
1.8
JDK1.8中采取了Node+CAS+Synchronized来保证并发安全。取消Segment,直接用table数组存储键值对
当HashEntry对象组成的链表长度超过了TREEIFFY_THRESHOLD时,链表转换为红黑树,提升性能。底层变为数组+链表+红黑树。
也将1.7中存放数据的hashEntry改为了NOde
其中val next都用了volatile修饰,保证了可见性
用内置锁Synchronized来代替重入锁ReentrantLock的好处:
- 粒度降低了
- JVM开发团队没有放弃Synchronized,而且基于JVM的Synchronized优化空间更大、更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存
Unsafe与CAS
在ConcurrentHashMap中,随处可以见到U,大量使用了U.compareAdmSwapXXX方法,这个方法是利用一个CAS算法来实现无锁化的修改值操作。可以大大降低锁代理的性能消耗。
算法思想:就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,相等就接受你的修改值,否则拒绝。
这和乐观锁,SVN的思想比较类似。
unsafe静态块
unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECEL。大量应用CAS方法进行变量、属性的修改工作。利用CAS进行无锁操作,可以提高性能。
三个核心方法
定义了三个原子操作,用于对指定位置的节点进行操作。正是这些原子操作保证了其线程安全
tabAt
//获得在i位置上的Node节点
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
casTabAt
//利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少
//在CAS算法中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受你的修改,否则拒绝你的修改
//因此当前线程中的值并不是最新的值,这种修改可能会覆盖掉其他线程的修改结果 有点类似于SVN
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
setTabAt
//利用volatile方法设置节点位置的值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
重要成员
重要属性
sizeCtl
- 负数表示正在进行初始化或扩容操作
- -1表示正在初始化
- -N表示有N-1线程正在进行扩容操作
- 0表示hash表还没有初始化,
- 正数表示初始化或下一次扩容的大小,它的值始终是当前容量的0.75倍。
table
盛装Node元素的数组,其大小是2的整数次幂
以下两个变量是用来控制扩容的时候单线程进入的变量
RESIZE_STAMP_BITS=16
RESIZE_STAMP_SHIFT=32-RESIZE_STAMP_BITS
MOVWD=-1 hash值是-1表示是一个
TREEBIN=-2
重要的类
Node
Node是最核心的内部类,它包装了key-value键值对,所有插入ConcurrentHashMap的数据都包装在这里面。它对value和next属性设置了volatile同步锁(与JDK7的Segment相同),它不允许调用setValue方法直接改变Node的value域,它增加了find方法辅助map.get()方法。
TreeNode
树的节点类。TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;转化的时候不是直接转换为红黑树,而是把这些节点包装成TreeNode 放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且其自带next指针,方便基于TreeBin的访问
TreeBin
是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。在实际的数组中存放的是TreeBin而不是TreeNode对象,另外这个类还带了读写锁
ForwardingNode
一个用于连接两个table的节点类。包含一个nexttable指针,用于指向下一个表。而且这个节点的key value指针全部为null,它的hash值为-1,这里定义的find方法是从nextTable里进行查询节点,而不是以自身为头节点进行查询
方法
构造方法
//构造方法
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)//判断参数是否合法
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY ://最大为2^30
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));//根据参数调整table的大小
this.sizeCtl = cap;//获取容量
//ConcurrentHashMap在构造函数中只会初始化sizeCtl值,并不会直接初始化table
}
//调整table的大小
private static final int tableSizeFor(int c) {
//返回一个大于输入参数且最小的为2的n次幂的数。
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
table初始化(initTable)
对于ConcurrentHashMap来说,调用构造方法仅仅是设置了一些参数而是,整个table的初始化是在第一次put操作(检查table==null)的时候发生的。
由于put是并发操作,所以要保证只初始化一次,这个时候需要关键属性sizeCtl。
如果这个值小于0,表示已经有线程在初始化了。当线程获得初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入,初始化后,将sizeCtl的值改为0.75*n
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片,
//由于sizeCtl是volatile的,保证了顺序性和可见性
if ((sc = sizeCtl) < 0)//sc保存了sizeCtl的值
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
//cas操作判断并置为-1
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;//DEFAULT_CAPACITY = 16,若没有参数则大小默认为16
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
table扩容(transfer)
当容量不足时,即table的元素数量达到容量阈值sizeCtl,需要对table进行扩容
- 首先先构件一个nextTable,大小为table的两倍(单线程完成的,这个单线程是保证是通过RESIZE_STAMP_SHIFT这个常量来保证)
- 把table的数据复制到nextTable中。(允许多线程)
- 单线程下:遍历、复制过程。首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素
- 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点。
- 如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们放在nextTable的i和i+n的位置上
- 遍历过所有节点以后就完成了复制工作,然后让nextTable作为新的table,并且把sizeCtl扩容0.75倍,完成扩容。
- 多线程下:
- 如果遍历到的节点是forward节点,就向后继续遍历,再给节点上锁。就完成了多线程的控制
- 多线程遍历节点,处理了一个节点,就把对应点的set值置为forward,另一个节点看到forward,就继续向后遍历。这样交叉就完成了复制工作。
- 单线程下:遍历、复制过程。首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素
put操作
put方法依旧沿用了HashMap put方法的思想。
根据hash值计算这个新插入的点在table中的位置i,如果为空就直接插入,否则判断,如果是树节点就按照树的方式插入,否则就插入到链表尾。
在此基础上,不允许key或者value为null,另外在多线程下:
- 如果一个或多个线程正在进行扩容操作,当前线程也要进入扩容的操作中,这个扩容的操作之所以能被检测到,因为transfer方法中在空节点上插入forward节点,如果检测到需要插入的位置被forward占有,就帮助进行扩容
- 如果检测到要插入的节点非空且不是forward,就对这个节点进行加锁。
get操作
给定一个key来确定value时,必须满足两个条件:key相同hash想用,对于节点可能在链表或书上的情况,需要分别去查找
helpTransger
这是一个协助扩容的方法,这个方法被调用的时候,当前ConcurrentHashMap已经有nextTable对象,首先拿到这个对象,然后调用transfer方法。
treeifyBin
这个方法用于将长的链表转换为TreeBin对象,但是并不是直接转换,而是进行一次容量判断,如果没有达到转换的要求,直接进行扩容操作并返回。
只有满足条件,链表结构才抓为TreeBin,并没有把TreeNode直接放入红黑树,而是利用TreeBin这个小容器来封装所有的TreeNode。
常见问题
1、ConcurrentHashMap和HashTable的区别
- ConcurrentHashMap是一个并发散列映射表,它允许完全并发读取,并且支持给定数量的并发更新
- HashTable和同步包装器包装的HashMap,使用一个全局的锁来同步不同线程间的并发访问,同一时间点,只能有一个线程持有锁。这虽然保证了多线程间的安全并发访问,但同时也导致了对容器的访问变成串行化的了。
2、ConcurrentHashMap 的并发度是什么?
-
程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。
-
当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)