Volatile

本文探讨了Java中变量修改后另一线程未更新的问题,通过实例和内存模型解析了Volatile关键字、同步机制(如synchronized和Thread.sleep)以及内存屏障的作用。重点讲解了为何这些方法能确保可见性,涉及CPU缓存一致性、指令重排序和内存同步策略。
摘要由CSDN通过智能技术生成

一个问题引发的思考 - 为什么变量修改后另一个线程没有更新?

先看一段代码

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

结果显示即使在main线程中改变stop的值,t1中也并不会读取到stop更新后的值,因此线程中的循环并没有退出

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SUvzJvnu-1600942179221)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924103239880.png)]

这是因为在代码编译后JIT对代码进行了如下优化:

while (!stop) {
    i++;
}

优化成了:
    
if (!stop) {
    while(true){
        i++;
	}
}

因此,当线程第一次加载stop的值之后就会存入cpu的缓存中,后续的while循环就不会再重新加载stop,当然就看不到更新后的stop的值。因此,这时main线程中的stop变量的修改对t1线程不可见。

几种方式使得变量的改变在线程中可见

  1. 将System.out.println()语句放到while循环内

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

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8Fxkm5fe-1600942179223)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924105128582.png)]

    1. Thread.sleep(0)

      private static boolean stop = false;
      public static void main(String[] args) throws InterruptedException {
          Thread t1 = new Thread(()->{
              int i = 0;
              while (!stop) {
                  i++;
                  try {
                      Thread.sleep(0);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
              }
              System.out.println(i);
          });
          t1.start();
          Thread.sleep(1000);
          stop = true;
      }
      

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3KyZ2lvW-1600942179224)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924105254932.png)]

  1. 将stop变量用volatile修饰,线程间就会对stop的更新可见

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

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P5eWsHTL-1600942179226)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924105936977.png)]

    1. 甚至将i用volatile修饰都可以

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

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O0A01ODT-1600942179230)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924110157129.png)]

为什么这几种方式可以解决可见性问题?

System.out.println()

先看一看println方法的源码

public void println(int x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

可以看到,在IO时会进行IO阻塞,加入了synchronized()锁,在同步代码块中会先调用print()方法来输出信息,再调用newLine()方法换行,而newLine()方法中也是加入了锁的。

public void print(int i) {
    write(String.valueOf(i));
}

private void newLine() {
    try {
        synchronized (this) {
            ensureOpen();
            textOut.newLine();
            textOut.flushBuffer();
            charOut.flushBuffer();
            if (autoFlush)
                out.flush();
        }
    }
    catch (InterruptedIOException x) {
        Thread.currentThread().interrupt();
    }
    catch (IOException x) {
        trouble = true;
    }
}
  • println底层用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存
  • println有加锁的操作,而释放锁的操作会强制性的把工作内存中涉及到的写操作同步到主内存中,所以可以看得到更新后的stop

也就是说,只需要加入一个锁(哪怕是个空的锁),就可以解决可见性问题

private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(()->{
        int i = 0;
        while (!stop) {
            i++;
            synchronized (VolatileDemo.class) {}
        }
        System.out.println(i);
    });
    t1.start();
    Thread.sleep(1000);
    stop = true;
}
  • 第三个角度,从IO角度来说,println本质上是一个IO操作,而磁盘IO的效率一定是远远低于CPU的计算效率的,所以IO操作可以使得CPU可以有足够的时间去刷新内存,从而导致解决了可见性问题。例如定义一个new File()也可以达到同样的目的。
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(()->{
        int i = 0;
        while (!stop) {
            i++;
            new File("a.txt");
        }
        System.out.println(i);
    });
    t1.start();
    Thread.sleep(1000);
    stop = true;
}

Thread.sleep()

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQA9s6Fu-1600942179231)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924113600383.png)]

Sleep and Yield

Thread.sleep causes the currently executing thread to sleep (temporarily cease execution) for the specified duration, subject to the precision and accuracy of system timers and schedulers. The thread does not lose ownership of any monitors, and resumption of execution will depend on scheduling and the availability of processors on which to execute the thread.

It is important to note that neither Thread.sleep nor Thread.yield have any synchronization semantics. In particular, the compiler does not have to flush writes cached in registers out to shared memory before a call to Thread.sleep or Thread.yield, nor does the compiler have to reload values cached in registers after a call to Thread.sleep or Thread.yield.

For example, in the following (broken) code fragment, assume that this.done is a non-volatile boolean field:

while (!this.done)
    Thread.sleep(1000);

The compiler is free to read the field this.done just once, and reuse the cached value in each execution of the loop. This would mean that the loop would never terminate, even if another thread changed the value of this.done.

从官方文档中可以看到,Thread.sleep不会失去任何监视器的所有权,这意味着其仍然会占用cpu资源

官方说明Thread.sleep不具有任何同步语意,编译器也不必将缓存在寄存器中的写操作刷新到共享内存中,调用Thread.sleep之后也不必重新加载缓存在寄存器中的值。

编译器可以自由读取字段一次或多次,并在循环的每次执行中重用缓存的值。这意味着即使另一个线程更改了 this.done

但是理解后认为,Thread.sleep()导致了线程的切换,线程切换导致了缓存失效,因此重新读取到了新的值。

Volatile关键字(保证可见性)

现在使用这段代码

private static int i = 0;
public static void main(String[] args) throws InterruptedException {
    i++;
}

借助hsdis工具可以查看class反编译的汇编指令

下面是普通变量的定义和修改的汇编命令

0x00000000036a64e5: mov     edi,dword ptr [rsi+68h]  ;*getstatic i
; - com.example.threaddemo.VolatileDemo::main@0 (line 14)

0x00000000036a64e8: inc     edi
0x00000000036a64ea: mov     dword ptr [rsi+68h],edi  ;*putstatic i
; - com.example.threaddemo.VolatileDemo::main@5 (line 14)

下面是加了Volatile关键字的变量的定义和修改的汇编命令

0x00000000030b4832: lock add dword ptr [rsp],0h  ;*putstatic i
; - com.example.threaddemo.VolatileDemo::<clinit>@1 (line 12)

可以看得到,volatile关键字修饰的变量在修改时生成的汇编指令中多了lock指令,这个指令会添加总线锁,使得cpu高速缓存中的数据同步,以达到缓存一致性。

Volatile是怎么保证可见性的

Volatile通过防止指令重排序的机制和内存屏障的机制来解决可见性

硬件层面

CPU/内存/IO设备

由于CPU计算速度 >> 内存读写速度 >> IO设备速度,这样的话整体的处理速度就要根据最慢的IO设备的运行速度来决定,所以CPU硬件工程师为了更高效的将CPU利用起来,进行了一些优化。

  • CPU层面增加了高速缓存(三级缓存,L1-d 一级数据缓存,L1-i 一级指令缓存,L2 二级缓存,L3 三级缓存)
  • 操作系统进入了进程、线程 | CPU时间片的切换
  • 编译器的优化,更合理的利用CPU的高速缓存

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kDmuS0hr-1600942179232)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924163653061.png)]

CPU中加入了高速缓存,CPU读取到变量后会存入缓存线cache line中,如果有修改的话,也会先在缓存线中修改,然后在一个合适的时机去同步到主内存中,而另一个CPU也会在合适的时机去主内存中读取缓存区中的变量的最新数据,当然,这个合适的时机可能是等CPU闲了或者什么的。那么如果这次访问的变量上次已经先存入了缓存区中,那么就可以直接从缓存区中取出这个变量,先取L1然后L2然后L3然后主内存。

但是这样的修改也引发了一些问题。

如下图,主内存中有变量stop = false,CPU0 和CPU1 都读取了stop变量,存到了高速缓存中。然后CPU0对stop做出了修改,将stop修改为true,但是还未同步到主内存中(或者已经同步到了主内存中),但是这时CPU1还未去主内存中获取最新的stop的值,所以这时读取到的还是缓存线中的stop = false,此时就会造成数据的不一致。这就带来了数据的可见性问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LGrnhtqV-1600942179233)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924164751072.png)]

为了解决这一问题,CPU层面可以使用主线锁或者一致性协议来解决可见性问题。

主线锁

所有对主内存的数据进行写入时会加上主线锁,这样修改时其他的CPU就无法同时修改,保证了数据的一致性,同时也解决了可见性问题。但这是强一致性,会造成CPU资源的开销和浪费。

一致性协议

CPU的一致性协议有很多,例如MSI、MESI、MOSI

MESI

以MESI来说,分别代表缓存区中数据资源的4中状态:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AgMdBHP4-1600942179234)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924173911766.png)]

刚开始CPU0将stop读入缓存中,这时stop只存在于CPU0的缓存中,因此此时CPU0的cache line状态是E,独占

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1IiA5N32-1600942179235)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924174002291.png)]

当CPU1也加载了stop后,stop同时存在于两个CPU0并且数据都有效,此时两个的状态都是S,共享

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LU3J0mrV-1600942179236)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924174053794.png)]

而当CPU0对stop做出修改时,CPU0的cache line状态从S变为M,此时对CPU1发出信号,使CPU1的cache line无效,状态改为I


放大一下这个修改过程,CPU0在做出写操作时,会对CPU1发出invalidate指令,然后CPU1接到指令后更改状态,会给CPU0返回一个ACK。在这期间,CPU0会一直处于阻塞状态来达到强一致性,待接收到ACK后,再将修改后的stop写入内存中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Un9KsnBP-1600942179237)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924174336364.png)]

Store Bufferes

虽然CPU1做出响应的时间可能会非常短,但是这还是会造成CPU的资源浪费,因此CPU硬件工程师引入了CPU存储缓冲区StoreBuffer,Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到StoreBufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。

也就是说,CPU0在修改stop时还是会对CPU1发出同样的失效指令,但是它不会再傻傻的等着CPU1的响应了,而是会先将最新的数据放入StoreBuffer中,然后去继续执行接下来的命令,等到CPU1返回了ACK后,再将存StoreBuffer中的数据存入cache line,然后写入内存中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1XgPuF8U-1600942179237)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924174925040.png)]


指令重排序

先来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0和executeToCPU1,分别由两个不同的CPU来执行。

然而引入Store Bufferes之后,就可能出现b==1返回true,而assert(a==1)却返回false

executeToCPU0(){
  a=1;
  b=1;
}
executeToCPU1(){
  while(b==1){
    assert(a==1);
 }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6QIctg7s-1600942179237)(C:\Users\86153\AppData\Roaming\Typora\typora-user-images\image-20200924175206384.png)]

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值