一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!

沙海 2022年5月20日05:42:01Java评论25字数 13985阅读46分37秒阅读模式
摘要

智能摘要

速蛙云 - 极致体验,强烈推荐!!!

智能摘要文章源自JAVA秀-https://www.javaxiu.com/65549.html

与强一致性相对的就是弱一致性,即数据更新之后,如果立即访问的话可能访问不到或者只能访问部分的数据。这部分的数据由于是线程独有的,所以不存在一致性问题(我们说的一致性问题往往指多线程间的数据一致性),一部分是线程共享的堆和方法区,我们重点看一下堆内存。put是有加锁的,所以其实如果get也加锁的话,那么毫无疑问get是可以立即拿到put的值的。文章源自JAVA秀-https://www.javaxiu.com/65549.html

原文约 1.1 万 | 图片 16 | 建议阅读 22 分钟 | 评价反馈文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!

芋道源码 文章源自JAVA秀-https://www.javaxiu.com/65549.html

以下文章来源于码海,作者坤哥文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

码海.文章源自JAVA秀-https://www.javaxiu.com/65549.html

一起进阶,一起牛逼!文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章源自JAVA秀-https://www.javaxiu.com/65549.html

点击上方“芋道源码”,选择“设为星标文章源自JAVA秀-https://www.javaxiu.com/65549.html

管她前浪,还是后浪?文章源自JAVA秀-https://www.javaxiu.com/65549.html

能浪的浪,才是好浪!文章源自JAVA秀-https://www.javaxiu.com/65549.html

每天 10:33 更新文章,每天掉亿点点头发...文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章源自JAVA秀-https://www.javaxiu.com/65549.html

源码精品专栏文章源自JAVA秀-https://www.javaxiu.com/65549.html

 文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章源自JAVA秀-https://www.javaxiu.com/65549.html

来源:码海文章源自JAVA秀-https://www.javaxiu.com/65549.html

上周我在极客时间某个课程看到某个讲师在讨论 ConcurrentHashMap(以下简称 CHM)是强一致性还是弱一致性时,提到这么一段话文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

这个解释网上也是流传甚广,那么到底对不对呢,在回答这个问题之前,我们得想清楚两个问题文章源自JAVA秀-https://www.javaxiu.com/65549.html

  1. 什么是强一致性,什么是弱一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

  2. 上文提到 get 没有加锁,所以没法即时获取 put 的数据,也就意味着如果加锁就可以立即获取到 put 的值了?那么除了加锁之外,还有其他办法可以立即获取到 put 的值吗文章源自JAVA秀-https://www.javaxiu.com/65549.html

强一致性与弱一致性

强一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

首先我们先来看第一个问题,什么是强一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

一致性(Consistency)是指多副本(Replications)问题中的数据一致性。可以分为强一致性、弱一致性。文章源自JAVA秀-https://www.javaxiu.com/65549.html

强一致性也被可以被称做原子一致性(Atomic Consistency)或线性一致性(Linearizable Consistency),必须符合以下两个要求文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 任何一次读都能立即 读到某个数据的最近一次写的数据文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

简单地说就是假定对同一个数据集合,分别有两个线程 A、B 进行操作,假定 A 首先进行了修改操作,那么从时序上在 A 这个操作之后发生的所有 B 的操作都应该能立即 (或者说实时 )看到 A 修改操作的结果。文章源自JAVA秀-https://www.javaxiu.com/65549.html

弱一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

与强一致性相对的就是弱一致性,即数据更新之后,如果立即访问的话可能访问不到或者只能访问部分的数据。如果 A 线程更新数据后 B 线程经过一段时间 后都能访问到此数据,则称这种情况为最终一致性,最终一致性也是弱一致性,只不过是弱一致性的一种特例而已文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么在 Java 中产生弱一致性的原因有哪些呢,或者说有哪些方式可以保证强一致呢,这就得先了解两个概念,可见性和有序性文章源自JAVA秀-https://www.javaxiu.com/65549.html

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能。文章源自JAVA秀-https://www.javaxiu.com/65549.html

项目地址:https://github.com/YunaiV/ruoyi-vue-pro文章源自JAVA秀-https://www.javaxiu.com/65549.html

一致性的根因:可见性与有序性

可见性

首先我们需要了解一下 Java 中的内存模型文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

上图是 JVM 中的 Java 内存模型,可以看到,它主要由两部分组成,一部分是线程独有的程序计数器虚拟机栈本地方法栈,这部分的数据由于是线程独有的,所以不存在一致性问题(我们说的一致性问题往往指多线程间的数据一致性),一部分是线程共享的方法区,我们重点看一下堆内存。文章源自JAVA秀-https://www.javaxiu.com/65549.html

我们知道,线程执行是要占用 CPU 的,CPU 是从寄存器里取数据的,寄存器里没有数据的话,就要从内存中取,而众所周知这两者的速度差异极大,可谓是一个天上一个地上,所以为了缓解这种矛盾,CPU 内置了三级缓存,每次线程执行需要数据时,就会把堆内存的数据以 cacheline(一般是 64 Byte) 的形式先加载到 CPU 的三级缓存中来,这样之后取数据就可以直接从缓存中取从而极大地提升了 CPU 的执行效率(如下图示)文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

但是这样的话由于线程加载执行完数据后数据往往会缓存在 CPU 的寄存器中而不会马上刷新到内存中,从而导致其他线程执行如果需要堆内存中共享数据的话取到的就不会是最新数据了,从而导致数据的不一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

举个例子,以执行以下代码为例文章源自JAVA秀-https://www.javaxiu.com/65549.html

//线程1执行的代码int i = 0;i = 10;//线程2执行的代码j = i;

在线程 1 执行完后 i 的值为 10,然后 2 开始执行,此时 j 的值很可能还是 0,因为线程 1 执行时,会先把 i = 0 的值从内存中加载到 CPU 缓存中,然后给 i 赋值 10,此时的 10 是更新在 CPU 缓存中的 ,而未刷新到内存中 ,当线程 2 开始执行时,首先会将 i 的值从内存中(其值为 0)加载到 CPU 中来,故其值依然为 0,而不是 10,这就是典型的由于 CPU 缓存而导致的数据不一致现象。文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么怎么解决可见性导致的数据不一致呢,其实只要让 CPU 修改共享变量时立即写回到内存中,同时通过总线协议(比如 MESI)通过其他 CPU 所读取的此数据所在 cacheline 无效以重新从内存中读取此值即可文章源自JAVA秀-https://www.javaxiu.com/65549.html

有序性

除了可见性造成的数据不一致外,指令重排序也会造成数据不一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

int x = 1;   ① boolean flag = true; ② int y = x + 1; ③ 

以上代码执行步骤可能很多人认为是按正常的 ①,②,③ 执行的,但实际上很可能编译器会将其调换一下位置,实际的执行顺序可能是 ①③②,或 ②①③,也就是说 ①③ 是紧邻的,为什么会这样呢,因为执行 1 后,CPU 会把 x = 1 从内存加载到寄存器中,如果此时直接调用 ③ 执行,那么 CPU 就可以直接读取 x 在寄存器中的值 1 进行计算,反之,如果先执行了语句 ②,那么有可能 x 在寄存器中的值被覆盖掉从而导致执行 ③ 后又要重新从内存中加载 x 的值,有人可能会说这样的指令重排序貌似也没有多大问题呀,那么考虑如下代码文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class Reordering {    private static boolean flag;    private static int num;    public static void main(String[] args) {        Thread t1 = new Thread(new Runnable() {            @Override            public void run() {                while (!flag) {                    Thread.yield();                }                System.out.println(num);            }        }, "t1");        t1.start();        num = 5;                ①         flag = true;        ②     }}

以上代码最终输出的值正常情况下是 5,但如果上述 ① ,②  两行指令发生重排序,那么结果是有可能为 0 的,从而导致我们观察到的数据不一致的现象发生,所以显然解决方案是避免指令重排序的发生,也就是保证指令按我们看到的代码的顺序有序执行,也就是我们常说的有序性,一般是通过在指令之间添加内存屏障来避免指令的重排序文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么如何保证可见性与有序性呢文章源自JAVA秀-https://www.javaxiu.com/65549.html

相信大家都非常熟悉了,使用 volatile 可以保证可见性与有序性,只要在声明属性变量时添加上 volatile 就可以让此变量实现强一致性,也就是说上述的 Reordering 类的 flag 只要声明为 volatile,那么打印结果就永远是 5!文章源自JAVA秀-https://www.javaxiu.com/65549.html

好了,现在问题来了,CHM 到底是不是强一致性呢,首先我们以 Java 8 为例来看下它的设计结构(和之前的版本相差不大,主要加上了红黑树提升了查询效率)文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

来看下这个 table 数组和节点的声明方式(以下定义 8 和 之前的版本中都是一样的):文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>    implements ConcurrentMap<K,V>, Serializable {        transient volatile Node<K,V>[] table;      ...}static class Node<K,V> implements Map.Entry<K,V> {    final int hash;    final K key;    volatile V val;    volatile Node<K,V> next;      ...}

可以看到 CHM 的 table 数组,Node 中的 值 val,下一个节点 next 都声明为了 volatile,于是有学员就提出了一个疑问文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

讲师的回答也提到 CHM 为弱一致性的重要原因:即如果 table 中的某个槽位为空,此时某个线程执行了 key,value 的赋值操作,那么此槽位会新增 一个 Node 节点,在 JDK 8 以前,CHM 是通过以下方式给槽位赋 Node 的文章源自JAVA秀-https://www.javaxiu.com/65549.html

V put(K key, int hash, V value, boolean onlyIfAbsent) {  lock();  ...    tab[index] = new HashEntry<K,V>(...);  ...    unlock();}

然后是通过以下方式来根据 key 来读取 value 的文章源自JAVA秀-https://www.javaxiu.com/65549.html

V get(Object key, int hash) {    if (count != 0) { // read-volatile        HashEntry<K,V> e = getFirst(hash);        while (e != null) {            if (e.hash == hash && key.equals(e.key)) {                V v = e.value;                if (v != null)                    return v;                return readValueUnderLock(e); // recheck            }            e = e.next;        }    }    return null;}

可以看到 put 时是直接给数组中的元素赋值的,而由于 get 没有加锁,所以无法保证线程 A put 的新元素对执行  get 的线程可见。文章源自JAVA秀-https://www.javaxiu.com/65549.html

put 是有加锁的,所以其实如果 get 也加锁的话,那么毫无疑问 get 是可以立即拿到 put 的值的。为什么加锁也可以呢,其实这是 JLS(Java Language Specification Java 语言规范) 规定的几种情况,简单地说就是支持 happens before 语义的可以保证数据的强一致性,在官网(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)中列出了几种支持 Happens before 的情况,其中指出使用 volatile,synchronize,lock 是可以确保 happens before 语义的 ,也就是说使用这三者可以保证数据的强一致性,可能有人就问了,到底什么是 happens before 呢,其实本质是一种能确保线程及时刷新数据到内存,另一线程能实时从内存读取最新数据以保证数据在线程之间保持一致性的一种机制 ,我们以 lock 为例来简单解释下文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class LockDemo {  private int x = 0;  private void test() {    lock();    x++;    unlock();  }}

如果线程 1 执行 test,由于拿到了锁,所以首先会把数据(此例中为 x = 0)从内存中加载到 CPU 中执行,执行 x++ 后,x 在 CPU 中的值变为 1,然后解锁,解锁时会把 x = 1 的值立即刷新到内存中,这样下一个线程再执行 test 方法时再次获取相同的锁时又从内存中获取 x 的最新值(即 1),这就是我们通常说的对一个锁的解锁, happens-before 于随后对这个锁的加锁 ,可以看到,通过这种方式可以保证数据的一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

至此我们明白了:在 Java 8 以前,CHM 的 get,put 确实是弱一致性的 ,可能有人会问为什么不对 get 加锁呢,加上了锁不就可以确保数据的一致性了吗,可以是可以,但别忘了 CHM 是为高并发设计而生的,加了锁不就导致并发性大幅度下降了么,那 CHM 存在的意义是啥?文章源自JAVA秀-https://www.javaxiu.com/65549.html

所以 put,get 就无法做到强一致性了吗?文章源自JAVA秀-https://www.javaxiu.com/65549.html

我们在上文中已经知道,使用 volatile,synchronize,lock 是可以确保 happens before 语义的,同时经过分析我们知道使用 synchronize,lock 加锁的设计是不满足我们设计 CHM 的初衷的,那么只剩下 volatile 了,遗憾的是由于 Java 数组在元素层面的元数据设计上的缺失,是无法表达元素是 final、volatile 等语义的,所以 volatile 可以修饰变量,却无法修饰数组中的元素 ,还有其他办法吗?来看看 Java 8 是怎么处理的(这里只列出了写和读方法中的关键代码)文章源自JAVA秀-https://www.javaxiu.com/65549.html

private static final sun.misc.Unsafe U;// 写final V putVal(K key, V value, boolean onlyIfAbsent) {      ...    for (Node<K,V>[] tab = table;;) {      if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {        if (casTabAt(tab, i, null,                     new Node<K,V>(hash, key, value, null)))          break;      }    }      ...}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);}// 读public V get(Object key) {  if ((tab = table) != null && (n = tab.length) > 0 &&      (e = tabAt(tab, (n - 1) & h)) != null) {    ...  }  return null;}@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);}

可以看到在 Java 8 中,CHM 使用了 unsafe 类来实现读写操作文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 对于写首先使用 compareAndSwapObject(即我们熟悉的 CAS)来更新内存中 数组中的元素文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 对于读则使用了 getObjectVolatile 来读取内存中 数组中的元素(在底层其实是用了 C++ 的 volatile 来实现 java 中的 volatile 效果,有兴趣可以看看)文章源自JAVA秀-https://www.javaxiu.com/65549.html

由于读写都是直接对内存操作的,所以通过这样的方式可以保证 put,get 的强一致性,至此真相大白!Java 8 以后 put,get 是可以保证强一致性的!CHM 是通过 compareAndSwapObject 来取代对数组元素直接赋值的操作,通过 getObjectVolatile 来补上无法对数组元素进行 volatile 读的坑来实现的文章源自JAVA秀-https://www.javaxiu.com/65549.html

注意并不是说 CHM 所有的操作都是强一致性的,比如 Java 8 中计算容量的方法 size() 就是弱一致性(Java 7 中此方法反而是强一致性),所以我们说强/弱一致性一定要确定好前提(比如指定 Java 8 下 CHM 的 put,get 这种场景)文章源自JAVA秀-https://www.javaxiu.com/65549.html

基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。文章源自JAVA秀-https://www.javaxiu.com/65549.html

项目地址:https://github.com/YunaiV/onemall文章源自JAVA秀-https://www.javaxiu.com/65549.html

总结

其实 Java 8 对 CHM 进行了一番比较彻底的重构,让它的性能大幅度得到了提升,比如弃用 segment 这种设计,改用对每个槽位做分段锁,使用红黑树来降低查询时的复杂度,扩容时多个线程可以一起参与扩容等等,可以说 Java 8 的 CHM 的设计非常精妙,集 CAS,synchroinize,泛型等 Java 基础语法之大成,又有巧妙的算法设计,读后确实让人大开眼界,有机会我会再和大家分享一下其中的设计精髓,另外我们对某些知识点一定要多加思考,最好能自己去翻翻源码验证一下真伪,相信你会对网上的一些谬误会更容易看穿。上周我在极客时间某个课程看到某个讲师在讨论 ConcurrentHashMap(以下简称 CHM)是强一致性还是弱一致性时,提到这么一段话文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

这个解释网上也是流传甚广,那么到底对不对呢,在回答这个问题之前,我们得想清楚两个问题文章源自JAVA秀-https://www.javaxiu.com/65549.html

  1. 什么是强一致性,什么是弱一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

  2. 上文提到 get 没有加锁,所以没法即时获取 put 的数据,也就意味着如果加锁就可以立即获取到 put 的值了?那么除了加锁之外,还有其他办法可以立即获取到 put 的值吗文章源自JAVA秀-https://www.javaxiu.com/65549.html

强一致性与弱一致性

强一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

首先我们先来看第一个问题,什么是强一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

一致性(Consistency)是指多副本(Replications)问题中的数据一致性。可以分为强一致性、弱一致性。文章源自JAVA秀-https://www.javaxiu.com/65549.html

强一致性也被可以被称做原子一致性(Atomic Consistency)或线性一致性(Linearizable Consistency),必须符合以下两个要求文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 任何一次读都能立即 读到某个数据的最近一次写的数据文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

简单地说就是假定对同一个数据集合,分别有两个线程 A、B 进行操作,假定 A 首先进行了修改操作,那么从时序上在 A 这个操作之后发生的所有 B 的操作都应该能立即 (或者说实时 )看到 A 修改操作的结果。文章源自JAVA秀-https://www.javaxiu.com/65549.html

弱一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

与强一致性相对的就是弱一致性,即数据更新之后,如果立即访问的话可能访问不到或者只能访问部分的数据。如果 A 线程更新数据后 B 线程经过一段时间 后都能访问到此数据,则称这种情况为最终一致性,最终一致性也是弱一致性,只不过是弱一致性的一种特例而已文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么在 Java 中产生弱一致性的原因有哪些呢,或者说有哪些方式可以保证强一致呢,这就得先了解两个概念,可见性和有序性文章源自JAVA秀-https://www.javaxiu.com/65549.html

一致性的根因:可见性与有序性

可见性

首先我们需要了解一下 Java 中的内存模型文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

上图是 JVM 中的 Java 内存模型,可以看到,它主要由两部分组成,一部分是线程独有的程序计数器虚拟机栈本地方法栈,这部分的数据由于是线程独有的,所以不存在一致性问题(我们说的一致性问题往往指多线程间的数据一致性),一部分是线程共享的方法区,我们重点看一下堆内存。文章源自JAVA秀-https://www.javaxiu.com/65549.html

我们知道,线程执行是要占用 CPU 的,CPU 是从寄存器里取数据的,寄存器里没有数据的话,就要从内存中取,而众所周知这两者的速度差异极大,可谓是一个天上一个地上,所以为了缓解这种矛盾,CPU 内置了三级缓存,每次线程执行需要数据时,就会把堆内存的数据以 cacheline(一般是 64 Byte) 的形式先加载到 CPU 的三级缓存中来,这样之后取数据就可以直接从缓存中取从而极大地提升了 CPU 的执行效率(如下图示)文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

但是这样的话由于线程加载执行完数据后数据往往会缓存在 CPU 的寄存器中而不会马上刷新到内存中,从而导致其他线程执行如果需要堆内存中共享数据的话取到的就不会是最新数据了,从而导致数据的不一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

举个例子,以执行以下代码为例文章源自JAVA秀-https://www.javaxiu.com/65549.html

//线程1执行的代码int i = 0;i = 10;//线程2执行的代码j = i;

在线程 1 执行完后 i 的值为 10,然后 2 开始执行,此时 j 的值很可能还是 0,因为线程 1 执行时,会先把 i = 0 的值从内存中加载到 CPU 缓存中,然后给 i 赋值 10,此时的 10 是更新在 CPU 缓存中的 ,而未刷新到内存中 ,当线程 2 开始执行时,首先会将 i 的值从内存中(其值为 0)加载到 CPU 中来,故其值依然为 0,而不是 10,这就是典型的由于 CPU 缓存而导致的数据不一致现象。文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么怎么解决可见性导致的数据不一致呢,其实只要让 CPU 修改共享变量时立即写回到内存中,同时通过总线协议(比如 MESI)通过其他 CPU 所读取的此数据所在 cacheline 无效以重新从内存中读取此值即可文章源自JAVA秀-https://www.javaxiu.com/65549.html

有序性

除了可见性造成的数据不一致外,指令重排序也会造成数据不一致文章源自JAVA秀-https://www.javaxiu.com/65549.html

int x = 1;   ① boolean flag = true; ② int y = x + 1; ③ 

以上代码执行步骤可能很多人认为是按正常的 ①,②,③ 执行的,但实际上很可能编译器会将其调换一下位置,实际的执行顺序可能是 ①③②,或 ②①③,也就是说 ①③ 是紧邻的,为什么会这样呢,因为执行 1 后,CPU 会把 x = 1 从内存加载到寄存器中,如果此时直接调用 ③ 执行,那么 CPU 就可以直接读取 x 在寄存器中的值 1 进行计算,反之,如果先执行了语句 ②,那么有可能 x 在寄存器中的值被覆盖掉从而导致执行 ③ 后又要重新从内存中加载 x 的值,有人可能会说这样的指令重排序貌似也没有多大问题呀,那么考虑如下代码文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class Reordering {    private static boolean flag;    private static int num;    public static void main(String[] args) {        Thread t1 = new Thread(new Runnable() {            @Override            public void run() {                while (!flag) {                    Thread.yield();                }                System.out.println(num);            }        }, "t1");        t1.start();        num = 5;                ①         flag = true;        ②     }}

以上代码最终输出的值正常情况下是 5,但如果上述 ① ,②  两行指令发生重排序,那么结果是有可能为 0 的,从而导致我们观察到的数据不一致的现象发生,所以显然解决方案是避免指令重排序的发生,也就是保证指令按我们看到的代码的顺序有序执行,也就是我们常说的有序性,一般是通过在指令之间添加内存屏障来避免指令的重排序文章源自JAVA秀-https://www.javaxiu.com/65549.html

那么如何保证可见性与有序性呢文章源自JAVA秀-https://www.javaxiu.com/65549.html

相信大家都非常熟悉了,使用 volatile 可以保证可见性与有序性,只要在声明属性变量时添加上 volatile 就可以让此变量实现强一致性,也就是说上述的 Reordering 类的 flag 只要声明为 volatile,那么打印结果就永远是 5!文章源自JAVA秀-https://www.javaxiu.com/65549.html

好了,现在问题来了,CHM 到底是不是强一致性呢,首先我们以 Java 8 为例来看下它的设计结构(和之前的版本相差不大,主要加上了红黑树提升了查询效率)文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

来看下这个 table 数组和节点的声明方式(以下定义 8 和 之前的版本中都是一样的):文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>    implements ConcurrentMap<K,V>, Serializable {        transient volatile Node<K,V>[] table;      ...}static class Node<K,V> implements Map.Entry<K,V> {    final int hash;    final K key;    volatile V val;    volatile Node<K,V> next;      ...}

可以看到 CHM 的 table 数组,Node 中的 值 val,下一个节点 next 都声明为了 volatile,于是有学员就提出了一个疑问文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

讲师的回答也提到 CHM 为弱一致性的重要原因:即如果 table 中的某个槽位为空,此时某个线程执行了 key,value 的赋值操作,那么此槽位会新增 一个 Node 节点,在 JDK 8 以前,CHM 是通过以下方式给槽位赋 Node 的文章源自JAVA秀-https://www.javaxiu.com/65549.html

V put(K key, int hash, V value, boolean onlyIfAbsent) {  lock();  ...    tab[index] = new HashEntry<K,V>(...);  ...    unlock();}

然后是通过以下方式来根据 key 来读取 value 的文章源自JAVA秀-https://www.javaxiu.com/65549.html

V get(Object key, int hash) {    if (count != 0) { // read-volatile        HashEntry<K,V> e = getFirst(hash);        while (e != null) {            if (e.hash == hash && key.equals(e.key)) {                V v = e.value;                if (v != null)                    return v;                return readValueUnderLock(e); // recheck            }            e = e.next;        }    }    return null;}

可以看到 put 时是直接给数组中的元素赋值的,而由于 get 没有加锁,所以无法保证线程 A put 的新元素对执行  get 的线程可见。文章源自JAVA秀-https://www.javaxiu.com/65549.html

put 是有加锁的,所以其实如果 get 也加锁的话,那么毫无疑问 get 是可以立即拿到 put 的值的。为什么加锁也可以呢,其实这是 JLS(Java Language Specification Java 语言规范) 规定的几种情况,简单地说就是支持 happens before 语义的可以保证数据的强一致性,在官网(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)中列出了几种支持 Happens before 的情况,其中指出使用 volatile,synchronize,lock 是可以确保 happens before 语义的 ,也就是说使用这三者可以保证数据的强一致性,可能有人就问了,到底什么是 happens before 呢,其实本质是一种能确保线程及时刷新数据到内存,另一线程能实时从内存读取最新数据以保证数据在线程之间保持一致性的一种机制 ,我们以 lock 为例来简单解释下文章源自JAVA秀-https://www.javaxiu.com/65549.html

public class LockDemo {  private int x = 0;  private void test() {    lock();    x++;    unlock();  }}

如果线程 1 执行 test,由于拿到了锁,所以首先会把数据(此例中为 x = 0)从内存中加载到 CPU 中执行,执行 x++ 后,x 在 CPU 中的值变为 1,然后解锁,解锁时会把 x = 1 的值立即刷新到内存中,这样下一个线程再执行 test 方法时再次获取相同的锁时又从内存中获取 x 的最新值(即 1),这就是我们通常说的对一个锁的解锁, happens-before 于随后对这个锁的加锁 ,可以看到,通过这种方式可以保证数据的一致性文章源自JAVA秀-https://www.javaxiu.com/65549.html

至此我们明白了:在 Java 8 以前,CHM 的 get,put 确实是弱一致性的 ,可能有人会问为什么不对 get 加锁呢,加上了锁不就可以确保数据的一致性了吗,可以是可以,但别忘了 CHM 是为高并发设计而生的,加了锁不就导致并发性大幅度下降了么,那 CHM 存在的意义是啥?文章源自JAVA秀-https://www.javaxiu.com/65549.html

所以 put,get 就无法做到强一致性了吗?文章源自JAVA秀-https://www.javaxiu.com/65549.html

我们在上文中已经知道,使用 volatile,synchronize,lock 是可以确保 happens before 语义的,同时经过分析我们知道使用 synchronize,lock 加锁的设计是不满足我们设计 CHM 的初衷的,那么只剩下 volatile 了,遗憾的是由于 Java 数组在元素层面的元数据设计上的缺失,是无法表达元素是 final、volatile 等语义的,所以 volatile 可以修饰变量,却无法修饰数组中的元素 ,还有其他办法吗?来看看 Java 8 是怎么处理的(这里只列出了写和读方法中的关键代码)文章源自JAVA秀-https://www.javaxiu.com/65549.html

private static final sun.misc.Unsafe U;// 写final V putVal(K key, V value, boolean onlyIfAbsent) {      ...    for (Node<K,V>[] tab = table;;) {      if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {        if (casTabAt(tab, i, null,                     new Node<K,V>(hash, key, value, null)))          break;      }    }      ...}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);}// 读public V get(Object key) {  if ((tab = table) != null && (n = tab.length) > 0 &&      (e = tabAt(tab, (n - 1) & h)) != null) {    ...  }  return null;}@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);}

可以看到在 Java 8 中,CHM 使用了 unsafe 类来实现读写操作文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 对于写首先使用 compareAndSwapObject(即我们熟悉的 CAS)来更新内存中 数组中的元素文章源自JAVA秀-https://www.javaxiu.com/65549.html

  • 对于读则使用了 getObjectVolatile 来读取内存中 数组中的元素(在底层其实是用了 C++ 的 volatile 来实现 java 中的 volatile 效果,有兴趣可以看看)文章源自JAVA秀-https://www.javaxiu.com/65549.html

由于读写都是直接对内存操作的,所以通过这样的方式可以保证 put,get 的强一致性,至此真相大白!Java 8 以后 put,get 是可以保证强一致性的!CHM 是通过 compareAndSwapObject 来取代对数组元素直接赋值的操作,通过 getObjectVolatile 来补上无法对数组元素进行 volatile 读的坑来实现的文章源自JAVA秀-https://www.javaxiu.com/65549.html

注意并不是说 CHM 所有的操作都是强一致性的,比如 Java 8 中计算容量的方法 size() 就是弱一致性(Java 7 中此方法反而是强一致性),所以我们说强/弱一致性一定要确定好前提(比如指定 Java 8 下 CHM 的 put,get 这种场景)文章源自JAVA秀-https://www.javaxiu.com/65549.html

总结

其实 Java 8 对 CHM 进行了一番比较彻底的重构,让它的性能大幅度得到了提升,比如弃用 segment 这种设计,改用对每个槽位做分段锁,使用红黑树来降低查询时的复杂度,扩容时多个线程可以一起参与扩容等等,可以说 Java 8 的 CHM 的设计非常精妙,集 CAS,synchroinize,泛型等 Java 基础语法之大成,又有巧妙的算法设计,读后确实让人大开眼界,有机会我会再和大家分享一下其中的设计精髓,另外我们对某些知识点一定要多加思考,最好能自己去翻翻源码验证一下真伪,相信你会对网上的一些谬误会更容易看穿。文章源自JAVA秀-https://www.javaxiu.com/65549.html

欢迎加入我的知识星球,一起探讨架构,交流源码。加入方式,长按下方二维码噢文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

已在知识星球更新源码解析如下:文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

一文澄清网上对 ConcurrentHashMap 的一个流传甚广的误解!文章源自JAVA秀-https://www.javaxiu.com/65549.html

最近更新《芋道 SpringBoot 2.X 入门》系列,已经 101 余篇,覆盖了 MyBatis、Redis、MongoDB、ES、分库分表、读写分离、SpringMVC、Webflux、权限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能测试等等内容。文章源自JAVA秀-https://www.javaxiu.com/65549.html

提供近 3W 行代码的 SpringBoot 示例,以及超 4W 行代码的电商微服务项目。文章源自JAVA秀-https://www.javaxiu.com/65549.html

获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章源自JAVA秀-https://www.javaxiu.com/65549.html

文章有帮助的话,在看,转发吧。谢谢支持哟 (*^__^*)
文章源自JAVA秀-https://www.javaxiu.com/65549.html
继续阅读
文章末尾固定信息...
weinxin
资源分享QQ群
本站是JAVA秀团队的技术分享社区, 会经常分享资源和教程; 分享的时代, 请别再沉默!
沙海
匿名

发表评论

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定