JUC并发编程高级篇第一章之Java内存模型(解决读取数据不一致的问题)
文章目录
1、为什么我们需要Java内存模型
1.1、 CPU和内存的那点事
首先根据下面的图片,我们可以知道在CPU和主寸(内存)之间存在着L1,L2,L3缓存, 运行速度关系之间为 CPU>L1缓存>L2缓存>L3缓存>内存;
当有了缓存后, CPU计算数据的具体一个请求流程为
- 当程序需要读取内存中的数据时,CPU会首先检查缓存中是否已经存在这个数据。
- 如果缓存中已经存在这个数据,CPU会直接从缓存中读取数据,这个过程非常快速。
- 如果缓存中不存在这个数据,CPU会从内存中读取数据,并将计算结果数据存储到缓存中。然后再将结果写回内存中。
- 如果没有L1缓存/L2缓存/L3缓存
当我们操作一个1+1的时候, 数据先经过网络,然后是磁盘,其次是内存 , 最后传递给CPU ; 因为CPU是运算速度最快的, 比如他计算一个结果需要1s , 结果内存为了把数据传递给CPU,光读取数据都花了2s , 这大大的减少了计算机的性能; - 如果有了L1缓存/L2缓存/L3缓存
为了拉平内存和CPU计算速度不一致性导致的性价下降,我们加入L1缓存/L2缓存/L3缓存 ; CPU会先检查缓存中有没有1+1这个数据, 如果有的话, 直接从缓存中拿取, 就不等内存的读了, 这样大大的增加了计算器的性能
1.2、 Java为什么要加入内存模型呢
原因1: 因为java的特性是全平台兼容,因为不同的系统结构他们之前的内存访问会有差异
原因2: 在多线程并发的情况下,如果多个线程同时访问同一个共享数据,如果没有内存模型的支持,可能会出现数据不一致、线程安全问题等,导致程序出现异常或不可预期的结果
所以Java定义出了Java内存模型,来统一对内存操作的统一规范
2、JMM的简介
2.1 、JMM的定义
JMM 即 Java Memory Model,它本身是一种抽象的概念,并不是真实存在的,他仅仅描述了是一组约定和规范;通过这个规范定义了程序多个变量的读写方式,并决定一个线程对共享变量的写入何时以及如何变成对一个线程可见,关键他它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等
2.1、JMM的三大特性
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
2.1.1、可见性
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
因为有了上面的机制,所以可能会有脏读问题的产生
- A,B两个线程读取主内存都是0
- 此时A线程完成了A+1操作,写会到主内存,此时主内存为1
- 此时线程此时也在做A+1的操作,但是因为没有收到A线程的通知,导致写到内存的数据也是1
2.1.2、原子性
指一个操作是不可打断的,即多线程环境下,操作不能被其他线程干扰
2.1.3、指令重拍
定义
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重新排序
。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致
,此过程叫指令的重排序。
优点
- JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
缺点
- 指令重排可以保证串行语义一致,但
没有义务保证多线程间的语义也一致(即可能产生"脏读")
,简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。-
指令重排的前提
- 前提1: 代码中存在数据依赖关系,即后面的指令需要依赖前面的指令的结果才能执行。
int a = 1;
int b = 2;
int c = a + b;
说明 : 在上述代码中,变量c的值依赖于变量a和b的值,因此指令c = a + b必须在指令a = 1和指令b = 2之后执行,否则c的值将不正确。
- 前提2: 指令之间没有数据依赖关系,即指令之间的执行顺序不会影响程序的结果。
int a = 1;
int b = 2;
int c = a + b;
int d = 3;
说明: 在上述代码中,变量d的值与变量a、b、c无关,因此指令d = 3可以在指令a = 1、指令b = 2和指令c = a + b之前或之后执行,不会影响程序的结果。
- 前提3: 指令之间不存在控制依赖关系,即程序执行的顺序不会受到条件判断和循环等控制结构的影响。
int a = 1;
if (a > 0) {int b = 2;
}
int c = 3;
说明: 在上述代码中,变量b的赋值语句只有在a > 0时才会执行,但是变量c的赋值语句与变量a、b无关,因此指令c = 3可以在变量b的赋值语句之前或之后执行,不会影响程序的结果。
3、happen-before原则
3.1、什么是happen-before原则
happen-before是Java内存模型中的一个概念,用于描述多线程程序中操作之间的执行顺序关系。
具体来说,如果操作A happen-before 操作B,那么操作B能够“看到”操作A的执行结果,也就是说,操作A对操作B是可见的。happen-before规则定义了一系列规则,用于描述在一个线程中,一个操作是否能够“看到”另一个线程中的操作的执行结果。
3.2、happen-before的八大原则
Java内存模型中定义了8条happen-before规则,用于描述多线程程序中操作之间的执行顺序关系。这8条规则分别是:
-
程序顺序规则 :
在一个线程内
,按照程序代码的顺序,前面的操作happen-before于后面的操作。 -
锁定规则:一个unlock操作happen-before于后续的lock操作。
-
volatile变量规则:对一个volatile变量的写操作happen-before于后续对这个变量的读操作。
-
传递性:如果操作A happen-before 操作B,操作B happen-before 操作C,那么操作A happen-before 操作C。
-
线程启动规则:Thread对象的start()方法happen-before于该线程的任何操作。
解释: 虽然System.out.println(“111”); 在第二行,但是还是优先执行start()方法
new Thread(()->{ System.out.println("111"); }).start();
-
线程终止规则:线程中的所有操作都happen-before于其他线程检测到该线程已经终止。
-
线程中断规则:对线程interrupt()方法的调用happen-before于被中断线程的代码检测到中断事件的发生。
-
对象终结规则:一个对象的初始化完成(构造函数执行结束)happen-before于它的finalize()方法的开始。
4、小结测试题
问题: 如果存在线程A和线程B, 线程A先调用了setValue, 接下来线程B调用了getValue ,此时线程B 获取逇结果是多少呢?
private int value = 0;public int getValue(){return value;}public int setValue(){return ++value;
答案:
- 我们无法通过happen-before原则推导出A hapens-before B 线程,
- 虽然时间上A 早于B 发生, 但是无法确定线程b获取的结果是什么
解析
- 由于是2个线程,所以不满足次序规则 (次序规则 关键字: 一个线程)
- 两个方法没有使用锁,所以不满足锁规则
- 变量不是用valiate修饰了,所以valite原则不满足
- 传递规则更不满足, 只有2个线程
优化办法
- 把setter 和getter 方法加上sync
- value 加入valiate