This commit is contained in:
Dragon
2020-10-19 20:15:51 +08:00
parent 9e9e7ed6b9
commit 0bbdc1d9d2
8 changed files with 0 additions and 12815 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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值是8B线程又执行9: getstatic得到前一个值是7。马上A线程就把8赋值给了num变量。但是B线程已经拿到了之前的值7B线程是在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。
情况40发生了指令重排
```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然后依次是 L2L3和主内存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 -> CJMM要求编译器和处理器都禁止这种重排序。
* 不会改变程序执行结果的重排序,比如 A -> BJMM对编译器和处理器不做要求允许这种重排序。
**举例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里缓存了该内存地址的数据无效。
为了提高处理速度处理器不直接和主内存进行通信而是先将系统内存的数据读到内部缓存L1L2或其他后再进行操作但操作完不知道何时会写到内存。如果对声明了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读同一个volatilestep 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缓存如L1L2等
### 保守策略下
- 在每个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的禁止重排序功能还是非常有用的。

View File

@@ -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和refreshMESI协议是如何与内存屏障搭配使用的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用于定位到拉链散列表中的某个buckettag是用于定位cache entryoffset是用于定位一个变量在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消息在无效队列里面
- 有序性:
简单的举两个例子
1StoreLoad重排序
```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后执行
2StoreStore重排序
```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的思想】