极客时间已完结课程限时免费阅读

加餐 | 什么是数据的强、弱一致性?

加餐 | 什么是数据的强、弱一致性?-极客时间

加餐 | 什么是数据的强、弱一致性?

讲述:李良

时长07:54大小7.25M

你好,我是刘超。
第 17 讲讲解并发容器的时候,我提到了“强一致性”和“弱一致性”。很多同学留言表示对这个概念没有了解或者比较模糊,今天这讲加餐就来详解一下。
说到一致性,其实在系统的很多地方都存在数据一致性的相关问题。除了在并发编程中保证共享变量数据的一致性之外,还有数据库的 ACID 中的 C(Consistency 一致性)、分布式系统的 CAP 理论中的 C(Consistency 一致性)。下面我们主要讨论的就是“并发编程中共享变量的一致性”。
在并发编程中,Java 是通过共享内存来实现共享变量操作的,所以在多线程编程中就会涉及到数据一致性的问题。
我先通过一个经典的案例来说明下多线程操作共享变量可能出现的问题,假设我们有两个线程(线程 1 和线程 2)分别执行下面的方法,x 是共享变量:
//代码1
public class Example {
int x = 0;
public void count() {
x++; //1
System.out.println(x)//2
}
}
如果两个线程同时运行,两个线程的变量的值可能会出现以下三种结果:

Java 存储模型

2,1 和 1,2 的结果我们很好理解,那为什么会出现以上 1,1 的结果呢?
我们知道,Java 采用共享内存模型来实现多线程之间的信息交换和数据同步。在解释为什么会出现这样的结果之前,我们先通过下图来简单了解下 Java 的内存模型(第 21 讲还会详解),程序在运行时,局部变量将会存放在虚拟机栈中,而共享变量将会被保存在堆内存中。
由于局部变量是跟随线程的创建而创建,线程的销毁而销毁,所以存放在栈中,由上图我们可知,Java 栈数据不是所有线程共享的,所以不需要关心其数据的一致性。
共享变量存储在堆内存或方法区中,由上图可知,堆内存和方法区的数据是线程共享的。而堆内存中的共享变量在被不同线程操作时,会被加载到自己的工作内存中,也就是 CPU 中的高速缓存。
CPU 缓存可以分为一级缓存(L1)、二级缓存(L2)和三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。当 CPU 要读取一个缓存数据时,首先会从一级缓存中查找;如果没有找到,再从二级缓存中查找;如果还是没有找到,就从三级缓存或内存中查找。
如果是单核 CPU 运行多线程,多个线程同时访问进程中的共享数据,CPU 将共享变量加载到高速缓存后,不同线程在访问缓存数据的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。
如果是多核 CPU 运行多线程,每个核都有一个 L1 缓存,如果多个线程运行在不同的内核上访问共享变量时,每个内核的 L1 缓存将会缓存一份共享变量。
假设线程 A 操作 CPU 从堆内存中获取一个缓存数据,此时堆内存中的缓存数据值为 0,该缓存数据会被加载到 L1 缓存中,在操作后,缓存数据的值变为 1,然后刷新到堆内存中。
在正好刷新到堆内存中之前,又有另外一个线程 B 将堆内存中为 0 的缓存数据加载到了另外一个内核的 L1 缓存中,此时线程 A 将堆内存中的数据刷新到了 1,而线程 B 实际拿到的缓存数据的值为 0。
此时,内核缓存中的数据和堆内存中的数据就不一致了,且线程 B 在刷新缓存到堆内存中的时候也将覆盖线程 A 中修改的数据。这时就产生了数据不一致的问题。
了解完内存模型之后,结合以上解释,我们就可以回过头来看看第一段代码中的运行结果是如何产生的了。看到这里,相信你可以理解图中 1,1 的运行结果了。

重排序

除此之外,在 Java 内存模型中,还存在重排序的问题。请看以下代码:
//代码1
public class Example {
int x = 0;
boolean flag = false;
public void writer() {
x = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int r1 = x; //4
System.out.println(r1==x)
}
}
}
如果两个线程同时运行,线程 2 中的变量的值可能会出现以下两种可能:
现在一起来看看 r1=1 的运行结果,如下图所示:
那 r1=0 又是怎么获取的呢?我们再来看一个时序图:
在不影响运算结果的前提下,编译器有可能会改变顺序代码的指令执行顺序,特别是在一些可以优化的场景。
例如,在以下案例中,编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值。如果没有进行重排序优化,正常的执行顺序是步骤 1\2\3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤 1/3/2 或者 2/1/3,这样就能减少一次寄存器的存取次数。
int x = 1;//步骤1:加载x变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
boolean flag = true; //步骤2 加载flag变量的内存地址到寄存器中,加载true到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
int y = x + 1;//步骤3 重新加载x变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
在 JVM 中,重排序是十分重要的一环,特别是在并发编程中。可 JVM 要是能对它们进行任意排序的话,也可能会给并发编程带来一系列的问题,其中就包括了一致性的问题。

Happens-before 规则

为了解决这个问题,Java 提出了 Happens-before 规则来规范线程的执行顺序:
程序次序规则:在单线程中,代码的执行是有序的,虽然可能会存在运行指令的重排序,但最终执行的结果和顺序执行的结果是一致的;
锁定规则:一个锁处于被一个线程锁定占用状态,那么只有当这个线程释放锁之后,其它线程才能再次获取锁操作;
volatile 变量规则:如果一个线程正在写 volatile 变量,其它线程读取该变量会发生在写入之后;
线程启动规则:Thread 对象的 start() 方法先行发生于此线程的其它每一个动作;
线程终结规则:线程中的所有操作都先行发生于对此线程的终止检测;
对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始;
传递性:如果操作 A happens-before 操作 B,操作 B happens-before 操作 C,那么操作 A happens-before 操作 C;
线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
结合这些规则,我们可以将一致性分为以下几个级别:
严格一致性(强一致性):所有的读写操作都按照全局时钟下的顺序执行,且任何时刻线程读取到的缓存数据都是一样的,Hashtable 就是严格一致性;
顺序一致性:多个线程的整体执行可能是无序的,但对于单个线程而言执行是有序的,要保证任何一次读都能读到最近一次写入的数据,volatile 可以阻止指令重排序,所以修饰的变量的程序属于顺序一致性;
弱一致性:不能保证任何一次读都能读到最近一次写入的数据,但能保证最终可以读到写入的数据,单个写锁 + 无锁读,就是弱一致性的一种实现。
今天的加餐到这里就结束了,如有疑问,欢迎留言给我。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 12

提建议

上一篇
20 | 答疑课堂:模块三热点问题解答
下一篇
21 | 磨刀不误砍柴工:欲知JVM调优先了解JVM内存模型
unpreview
 写留言

精选留言(28)

  • THROW
    2019-07-08
    老师您好,都说concurrenthashmap的get是弱一致性,但我不理解啊,volatile 修饰的变量读操作为什么会读不到最新的数据?

    作者回复: 我们知道Node<k,v>以及Node<k,v>的value是volatile修饰的,所以在一个线程对其进行修改后,另一个线程可以马上看到。 如果是一个新Node,那么就不能马上看到,虽然Node的数组table被volatile修饰,但是这样只是代表table的引用地址如果被修改,其他线程可以立马看到,并不代表table里的数据被修改立马可以看到。

    共 5 条评论
    39
  • 明翼
    2019-07-10
    早看到就好了😁,老师请教下这么多知识点你是怎么记住的?

    作者回复: 平时善于做笔记,除此之外,尝试将自己学到的知识点分享给其他人。

    共 2 条评论
    16
  • Liam
    2019-07-06
    老师好,请教一个问题: 文中举例,数据不一致是多核CPU的高速缓存不一致导致的,是否意味着单核CPU多线程操作就不会发生数据不一致呢

    作者回复: 也会的,线程安全除了要保证可见性,还需要保证原子性、有序性。

    共 4 条评论
    9
  • 2019-09-10
    数据只要在不同的地方,且存在写操作就可能存在不一致性。不管是各级缓存中还是分布式集群中的某些节点中,都有类似的问题。线程间的数据一致性问题,由操作系统来去保证,分布式系统中的数据一致性问题由分布式协议的实现者去保证,不过确实不好弄,令人头疼。 给老师点赞,讲的很棒,不过知识真是太多了,感觉越学越多,买的专栏都学不完,不学是不行的不进则退,如果英语好就占优势了,可以直接学习第一手的学习资料。
    展开

    作者回复: 坚持看英文文档,到最后你就能流利的读任何英文文档说明了

    6
  • 青梅煮酒
    2019-07-16
    老师,请问一下,每核CPU都有自己的L1和L2,那么L1和L2的主要区别是什么呢?为什么不能合到一起呢?

    作者回复: L1\L2\L3三个缓存的作用和实现的技术是不一样的,L1的内存大小是非常有限的,所以很多时候在L1获取缓存数据的命中率非常低。为了提高CPU读取的速率,在L1没有命中的缓存,可以进入到L2进行获取,L2的容量要比L1大,但离CPU核心更远。但还是能提高CPU读取缓存数据的速率。

    5
  • Lost In The Echo...
    2019-07-06
    老师,请问强一致性和顺序一致性有什么区别吗?

    作者回复: 顺序一致性是指单个线程的执行的顺序性,强一致性则指的是多个线程在全局时钟下的执行的顺序性。

    4
  • -W.LI-
    2019-07-06
    老师容我问一个很基础的问题!父类private的属性会被子类继承么?子类创建的时候JVM给子类分配内存的时候,我看书上有说父类的属性会排在子类前面有可能穿插。可是没写是否会给子类分配父类的私有属性内存空间。子类创建的时候,会默认调用父类的无参构造器。这时候就会实例化一个父类对象么?(如果父类没有无参构造器会报错或者需要显示调用父类的有参构造器)。如果每次实力子类对象的时候都会先创建一个父类对象的话,滥用继承。就会浪费很多内存是么?对象头就需要8字节了。
    展开

    作者回复: 子类会继承父类的private属性,但子类无法直接访问到private属性; 子类创建时,不会创建一个父类对象的,只是调用了父类的构造函数初始化而已。

    共 2 条评论
    4
  • 菜菜
    2019-10-08
    针对老师对留言1的回复,我想问下老师,Node<k,v>中除了value被volatile修饰了,next也被volatile修饰了呀,这样如果是新增新的Node的话,其他线程也不可以看到吗?

    作者回复: 获取节点时是通过key值获取,并不一定通过next获取,所以不能代表对应key值中的value是最新的

    2
  • 尔冬橙
    2019-09-21
    老师可以不可以讲一下threadlocal

    作者回复: mark

    2
  • 云封
    2019-07-07
    老师,请问下,如果不存在操作共享变量的情况或者把共享产量存在redis中,多线程结果就不会发生由于指令重排而导致结果不一致的情况。

    作者回复: 指令重排序不一定是由于共享变量导致的,这块需要结合具体的场景分析。

    2
  • InnerPeace
    2021-01-07
    对于顺序一致性有点疑问。如果是单线程,无论是读还是写,都是串行,所以任何一次读都能读到最近一次写,这是显而易见的,为什么叫顺序一致性呢?
    1
  • GaGi
    2020-05-02
    老师,图中CPU多级缓存图中,L3缓存应该是多个CPU核心共用的

    作者回复: 是的

    1
  • 2019-10-25
    老师,我指出一个错误,时序图中最后flag=true不是false

    作者回复: 收到,谢谢提醒

    1
  • -W.LI-
    2019-07-07
    老师好volatile+cas是强一致性么?。L1直接刷回主存,L2和L3需要做什么操作么?开头说每一级都是上一级的子集来着。

    作者回复: cas+volatile可以解决单个变量的强一致性问题。

    1
  • 东方奇骥
    2019-07-06
    上面例子,flag加volatile修饰,根据happens before中的顺序性选择和volatile的原则,就能保证另一个线程读到写入的值了。

    作者回复: 对的,volatile除了可以保证变量的可见性,可以阻止局部指令重排序。

    1
  • 杯莫停
    2022-07-27
    “CPU 缓存可以分为一级缓存(L1)、二级缓存(L2)和三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分” 就是说L1中的数据在L2中也有一份?为什么要这么设计,L2存增量不就好了吗?
  • Bumblebee
    2022-06-01
    我觉得concurrenthashmap的get是弱一致性,可以这么理解,写操作是加锁的,get操作是无锁的,因此get操作有可能拿到写操作的中间值,因此是弱一致性(尽管volatile保证了可见性); 希望老师能点评一下,不知道这样理解对不对;
    展开
  • Only now
    2020-11-03
    关于存储模型这一节,通过实验结果很难接受。还请老师解答。 在实验中,非volatile共享堆变量,是一个bool值, 使用x86_64机器, Oracle jdk 1.8进行测试。 extern boolean stop = false; new Thread(()->{ while(!stop){} System.out.println("over"); }).start(); System.sleep(100L); stop = true; 这样一段代码实际上不会停下来。按照三级缓存的这个说法,当内核在进行线程调度时会失效缓存,所以当线程被再次调入执行,它就应该可以看到stop更改的值,因为这个时候缓存已经更换过了。 然而事实并不会发现打印输出并停下来。 另有,缓存一致性协议EMSI应可以同步缓存,实际上也没效果。
    展开
    共 1 条评论
  • 天使梦泪
    2020-07-07
    老师好,俩个线程共同执行X共享变量,有种结果是2,1,这个结果是怎么出现的哈?可以帮分析下么?
    共 1 条评论
  • will
    2020-04-04
    大概理解了所谓的数据一致性,还需要多复习几遍