ConcurrentHashMap
ConcurrentHashMap是JUC包中提供的一个并发集合类,是线程安全且高效的HashMap(该文章有对HashMap的详细介绍,结构方面二者相似)实现。
之所以引入ConcurrentHashMap,是因为
- HashMap线程不安全:在多线程环境下,使用HashMap的put操作会引起死循环,原因是多线程会导致HashMap的Entry链表形成环形数据结构,导致Entry的next节点永远不为空,就会产生死循环获取Entry。(HashMap的key,value均可为null,其他两个不行。)
- HashTable效率低下:HashTable容器使用sychronized来保证线程安全,采取锁住整个表结构来达到同步目的,在线程竞争激烈的情况下,当一个线程访问HashTable的同步方法,其他线程也访问同步方法时,会进入阻塞或轮询状态;如线程1使用put方法时,其他线程既不能使用put方法,也不能使用get方法,效率非常低下。
-
ConcurrentHashMap的锁分段技术可提升并发访问效率:首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
以下介绍JDK8中的ConcurrentHashMap
结构:
JDK1.8以后:和 1.8 HashMap 结构类似,即数组+链表+红黑树,节点的val和next都为volatile修饰。
JDK1.8以前:ConcurrentHashMap由多个 Segment 组合而成。Segment 本身就相当于一个 HashMap 对象。同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表,即其每个节点为一个小的hashmap。每个HashEntry中的value虽然是volatile修饰的,但不能保证其原子性,因此需要使用Segment锁锁住修改节点。
put:
JDK1.8以前:
1、加锁操作;(加锁时若失败会自旋,自旋达到一定次数后变为阻塞锁,即线程进入等待队列)
2、遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
3、为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
4、释放锁;
Put 操作时,锁的是某个 Segment,其他线程对其他 Segment 的读写操作均不影响。因此解决了线程安全问题。
JDK1.8以后:
1、根据 key 计算出 hashcode,然后开始遍历 table;
2、判断是否需要初始化;
3、f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
4、如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
5、如果都不满足,则利用 synchronized 锁写入数据。
7、如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
get:
JDK1.8以前:
1、Key 通过 Hash 之后定位到具体的 Segment;
2、再通过一次 Hash 定位到具体的元素上;
3、由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
JDK1.8以后:
1、根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
2、如果是红黑树那就按照树的方式获取值。
3、都不满足那就按照链表的方式遍历获取值。
扩容:
通过判断该节点的hash值是不是等于-1(MOVED),代码为(fh = f.hash) == MOVED,说明 Map 正在扩容。那么就帮助 Map 进行扩容,以加快速度。
这里重点是多线程并发扩容,可以大大加快扩容速度。
关键方法:
ConcurrentHashMap支持Java8中的lambda表达式,对代码进行了简化。
computeIfAbsent:如果key不存在,则调用后面的函数式接口计算,把计算后的val作为值。
public class ConcurrentHashMapDemo { private static final ConcurrentHashMap<String, Integer> USER_ACCESS_COUNT = new ConcurrentHashMap<>(); public static void main(String[] args) { //如果key不存在,则调用后面的函数式接口计算,把计算后的val作为值 USER_ACCESS_COUNT.computeIfAbsent("cc", k -> 1); System.out.println(USER_ACCESS_COUNT.get("cc")); } }
computeIfPresent:如果key存在则修改并返回value,如果不存在则返回null。
public class ConcurrentHashMapDemo { private static final ConcurrentHashMap<String, Integer> USER_ACCESS_COUNT = new ConcurrentHashMap<>(); public static void main(String[] args) { //如果key存在则修改,如果不存在则返回null System.out.println(USER_ACCESS_COUNT.computeIfPresent("cc", (k, v) -> v + 1)); } }
merge(合并数据)
如果指定的键尚未与(非空)值相关联,则将其与给定值相关联。
-
key - 与指定值关联的键
-
value - 如果不存在则使用的值
-
remappingFunction - 重新计算值(如果存在)的函数
public class ConcurrentHashMapDemo { private static final ConcurrentHashMap<String, Integer> USER_ACCESS_COUNT = new ConcurrentHashMap<>(); //merge public static void main(String[] args) { ConcurrentHashMap<Integer, Integer> concurrentHashMap = new ConcurrentHashMap(); Stream.of(1, 2, 3, 4, 6, 2, 3, 6, 8, 1).forEach(v -> { concurrentHashMap.merge(v, 5, Integer::sum); }); System.out.println(concurrentHashMap); } }
//输出结果:{1=10, 2=10, 3=10, 4=5, 6=10, 8=5}
在JDK1.7和JDK1.8中的区别:
在JDK1.8主要设计上的改进有以下几点:
1、不采用segment而采用node,锁住node来实现减小锁粒度。
2、设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。
3、使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。
4、sizeCtl的不同值来代表不同含义,起到了控制的作用。
采用synchronized而不是ReentrantLock:
1. 减少内存开销
假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
2. 获得JVM的支持
可重入锁毕竟是API这个级别的,后续的性能优化空间很小。
synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。
有关HashMap的补充
HashMap源码中,对于key的hash代码如下:
之所以要将hashCode右移16位再和原值异或,是因为在HashMap中取数组下标时,下标值=(n-1)&hash;n为Node数组长度,因为数组长度一般不会超过16位,所以为了能够充分利用HashCode的随机性,需要把高16位也利用起来,从而避免了高16位的浪费,且增加了低16位的随机性和均匀性,减少了Hash冲突的几率。
参考文章:https://blog.csdn.net/mrlin6688/article/details/104646661
https://blog.csdn.net/qq_41432730/article/details/121318157
https://blog.csdn.net/qq_29051413/article/details/107869427