一、指令重排(1)

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
分析一下下面的代码:

  1. a = b + c;
  2. d = e - f ;

先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为以下三种:

1、编译器重排优化

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2、指令并行重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

3、内存系统重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
image.png

as-if-serial语义

as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。 以下数据有依赖关系,不能重排序。
写后读:

  1. int a = 1;
  2. int b = a;

写后写:

  1. int a = 1;
  2. int a = 2;

读后写:

  1. int a = 1;
  2. int b = a;
  3. int a = 2;

编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

  1. int a = 1;
  2. int b = 2;
  3. int c = a + b;

二、Java内存模型

JMM(Java Memory Molde)

1、Java Memory Molde (Java内存模型/JMM),千万不要和Java内存结构(JVM划分的那个堆,栈,方法区)混淆。
2、 Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。 Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。
3、Java内存模型根据官方的解释,主要是在说两个关键字,一个是volatile,一个是synchronized。
主内存
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
工作内存
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操 作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接 访问对方工作内存中的变量。
image.png
Java的线程不能直接在主内存中操作共享变量。而是首先将主内存中的共享变量赋值到自己的工作内存中,再进行操作,操作完成之后,刷回主内存。
Java内存模型的作用
Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。 synchronized,volatile

CPU缓存,内存与Java内存模型的关系

通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行终都会映射到硬件处理器上进行执行。 但Java内存模型和硬件内存架构并不完全一致。 对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存和主内存之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响, 因为JMM只是一种抽象的概念,是一组规则,不管是工作内存的数据还是主内存的数据,对于计算机硬 件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说, Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

image.png

再谈可见性

image.pngimage.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种操作本身都是原子性的。虚拟机实现时必须保证下面 提及的每一种操作都是原子的、不可再分的。
image.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就是通过这样来保证可见性的。