volatile原理解析

public class VolatileDemo {

    public  static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
        });
        thread.start();
        System.out.println("begin");
        Thread.sleep(1000);
        stop =true;
    }
}

在这里插入图片描述

上述伪代码看着他会等 main线程Thread.sleep 睡眠完之后将 stop 值改成true 然后线程1中的判断条件就不成立 然后就会跳出循环了 但是其实他并没有 他一直在循环 小伙伴们可能此时会不得其解 为什么 怎么解决 其实很简单 只要在 stop变量加一个 volatile就可以了

public class VolatileDemo {

    public volatile   static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
        });
        thread.start();
        System.out.println("begin");
        Thread.sleep(1000);
        stop =true;
    }
}

在这里插入图片描述
可以看到马上退出了循环 可这是为什么呢?

线程可见性问题的本质

在这里插入图片描述
拿上面代码举例 stop 存储在内存中 然后 cpu 需要从内存中读取数据 但是CPU的执行效率比内存高的多的多 这时候就会出现cpu 从内存中读取数据会等待内存返回数据 此时CPU处于阻塞状态

CPU资源优化

CPU增加高速缓存

cpu 高速缓存是由 若干个缓存行 组成的,缓存行是cpu 高速缓存的最小存储单位 也是cpu和内存交互的最小单元在x86架构中每个缓存行大小为64位 即8字节 cpu每次从内存中加载8字节的数据作为一个缓存行保存到高速缓存中

缓存行

cpu的缓存是由多个缓存行组成的 是CPU和内存交互的最小交互单元 X86每个缓存行是64个字节

伪共享

当Cpu从内存加载数据到缓存行时会把临近的64位数据一起保存到缓存行中基于空间局部性原理 CPU在读取第二个数据时发现该数据已经存在于缓存行中则不会再去内存寻址 而是直接读取
在这里插入图片描述
缓存行加载xyz这一段缓存 然后加载进cpu中 此时cpu0 需要读取x cpu1需要读取y 而 xyz在同一个缓存行中就存在缓存行竞争 如果cpu0获取权限就会使得CPU1失效 当CPU1获得所有权操作会导致CPU0失效 所以会消耗性能

伪共享解决方案

对齐填充 读取X的时候填满64个字节 让他每个是64或者64的倍数

@Contended
配置jvm 参数  -XX:-RestrictContended

缓存一致性问题

在这里插入图片描述

CPUA修改缓存x=20的值改为40 同步到本地缓存行 但是没同步到 内存中 所以CPUB读到的还是X=20是之前的值

缓存一致性问题解决方案

在这里插入图片描述
加了volatile 之后会在汇编层面加了一个

总线

cpu与内存 输出/输入设备传递信息的公共通道 当cpu访问内存进行数据交互时 必须经过总线来传输

总线锁

在总线上声明了一个lock#信号 这个信号可以确保共享内存只有当前cpu可以访问

缓存锁

如果当前cpu访问的数据已经被缓存到其他cpu高速缓存中 那么 cpu不会在总线上生命Lock#信号 而是采用缓存一致性协议来保证多个cpu的缓存一致性

缓存一致性协议

M(Modify)

表示共享数据只缓存在当前CPU缓存中 并且是被修改状态 缓存的数据和主内存的数据一致

E(Exclusive)

表示缓存的独占状态 数据只缓存在当前CPU缓存中 并且没有被修改

S(Shared)

表示数据可能被多个CPU缓存 并且各个缓存中的数据和主内存数据一致

I(Invalid)表示缓存已经失效

如果一个缓存行处于M状态 则必须监听所有试图获取该缓存行对应的主内存地址的操作 如果监听这类操作的发生 则必须在该操作 之前把缓存行中的数据写回主内存

如果缓存行处于S状态 那么则必须要监听使该缓存行状态设置为Invalid 或者对缓存行执行Exclusive操作的请求 如果存在则必须要把当前缓存行状态设置为Invalid

如果一个缓存行状态为E状态 那么它必须要监听其他试图读取该缓存行对应的主内地址的操作一旦有这种操作该缓存行需要设置成Shared

监听过程由嗅探协议完成

读取数据过程

1.Cpu0发出一条从内存中读取X变量的指令 主内存通过总线返回数据后缓存到CPU0的高速缓存中 将状态设置成E
2.如果此时CPU1发出对变量X的读取指令 那么当CPU0检测到缓存地址冲突就会针对该消息作出响应,将缓存在cpu0的x的值通过ReadResponse消息返回给CPU1 此时X分别存在CPU0和CPU1的高速缓存中所有X的状态为S
3.然后CPU0把X变量的值修改成x=30 把自己的缓存行状态设置成E 接着把修改后的数据写入内存中 此时X的缓存行是共享状态 同时需要发送一个Invalidate消息给其他缓存 Cpu1收到消息后把高速缓存的x置为Invalid状态

操作系统中 增加进程,线程->通过CPU的时间片切换 提升CPU利用率

编译器优化(JVM深度优化)

指令重排序
public class HappenBefore {
    static int x,y,m,n;//测试用的信号变量
   
    public static void main(String[] args) throws InterruptedException {
       int count = 10000;
       for(int i=0;i<count;++i){
           x=y=m=n=0;
           //线程一
           Thread one = new Thread(){
              public void run() {
                  m=1;
                  x=n;
              };
           };
           //线程二
           Thread two = new Thread(){
              public void run() {
                  n=1;
                  y=m;
              };
           };
           //启动并等待线程执行结束
           one.start();
           two.start();
           one.join();
           two.join();
           //输出结果
           System.out.println("index:"+i+" {x:"+x+",y:"+y+"}");
       }
    }
}

这段代码循环1w次, 每次启动两个线程去修改x、y、m、n四个变量,能得到什么结果的呢?运行一下,很容易得到x=1,y=0;x=0,y=1两种结果,事实上根据JVM规范以及CPU的特性,我们很可能还能得到x=0,y=0或者x=1,y=1的情况。当然上端代码大家不一定能得到x=0,y=0或者x=1,y=1的结果,这是因为这段代码太简单了,以现在CPU 的运算速度,根本无需做线程切换就能将这些很快的执行完毕。x=1,y=1这种情况大家也许还能理解,当发生线程切换时,第一个线程第一行代码执行完毕,再次执行第二线程的第一行代码,就会发生x=1,y=1的结果。但x=0,y=0是否可能发生?按照现在的JVM和CPU特性,这种情况的确是存在的。由于线程的run方法里面的动作对结果是无关的,因此里面的指令可能发生指令重排序,即使是按照程序的顺序方法执行,数据从线程缓冲区刷新到主存也是需要时间的。假定是按照m=1,x=n,n=1,y=m执行的,显然x=0是很正常的,m=1虽然在y=m之前执行,但是线程one有可能还没来得及将m=1的数据从高速缓存(work memory)写入主存,线程two就从主存中取m的数据,所以还有可能是0,这样就发生了数据错误!尤其是在大并发和多核CPU的执行下,数据的结果就更无法确定了!

指令重排序的阶段
编译器重排序

在编译过程中编译器根据上下文分析对指令进行重排序 目的是减少CPU和内存的交互 重排序之后尽可能保证CPU从寄存器或缓存行中读取数据

处理器重排序

1.并行指令集重排序
处理器可以改变指令的执行顺序
2.内存系统重排序
引入Store Buffer缓冲区延时写入产生的指令顺序执行不一致的问题

解决方案
缓存一致性问题导致的问题

在这里插入图片描述
CPU0 必须要等到CPU1 响应才能继续进行下面的步骤 会导致CPU0的阻塞

Store Buffer

防止缓存一致性导致的不必要的CPU阻塞所以每个缓存行之间增加一个Store Buffer
在这里插入图片描述Cpu0引入Store Buffer的设计后 CPU0会先发送一个Invalidate消息给其他包含该缓存行的CPU1 并把当前修改的数据写入StoreBuffers中 然后继续执行后续的指令 等收到CPU1的Ack消息后 Cpu0再把Store Buffer挪到缓存行

Store Buffer 导致的问题

在这里插入图片描述
1.假设a 变量的缓存状态是SharedCpu0执行a=1的指令 此时a不存在cpu0的缓存中 但是在其他cpu缓存中他是Shared状态 所以cpu0会发送一个MESI协议消息 read invalidate给CPU1 企图从其他缓存了该变量 cpu中去读取值 并使得其他cpu缓存行失效
2.cpu0把a=1写入cpu0的store buffer中
3.CPU1收到read invalidate消息后 返回 ReadResponse 把 a=0返回 并让cpu1的缓存行失效
4.由于StoreBuffer存在 cpu0在等待cpu1返回之前就继续往下执行b=a+1的指令此时Cache 中还没有加载b 所以b=0
5.cpu0 收到其他cpu返回的结果 更新了缓存行 a=0 接着加载出了a=0
6.接着cpu0把store buffer a=1的数据同步到缓存行中
7.结果判断失败

Store Forwarding

store buffer可能导致破坏程序顺序的问题,硬件工程师在store buffer的基础上,又实现了”store forwarding”技术: cpu可以直接从store buffer中加载数据,即支持将cpu存入store buffer的数据传递(forwarding)给后续的加载操作,而不经由cache
在这里插入图片描述

导致的问题

在这里插入图片描述

1.CPU0执行a=1的指令 a是独占且a不存在cou0的缓存行中 因此cpu0把a=1写入StoreBuffer中并发送MESI协议消息给 cpu1
2.cpu1执行 b=1的操作 cpu1的缓存行中没有b的缓存 所以cpu1发出一个MESI协议消息 给cpu0
3.cpu0执行 b=1的指令 而B变量 存在于CPU0的缓存行中 也就说缓存行属于M或者E状态 因此直接把b=1写入缓存行
4.此时cpu0收到 cpu1发来的消息 将缓存行中的B=1返回给cpu1 并修改缓存状态为 S
5.CPU1 修改缓存行 b=1并将状态设置为S
6.获取b=1后 cpu1继续执行assert(a=1)的指令 此时cpu1的缓存行中 a=0
7.cpu1收到cpu0的消息把包含a=0的缓存行 返回给cpu0 并设置成I(失效)但此时这个过程比前面的异步步骤执行晚已经导致了问题
8.CPU0收到包含a的缓存行后 把 stre buffer中a=1同步到缓存行

问题原因

cpu之间不知道a和b的数据依赖 通信之间有延迟

Invalidate Queues(用于让缓存行失效的消息)

store Buffers本身存储容量是有限的 如果被填满就必须要等到 cpu返回 ack消息后storebuffes才会对对应的指令进行清理 而这个过程必须要等待

流程

当cpu 收到 Invalidate 消息时 把让缓存行失效的消息放入Invalidate Queues 然后同步返回ack 消息

在这里插入图片描述

导致的问题

cpu内存系统的write操作的重排序
在这里插入图片描述

  1. cpu0执行a=1,由于其有包含a的cache line,将a写入store buffer,并发出Invalidate a消息。
  2. cpu1执行while(b == 0),它没有b的cache,发出Read b消息。
  3. cpu1收到cpu0的Invalidate a消息,将其放入Invalidate Queue,返回Invalidate ACK。
  4. cpu0收到Invalidate ACK,将store buffer中的a=1刷新到cache line,标记为Modified。
  5. cpu0看到smp_wmb()内存屏障,但是由于其store buffer为空,因此它可以直接跳过该语句。
  6. cpu0执行b=1,由于其cache独占b,因此直接执行写入,cache line标记为Modified。
  7. cpu0收到cpu1发的Read b消息,将包含b的cache line写回内存并返回该cache line,本地的cache line标记为Shared。
  8. cpu1收到包含b(当前值1)的cache line,结束while循环。
  9. cpu1执行assert(a == 1),由于其本地有包含a旧值的cache line,读到a初始值0,断言失败。
  10. cpu1这时才处理Invalid Queue中的消息,将包含a旧值的cache line置为Invalid

CPU性能优化之路

在这里插入图片描述

内存屏障(解决指令重排序问题)

读屏障(ifence)

将Invalidate Queues中的指令立即处理 并且强制读取cpu的缓存行 执行 ifence指令之后的读操作不会被重排序到ifence指令之前这意味着其他cpu 暴露出来的缓存行对当前cpu可见

写屏障(sfence)

会把 store Buffers中修改刷新到本地缓存中 使得其他cpu可以看到这些修改 而且在执行sfence指令之后的写操作不会重排序到 sfence指令之前 这意味着sfence指令之前的写操作全局可见

读写屏障(mfence)

保证了 mfence指令执行前后的读写操作的顺序 同时要求执行 mfence指令之后的写操作全局可见 之前的写操作全局可见

Java Memory Mode(JMM)

Happens-Before模型

两个操作指令的顺序关系

as-if-serial

一个线程中 存在两个操作 x和y 并且x源代码现在y之前

传递性规则

A Happens-Before B
B Happens-Before C
A Happens-Before C

volatile规则

通过内存屏障来保证 volatile变量修饰的写操作一定 Happens-Before读操作

监视器锁规则

一个线程释放锁必须 Happens-Before 后续线程的加锁操作

start 规则

一个线程调用start方法之前的所有操作 Happens-Before 线程B的任意操作

join 规则

main线程执行了一个线程A的join方法并成功返回 那么线程A中的任意操作 Happens-Before 于main线程线程join方法返回之后的操作

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值