手记

java 内存模型-03-缓存和重排序

缓存

为了提升性能,JVM 做了 2 件事情。

缓存+重排序

为什么会出现线程可见性问题

要想解释为什么会出现线程可见性问题,需要从计算机处理器结构谈起。

我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。

CPU要与内存进行交互,如读取运算数据、存储运算结果等。

由于内存和CPU的计算速度有几个数量级的差距,为了提高CPU的利用率,现代处理器结构都加入了一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存与CPU之间的缓冲:
将运算需要使用的数据复制到缓存中,让CPU运算可以快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。

高速缓存的引入解决了CPU和内存之间速度的矛盾,但是在多CPU系统中也带来了新的问题:缓存一致性

在多CPU系统中,每个CPU都有自己的高速缓存,所有的CPU又共享同一个主内存。
如果多个CPU的运算任务都涉及到主内存中同一个变量时,那同步回主内存时以哪个CPU的缓存数据为准呢?这就需要各个CPU在数据读写时都遵循同一个协议进行操作。

处理器=》高速缓存=》缓存一致性协议=》主内存
处理器=》高速缓存=》缓存一致性协议=》主内存

参考上图,假设有两个线程A、B分别在两个不同的CPU上运行,它们共享同一个变量X。

如果线程A对X进行修改后,并没有将 X 更新后的结果同步到主内存,则变量X的修改对B线程是不可见的。

所以CPU与内存之间的高速缓存就是导致线程可见性问题的一个原因。

另一个原因就是重排序

重排序

目的

现在的CPU一般采用流水线来执行指令。

一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。

指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。

重排序的目的是为了性能

  • Example

理想情况下:

过程A:cpu0—写入1—> bank0;
过程B:cpu0—写入2—> bank1;

如果bank0状态为busy, 则A过程需要等待

如果进行重排序,则直接可以先执行B过程。

类别

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序

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

  • 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。
如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统的重排序

由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

执行过程

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

{源代码 -> 编译器优化重排序(1) -> 指令级并行重排序(2) -> 内存系统重排序(3) -> 最终执行的指令顺序}

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,
通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

不进行重排序的场景

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

| 名称       |    示例             | 说明 |
| 写后读   |   a = 1; b = a;   | 写一个变量后再读这个位置 |
| 写后写   |   a = 1; a = 2;   | 写一个变量后再写这个变量 |
| 读后写   |   a = b; b = 1;   | 读一个变量后再写这个变量 |

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

所以有数据依赖性的语句不能进行重排序。

as-if-serial

  • 概念

as-if-serial语义就是: 不管怎么重排序(编译器和处理器为了提高并行度), 单线程程序的执行结果不能被改变。所以编译器和cpu进行指令重排序时候回遵守as-if-serial语义。

  • 栗子

public int add() {  int x = 1;    //1
  int y = 1;    //2
  int ans = x + y;  //3
  return ans
}

上面三条指令, 指令1和指令2没有数据依赖关系, 指令3依赖指令1和指令2。

根据上面我们讲的重排序不会改变我们的数据依赖关系, 依据这个结论, 我们可以确信指令3是不会重排序于指令1和指令2的前面。

我们看一下上面上条指令编译成字节码文件之后

public int add();
    Code:       0: iconst_1     // 将int型数值1入操作数栈
       1: istore_1     // 将操作数栈顶数值写到局部变量表的第2个变量(因为非静态方法会传入this, this就是第一个变量)
       2: iconst_1     // 将int型数值1入操作数栈
       3: istore_2     // 将将操作数栈顶数值写到局部变量表的第3个变量
       4: iload_1      // 将第2个变量的值入操作数栈
       5: iload_2      // 将第三个变量的值入操作数栈
       6: iadd         // 操作数栈顶元素和栈顶下一个元素做int型add操作, 并将结果压入栈
       7: istore_3     // 将栈顶的数值存入第四个变量
       8: iload_3      // 将第四个变量入栈
       9: ireturn      // 返回



作者:叶止水
链接:https://www.jianshu.com/p/ff016da0262d


0人推荐
随时随地看视频
慕课网APP