1.ConcurrentHashMap:无锁化并且线程安全,(CAS & AQS)
必备知识点:
- ConcurrentHashMap底层数据结构是哈希表(数组 + 链表 + 红黑树)
- put的返回值
- 默认大小为16
- 每次扩容大小为当前的两倍(table[ ] 的长度永远为2的n次幂)
- 默认扩容阈值为0.75
- 存放100个key,value对 不指定阈值时,初始大小为256
- Map<String, String> map = new ConcurrentHashMap<String, String>(100);之后调用 map.size的值为0
- 使用自定义key类型,需要实现hashcode() 和 equals() 方法
- 链表长度>8时链表有可能转为红黑树
- 链表加入元素为尾插法
- 红黑树结点 < 6时转为链表
- 整个API几乎没什么锁
- 扩容时,可多个线程一起扩容
- 扩容时每个线程获取的任务量和CPU有关
- sizeCtl 含义很多
这些都是为什么呢?
- 哈希表的查找效率比链表、数组、树快很多,时间复杂度为O(1)
- 如果put的key相同,则返回 oldValue 旧的value值,否则为null,使hashmap使用更灵活
- 16是研究结论
- 数组大小永远为2的n次幂是为了后面快速的hash计算,用位运算代替 取余,详细参考下文内容
- 0.75是参考值,可修改,0.75的计算为(当前大小 - 当前大小无符号右移2位)也比较快
- 长度始始终为2的n次幂,所以需要至少128的大小,未指定阈值,采用默认值0.75
- 100/128 > 0.75 会触发扩容,因此为256
- 初始化在第一次put,无论你是否指定他大小,优点参考类加载的优点
- 在第一次hash时候,调用hashcode 方法确定位置,hashcode 相同时会调用equals() 进行比较,因此需要实现
- 链表长度>8,且table长度 > 64 时才转化为红黑树,否则触发扩容,研究结论,8:参考泊松分布,8的概率<千万分之一
- 尾插法不如头插法快,但头插法可能造成死循环,同时为了兼容红黑树,因此尾插法更合适
- < 6 而不是 7 是为了有弹性,避免频繁发生 链表 ---- 红黑树的转换
- 重量锁会严重降低并发性能,采用CAS来替代锁
- 有一个线程触发扩容,其他使用ConcurrentHashMap线程不会袖手旁观,会进来帮助一起扩容,每个线程领取一份扩容任务,大家一起做,扩容会更快。
- 充分压榨CPU的性能,杜绝性能浪费
- 1.减少内存占用 2.体现写代码人技能巧夺天工
put操作源码:
public V put(K key, V value) {
return putVal(key, value, false);
}
发现put调用了putVal 方法,且最后一个参数为false,意思是如果key存在,则更新value值,返回旧val值。如果putVal最后一个参数为true,如果key存在,则只返回旧val值。
final V putVal(K key, V value, boolean onlyIfAbsent) {
//ConcurrentHashMap中key和value都不允许为空
if (key == null || value == null) throw new NullPointerException();
//计算key的哈希值
int hash = spread(key.hashCode());
//冲突次数 或者说 链表存放尝试次数
int binCount = 0;
for (Node<K,V>[] tab = table;;) {// 等价于while true
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0) // 如果还没初始化则进行初始化
tab = initTable();// 初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 如果table对应位置为空,DCL机制存放到相应位置,并跳出循环
// 用CAS的方式插入新结点
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else { // table数组想放的哪个位置已经存放了数据
V oldVal = null;// 用于保存旧的value值
synchronized (f) { // 这里加了个锁,这个f是table[]的一个小格子,很微小,只锁定了hashcode相同的key的操作
if (tabAt(tab, i) == f) { // DCL机制,再来看看现在的f是不是之前的f,因为之前没加锁,有可能会不一样
if (fh >= 0) {// fh就是f的hashcode值
binCount = 1; // 尝试次数为1了,因为放到table对应位置,发现不为null才来的这里
for (Node<K,V> e = f;; ++binCount) { // 作用就是把新的key放到链表尾部
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {//把新的key放到链表尾部
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}else if (f instanceof TreeBin) {//如果是红黑树就放到红黑树里
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {//判断binCount binCount 肯定大于0
if (binCount >= TREEIFY_THRESHOLD)// 转成红黑树的阈值8
treeifyBin(tab, i);
if (oldVal != null)//如果是覆盖了原来的值,那就返回原来的值
return oldVal;
break;
}
}
}
addCount(1L, binCount);//map中的key个数+1
return null;
}
重要的方法:
- spread(key.hashCode());------------------计算hash
- initTable();------------------初始化,
- tabAt(tab, i = (n - 1) & hash))------------------查看内存中最新值
- casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))------------------用CAS的方式插入新结点
- helpTransfer(tab, f);------------------???
- putTreeVal(hash, key,value)--------------把新结点放到红黑树里
- treeifyBin(tab, i);--------------------把 tab 第 i 个节点下的链表转成红黑树
- addCount(1L, binCount);------------------执行完putVal,相当于count ++
spread函数:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
将 key 的 hashCode() h,将其与 自己的高16位进行异或运算,与HASH_BITS进行与运算消除负hash。
initTable()函数:
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)//为什么判断他小于0?
Thread.yield(); // 线程让步,有前人正在初始化,退让,下次获取权限就退出该函数
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//通过CAS的方式,如果sc=0,就把它置为-1
try {
if ((tab = table) == null || tab.length == 0) {//DCL 双重验证
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;//sc如果小于0,则置为16,按照我们当前的思路,这里肯定大于0,没变相当于,!sizeCtl可以表示初始化的大小
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];//创建
table = tab = nt;
sc = n - (n >>> 2);//sc的值改变为 n - 0.25n,也就是 0.75n
}
} finally {
sizeCtl = sc;//sc 赋值给 sizeCtl !sizeCtl可以表示阈值,
}
break;
}
}
return tab;
}
tabAt函数:
@SuppressWarnings("unchecked")
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);
}
查看当前内存中最新的 table[i] 的值。
问table不是volatie修饰的么?为什么还要这样去看?
答:
table确实是volatie的,这只是代表数组的引用是内存可见的,但不代表引用地址指向的内容是可见的,因此需要用这种方式来查看内存中最新的。
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);
}
用CAS的方式来放进去。
putTreeVal:
在红黑树中添加一个节点的操作。
treeifyBin:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)//MIN_TREEIFY_CAPACITY=64,只有table.length 不< 64 时候,他才会转成红黑树
tryPresize(n << 1);//tab.length < 64 进行双倍扩容(左移一位)
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {//DCL 检查
TreeNode<K,V> hd = null, tl = null;//下面是转红黑树的操作,对算法感兴趣的可以看看,看思路务必先略过
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd)); // 与上面类似,为了安全
}
}
}
}
}
重点:
addCount 就是让count++,并且如果table不够大,那就扩容,如果已经在扩容,那就去帮着扩容。
自旋锁(自旋锁如果已经被别的执行单元保持,调用者就一直循环等待在那里看是否该自旋锁的保持者已经释放了锁;和互斥锁类似,互斥锁如果已经被别的执行单元保持,那么调用者会进入 “睡眠” 状态)是有可能失败的,所以他是unsafe底下的方法,ConcurrentHashMap为了让它变成安全,Doug Lea加了两个内存可见的变量:
/**
* Base counter value, used mainly when there is no contention,
* but also as a fallback during table initialization
* races. Updated via CAS.
翻译:
基本计数器值,主要用于没有争用时,但也用作表初始化竞赛期间的后备值。通过CAS更新
*/
private transient volatile long baseCount;//也就是这个变量是存储当前一共有多少key,(但不一定准确,因为CAS 时候有可能失败,该变量未统计CAS失败的个数)
/**
* Table of counter cells. When non-null, size is a power of 2.
*/
private transient volatile CounterCell[] counterCells;//CAS计数失败时候保存在这里
//可以猜出来counterCells不为null之前已经有过baseCount CAS失败,发生失败代表并发不低
//baseCount CAS失败时 则在counterCells数组中使用随机数随便取一个索引位置之前记录的数据进行数量累加CAS
//这也解释了为什么额外的计数器采用数组而不是int之类的,因为CAS的机制,并发太高时候他就容易失败,采用数组做缓冲。
/**
* Adds to count, and if table is too small and not already
* resizing, initiates transfer. If already resizing, helps
* perform transfer if work is available. Rechecks occupancy
* after a transfer to see if another resize is already needed
* because resizings are lagging additions.
*
* @param x the count to add
* @param check if <0, don't check resize, if <= 1 only check if uncontended
翻译:
*添加计数
如果表太小且尚未调整大小,则调用transfer。
如果正在调整大小,且还有活干,那就去帮忙去。
在转移之后重新检查占用率,看看是否已经需要再调整大小,因为调整大小是滞后的添加。
*@ PARAM-X要添加的计数
* @ PARAM检查是否为0,不检查调整大小,如果<=1只检查是否未争用
*/
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//..省略处理高并发的部分
if (check >= 0) {//put可以进来
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
//sc=sizeCtl初始是阀值,sc肯定大于0,先不看这
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//启动transfer,
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
2. ConcurrentHashMap与HashMap等的区别?
HashMap
HashMap是线程不安全的,在多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。
HashTable
HashTable和HashMap的实现原理几乎一样,差别无非是HashTable不允许key和value为null,HashTable是线程安全的。
但是HashTable线程安全的策略实现代价太大,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。
多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
ConcurrentHashMap
主要是为了应对hashmap在并发环境下不安全而诞生的,ConcurrentHashMap大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。
1)ConcurrentHashMap在JDK1.7和1.8中的不同:
在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。
-
Segment(分段锁)
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。 -
内部结构
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。
- 第一次Hash定位到Segment;
- 第二次Hash定位到元素所在的链表的头部。
缺点:
Hash的过程要比普通的HashMap要长。
优点:
写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。
所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
2) JDK1.8版本的CurrentHashMap的实现原理:
JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作。
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。
Java中的乐观锁和悲观锁:
悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。
而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
**JDK8中彻底放弃了Segment转而采用的是Node,**其设计思想也不再是JDK1.7中的分段锁思想。
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。
class Node<K,V>implements Map.Entry<K,V>{
finalint hash;
final K key;
volatile V val;
volatileNode<K,V> next;//... 省略部分代码
}
Java8 ConcurrentHashMap 结构基本上和 Java8 HashMap 一样,不过保证线程安全性。
在JDK8中ConcurrentHashMap的结构,由于引入了红黑树,使得ConcurrentHashMap的实现非常复杂,我们都知道,红黑树是一种性能非常好的二叉查找树,其查找性能为O(logN),但是其实现过程也非常复杂,而且可读性也非常差。
总结
可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。
从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。
JDK1.7 | JDK 1.8 | |
---|---|---|
数据结构 | Segment分段锁 | 数组+链表+红黑树 |
保证线程安全机制 | 采用segment的分段锁机制 | 采用CAS+synchronized保证线程安全 |
锁的粒度 | 对需要进行数据操作的Segment加锁 | 对每个数组元素加锁(Node) |
查询时间复杂度 | 遍历链表O(n) | 遍历红黑树O(logN) |
链表转化为红黑树 | 定位结点的hash算法简化会带来弊端,Hash冲突加剧 | 链表节点数量大于8时,会将链表转化为红黑树进行存储 |