四、JMM Java内存模型
4.1 硬件层的并发优化的基础知识
寄存器如何读取硬盘中的内容, 首先将硬盘的数据load到内存中,然后寄存器先到高速缓存中去找,如果找到就直接使用,速度是非常快的,如果没找到,就去下层的高速缓存中去寻找,依次类推。
假如 有一个数字在主存中,这个数字会被load到L3缓存中,L2和L1高速缓存是在CPU的内部的,主存中的数字会被load到不同的CPU中,第一个cpu把x赋值为1,第二个CPU把x赋值为2 那么就会产生一个问题 数据一致性问题。
当多个CPU 访问主存的时候,会因为更改主存中的数据值,可以给总线加锁,也就是说当一个CPU访问主存中的数据的时候 其他的CPU不能对总线进行访问,老的CPU的做法,这个锁被称为总线锁, 但是这种方法效率是比较低的。
新的CPU 会使用各种各样的办法来解决一致性的问题。
MESI 数据一致性协议的一种。
MSI MESI MOSI Synapse FireFly Dragon 都是数据的一致性协议。
为什么会有这么多数据的一致性的协议,CPU的厂商特别多
MESI intel的CPU 使用的MESI一致性协议。
https://blog.csdn.net/xiaowenmu1/article/details/89705740
MESI 这个协议是给每个缓存的内容做了一个标记,如果CPU读取了一个数字进来 X,这个x和主存中的内容相比,到底有没有改变这个值,如果更改了,就标记为m也就是modified如果x可以被独享,就标记为Excusive,如果这个x 被其他的别人也可以使用,就标记为shared,如果x在读的时候被其他CPU改过了,就标记为Invalid。
MESI协议让各个CPU的缓存保持一致性的,如果要使用的数字是Invalid,马上要对这个数字进行计算,那么这种情况 需要到主存中再把这个数字读一遍,它就变得有效了。
MESI 也叫缓存锁。
现在的CPU的底层解决数据的一致性: 通过MESI缓存锁+总线锁 组合来实现的。
4.2 缓存行和伪共享
当我们要把内存中的数据放入到缓存里的时候,它不会只把这一个数据放入到缓存中,
例如int i= 12 它只有4个字节,不会只把这4个字节读取到缓存中,为了提高效率,而是会把这4个字节后面的一对内容都读进去 所读进去的这一行内容 就是一个基本的缓存单位,这个缓存的单位被称为缓存行。
Cacheline 缓存行 ----基本单位。
伪共享 面试题
X和Y 位于同一个缓存行 第一个cpu只使用x 读的时候会把x和y都读出来,第二个CPU只要y变量也会把x和y都读进来。这种情况下会出现伪共享问题。
第一个cpu在修改了x的值之后,会通知其他cpu,x已经被修改了,其他CPU会标记x为invalid 状态,在通知的时候 变更的是整个缓存行的内容,那么这个时候,第二个cpu要使用y这个变量,就会到主存中再次去读取整个缓存行的内容,那么y变量才有效,第二个cpu也会通知其他cpu y的变量又改了一遍,而第一个cpu跟y是没关系,他只要读x 结果他又要把整个缓存行重新读一遍。
两个不相干的cpu在读取内容的时候,会因为缓存行的关系 产生互相影响。这种情况叫做伪共享。
代码:
package com.openlab;
public class CacheLineTest01 {
private static class T{
public volatile long x = 0L;
}
public static T[] arr = new T[2];
static{
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (long i = 0; i<1000_0000L;i++){
arr[0].x = i;
}
});
Thread t2 = new Thread(()->{
for (long i = 0; i<1000_0000L;i++){
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}
200多-300多
代码二 缓存行对其
package com.openlab;
public class CacheLineTest02 {
private static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7; //缓存行对其
}
private static class T extends Padding {
public volatile long x = 0L;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}
类T 继承了Padding 类 T 对象new 处理就占了64个字节 正好对应一个缓存行,arr [0].x
在写回主存的时候,不需要通知Cpu2 就不会频繁的再去重主存中更新数据。
执行时间
120-130左右
解决伪共享的问题: 使用缓存行对其的方式 能够提高效率 它也会浪费一定的空间。
有volatile 去修饰变量 在进行写的操作 汇编代码 出现第二行汇编代码
Lock前缀的指令在多核的处理器下引发两件事情
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使其他的cpu李的缓存的该内存地址的数据无效
为了去提高处理的速度,CPU不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1 和L2 或其他)后,再进行操作,但操作完不知道何时会写入到内存,如果声明volatile的变量的写的操作 JVM就会向CPU发送一条Lock前缀指令,将这个变量所在的缓存行的数据写回到系统内存,就算写回处理内存,其他CPU处理的缓存的值还是旧的,再执行计算操作就会有问题,所以在多个cpu 为了保证各个处理器的缓存是一致的,就会实现缓存的一致性协议。
Volatile的两条实现原则:
1 Lock前缀指令会引起CPU缓存回写到内存中
Lock前缀指令导致在执行指令期间,声明的CPU处理器的Lock信号,该信号在多CPU环境中,cpu可以独占任何共享内存,但CPU的lock信号 一般不锁总线,而是锁缓存,因为总线锁的开销比较大。
对于Intel486和Pentium处理器 在锁的操作时,经常的在总线上声明lock信号,相反它会锁定这块内存区域的缓存,并写回的内存,使用缓存的一致性机制来确保修改的原子性,所以这个操作被称为“缓存锁定”缓存一致性的机制,会阻止同时修改由两个以上的CPU缓存的内存区域数据。
2.一个CPU的缓存回写到内存会导致其他CPU缓存无效。
Intel处理器 使用MESI协议去维护内部缓存和其他处理器缓存的一致性
缓存行对其、追加字节
LinkedTransferQueue 使用的一个内部类类型来定义队列的头head和尾节点,这个内部类PaddedAtomicReferecne相对于父类AtimicReference 只做了一件事情。就是将共享变量追加到64个字节。
4.3 乱序问题
现在的cpu 为了提高效率 会有各种各样的优化,这个优化被叫做CPU乱序执行。
CPU为了提高效率会打乱指令的执行顺序。
读的时候 乱序执行还是好理解一些,写的时候并发叫做合并写。
WCBuffer Write Combine Buffer 合并写。
当cpu需要给某一个数做计算,然后把这个数存储到主存中,在写回主存的时候有L1和L2两个高速缓存。Cpu先把这个数写入L1中 假如L1中没有个数,缓存就没有命中,那么会写入L2中,但是因为L2的速度比较慢,所以在写的过程中,后续的指令也改变了这个数值。那么它就会把这些指令合并到一起 扔到一个合并缓存中,做一个最终的计算结果扔到L2中 这个情况合并写。
WCBuffer 这个缓存的级别是高于L1高速缓存 Intel的cpu里面 其实只有4个WC可以被我们同时使用。
一种方式: 写操作的时候 小于4个wc 那么可以同时写1-3个WC 将其写入到L2中
二种方式: 写操作的时候 如果一次写超过4个wc 需要分2次把合并的内容写入到L2中
充分的利用合并写 能够看出来分开执行的效率是速度更快的 效率更高
正是由于CPU有一个特别告诉的缓存 只有4个字节 所以一次填写4个的执行效率更快一些 而4+2 模式 4个填充之后 还要等待另外两个其他的字节进行填充,而3+3的模式只要等待1个字节填充就可以执行,执行完了之后还要等待第二次的执行。
4.4 乱序证明
package com.openlab;
// 这个程序是美团的人写的
public class Disorder {
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;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
// 1 0 0 1 1 1 0 0
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(100000);
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while(start + interval >= end);
}
}
1 0 0 1 1 1 如果一旦出现 a-0 b-0 乱序的问题就发生了。
4.5 如何保证特定情况下不乱序
Java的层面 应用volatile关键字去修饰变量 保证有序性
硬件层面:使用CPU的汇编指令
-
加锁 加锁百分百能够完成了 提高效率 在CPU的指令级别中很多cpu都做了同一件事,内存屏障 也叫内存栅栏。
-
这里说的cpu的内存屏障 和java的内存屏障没有关系。
拿Intel处理器的内存屏障来说:
不同的CPU 它的内存屏障指令是不一样的,而且有逻辑上也有区别。
Intel 的内存屏障的设计比较简单 只有三条指令。
指令1 :sfence 写屏障 在sfence 指令前的写操作 必须在sfence 指令的写操作前完成。
指令2: lfence 读屏障 在lfence指令前的读操作 必须在lfence指令的读操作前完成。
指令3:mfence 读写屏障 在mfence 指令的读写操作 必须在mfence指令的读写操作前完成。
有序性保障:
Intel lock 指令
原子指令 比如X86上的lock指令是一个FullBarrire 执行的时候会锁住内存子系统来确保执行顺序 甚至可以跨越多个CPU
Software locks 通常使用内存屏障或原子指令来实现变量的可见性和保持程序的执行顺序。
内存屏障(cpu内存屏障 与java内存屏障)
如何实现并发的原子性 可见性 有序性
上一篇 jvm 之 类加载和初始化
下一篇 JVM内存模型