diff --git a/JavaConcurrency/Chapter4/README.md b/JavaConcurrency/Chapter4/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bb02fbde0451fb98b34b14f4506aa17a94f7cc07 --- /dev/null +++ b/JavaConcurrency/Chapter4/README.md @@ -0,0 +1,383 @@ +# Table of Contents + +* [1 来源](#1-来源) +* [2 `CPU`缓存](#2-cpu缓存) + * [2.1 缓存模型](#21-缓存模型) + * [2.2 缓存一致性问题](#22-缓存一致性问题) + * [2.2.1 总线加锁](#221-总线加锁) + * [2.2.2 缓存一致性协议](#222-缓存一致性协议) +* [3 `JMM`](#3-jmm) +* [4 并发编程的三个特性](#4-并发编程的三个特性) + * [4.1 原子性](#41-原子性) + * [4.2 可见性](#42-可见性) + * [4.3 有序性](#43-有序性) +* [5 `volatile`](#5-volatile) + * [5.1 语义](#51-语义) + * [5.2 如何保证可见性以及有序性](#52-如何保证可见性以及有序性) + * [5.2.1 可见性](#521-可见性) + * [5.2.2 有序性](#522-有序性) + * [5.2.3 原子性](#523-原子性) + * [5.3 实现原理](#53-实现原理) + * [5.4 使用场景](#54-使用场景) + * [5.5 与`synchronized`的区别](#55-与synchronized的区别) + + +# 1 来源 + +- 来源:《Java高并发编程详解 多线程与架构设计》,汪文君著 +- 章节:第十二、十三章 + +本文是两章的笔记整理。 + +# 2 `CPU`缓存 +## 2.1 缓存模型 +计算机中的所有运算操作都是由`CPU`完成的,`CPU`指令执行过程需要涉及数据读取和写入操作,但是`CPU`只能访问处于内存中的数据,而内存的速度和`CPU`的速度是远远不对等的,因此就出现了缓存模型,也就是在`CPU`和内存之间加入了缓存层。一般现代的`CPU`缓存层分为三级,分别叫`L1`缓存、`L2`缓存和`L3`缓存,简略图如下: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210515234450950.png) + +- `L1`缓存:三级缓存中访问速度最快,但是容量最小,另外`L1`缓存还被划分成了数据缓存(`L1d`,`data`首字母)和指令缓存(`L1i`,`instruction`首字母) +- `L2`缓存:速度比`L1`慢,但是容量比`L1`大,在现代的多核`CPU`中,`L2`一般被单个核独占 +- `L3`缓存:三级缓存中速度最慢,但是容量最大,现代`CPU`中也有`L3`是多核共享的设计,比如`zen3`架构的设计 + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516000524728.png) + +缓存的出现,是为了解决`CPU`直接访问内存效率低下的问题,`CPU`进行运算的时候,将需要的数据从主存复制一份到缓存中,因为缓存的访问速度快于内存,在计算的时候只需要读取缓存并将结果更新到缓存,运算结束再将结果刷新到主存,这样就大大提高了计算效率,整体交互图简略如下: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516001151927.png) + +## 2.2 缓存一致性问题 +虽然缓存的出现,大大提高了吞吐能力,但是,也引入了一个新的问题,就是缓存不一致。比如,最简单的一个`i++`操作,需要将内存数据复制一份到缓存中,`CPU`读取缓存值并进行更新,先写入缓存,运算结束后再将缓存中新的刷新到内存,具体过程如下: + +- 读取内存中的`i`到缓存中 +- `CPU`读取缓存`i`中的值 +- 对`i`进行加1操作 +- 将结果写回缓存 +- 再将数据刷新到主存 + +这样的`i++`操作在单线程不会出现问题,但在多线程中,因为每个线程都有自己的工作内存(也叫本地内存,是线程自己的缓存),变量`i`在多个线程的本地内存中都存在一个副本,如果有两个线程执行`i++`操作: + +- 假设两个线程为A、B,同时假设`i`初始值为0 +- 线程A从内存中读取`i`的值放入缓存中,此时`i`的值为0,线程B也同理,放入缓存中的值也是0 +- 两个线程同时进行自增操作,此时A、B线程的缓存中,`i`的值都是1 +- 两个线程将`i`写入主内存,相当于`i`被两次赋值为1 +- 最终结果是`i`的值为1 + +这个就是典型的缓存不一致问题,主流的解决办法有: + +- 总线加锁 +- 缓存一致性协议 + +### 2.2.1 总线加锁 +这是一种悲观的实现方式,具体来说,就是通过处理器发出`lock`指令,锁住总线,总线收到指令后,会阻塞其他处理器的请求,直到占用锁的处理器完成操作。特点是只有一个抢到总线锁的处理器运行,但是这种方式效率低下,一旦某个处理器获取到锁其他处理器只能阻塞等待,会影响多核处理器的性能。 + +### 2.2.2 缓存一致性协议 +图示如下: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516002914904.png) + +缓存一致性协议中最出名的就是`MESI`协议,`MESI`保证了每一个缓存中使用的共享变量的副本都是一致的。大致思想是,`CPU`操作缓存中的数据时,如果发现该变量是一个共享变量,操作如下: + +- 读取:不做其他处理,只是将缓存中数据读取到寄存器中 +- 写入:发出信号通知其他`CPU`将该变量的缓存行设置为无效状态(`Invalid`),其他`CPU`进行该变量的读取时需要到主存中再次获取 + +具体来说,`MESI`中规定了缓存行使用4种状态标记: + +- `M`:`Modified`,被修改 +- `E`:`Exclusive`,独享的 +- `S`:`Shared`,共享的 +- `I`:`Invalid`,无效的 + +有关`MESI`详细的实现超出了本文的范围,想要详细了解可以参考[此处](https://www.cnblogs.com/yanlong300/p/8986041.html)或[此处](http://www2.in.tum.de/hp/file?fid=1276)。 + + +# 3 `JMM` +看完了`CPU`缓存再来看一下`JMM`,也就是`Java`内存模型,指定了`JVM`如何与计算机的主存进行工作,同时也决定了一个线程对共享变量的写入何时对其他线程可见,`JMM`定义了线程和主内存之间的抽象关系,具体如下: + +- 共享变量存储于主内存中,每个线程都可以访问 +- 每个线程都有私有的工作内存或者叫本地内存 +- 工作内存只存储该线程对共享变量的副本 +- 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存 +- 工作内存和`JMM`内存模型一样也是一个抽象概念,其实并不存在,涵盖了缓存、寄存器、编译期优化以及硬件等 + +简略图如下: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516104445340.png) + +与`MESI`类似,如果一个线程修改了共享变量,刷新到主内存后,其他线程读取工作内存的时候发现缓存失效,会从主内存再次读取到工作内存中。 + +而下图表示了`JVM`与计算机硬件分配的关系: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516192712715.png) + +# 4 并发编程的三个特性 +文章都看了大半了还没到`volatile`?别急别急,先来看看并发编程中的三个重要特性,这对正确理解`volatile`有很大的帮助。 + +## 4.1 原子性 +原子性就是在一次或多次操作中: + +- 要么所有的操作全部都得到了执行,且不会受到任何因素的干扰而中断 +- 要么所有的操作都不执行 + +一个典型的例子就是两个人转账,比如A向B转账1000元,那么这包含两个基本的操作: + +- A的账户扣除1000元 +- B的账户增加1000元 + +这两个操作,要么都成功,要么都失败,也就是不能出现A账户扣除1000但是B账户金额不变的情况,也不能出现A账户金额不变B账户增加1000的情况。 + +需要注意的是两个原子性操作结合在一起未必是原子性的,比如`i++`。本质上来说,`i++`涉及到了三个操作: + +- `get i` +- `i+1` +- `set i` + +这三个操作都是原子性的,但是组合在一起(`i++`)就不是原子性的。 + +## 4.2 可见性 +另一个重要的特性是可见性,可见性是指,一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。 + +一个简单的例子如下: +```java +public class Main { + private int x = 0; + private static final int MAX = 100000; + public static void main(String[] args) throws InterruptedException { + Main m = new Main(); + Thread thread0 = new Thread(()->{ + while(m.x < MAX) { + ++m.x; + } + }); + + Thread thread1 = new Thread(()->{ + while(m.x < MAX){ + } + System.out.println("finish"); + }); + + thread1.start(); + TimeUnit.MILLISECONDS.sleep(1); + thread0.start(); + } +} +``` + +线程`thread1`会一直运行,因为`thread1`把`x`读入工作内存后,会一直判断工作内存中的值,由于`thread0`改变的是`thread0`工作内存的值,并没有对`thread1`可见,因此永远也不会输出`finish`,使用`jstack`也可以看到结果: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/20210516121151277.png) + +## 4.3 有序性 +有序性是指代码在执行过程中的先后顺序,由于`JVM`的优化,导致了代码的编写顺序未必是代码的运行顺序,比如下面的四条语句: +```java +int x = 10; +int y = 0; +x++; +y = 20; +``` +有可能`y=20`在`x++`前执行,这就是指令重排序。一般来说,处理器为了提高程序的效率,可能会对输入的代码指令做一定的优化,不会严格按照编写顺序去执行代码,但可以保证最终运算结果是编码时的期望结果,当然,重排序也有一定的规则,需要严格遵守指令之间的数据依赖关系,并不是可以任意重排序,比如: +```java +int x = 10; +int y = 0; +x++; +y = x+1; +``` +`y=x+1`就不能先优于`x++`执行。 + +在单线程下重排序不会导致预期值的改变,但在多线程下,如果有序性得不到保证,那么将可能出现很大的问题: +```java +private boolean initialized = false; +private Context context; +public Context load(){ + if(!initialized){ + context = loadContext(); + initialized = true; + } + return context; +} +``` +如果发生了重排序,`initialized=true`排序到了`context=loadContext()`的前面,假设两个线程A、B同时访问,且`loadContext()`需要一定耗时,那么: + +- 线程A通过判断后,先设置布尔变量的值为`true`,再进行`loadContext()`操作 +- 线程B中由于布尔变量被设置为`true`,会直接返回一个未加载完成的`context` + +# 5 `volatile` +好了终于到了`volatile`了,前面说了这么多,目的就是为了能彻底理解和明白`volatile`。这部分分为四个小节: + +- `volatile`的语义 +- 如何保证有序性以及可见性 +- 实现原理 +- 使用场景 +- 与`synchronized`区别 + +先来介绍一下`volatile`的语义。 +## 5.1 语义 +被`volatile`修饰的实例变量或者类变量具有两层语义: + +- 保证了不同线程之间对共享变量操作时的可见性 +- 禁止对指令进行重排序操作 + +## 5.2 如何保证可见性以及有序性 +先说结论: + +- `volatile`能保证可见性 +- `volatile`能保证有序性 +- `volatile`不能保证原子性 + +下面分别进行介绍。 + +### 5.2.1 可见性 +`Java`中保证可见性有如下方式: + +- `volatile`:当一个变量被`volatile`修饰时,对共享资源的读操作会直接在主内存中进行(准确来说也会读取到工作内存中,但是如果其他线程进行了修改就必须从主内存重新读取),写操作是先修改工作内存,但是修改结束后立即刷新到主内存中 +- `synchronized`:`synchronized`一样能保证可见性,能够保证同一时刻只有一个线程获取到锁,然后执行同步方法,并且确保锁释放之前,变量的修改被刷新到主内存中 +- 使用显式锁`Lock`:`Lock`的`lock`方法能保证同一时刻只有一个线程能够获取到锁然后执行同步方法,并且确保锁释放之前能够将对变量的修改刷新到主内存中 + +具体来说,可以看一下之前的例子: +```java +public class Main { + private int x = 0; + private static final int MAX = 100000; + public static void main(String[] args) throws InterruptedException { + Main m = new Main(); + Thread thread0 = new Thread(()->{ + while(m.x < MAX) { + ++m.x; + } + }); + + Thread thread1 = new Thread(()->{ + while(m.x < MAX){ + } + System.out.println("finish"); + }); + + thread1.start(); + TimeUnit.MILLISECONDS.sleep(1); + thread0.start(); + } +} +``` +上面说过这段代码会不断运行,一直没有输出,就是因为修改后的`x`对线程`thread1`不可见,如果在`x`的定义中加上了`volatile`,就不会出现没有输出的情况了,因为此时对`x`的修改是线程`thread1`可见的。 + +### 5.2.2 有序性 +`JMM`中允许编译期和处理器对指令进行重排序,在多线程的情况下有可能会出现问题,为此,`Java`同样提供了三种机制去保证有序性: + +- `volatile` +- `synchronized` +- 显式锁`Lock` + +另外,关于有序性不得不提的就是`Happens-before`原则。`Happends-before`原则说的就是如果两个操作的执行次序无法从该原则推导出来,那么就无法保证有序性,`JVM`或处理器可以任意重排序。这么做的目的是为了尽可能提高程序的并行度,具体规则如下: + +- 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生与编写在前面的操作之后 +- 锁定规则:如果一个锁处于锁定状态,则`unlock`操作要先行发生于对同一个锁的`lock`操作 +- `volatile`变量规则:对一个变量的写操作要早于对这个变量之后的读操作 +- 传递规则:如果操作A先于操作B,操作B先于操作C,那么操作A先于操作C +- 线程启动规则:`Thread`对象的`start()`方法先行发生于对该线程的任何动作 +- 线程中断规则:对线程执行`interrupt()`方法肯定要优于捕获到中断信号,换句话说,如果收到了中断信号,那么在此之前必定调用了`interrupt()` +- 线程终结规则:线程中所有操作都要先行发生于线程的终止检测,也就是逻辑单元的执行肯定要发生于线程终止之前 +- 对象终结规则:一个对象初始化的完成先行发生于`finalize()`之前 + +对于`volatile`,会直接禁止对指令重排,但是对于`volatile`前后无依赖关系的指令可以随意重排,比如: +```java +int x = 0; +int y = 1; +//private volatile int z; +z = 20; +x++; +y--; +``` +在`z=20`之前,先定义`x`或先定义`y`并没有要求,只需要在执行`z=20`的时候,可以保证`x=0,y=1`即可,同理,`x++`或`y--`具体先执行哪一个并没有要求,只需要保证两者执行在`z=20`之后即可。 + +### 5.2.3 原子性 +在`Java`中,所有对基本数据类型变量的读取赋值操作都是原子性的,对引用类型的变量读取和赋值也是原子性的,但是: + +- 将一个变量赋值给另一个变量的操作不是原子性的,因为涉及到了一个变量的读取以及一个变量的写入,两个原子性操作结合在一起就不是原子性操作 +- 多个原子性操作在一起就不是原子性操作,比如`i++` +- `JMM`只保证基本读取和赋值的原子性操作,其他的均不保证,如果需要具备原子性,那么可以使用`synchronized`或`Lock`,或者`JUC`包下的原子操作类 + +也就是说,`volatile`并不能保证原子性,例子如下: +```java +public class Main { + private volatile int x = 0; + private static final CountDownLatch latch = new CountDownLatch(10); + + public void inc() { + ++x; + } + + public static void main(String[] args) throws InterruptedException { + Main m = new Main(); + IntStream.range(0, 10).forEach(i -> { + new Thread(() -> { + for (int j = 0; j < 1000; j++) { + m.inc(); + } + latch.countDown(); + }).start(); + }); + latch.await(); + System.out.println(m.x); + } +} +``` +最后输出的`x`的值会少于`10000`,而且每次运行的结果也并不相同,至于原因,可以从两个线程A、B开始分析,图示如下: + +![在这里插入图片描述](https://img-blog.csdnimg.cn/202105161331204.png) + +- `0-t1`:线程A将`x`读入工作内存,此时`x=0` +- `t1-t2`:线程A时间片完,`CPU`调度线程B,线程B将`x`读入工作内存,此时`x=0` +- `t2-t3`:线程B对工作内存中的`x`进行自增操作,并更新到工作内存中 +- `t3-t4`:线程B时间片完,`CPU`调度线程A,同理线程A对工作内存中的`x`自增 +- `t4-t5`:线程A将工作内存中的值写回主内存,此时主内存中的值为`x=1` +- `t5`以后:线程A时间片完,`CPU`调度线程B,线程B也将自己的工作内存写回主内存,再次将主内存中的`x`赋值为1 + +也就是说,多线程操作的话,会出现两次自增但是实际上只进行一次数值修改的操作。想要`x`的值变为`10000`也很简单,加上`synchronized`即可: +```java +new Thread(() -> { + synchronized (m) { + for (int j = 0; j < 1000; j++) { + m.inc(); + } + } + latch.countDown(); +}).start(); +``` + +## 5.3 实现原理 +前面已经知道,`volatile`可以保证有序性以及可见性,那么,具体是如何操作的呢? + +答案就是一个`lock;`前缀,该前缀实际上相当于一个内存屏障,该内存屏障会为指令的执行提供如下几个保障: + +- 确保指令重排序时不会将其后面的代码排到内存屏障之前 +- 确保指令重排序时不会将其前面的代码排到内存屏障之后 +- 确保执行到内存屏障修饰的指令时前面的代码全部执行完成 +- 强制将线程工作内存中的值修改刷新到主存中 +- 如果是写操作,会导致其他线程工作内存中的缓存数据失效 + + +## 5.4 使用场景 +一个典型的使用场景是利用开关进行线程的关闭操作,例子如下: +```java +public class ThreadTest extends Thread{ + private volatile boolean started = true; + + @Override + public void run() { + while (started){ + + } + } + + public void shutdown(){ + this.started = false; + } +} +``` +如果布尔变量没有被`volatile`修饰,那么很可能新的布尔值刷新不到主内存中,导致线程不会结束。 + +## 5.5 与`synchronized`的区别 +- 使用上的区别:`volatile`只能用于修饰实例变量或者类变量,但是不能用于修饰方法、方法参数、局部变量等,另外可以修饰的变量为`null`。但`synchronized`不能用于对变量的修饰,只能修饰方法或语句块,而且`monitor`对象不能为`null` +- 对原子性的保证:`volatile`无法保证原子性,但是`synchronized`可以保证 +- 对可见性的保证:`volatile`与`synchronized`都能保证可见性,但是`synchronized`是借助于`JVM`指令`monitor enter`/`monitor exit`保证的,在`monitor exit`的时候所有共享资源都被刷新到主内存中,而`volatile`是通过`lock;`机器指令实现的,迫使其他线程工作内存失效,需要到主内存加载 +- 对有序性的保证:`volatile`能够禁止`JVM`以及处理器对其进行重排序,而`synchronized`保证的有序性是通过程序串行化执行换来的,并且在`synchronized`代码块中的代码也会发生指令重排的情况 +- 其他区别:`volatile`不会使线程陷入阻塞,但`synchronized`会 diff --git a/JavaConcurrency/Chapter4/pic.odg b/JavaConcurrency/Chapter4/pic.odg new file mode 100644 index 0000000000000000000000000000000000000000..84b01bec9e3270c9a0fa66725f67de2b41bc630b Binary files /dev/null and b/JavaConcurrency/Chapter4/pic.odg differ diff --git a/JavaConcurrency/README.md b/JavaConcurrency/README.md index 28c2988adcdee7240cd72512c1dd4e5e32e8fe16..a2d06f4ccfecce3235dcd208312f8844e9c962b7 100644 --- a/JavaConcurrency/README.md +++ b/JavaConcurrency/README.md @@ -3,3 +3,4 @@ - [第一章:Thread 详解](https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter1/README.md) - [第二章:线程安全与ThreadGroup](https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter2/README.md) - [第三章:类加载](https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter3/README.md) +- [第四章:volatile](https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter4/README.md)