mirror of
https://github.com/youthlql/JavaYouth.git
synced 2026-03-13 21:33:42 +08:00
清空
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,801 +0,0 @@
|
|||||||
> - 本阶段文章讲的略微深入,一些基础性问题不会讲解,如有基础性问题不懂,可自行查看我前面的文章,或者自行学习。
|
|
||||||
> - 本篇文章比较适合校招和社招的面试,笔者在2020年面试的过程中,也确实被问到了下面的一些问题。
|
|
||||||
|
|
||||||
# 并发编程中的三个问题
|
|
||||||
|
|
||||||
> 由于这个东西,和这篇文章比较配。所以虽然在第一阶段写过了,这里再回顾一遍。
|
|
||||||
|
|
||||||
## 可见性
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 可见性概念
|
|
||||||
|
|
||||||
可见性(Visibility):是指一个线程对共享变量进行修改,另一个先立即得到修改后的新值。
|
|
||||||
|
|
||||||
### 可见性演示
|
|
||||||
|
|
||||||
```java
|
|
||||||
/* 笔记
|
|
||||||
* 1.当没有加Volatile的时候,while循环会一直在里面循环转圈
|
|
||||||
* 2.当加了之后Volatile,由于可见性,一旦num改了之后,就会通知其他线程
|
|
||||||
* 3.还有注意的时候不能用if,if不会重新拉回来再判断一次。(也叫做虚假唤醒)
|
|
||||||
* 4.案例演示:一个线程对共享变量的修改,另一个线程不能立即得到新值
|
|
||||||
* */
|
|
||||||
public class Video04_01 {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
MyData myData = new MyData();
|
|
||||||
|
|
||||||
new Thread(() ->{
|
|
||||||
System.out.println(Thread.currentThread().getName() + "\t come in ");
|
|
||||||
try {
|
|
||||||
TimeUnit.SECONDS.sleep(3);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
//睡3秒之后再修改num,防止A线程先修改了num,那么到while循环的时候就会直接跳出去了
|
|
||||||
myData.addTo60();
|
|
||||||
System.out.println(Thread.currentThread().getName() + "\t come out");
|
|
||||||
},"A").start();
|
|
||||||
|
|
||||||
|
|
||||||
while(myData.num == 0){
|
|
||||||
//只有当num不等于0的时候,才会跳出循环
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyData{
|
|
||||||
int num = 0;
|
|
||||||
|
|
||||||
public void addTo60(){
|
|
||||||
this.num = 60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
由上面代码可以看出,并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。
|
|
||||||
|
|
||||||
## 原子性
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 原子性概念
|
|
||||||
|
|
||||||
原子性(Atomicity):在一次或多次操作中,要么所有的操作都成功执行并且不会受其他因素干扰而中 断,要么所有的操作都不执行或全部执行失败。不会出现中间状态
|
|
||||||
|
|
||||||
### 原子性演示
|
|
||||||
|
|
||||||
案例演示:5个线程各执行1000次 i++;
|
|
||||||
|
|
||||||
```java
|
|
||||||
/**
|
|
||||||
* @Author: 吕
|
|
||||||
* @Date: 2019/9/23 15:50
|
|
||||||
* <p>
|
|
||||||
* 功能描述: volatile不保证原子性的代码验证
|
|
||||||
*/
|
|
||||||
public class Video05_01 {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
MyData03 myData03 = new MyData03();
|
|
||||||
|
|
||||||
for (int i = 0; i < 20; i++) {
|
|
||||||
new Thread(() ->{
|
|
||||||
for (int j = 0; j < 1000; j++) {
|
|
||||||
myData03.increment();
|
|
||||||
}
|
|
||||||
},"线程" + String.valueOf(i)).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
//需要等待上面的20个线程计算完之后再查看计算结果
|
|
||||||
while(Thread.activeCount() > 2){
|
|
||||||
Thread.yield();
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("20个线程执行完之后num:\t" + myData03.num);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MyData03{
|
|
||||||
static int num = 0;
|
|
||||||
|
|
||||||
public void increment(){
|
|
||||||
num++;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1、控制台输出:(由于并发不安全,每次执行的结果都可能不一样)
|
|
||||||
|
|
||||||
> 20个线程执行完之后num: 19706
|
|
||||||
|
|
||||||
正常来说,如果保证原子性的话,20个线程执行完,结果应该是20000。控制台输出的值却不是这个,说明出现了原子性的问题。
|
|
||||||
|
|
||||||
2、使用javap反汇编class文件,对于num++可以得到下面的字节码指令:
|
|
||||||
|
|
||||||
```java
|
|
||||||
9: getstatic #12 // Field number:I 取值操作
|
|
||||||
12: iconst_1
|
|
||||||
13: iadd
|
|
||||||
14: putstatic #12 // Field number:I 赋值操作
|
|
||||||
```
|
|
||||||
|
|
||||||
由此可见num++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多线程情况下就可能会出现问题。
|
|
||||||
|
|
||||||
比如num刚开始值是7。A线程在执行13: iadd时得到num值是8,B线程又执行9: getstatic得到前一个值是7。马上A线程就把8赋值给了num变量。但是B线程已经拿到了之前的值7,B线程是在A线程真正赋值前拿到的num值。即使A线程最终把值真正的赋给了num变量,但是B线程已经走过了getstaitc取值的这一步,B线程会继续在7的基础上进行++操作,最终的结果依然是8。本来两个线程对7进行分别进行++操作,得到的值应该是9,因为并发问题,导致结果是8。
|
|
||||||
|
|
||||||
3、并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共 享变量,干扰了前一个线程的操作。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 有序性
|
|
||||||
|
|
||||||
### 有序性概念
|
|
||||||
|
|
||||||
有序性(Ordering):是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化(重排序)来加快速度,会导致程序终的执行顺序不一定就是我们编写代码时的顺序
|
|
||||||
|
|
||||||
```java
|
|
||||||
instance = new SingletonDemo() 是被分成以下 3 步完成
|
|
||||||
memory = allocate(); 分配对象内存空间
|
|
||||||
instance(memory); 初始化对象
|
|
||||||
instance = memory; 设置 instance 指向刚分配的内存地址,此时 instance != null
|
|
||||||
```
|
|
||||||
|
|
||||||
步骤2 和 步骤3 不存在数据依赖关系,重排与否的执行结果单线程中是一样的。这种指令重排是被 Java 允许的。当 3 在前时,instance 不为 null,但实际上初始化工作还没完成,会变成一个返回 null 的getInstance。这时候数据就出现了问题。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 有序性演示
|
|
||||||
|
|
||||||
jcstress是java并发压测工具。https://wiki.openjdk.java.net/display/CodeTools/jcstress 修改pom文件,添加依赖:
|
|
||||||
|
|
||||||
```Java
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjdk.jcstress</groupId>
|
|
||||||
<artifactId>jcstress-core</artifactId>
|
|
||||||
<version>${jcstress.version}</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
import org.openjdk.jcstress.annotations.*;
|
|
||||||
import org.openjdk.jcstress.infra.results.I_Result;
|
|
||||||
|
|
||||||
@JCStressTest
|
|
||||||
// @Outcome: 如果输出结果是1或4,我们是接受的(ACCEPTABLE),并打印ok
|
|
||||||
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
|
|
||||||
//如果输出结果是0,我们是接受的并且感兴趣的,并打印danger
|
|
||||||
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
|
|
||||||
@State
|
|
||||||
public class Test03Ordering {
|
|
||||||
|
|
||||||
int num = 0;
|
|
||||||
boolean ready = false;
|
|
||||||
// 线程1执行的代码
|
|
||||||
@Actor //@Actor:表示会有多个线程来执行这个方法
|
|
||||||
public void actor1(I_Result r) {
|
|
||||||
if (ready) {
|
|
||||||
r.r1 = num + num;
|
|
||||||
} else {
|
|
||||||
r.r1 = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 线程2执行的代码
|
|
||||||
// @Actor
|
|
||||||
public void actor2(I_Result r) {
|
|
||||||
num = 2;
|
|
||||||
ready = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1、实际上上面两个方法会有很多线程来执行,为了讲解方便,我们只提出线程1和线程2来讲解。
|
|
||||||
|
|
||||||
2、I_Result 是一个保存int类型数据的对象,有一个属性 r1 用来保存结果,在多线程情况下可能出现几种结果?
|
|
||||||
|
|
||||||
情况1:线 程1先执行actor1,这时ready = false,所以进入else分支结果为1。
|
|
||||||
|
|
||||||
情况2:线程2执行到actor2,执行了num = 2;和ready = true,线程1执行,这回进入 if 分支,结果为 4。
|
|
||||||
|
|
||||||
情况3:线程2先执行actor2,只执行num = 2;但没来得及执行 ready = true,线程1执行,还是进入 else分支,结果为1。
|
|
||||||
|
|
||||||
情况4:0,发生了指令重排
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 线程2执行的代码
|
|
||||||
// @Actor
|
|
||||||
public void actor2(I_Result r) {
|
|
||||||
num = 2; //pos_1
|
|
||||||
ready = true;//pos_2
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
pos_1处代码和pos_2处代码没有什么数据依赖关系,或者说没有因果关系。Java可能对其进行指令重排,排成下面的顺序。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 线程2执行的代码
|
|
||||||
// @Actor
|
|
||||||
public void actor2(I_Result r) {
|
|
||||||
ready = true;//pos_2
|
|
||||||
num = 2; //pos_1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
此时如果线程2先执行到`ready = true;`还没来得及执行 `num = 2;` 。线程1执行,直接进入if分支,此时num默认值为0。 得到的结果也就是0。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 指令重排
|
|
||||||
|
|
||||||
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
|
|
||||||
|
|
||||||
## 为什么指令重排序可以提高性能?
|
|
||||||
|
|
||||||
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,**流水线技术**产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
|
|
||||||
|
|
||||||
但是,流水线技术最害怕**中断**,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
|
|
||||||
|
|
||||||
我们分析一下下面这个代码的执行情况:
|
|
||||||
|
|
||||||
```java
|
|
||||||
a = b + c;
|
|
||||||
d = e - f ;
|
|
||||||
```
|
|
||||||
|
|
||||||
先加载b、c(**注意,即有可能先加载b,也有可能先加载c**),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
|
|
||||||
|
|
||||||
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
|
|
||||||
|
|
||||||
综上所述,**指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。**
|
|
||||||
|
|
||||||
指令重排一般分为以下三种:
|
|
||||||
|
|
||||||
* **编译器优化重排**
|
|
||||||
|
|
||||||
编译器在**不改变单线程程序语义**的前提下,可以重新安排语句的执行顺序。
|
|
||||||
|
|
||||||
* **指令并行重排**
|
|
||||||
|
|
||||||
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果**不存在数据依赖性**(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
|
|
||||||
|
|
||||||
* **内存系统重排**
|
|
||||||
|
|
||||||
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0001.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致**。所以在多线程下,指令重排序可能会导致一些问题。
|
|
||||||
|
|
||||||
## as-if-serial语义
|
|
||||||
|
|
||||||
as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。 以下数据有依赖关系,不能重排序。
|
|
||||||
|
|
||||||
写后读:
|
|
||||||
|
|
||||||
```
|
|
||||||
int a = 1;
|
|
||||||
int b = a;
|
|
||||||
```
|
|
||||||
|
|
||||||
写后写:
|
|
||||||
|
|
||||||
```
|
|
||||||
int a = 1;
|
|
||||||
int a = 2;
|
|
||||||
```
|
|
||||||
|
|
||||||
读后写:
|
|
||||||
|
|
||||||
```
|
|
||||||
int a = 1;
|
|
||||||
int b = a;
|
|
||||||
int a = 2;
|
|
||||||
```
|
|
||||||
|
|
||||||
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
|
|
||||||
|
|
||||||
```
|
|
||||||
int a = 1;
|
|
||||||
int b = 2;
|
|
||||||
int c = a + b;
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Java内存模型(JMM)
|
|
||||||
|
|
||||||
在介绍Java内存模型之前,先来看一下到底什么是计算机内存模型。
|
|
||||||
|
|
||||||
## 计算机结构
|
|
||||||
|
|
||||||
### 计算机结构简介
|
|
||||||
|
|
||||||
冯诺依曼,提出计算机由五大组成部分,输入设备,输出设备存储器,控制器,运算器。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0002.png">
|
|
||||||
|
|
||||||
输入设备:鼠标,键盘等等
|
|
||||||
|
|
||||||
输出设备:显示器,打印机等等
|
|
||||||
|
|
||||||
存储器:内存条
|
|
||||||
|
|
||||||
运算器和控制器组成CPU
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### CPU
|
|
||||||
|
|
||||||
中央处理器,是计算机的控制和运算的核心,我们的程序终都会变成指令让CPU去执行,处理程序中 的数据。
|
|
||||||
|
|
||||||
### 内存
|
|
||||||
|
|
||||||
我们的程序都是在内存中运行的,内存会保存程序运行时的数据,供CPU处理。
|
|
||||||
|
|
||||||
### 缓存
|
|
||||||
|
|
||||||
CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内 存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。靠近CPU 的缓存称为L1,然后依次是 L2,L3和主内存,CPU缓存模型如图下图所示。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0003.png">
|
|
||||||
|
|
||||||
CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。速度越快的价格越贵。
|
|
||||||
|
|
||||||
1、L1是接近CPU的,它容量小,例如32K,速度快,每个核上都有一个L1 Cache。
|
|
||||||
|
|
||||||
2、L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache。
|
|
||||||
|
|
||||||
3、L3 Cache是三级缓存中大的一级,例如12MB,同时也是缓存中慢的一级,在同一个CPU插槽 之间的核共享一个L3 Cache。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0004.png">
|
|
||||||
|
|
||||||
上面的图中有一个Latency指标。比如Memory这个指标为59.4ns,表示CPU在操作内存的时候有59.4ns的延迟,一级缓存最快只有1.2ns。
|
|
||||||
|
|
||||||
**CPU处理数据的流程**
|
|
||||||
|
|
||||||
Cache的出现是为了解决CPU直接访问内存效率低下问题的。
|
|
||||||
|
|
||||||
1、程序在运行的过程中,CPU接收到指令 后,它会先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,如果命中缓存,CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人,当运算结束之后,再将CPUCache中的新数据刷新 到主内存当中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能 力。
|
|
||||||
|
|
||||||
2、但是由于一级缓存(L1 Cache)容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二 级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3 Cache、内存(主存)和硬盘。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Java内存模型
|
|
||||||
|
|
||||||
1、Java Memory Molde (Java内存模型/JMM),千万不要和Java内存结构(JVM划分的那个堆,栈,方法区)混淆。关于“Java内存模型”的权威解释,参考 https://download.oracle.com/otn-pub/jcp/memory_model1.0-pfd-spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf。
|
|
||||||
|
|
||||||
2、 Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。 Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。
|
|
||||||
|
|
||||||
3、Java内存模型根据官方的解释,主要是在说两个关键字,一个是`volatile`,一个是`synchronized`。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**主内存**
|
|
||||||
|
|
||||||
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
|
|
||||||
|
|
||||||
**工作内存**
|
|
||||||
|
|
||||||
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操 作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接 访问对方工作内存中的变量。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0005.png">
|
|
||||||
|
|
||||||
Java的线程不能直接在主内存中操作共享变量。而是首先将主内存中的共享变量赋值到自己的工作内存中,再进行操作,操作完成之后,刷回主内存。
|
|
||||||
|
|
||||||
**Java内存模型的作用**
|
|
||||||
|
|
||||||
Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。 synchronized,volatile
|
|
||||||
|
|
||||||
## CPU缓存,内存与Java内存模型的关系
|
|
||||||
|
|
||||||
- 通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行终都会映射到硬件处理器上进行执行。 但Java内存模型和硬件内存架构并不完全一致。
|
|
||||||
- 对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存和主内存之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响, 因为JMM只是一种抽象的概念,是一组规则,不管是工作内存的数据还是主内存的数据,对于计算机硬 件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说, Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
|
|
||||||
|
|
||||||
JMM内存模型与CPU硬件内存架构的关系:
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0006.png">
|
|
||||||
|
|
||||||
工作内存:可能对应CPU寄存器,也可能对应CPU缓存,也可能对应内存。
|
|
||||||
|
|
||||||
- Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量 存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 再谈可见性
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0007.png">
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0008.png">
|
|
||||||
|
|
||||||
1、图中所示是 个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的1级缓存,在有些架构里面还有1个所有 CPU 共享的2级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 或者 L2 存或者 CPU 寄存器。
|
|
||||||
|
|
||||||
2、一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。
|
|
||||||
|
|
||||||
3、那么假如线程A和线程B同时处理一个共享变量,会出现什么情况?我们使用图所示CPU架构,假设线程A和线程B使用不同CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将会导致内存不可见问题,具体看下面的分析。
|
|
||||||
|
|
||||||
- 线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache 内和主内存里面的X的值都是1。
|
|
||||||
- 线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=1。然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X 的值为2;到这里一切都是好的。
|
|
||||||
- 线程A 这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。那么如何解决共享变量内存不可见问题?使用Java中的volatile和synchronized关键字就可以解决这个问题,下面会有讲解。
|
|
||||||
|
|
||||||
# 主内存与工作内存之间的交互
|
|
||||||
|
|
||||||
为了保证数据交互时数据的正确性,Java内存模型中定义了8种操作来完成这个交互过程,这8种操作本身都是原子性的。虚拟机实现时必须保证下面 提及的每一种操作都是原子的、不可再分的。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0009.png">
|
|
||||||
|
|
||||||
> (1)lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
|
|
||||||
>
|
|
||||||
> (2)unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
|
|
||||||
>
|
|
||||||
> (3)read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
|
|
||||||
>
|
|
||||||
> (4)load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
|
|
||||||
>
|
|
||||||
> (5)use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时都会执行这个操作。
|
|
||||||
>
|
|
||||||
> (6)assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
|
|
||||||
>
|
|
||||||
> (7)store:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write使用。
|
|
||||||
>
|
|
||||||
> (8)write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
1. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值
|
|
||||||
2. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
|
|
||||||
3. lock和unlock操作只有加锁才会有。synchronized就是通过这样来保证可见性的。
|
|
||||||
|
|
||||||
如果没有synchronized,那就是下面这样的
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0010.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# happens-before
|
|
||||||
|
|
||||||
## 什么是happens-before?
|
|
||||||
|
|
||||||
一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。
|
|
||||||
|
|
||||||
JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,**只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。**
|
|
||||||
|
|
||||||
而对于程序员,JMM提供了**happens-before规则**(JSR-133规范),满足了程序员的需求——**简单易懂,并且提供了足够强的内存可见性保证。**换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。
|
|
||||||
|
|
||||||
JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
|
|
||||||
|
|
||||||
happens-before关系的定义如下:
|
|
||||||
|
|
||||||
1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
|
|
||||||
2. **两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。**
|
|
||||||
|
|
||||||
happens-before关系本质上和as-if-serial语义是一回事。
|
|
||||||
|
|
||||||
as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。
|
|
||||||
|
|
||||||
总之,**如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。**
|
|
||||||
|
|
||||||
## 天然的happens-before关系
|
|
||||||
|
|
||||||
在Java中,有以下天然的happens-before关系:
|
|
||||||
|
|
||||||
* 1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
|
|
||||||
|
|
||||||
* 2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作,比如说在代码里有先对一个lock.lock(),lock.unlock(),lock.lock()
|
|
||||||
|
|
||||||
* 3、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作,volatile变量写,再是读,必须保证是先写,再读
|
|
||||||
|
|
||||||
* 4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
|
|
||||||
|
|
||||||
* 5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作,thread.start(),thread.interrupt()
|
|
||||||
|
|
||||||
* 6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
|
|
||||||
|
|
||||||
* 7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
|
|
||||||
|
|
||||||
* 8、对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
|
|
||||||
|
|
||||||
|
|
||||||
上面这8条原则的意思很显而易见,就是程序中的代码如果满足这个条件,就一定会按照这个规则来保证指令的顺序。
|
|
||||||
|
|
||||||
**举例1:**
|
|
||||||
|
|
||||||
```java
|
|
||||||
int a = 1; // A操作
|
|
||||||
int b = 2; // B操作
|
|
||||||
int sum = a + b;// C 操作
|
|
||||||
System.out.println(sum);
|
|
||||||
```
|
|
||||||
|
|
||||||
根据以上介绍的happens-before规则,假如只有一个线程,那么不难得出:
|
|
||||||
|
|
||||||
```
|
|
||||||
1> A happens-before B
|
|
||||||
2> B happens-before C
|
|
||||||
3> A happens-before C
|
|
||||||
```
|
|
||||||
|
|
||||||
注意,真正在执行指令的时候,其实JVM有可能对操作A & B进行重排序,因为无论先执行A还是B,他们都对对方是可见的,并且不影响执行结果。
|
|
||||||
|
|
||||||
如果这里发生了重排序,这在视觉上违背了happens-before原则,但是JMM是允许这样的重排序的。
|
|
||||||
|
|
||||||
所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。
|
|
||||||
|
|
||||||
重排序有两类,JMM对这两类重排序有不同的策略:
|
|
||||||
|
|
||||||
* 会改变程序执行结果的重排序,比如 A -> C,JMM要求编译器和处理器都禁止这种重排序。
|
|
||||||
* 不会改变程序执行结果的重排序,比如 A -> B,JMM对编译器和处理器不做要求,允许这种重排序。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**举例2:**
|
|
||||||
|
|
||||||
```Java
|
|
||||||
//伪代码
|
|
||||||
volatile boolean flag = false;
|
|
||||||
//线程1
|
|
||||||
prepare();
|
|
||||||
|
|
||||||
flag = false;
|
|
||||||
|
|
||||||
//线程2
|
|
||||||
while(!flag){
|
|
||||||
sleep();
|
|
||||||
}
|
|
||||||
|
|
||||||
//基于准备好的资源进行操作
|
|
||||||
execute();
|
|
||||||
```
|
|
||||||
|
|
||||||
这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。
|
|
||||||
|
|
||||||
比如这个例子,如果用volatile来修饰flag变量,一定可以让prepare()指令在flag = true之前先执行,这就禁止了指令重排。
|
|
||||||
|
|
||||||
因为volatile要求的是,volatile前面的代码一定不能指令重排到volatile变量操作后面,volatile后面的代码也不能指令重排到volatile前面。
|
|
||||||
|
|
||||||
# volatile
|
|
||||||
|
|
||||||
volatile不保证原子性,只保证可见性和禁止指令重排
|
|
||||||
|
|
||||||
## CPU术语介绍
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0011.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
private static volatile SingletonDemo instance = null;
|
|
||||||
|
|
||||||
private SingletonDemo() {
|
|
||||||
System.out.println(Thread.currentThread().getName() + "\t 执行单例构造函数");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SingletonDemo getInstance(){
|
|
||||||
|
|
||||||
if(instance == null){
|
|
||||||
synchronized (SingletonDemo.class){
|
|
||||||
if(instance == null){
|
|
||||||
instance = new SingletonDemo(); //pos_1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**pos_1处的代码转换成汇编代码如下**
|
|
||||||
|
|
||||||
```shell
|
|
||||||
0x01a3de1d: movb $0×0,0×1104800(%esi);
|
|
||||||
0x01a3de24: lock addl $0×0,(%esp);
|
|
||||||
```
|
|
||||||
|
|
||||||
## volatile保证可见性原理
|
|
||||||
|
|
||||||
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架 构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情。
|
|
||||||
|
|
||||||
1)将当前处理器缓存行的数据写回到系统内存。
|
|
||||||
|
|
||||||
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
|
|
||||||
|
|
||||||
为了提高处理速度,处理器不直接和主内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现MESI缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
|
|
||||||
|
|
||||||
> 注意:lock前缀指令是同时保证可见性和有序性(也就是禁止指令重排)的
|
|
||||||
|
|
||||||
> 注意:lock前缀指令相当于一个内存屏障【后文讲】
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## volatile禁止指令重排的原理
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class VolatileExample {
|
|
||||||
int a = 0;
|
|
||||||
volatile boolean flag = false;
|
|
||||||
|
|
||||||
public void writer() {
|
|
||||||
a = 1; // step 1
|
|
||||||
flag = true; // step 2
|
|
||||||
}
|
|
||||||
|
|
||||||
public void reader() {
|
|
||||||
if (flag) { // step 3
|
|
||||||
System.out.println(a); // step 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。那上面的案例中,可能就会被重排序成下列时序来执行:
|
|
||||||
|
|
||||||
1. 线程A写volatile变量,step 2,设置flag为true;
|
|
||||||
2. 线程B读同一个volatile,step 3,读取到flag为true;
|
|
||||||
3. 线程B读普通变量,step 4,读取到 a = 0;
|
|
||||||
4. 线程A修改普通变量,step 1,设置 a = 1;
|
|
||||||
|
|
||||||
可见,如果volatile变量与普通变量发生了重排序,虽然volatile变量能保证内存可见性,也可能导致普通变量读取错误。
|
|
||||||
|
|
||||||
所以在旧的内存模型中,volatile的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供一种比锁更轻量级的**线程间的通信机制**,**JSR-133**专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。
|
|
||||||
|
|
||||||
编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过**内存屏障**来实现的。
|
|
||||||
|
|
||||||
什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:
|
|
||||||
|
|
||||||
1. 阻止屏障两侧的指令重排序;
|
|
||||||
2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
|
|
||||||
|
|
||||||
> 注意这里的缓存主要指的是上文说的CPU缓存,如L1,L2等
|
|
||||||
|
|
||||||
### 保守策略下
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 在每个volatile写操作的前面插入一个StoreStore屏障。
|
|
||||||
|
|
||||||
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
|
|
||||||
|
|
||||||
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
|
|
||||||
|
|
||||||
- 在每个volatile读操作的后面插入一个LoadStore屏障。
|
|
||||||
|
|
||||||
编译器在**生成字节码时**,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个**比较保守的JMM内存屏障插入策略**,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义。
|
|
||||||
|
|
||||||
> 再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作
|
|
||||||
>
|
|
||||||
> **LoadLoad屏障**:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
|
|
||||||
> **StoreStore屏障**:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,这个屏障会吧Store1强制刷新到内存,保证Store1的写入操作对其它处理器可见。
|
|
||||||
> **LoadStore屏障**:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
|
|
||||||
> **StoreLoad屏障**:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
|
|
||||||
|
|
||||||
对于连续多个volatile变量读或者连续多个volatile变量写,编译器做了一定的优化来提高性能,比如:
|
|
||||||
|
|
||||||
> 第一个volatile读;
|
|
||||||
>
|
|
||||||
> LoadLoad屏障;
|
|
||||||
>
|
|
||||||
> 第二个volatile读;
|
|
||||||
>
|
|
||||||
> LoadStore屏障
|
|
||||||
|
|
||||||
**1、下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图**
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0012.png">
|
|
||||||
|
|
||||||
> 图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任 意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时, 选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**2、下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图**
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0013.png">
|
|
||||||
|
|
||||||
> 图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。 LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**优化举例:**
|
|
||||||
|
|
||||||
```java
|
|
||||||
class VolatileBarrierExample {
|
|
||||||
int a;
|
|
||||||
volatile int v1 = 1;
|
|
||||||
volatile int v2 = 2;
|
|
||||||
|
|
||||||
void readAndWrite() {
|
|
||||||
int i = v1; // 第一个volatile读
|
|
||||||
int j = v2; // 第二个volatile读
|
|
||||||
a = i + j; // 普通写
|
|
||||||
v1 = i + 1; // 第一个volatile写
|
|
||||||
v2 = j * 2; // 第二个 volatile写
|
|
||||||
}
|
|
||||||
// 其他方法 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0014.png">
|
|
||||||
|
|
||||||
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。
|
|
||||||
|
|
||||||
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图中除最后的StoreLoad屏障外,其他的屏障都会被省略。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### X86处理器优化
|
|
||||||
|
|
||||||
前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。
|
|
||||||
|
|
||||||
X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0015.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## volatile的用途
|
|
||||||
|
|
||||||
> 下面的代码在前面可能已经写过了,这里总结一下
|
|
||||||
|
|
||||||
从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序。
|
|
||||||
|
|
||||||
在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个**临界区代码**的执行具有原子性。所以**在功能上,锁比volatile更强大;在性能上,volatile更有优势**。
|
|
||||||
|
|
||||||
在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class Singleton {
|
|
||||||
|
|
||||||
private static Singleton instance; // 不使用volatile关键字
|
|
||||||
|
|
||||||
// 双重锁检验
|
|
||||||
public static Singleton getInstance() {
|
|
||||||
if (instance == null) { // 第7行
|
|
||||||
synchronized (Singleton.class) {
|
|
||||||
if (instance == null) {
|
|
||||||
instance = new Singleton(); // 第10行
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:
|
|
||||||
|
|
||||||
```java
|
|
||||||
instance = new Singleton(); // 第10行
|
|
||||||
|
|
||||||
// 可以分解为以下三个步骤
|
|
||||||
1 memory=allocate();// 分配内存 相当于c的malloc
|
|
||||||
2 ctorInstanc(memory) //初始化对象
|
|
||||||
3 s=memory //设置s指向刚分配的地址
|
|
||||||
|
|
||||||
// 上述三个步骤可能会被重排序为 1-3-2,也就是:
|
|
||||||
1 memory=allocate();// 分配内存 相当于c的malloc
|
|
||||||
3 s=memory //设置s指向刚分配的地址
|
|
||||||
2 ctorInstanc(memory) //初始化对象
|
|
||||||
```
|
|
||||||
|
|
||||||
而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
|
|
||||||
|
|
||||||
所以JSR-133对volatile做了增强后,volatile的禁止重排序功能还是非常有用的。
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
# 可见性设计的硬件
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0016.png">
|
|
||||||
|
|
||||||
从硬件的级别来考虑一下可见性的问题
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**1、第一个可见性的场景:**每个处理器都有自己的寄存器(register),所以多个处理器各自运行一个线程的时候,可能导致某个变量给放到寄存器里去,接着就会导致各个线程没法看到其他处理器寄存器里的变量的值修改了,就有可能在寄存器的级别,导致变量副本的更新,无法让其他处理器看到。
|
|
||||||
|
|
||||||
**2、第二个可见性的场景:**然后一个处理器运行的线程对变量的写操作都是针对写缓冲来的(store buffer)并不是直接更新主内存,所以很可能导致一个线程更新了变量,但是仅仅是在写缓冲区里罢了,没有更新到主内存里去。这个时候,其他处理器的线程是没法读到他的写缓冲区的变量值的,所以此时就是会有可见性的问题。
|
|
||||||
|
|
||||||
**3、第三个可见性的场景:**然后即使这个时候一个处理器的线程更新了写缓冲区之后,将更新同步到了自己的高速缓存里(cache,或者是主内存),然后还把这个更新通知给了其他的处理器,但是其他处理器可能就是把这个更新放到无效队列里去,没有更新他的高速缓存。此时其他处理器的线程从高速缓存里读数据的时候,读到的还是过时的旧值。【处理器是优先从自己的高速缓存里取读取变量副本】
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
可见性发生的问题
|
|
||||||
|
|
||||||
如果要实现可见性的话,其中一个方法就是通过MESI协议,这个MESI协议实际上有很多种不同的时间,因为他不过就是一个协议罢了,具体的实现机制要靠具体底层的系统如何实现。
|
|
||||||
|
|
||||||
根据具体底层硬件的不同,MESI协议的实现是有区别的。比如说MESI协议有一种实现,就是一个处理器将另外一个处理器的高速缓存中的更新后的数据拿到自己的高速缓存中来更新一下,这样大家的缓存不就实现同步了,然后各个处理器的线程看到的数据就一样了。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# MESI-缓存一致性协议(简介)
|
|
||||||
|
|
||||||
1、为了实现MESI协议,有两个配套的专业机制要给大家说一下:flush处理器缓存、refresh处理器缓存。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- flush处理器缓存,他的意思就是把自己更新的值刷新到高速缓存里去(或者是主内存),因为必须要刷到高速缓存(或者是主内存)里,才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存(或者是主内存)里读取到更新的值
|
|
||||||
|
|
||||||
- 除了flush以外,他还会发送一个消息到总线(bus),通知其他处理器,某个变量的值被他给修改了
|
|
||||||
|
|
||||||
- refresh处理器缓存,他的意思就是说,处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、所以说,为了保证可见性,在底层是通过MESI协议、flush处理器缓存和refresh处理器缓存,这一整套机制来保障的
|
|
||||||
|
|
||||||
3、要记住,flush和refresh,这两个操作,flush是强制刷新数据到高速缓存(主内存),不要仅仅停留在写缓冲器里面;refresh,是从总线嗅探发现某个变量被修改,必须强制从其他处理器的高速缓存(或者主内存)加载变量的最新值到自己的高速缓存里去。【不同的硬件,实现可能略有不同】
|
|
||||||
|
|
||||||
4、内存屏障的使用,在底层硬件级别的原理,其实就是在执行flush和refresh,MESI协议是如何与内存屏障搭配使用的(flush、refresh)
|
|
||||||
|
|
||||||
```java
|
|
||||||
volatile boolean isRunning = true;
|
|
||||||
|
|
||||||
|
|
||||||
isRunning = false; => 写volatile变量,就会通过执行一个内存屏障,在底层会触发flush处理器缓存的操作;while(isRunning) {},读volatile变量,也会通过执行一个内存屏障,在底层触发refresh操作
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 内存屏障的相关讲解
|
|
||||||
|
|
||||||
> 上面的文章可能已经把读者搞混了,其实可见性和有序性最主要的就是内存屏障,下面来介绍下内存屏障帮读者梳理一下。
|
|
||||||
|
|
||||||
内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,实现可见性。
|
|
||||||
|
|
||||||
举例:将写缓冲器数据写入高速缓存,能够避免不同处理器之间不能访问写缓冲器而导致的可见性问题,以及有效地避免了存储转发问题;清空无效队列保证该处理器上高速缓存中不存在旧的副本,进而拿到最新数据
|
|
||||||
|
|
||||||
## 基本内存屏障
|
|
||||||
|
|
||||||
- LoadLoad屏障: 对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
|
|
||||||
|
|
||||||
- StoreStore屏障:对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
|
|
||||||
|
|
||||||
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕。
|
|
||||||
|
|
||||||
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
|
|
||||||
|
|
||||||
以上的四种屏障主要依据不同处理器支持的重排序(读写,读读,写写,写读)来确定的,比如某些处理器只支持写读重排序,因此只需要StoreLoad屏障
|
|
||||||
|
|
||||||
下面对上述的基本屏障进行利用,以针对不同的目的用相应的屏障。
|
|
||||||
|
|
||||||
## 可见性保障
|
|
||||||
|
|
||||||
主要分为加载屏障(Load Barrier)和存储屏障(Store Barrier)
|
|
||||||
|
|
||||||
- 加载屏障:StoreLoad屏障作为万能屏障,作用是冲刷写缓冲器,清空无效化队列,这样处理器在读取共享变量时,因为本高速缓存中的数据是无效的,因此先从主内存或其他处理器的高速缓存中读取相应变量,更新到自己的缓存中
|
|
||||||
|
|
||||||
- 存储屏障:同样使用StoreLoad屏障,作用是将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主内存中 ,同时解决存储转发问题,使得写缓冲器中的数据不存在旧值
|
|
||||||
|
|
||||||
以上两种屏障解决可见性问题。
|
|
||||||
|
|
||||||
## 有序性保障
|
|
||||||
|
|
||||||
主要分为获取屏障(Acquire Barrier)和释放屏障(Release Barrier)
|
|
||||||
|
|
||||||
- 获取屏障:相当于LoadLoad屏障和LoadStore屏障的组合,它能禁止该屏障之前的任何读操作与该屏障之后的任何读、写操作之间进行重排序;
|
|
||||||
- 释放屏障:相当于StoreLoad屏障与StoreStore屏障的组合,它能够禁止该屏障之前的任何读、写操作与该屏障之后的任何写操作之间进行重排序
|
|
||||||
|
|
||||||
对于其实大家记住volatile修饰的字段和普通修饰的字段同样不可以重排序,因此只要存在读写、写写、写读、读读等操作,包含了volatile关键字,都会在操作指令之间插入屏障的,具体插入什么屏障可以根据对应的操作插入。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## synchronized
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
结论:
|
|
||||||
(1)原子性:加锁和释放锁,ObjectMonitor
|
|
||||||
|
|
||||||
(2)可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
|
|
||||||
|
|
||||||
(3)有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的
|
|
||||||
```
|
|
||||||
|
|
||||||
举个例子说明加屏障的顺序:
|
|
||||||
|
|
||||||
```java
|
|
||||||
int b = 0;
|
|
||||||
|
|
||||||
int c = 0;
|
|
||||||
|
|
||||||
synchronized(this) { -> monitorenter
|
|
||||||
|
|
||||||
//Load内存屏障
|
|
||||||
|
|
||||||
//Acquire内存屏障
|
|
||||||
|
|
||||||
int a = b;
|
|
||||||
|
|
||||||
c = 1; // synchronized代码块里面还是可能会发生指令重排
|
|
||||||
|
|
||||||
//Release内存屏障
|
|
||||||
|
|
||||||
} -> monitorexit
|
|
||||||
|
|
||||||
//Store内存屏障
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1、java的并发技术底层很多都对应了内存屏障的使用,包括synchronized,他底层也是依托于各种不同的内存屏障来保证可见性和有序性的
|
|
||||||
|
|
||||||
2、按照可见性来划分的话,内存屏障可以分为Load屏障和Store屏障。
|
|
||||||
|
|
||||||
- Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的处理器更新过的变量,从其他处理器的高速缓存(或者主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据。
|
|
||||||
- Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去
|
|
||||||
|
|
||||||
+ 在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去;然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来
|
|
||||||
+ 所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3、按照有序性保障来划分的话,还可以分为Acquire屏障和Release屏障。
|
|
||||||
|
|
||||||
- 在monitorenter指令之后,Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。
|
|
||||||
- 所以说,通过 Acquire屏障和Release屏障,就可以让synchronzied保证有序性,只有synchronized内部的指令可以重排序,但是绝对不会跟外部的指令发生重排序。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## volatile
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
前面讲过,lock前缀指令相当于一个内存屏障,lock前缀指令同时保证可见性和有序性
|
|
||||||
|
|
||||||
(1)可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
|
|
||||||
|
|
||||||
(2)有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的
|
|
||||||
|
|
||||||
(3)不保证原子性
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1、volatile对原子性的保证真的是非常的有限,其实主要就是32位jvm中的long/double类型变量的赋值操作是不具备原子性的,加上volatile就可以保证原子性了。但是总体上就说不保证原子性。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、
|
|
||||||
|
|
||||||
```java
|
|
||||||
volatile boolean isRunning = true;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
线程1:
|
|
||||||
|
|
||||||
Release屏障
|
|
||||||
|
|
||||||
isRunning = false;
|
|
||||||
|
|
||||||
Store屏障
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
线程2:
|
|
||||||
|
|
||||||
Load屏障
|
|
||||||
|
|
||||||
while(isRunning) {
|
|
||||||
|
|
||||||
Acquire屏障
|
|
||||||
|
|
||||||
// 代码逻辑
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 在volatile变量写操作的前面会加入一个Release屏障,然后在之后会加入一个Store屏障,这样就可以保证volatile写跟Release屏障之前的任何读写操作都不会指令重排,然后Store屏障保证了,写完数据之后,立马会执行flush处理器缓存的操作
|
|
||||||
- 在volatile变量读操作的前面会加入一个Load屏障,这样就可以保证对这个变量的读取时,如果被别的处理器修改过了,必须得从其他处理器的高速缓存(或者主内存)中加载到自己本地高速缓存里,保证读到的是最新数据;在之后会加入一个Acquire屏障,禁止volatile读操作之后的任何读写操作会跟volatile读指令重排序
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
跟之前讲解的volatie读写内存屏障的知识对比一下,其实你看一下是类似的意思的。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 强调
|
|
||||||
|
|
||||||
- 其实不要对内存屏障这个东西太较真,因为说句实话,不同版本的JVM,不同的底层硬件,都可能会导致加的内存屏障有一些区别,所以这个本来就没完全一致的。你只要知道内存屏障是如何保证volatile的可见性和有序性的就可以了
|
|
||||||
- 看各种并发相关的书和文章,对内存屏障到底是加的什么屏障,莫衷一是,没有任何一个官方权威的说法,因为这个内存屏障太底层了,底层到了涉及到了硬件,硬件不同对内存屏障的实现是不一样的
|
|
||||||
- 内存屏障这个东西,大概来说,其实就是大概的给你说一下这个意思,尤其是Release屏障,Store屏障和Load屏障还好理解一些,比较简单,Acqurie屏障,莫衷一是,我也没法给你一个官方的定论
|
|
||||||
|
|
||||||
- 如果你一定 要了解清除,到底加的准确的屏障是什么?到底是如何跟上下的指令避免重排的,你自己去研究吧。【我也看过很多的资料,做过很多的研究,硬件对这个东西的实现和承诺,莫衷一是,没有标准和官方定论。-----这句话是某BAT大佬说的】
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
内存屏障对应的底层的一些基本的硬件级别的原理,也都讲清楚了
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# MESI-缓存一致性协议(进阶)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## MESI-初步
|
|
||||||
|
|
||||||
1、处理器高速缓存的底层数据结构实际是一个拉链散列表的结构,就是有很多个bucket,每个bucket挂了很多的cache entry,每个cache entry由三个部分组成:tag、cache line和flag,其中的cache line【缓存行】就是缓存的数据。tag指向了这个缓存数据在主内存中的数据的地址,flag标识了缓存行的状态,另外要注意的一点是,cache line中可以包含多个变量的值
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、处理器会操作一些变量,怎么在高速缓存里定位到这个变量呢?
|
|
||||||
|
|
||||||
- 那么处理器在读写高速缓存的时候,实际上会根据变量名执行一个内存地址解码的操作,解析出来3个东西,index、tag和offset。index用于定位到拉链散列表中的某个bucket,tag是用于定位cache entry,offset是用于定位一个变量在cache line中的位置
|
|
||||||
- 如果说可以成功定位到一个高速缓存中的数据,而且flag还标志着有效,则缓存命中;否则不满足上述条件,就是缓存未命中。如果是读数据未命中的话,会从主内存重新加载数据到高速缓存中,现在处理器一般都有三级高速缓存,L1、L2、L3,越靠前面的缓存读写速度越快
|
|
||||||
|
|
||||||
3、因为有高速缓存的存在,所以就导致各个处理器可能对一个变量会在自己的高速缓存里有自己的副本,这样一个处理器修改了变量值,别的处理器是看不到的,所以就是为了这个问题引入了缓存一致性协议(MESI协议)
|
|
||||||
|
|
||||||
4、MESI协议规定:对一个共享变量的读操作可以是多个处理器并发执行的,但是如果是对一个共享变量的写操作,只有一个处理器可以执行,其实也会通过排他锁的机制保证就一个处理器能写
|
|
||||||
|
|
||||||
之前说过那个cache entry的flag代表了缓存数据的状态,MESI协议中划分为:
|
|
||||||
|
|
||||||
- invalid:无效的,标记为I,这个意思就是当前cache entry无效,里面的数据不能使用
|
|
||||||
- shared:共享的,标记为S,这个意思是当前cache entry有效,而且里面的数据在各个处理器中都有各自的副本,但是这些副本的值跟主内存的值是一样的,各个处理器就是并发的在读而已
|
|
||||||
- exclusive:独占的,标记为E,这个意思就是当前处理器对这个数据独占了,只有他可以有这个副本,其他的处理器都不能包含这个副本
|
|
||||||
- modified:修改过的,标记为M,只能有一个处理器对共享数据更新,所以只有更新数据的处理器的cache entry,才是exclusive状态,表明当前线程更新了这个数据,这个副本的数据跟主内存是不一样的
|
|
||||||
|
|
||||||
MESI协议规定了一组消息,就说各个处理器在操作内存数据的时候,都会往总线发送消息,而且各个处理器还会不停的从总线嗅探最新的消息,通过这个总线的消息传递来保证各个处理器的协作
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**下面来详细的图解MESI协议的工作原理:**
|
|
||||||
|
|
||||||
1、处理器0读取某个变量的数据时,首先会根据index、tag和offset从高速缓存的拉链散列表读取数据,如果发现状态为I,也就是无效的,此时就会发送read消息到总线
|
|
||||||
|
|
||||||
2、接着主内存会返回对应的数据给处理器0,处理器0就会把数据放到高速缓存里,同时cache entry的flag状态是S
|
|
||||||
|
|
||||||
3、在处理器0对一个数据进行更新的时候,如果数据状态是S,则此时就需要发送一个invalidate消息到总线,尝试让其他的处理器的高速缓存的cache entry全部变为I,以获得数据的独占锁。
|
|
||||||
|
|
||||||
4、其他的处理器1会从总线嗅探到invalidate消息,此时就会把自己的cache entry设置为I,也就是过期掉自己本地的缓存,然后就是返回invalidate ack消息到总线,传递回处理器0,处理器0必须收到所有处理器返回的ack消息
|
|
||||||
|
|
||||||
5、接着处理器0就会将cache entry先设置为E,独占这条数据,在独占期间,别的处理器就不能修改数据了,因为别的处理器此时发出invalidate消息,这个处理器0是不会返回invalidate ack消息的,除非他先修改完再说
|
|
||||||
|
|
||||||
6、接着处理器0就是修改这条数据,接着将数据设置为M,也有可能是把数据此时强制写回到主内存中,具体看底层硬件实现
|
|
||||||
|
|
||||||
7、然后其他处理器此时这条数据的状态都是I了,那如果要读的话,全部都需要重新发送read消息,从主内存(或者是其他处理器)来加载,这个具体怎么实现要看底层的硬件了,都有可能的
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0017.jpg">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## MESI-优化
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0018.png">
|
|
||||||
|
|
||||||
MESI协议如果每次写数据的时候都要发送invalidate消息等待所有处理器返回ack,然后获取独占锁后才能写数据,那可能就会导致性能很差了,因为这个对共享变量的写操作,实际上在硬件级别变成串行的了。所以为了解决这个问题,硬件层面引入了写缓冲器和无效队列
|
|
||||||
|
|
||||||
1、
|
|
||||||
|
|
||||||
写缓冲器的作用是,一个处理器写数据的时候,直接把数据写入缓冲器,同时发送invalidate消息,然后就认为写操作完成了,接着就干别的事儿了,不会阻塞在这里。接着这个处理器如果之后收到其他处理器的ack消息之后才会把写缓冲器中的写结果拿出来,通过对cache entry设置为E加独占锁,同时修改数据,然后设置为M。
|
|
||||||
|
|
||||||
其实写缓冲器的作用,就是处理器写数据的时候直接写入缓冲器,不需要同步阻塞等待其他处理器的invalidate ack返回,这就大大提升了硬件层面的执行效率了
|
|
||||||
|
|
||||||
包括查询数据的时候,会先从写缓冲器里查,因为有可能刚修改的值在这里,然后才会从高速缓存里查,这个就是存储转发
|
|
||||||
|
|
||||||
2、
|
|
||||||
|
|
||||||
引入无效队列,就是说其他处理器在接收到了invalidate消息之后,不需要立马过期本地缓存,直接把消息放入无效队列,就返回ack给那个写处理器了,这就进一步加速了性能,然后之后从无效队列里取出来消息,过期本地缓存即可
|
|
||||||
|
|
||||||
通过引入写缓冲器和无效队列,一个处理器要写数据的话,这个性能其实很高的,他直接写数据到写缓冲器,发送一个validate消息出去,就立马返回,执行别的操作了;其他处理器收到invalidate消息之后直接放入无效队列,立马就返回invalidate ack
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 硬件层面的MESI协议为何会引发有序性和可见性的问题?
|
|
||||||
|
|
||||||
MESI协议在硬件层面的原理其实大家都已经了解的很清晰了。
|
|
||||||
|
|
||||||
讲了这么多,再来看一下MESI-协议为何会引发可见性和有序性的问题
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 可见性:写缓冲器和无效队列导致的,写数据不一定立马写入自己的高速缓存(或者主内存),是因为可能写入了写缓冲器;读数据不一定立马从别人的高速缓存(或者主内存)刷新最新的值过来,invalidate消息在无效队列里面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 有序性:
|
|
||||||
|
|
||||||
简单的举两个例子
|
|
||||||
|
|
||||||
(1)StoreLoad重排序
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```java
|
|
||||||
int a = 0;
|
|
||||||
|
|
||||||
int c = 1;
|
|
||||||
|
|
||||||
线程1:
|
|
||||||
|
|
||||||
a = 1; //Store操作
|
|
||||||
|
|
||||||
int b = c; //因为要读C的值,所以这个是load操作
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
这个很简单吧,第一个是Store,第二个是Load。但是可能处理器对store操作先写入了写缓冲器,此时这个写操作相当于没执行。然后就执行了第二行代码,第二行代码的b是局部变量,那这个操作等于是读取c的值,是load操作。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 第一个store操作写到写缓冲器里去了,导致其他的线程是读不到的,看不到的,好像是第一个写操作没执行一样;第二个load操作成功的执行了
|
|
||||||
|
|
||||||
- 这就导致好像第二行代码的load先执行了,第一行代码的store后执行
|
|
||||||
|
|
||||||
StoreLoad重排,明明Store先执行,Load后执行;看起来好像Load先执行,Store后执行
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(2)StoreStore重排序
|
|
||||||
|
|
||||||
```java
|
|
||||||
resource = loadResource();
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 两个写操作,但是可能第一个写操作写入了写缓冲器,然后第二个写操作是直接修改的高速缓存【可能此时第二个数据的状态是m】,这个时候不就导致了两个写操作顺序颠倒了?诸如此类的重排序,都可能会因为MESI的机制发生
|
|
||||||
- 可见性问题也是一样的,写入写缓冲器之后,没刷入高速缓存,导致别人读不到;读数据的时候,可能invalidate消息在无效队列里,导致没法立马感知到过期的缓存,立马加载最新的数据
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 内存屏障在硬件层面的实现原理
|
|
||||||
|
|
||||||
1、可见性问题:
|
|
||||||
|
|
||||||
> Store屏障 + Load屏障
|
|
||||||
|
|
||||||
如果加了Store屏障之后,就会强制性要求你对一个写操作必须阻塞等待到其他的处理器返回invalidate ack之后,对数据加锁,然后修改数据到高速缓存中,必须在写数据之后,强制执行flush操作。
|
|
||||||
|
|
||||||
他的效果,要求一个写操作必须刷到高速缓存(或者主内存),不能停留在写缓冲里
|
|
||||||
|
|
||||||
如果加了Load屏障之后,在从高速缓存中读取数据的时候,如果发现无效队列里有一个invalidate消息,此时会立马强制根据那个invalidate消息把自己本地高速缓存的数据,设置为I(过期),然后就可以强制从其他处理器的高速缓存中加载最新的值了。这就是refresh操作
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、有序性问题
|
|
||||||
|
|
||||||
> 内存屏障,Acquire屏障,Release屏障,但是都是由基础的StoreStore屏障,StoreLoad屏障,可以避免指令重排序的效果
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
StoreStore屏障,会强制让写数据的操作全部按照顺序写入写缓冲器里,他不会让你第一个写到写缓冲器里去,第二个写直接修改高速缓存了。
|
|
||||||
|
|
||||||
```java
|
|
||||||
resource = loadResource();
|
|
||||||
|
|
||||||
StoreStore屏障
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
StoreLoad屏障,他会强制先将写缓冲器里的数据写入高速缓存中,接着读数据的时候强制清空无效队列,对里面的validate消息全部过期掉高速缓存中的条目,然后强制从主内存里重新加载数据
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
a = 1; // 强制要求必须直接写入高速缓存,不能停留在写缓冲器里,清空写缓冲器里的这条数据 store
|
|
||||||
|
|
||||||
int b = c; //load
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> java内存模型是对底层的硬件模型,cpu缓存模型,做了大幅度的简化,提供一个抽象和统一的模型给java程序员易于理解,很多时候如果要理解一些技术的本质,还是要深入到底层去研究的。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 原子操作的实现原理
|
|
||||||
|
|
||||||
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
|
|
||||||
|
|
||||||
## 相关术语
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0019.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 处理器如何实现原子操作
|
|
||||||
|
|
||||||
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操 作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
|
|
||||||
|
|
||||||
### 使用总线锁保证原子性
|
|
||||||
|
|
||||||
**第一个机制是通过总线锁保证原子性。**如果多个处理器同时对共享变量进行读改写操作 (i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操 作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行 两次i++操作,我们期望的结果是3,但是有可能结果是2,如图所示。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0020.png">
|
|
||||||
|
|
||||||
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入 系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享 变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
|
|
||||||
|
|
||||||
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 使用缓存锁保证原子性
|
|
||||||
|
|
||||||
**第二个机制是通过缓存锁定来保证原子性**。在同一时刻,我们只需保证对某个内存地址 的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
|
|
||||||
|
|
||||||
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如上图所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
|
|
||||||
|
|
||||||
**但是有两种情况下处理器不会使用缓存锁定。**
|
|
||||||
|
|
||||||
第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
|
|
||||||
|
|
||||||
第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Java如何实现原子操作
|
|
||||||
|
|
||||||
(1)使用循环CAS实现原子操作
|
|
||||||
|
|
||||||
(2)使用锁机制实现原子操作
|
|
||||||
|
|
||||||
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用`mutex`互斥锁,最底层实现依赖于`futex`,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了`synchronized`关键字但**运行时并没有多线程竞争,或两个线程接近于交替执行的情况**,使用传统锁机制无疑效率是会比较低的。
|
|
||||||
|
|
||||||
futex由一个内核层的队列和一个用户空间层的atomic integer构成。当获得锁时,尝试cas更改integer,如果integer原始值是0,则修改成功,该线程获得锁,否则就将当期线程放入到 wait queue中(即操作系统的等待队列)。【及其类似于AQS的设计思想,可能AQS就参考了futex的思想】
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user