ConcurrentHashMap
前言:现在很多会涉及到高并发的应用场景,最常用的双列集合HashMap是线程不安全的,HashTable虽然线程安全但却效率太低,所以就在HashMap的基础上有了并发安全的ConcurrentHashMap。
- 为什么HashMap线程不安全:
- 多线程put操作,导致元素丢失。
- 多线程put操作后,get操作导致死循环。
- 为什么HashTable线程安全:因为它的remove,put,get都做成了同步方法。
- HashTable安全机制的缺点:只有一把锁,效率很低。比如只要有一个线程put,其他线程即使put在其他桶里,也不能进行put操作。
- 改进1:能不能多几把锁?—— 于是就有了JDK7中的分段锁,但只要有锁就不可避免有等待和竞争,会降低效率。
- 改进2:能不能尽量不用锁?——于是就有了JDK8中的用到的无锁算法CAS和部分加锁。
- ConcurrentHashMap的总体特征:
- 底层数据结构与HashMap相同为 数组+链表+红黑树
- 支持高并发的访问和更新,它是线程安全的
- 检索操作不用加锁,get方法是非阻塞的
- key和value都不允许为null
其他前置知识:
- 线程安全三大特征:
- 原子性和事务的原子性一样,对于一个操作或者多个操作,要么都执行,要么都不执行。
- 指令有序性是指,在我们编写的代码中,上下两个互不关联的语句不会被指令重排序。指令重排序是指处理器为了性能优化,在无关联的代码的执行是可能会和代码顺序不一致。比如说int i = 1;int j = 2;那么这两条语句的执行顺序可能会先执行int j = 2;
- 线程可见性是指一个线程修改了某个变量,其他线程能马上知道。
- volatile:vvolatile修饰的变量具有可见性与有序性,但不保证原子性。
- Java内存模型:每个线程都需要从主内存中获得变量的值,获得数据之后会放入自己的工作内存进行操作。
- CAS算法:
- 为什么: 并发的问题根本在于缓存,两个线程改同一个数据时,并不是改主内存中的这个数据,而是改分别的CPU中的缓存,所以会出现改到同一个值。
- 是什么:Compare and swap,先比较与内存中是否相等,如果相等再替换。
- 三个操作数:内存值V;旧的预期值A;要修改的新值B
- 当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做
- 怎么做:USAFE类中的 CompareAndSwapXXX 相关方法
与HashMap的区别
与HashMap基本一样是 数组+链表+红黑树,以下主要说明不同点
- Node节点的变化:value和next都用volatile修饰,保证并发的可见性。
- TreeNode和TreeBin:在ConcurrentHashMap中不是直接存储TreeNode来实现的,而是用TreeBin来包装TreeNode来实现的。这里也是与HashMap之间比较大的区别。
- ForwordingNode:扩容阶段使用
- 构造方法
- 多了一个 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)
- 初始化过程也有区别:
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
//如果ic设为32,hm和1.7的chm会初始为32,但1.8的chm会初始为64
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
* @param concurrencyLevel the estimated number of concurrently
* updating threads. The implementation may use this value as
* a sizing hint.
// 并发度:对并发线程数量的一个估计值
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
并发安全的原理
概述:ConcurrentHashMap取消了segment分段锁,而采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
下面主要通过阅读put操作源码来分析是如果利用CAS和Synchronized进行高效的同步更新数据。
- put流程总结:
- 初始化:判断Node[]数组是否初始化,没有则进行初始化操作
- 索引与CAS添加节点:通过hash定位Node[]数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头结点),添加失败则进入下次循环。分类型添加链表节点或树节点。
- 扩容检查:检查到内部正在扩容,如果正在扩容,就帮助它一块扩容。
- 锁住头结点:如果f!=null,则使用synchronized锁住f元素(链表/红黑二叉树的头元素)
- 判断链表长度:已经达到临界值8 就需要把链表转换为树结构。
- 与HashMap.put()不同点:用CAS添加节点,用synchronized锁住头结点,扩容方面多线程辅助扩容。
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//ConcurrentHashMap 不允许插入null键,HashMap允许插入一个null键
if (key == null || value == null) throw new NullPointerException();
//计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
//for循环的作用:因为更新元素是使用CAS机制更新,需要不断的失败重试,直到成功为止。
for (Node<K,V>[] tab = table;<