- 硬件内存模型
- Java内存模型
- 线程之间通信
- 同步性原则
- 可能出现的问题
- 可见性
- 原子性
- 有序性
硬件内存模型
工程师为了追求横向的拓展,就是在单台计算机中使用更多的处理器。
众所周知,目前CPU的处理器速度与内存速度的读写速度不在一个数量级,所以需要在CPU和内存之间加上缓存来进行提速,这样的就呈现了一种CPU-寄存器-缓存-主存的访问结构。
cpu:包含运算器和控制器。根据冯诺依曼体系,CPU的工作分为以下 5 个阶段:取指令阶段、指令译码阶段、执行指令阶段、访存取数和结果写回。
寄存器: 指令寄存器、程序计数器等
结构: CPU-寄存器-缓存-主存
这种结构在单CPU时期运行的很好,但是当一台计算机中引入了多个CPU时,出现了一个棘手的问题,假如CPU A将数据D从主存读取到独占的缓存内,通过计算之后修改了数据D,变为D1,但是还没有刷新回到主存,此时CPU B将数据D从主存读取到独占缓存内,也对D进行计算变为D2,显而易见这时候的数据产生了不同步。
到底是以D1为准还是以D2为准,针对这个问题,科学家们设计了缓存一致性协议。
image-20220430182347162.png主要就是为了解决多个CPU缓存之间的同步问题,CPU缓存一致性协议有很多,大致可以分为两类。
窥探型和基于目录型,当CPU缓存想要访问主存时,需要经过一致性协议这种软件层面的措施来保证数据的一致性,协议本事的实现细节,可以猜想的是,其中的内容一定是一些和数据同步相关的操作,既然要进行数据同步,很可能出现等待唤醒这样的措施,这将可能导致性能问题,尤其是对于CPU这种运算速度极快的组件来说,丝毫的等待都是极大的浪费,比如CPU B想要读取数据 D的时候,还需要等待CPU A将D写回主存,这种行为是难以忍受的,因此,计算机科学家们做出了一些优化,整体思路上就是将同步改为异步,比如CPU B要读取数据D时,发现D正在被其他的CPU修改,那么此时CPU B 可以注册一个读取D的消息,自己能回头去做其他事情,其他CPU写会数据D后,响应了这个注册消息,此时CPU B发现消息被响应后,再去读取D 这样的就能够提升效率。但是对于CPU B来说,程序看上去就不是顺序执行了,可能会出现先运行后面的指令,再回头去运行前面的指令,这一种行为就体现出了一种指令重排序。虽然指令被重排了,但CPU依然需要保证程序执行结果的正确性,就是说无论指令怎么重排,最后的执行结果一定要和顺序执行的结果是一样的,这具体是如何实现的呢?可以做一个扩展
指令重排相关知识点(了解)
1.Store Buffer
2.Store Forwarding
3.Invalid Queue
4.写屏障
5.内存屏障
硬件内存模型的目标是为了让汇编代码能够运行在一个具有一致性的内存视图上。随着高级语言的流行。工程师们开始设计编程语言级别的内存模型,这是为了能够使用该语言编程的也能拥有一个一致性的内存视图。
一致性的内存视图。各种硬件内存模型抽象出相同的内存视图。
Java内存模型
于是在硬件模型之上,还存在着为编程语言设计的内存模型,比如Java内存模型 JMM (Java Memory Model)就屏蔽了各种硬件和操作系统的内存访问差异,实现了让Java程序能够在各种硬件平台下,都能够按照预期的方式来运行。
他的抽象,如图,
image-20220430184342044.png概括来说每个工作流程都拥有独占的本地内存,本地内存中的存储的是私有变量以及共享变量的副本,并且使用一定机制来控制本地内存和主存之间读写数据时的同步问题,更加具体一点,我们将工作线程和本地内存具象为 thread stack 将主存具象为heap 。
image-20220430185027769.pngThread stack中有两种类型的变量。其中原始类型的变量,总是存储在线程栈上,对象类型的变量 引用或者说指针本身是存储在线程栈上,而引用指向的对象的是存储在堆上的。在Heap中存储对象本身,持有对象引用的线程就都能够访问该对象,heap本身他不关心哪个线程正在访问对象
我们可以这么理解 Java线程模型中的thread stack 和heap都是对物理内存的一种抽象。这样开发者只需要关心自己写的程序使用到了thread stack/heap ,而不需要关心更下层的寄存器 cpu缓存 主存。可以猜测,线程在工作时的大部分情况下都在读写thread stack中的本地内存,也就是说本地内存对速度的要求更高,那么他可能大部分都是使用寄存器和CPU缓存来实现的,而heap中需要存储大量的对象,需要更大的容量。那么他可能大部分都是使用主存来实现的。
image-20220430190514847.png线程之间通信
这样想来,大概就能理解Java内存模型与硬件内存模型之间这种模糊的内容映射关系了。上面我们提到了Java内存模型需要设计一些机制,来实现主存与工作内存之间的数据传输与同步,这种数据的传递,正式线程之间的通信方式。
主存和工作内存之间通过这八个指令来实现数据的读写与同步,按照作用域分别分为两类:
一类是作用于主存,一类是作用于工作内存。下图是一个通信的例子:
image-20220430192610266.png比如说线程A现在调用lock指令,将x变量标记独占状态,接下来他assign/store两个指令来对x进行赋值,使他变为2 再继续调用write指令,将x这个变量写入主存,此时A的操作已经完成,并进行解锁,于是调用了unlock指令释放x锁定状态 这时候呢,线程B要读取变量X,于是他调用了read指令来读取了x这个变量,调用load将变量加载到自己的本地内存中,最后他再调用use指令来让计算资源对这个变量进行操作。这一套下来就实现了线程A和线程B之间的通信。不过这张图上演示的是一种比较理想的状态。而实际的线程通信中还存在着一些问题需要解决。
可能出现的问题
第一个问题:
假如本地内存A和本地内存B中存在x副本且值都是1,当线程A将x修改为2并且写入主存后,此时线程B想要读取x,默认会从本地内存B中读取,而本地内存B中的x依然是等于1的,换言之,线程A刷新了主存中的x,线程B如何才能读取到最新的值,那么这个问题被称为一种可见性的问题。
第二个问题:
加入线程A和B都从主存中读取了变量x,此时x=1,分别在各自的本地内存中自增1,x变为了2,然后再刷新回主存,这里就有一个问题,实际上自增了两次,x应该变为3,但是主存中的x 却为2。那么这种问题被称为一种原子性的问题。
上面所说的两个问题其实就是反应了线程通信之间的同步问题。当多个线程在并发操作共享数据时,可能回引发各种各样的问题。这些问题,被总结为三个要素。 可见性 原子性 有序性
上面所说的两个问题呢,分别对应可见性和原子性,这三个要素事实上并不是完全割裂的,尤其是可见性和有序性。
可见性
可见性指的是:当一个线程修改共享变量的值,其他线程需要能够立刻得知这个修改。
这句话其实有两层含义:
1.线程A修改了数据D,线程B需要督导修改后最新的D。(由刷新主存的时机引起的)
对应到Java内存模型中,当一个线程在自己的工作内存中修改了某个变量,应该把该变量立即刷新到主存中,并让其他线程知道。
对应的代码:
static int a = 1;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
while (a != 2) {
// do nothing
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
a = 2;
}
});
thread1.start();
Thread.sleep(1000);
thread2.start();
}
当我们执行main方法时,首先线程1会启动,由于a的值为1,线程1将会执行死循环, 一秒后线程2启动。线程2将a的值改为2,此时如果线程1能够读到a的值被修改为2的话,将会跳出死循环,但是你会发现事实上并没有跳出,死循环将一致执行下去。说明变量a的修改并没有被线程1读到,那么说明a此时不满足可见性,针对此种情况,如何解决呢?
当某个线程修改了变量,其他线程如何才能立刻获取到最新值,这里主要由两种解决办法。
第一种:利用volataile关键字。volatile关键字的下层实现保证了,若一个被volatile写volatile修饰的变量被修改,那么总会主动写入主存,若要读取一个volatile变量,那么总是从主存中读取。这样的话,相当于操作volatile变量都是直接去读写主存。这样就能够解决上面的可见性问题。
第二种:利用Synchronized关键字,Synchronized关键字实现的一个特性。在同步代码块中,monitor的基础上,读写变量时,将会隐式地执行上文提到的内存lock指令,并清空工作内存中该变量的值,需要使用该变量时必须从主存中读取。同理,也会隐式的执行内存unlock指令,将修改过的变量刷新回主存。这样也能够解决可见性问题。
对应的代码:
static int a = 1;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
while (a != 2) {
synchronized (this) {
int b = a + 1;
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
a = 2;
}
});
thread1.start();
Thread.sleep(1000);
thread2.start();
}
2.第二种可见性问题:线程B需要读到被修改的变量D,线程A应该修改,但是因为重排序导致线程A没有及时修改变量D。(由指令重排引起的)
代码:
static int a = 0;
static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
a = 1; // 1
flag = true; // 2
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
if (flag) { // 3
int i = a; // 4
}
}
});
thread1.start();
thread2.start();
}
如果代码执行到注释4这行时,变量i是否一定等于1,答案是否定的,我们在最上面提到过,硬件内存模型中存在指令重排序机制,Java内存模型中也存在指令重排,他们的作用和约束都是一样的,第一是为了更高的执行效率,第二个在单线程中指令重排后能够保证程序执行结果的正确性,就是说和顺序执行的结果是一样的。所以线程一中的代码一和代码二完全有可能在编译后被重排,出现了下面这样的执行顺序 代码2->代码3->代码4->代码1。在这种情况下,变量i还是等于0,但是从程序顺序执行的逻辑上看,似乎只要执行到代码4,变量i的值就一定是1,这里就出现了可见性问题,说明变量a此时不满足可见性。
同样的,我们也可以通过volatile和sync这两个关键字来解决这种可见性问题。第一种,使用volatile关键字,volatile关键字禁止当前变量与之前的代码语句进行重排序,可以这么理解,当程序执行到volatile变量的读写时(还未执行),之前的代码语句的执行结果是满足可见性的。当执行volatile的读写时,上文讲过变量将会与主存进行同步,所以volatile变量保证了可见性。
在这个例子中,我们只要给付那个变量加上volatile修饰,那么就能够禁止代码1和代码2的重排,因为代码2中的变量是被volatile修饰的,根据上一段所说,就能够保证代码1的可见性。线程2中的代码4就能够成功的读到a的值为1,synchronized关键字,我们再看上面的这个例子导致可见性问题的根源就是代码1和代码2被重排了,并且在执行期间线程2读到了线程1的中间状态,那么如果代码1和代码2变成了一个不可分割的代码块,这时无论其内部如何进行重排,外部都只能读到最终结果,所以也就避免了可见性的问题。
特别提醒:Java的指令重排有两次,第一次发生在将字节码编译成机器码的阶段,第二次发生在CPU执行的时候,也会适当的进行指令重排。
关于指令重排的的复现代码:
package com.example.demo0413.test;
public class VolatileReOrderSample {
//定义四个静态变量
private static int x=0,y=0;
private static int a=0,b=0;
public static void main(String[] args) throws InterruptedException {
int i=0;
while (true){
i++;
x=0;y=0;a=0;b=0;
//开两个线程,第一个线程执行a=1;x=b;第二个线程执行b=1;y=a
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
//线程1会比线程2先执行,因此用nanoTime让线程1等待线程2 0.01毫秒
shortWait(10000);
a=1;
x=b;
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
b=1;
y=a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
//等两个线程都执行完毕后拼接结果
String result="第"+i+"次执行x="+x+"y="+y;
//如果x=0且y=0,则跳出循环
if (x==0&&y==0){
System.out.println(result);
break;
}else{
System.out.println(result);
}
}
}
//等待interval纳秒
private static void shortWait(long interval) {
long start=System.nanoTime();
long end;
do {
end=System.nanoTime();
}while (start+interval>=end);
}
}
happens-before原则
设计内存的前辈们为我们解决了这些事,他们定义了一组原则,被称为 happens-before原则,这组原则规定了对于两个操作A和B 这两个操作可以在不同的线程中执行。如果A happens-before B 那么可以保证 当A操作执行完后,A操作的执行结果对 B操作是可见的。事实上,之所以很多人在日常开发中对可见性问题没有太多的感知,那是因为在不知不觉中就已经满足了happens-before原则之一。
这个原则有八条:
-
程序顺序规则
-
锁定规则
-
volatile变量规则
-
线程启动规则
-
线程结束规则
-
中断规则
-
终结期规则
-
传递性规则
前面比较重要的三条,
程序顺序原则,
在一个线程的内部按照程序代码的书写顺序,书写在前面的代码操作 happens-before于书写在后面的代码操作。因为在单个线程中程序员编写的代码在语义上是需要穿行顺序地执行,即使在编译后的代码可能会进行重排,但是内存模型会保证程序执行结果的正确性。也就是说,无论他的内部怎么重排,他最终的执行结果和顺序执行的结果是一致的。这也是大部分程序员在执行自己所写的代码时没有出现可见性问题的主要原因。
锁定规则,
对于一个锁的解锁,总是happens-before这个锁的加锁。synchronized保证可见性的主要原理就是刷新储存和原子化多个操作。
volatile规则,
对于一个volatile变量的写,总是happens-before 于后续对这个volatile变量的读,其中的原理主要是刷新主存和禁止重排序。
原子性
原子性指的是 一个操作是不可中断的,要么全部执行成功,要么全部执行失败。原子操作我按照自己的理解分为两种,一种是单指令原子操作,单指令原子操作指的是,当你执行单个指令,要么成功要么失败,比如工作内存和主存之间进行读写的8个指令,这些指令是不可再分的,每个指令都是原子操作。第二种利用锁的组合指令原子操作,有时候开发者想让一组操作要么执行成功,要么执行失败,也就是想要保证一组指令的原子性,这时候就要用到锁,比如8个内存指令中就有lock和unlock这两个和锁有关的指令。利用他们,可以支持一组指令的原子性,反应到上层就是synchronized。
有序性
无论是从硬件内存模型还是Java内存模型来看,都支持指令重排这种优化操作,在单线程中虽然指令可能会被重排,但是在单线程中内存模型能够保证执行结果的准确。也就是说在单线程中无论指令如何重排,他最终的执行结果和顺序执行的结果是一样的,但是在多线程环境下就可能因为指令重排而导致一些问题。
有序性和可见性是不能完全分开讲的,指令重排引起的乱序最有可能导致的就是可见性问题。我们之前又说happens-before原则来解决部分由于重排而导致的可见性问题,并且针对volatile原则和锁原则,他们为什么可以实现内部可见性的原理。
网友评论