【java并发】volatile关键字总结

文章目录

  • 概念
  • 主要规则
    • 保证可见性
    • 保证有序性
    • 不能保证原子性
      • 内存屏障
        • 什么是内存屏障?
        • 内存屏障有什么作用?
        • 内存屏障和volatile关键字有什么关系?
        • 多线程环境下volatile关键字是怎样处理工作内存的?
        • 怎样保证多个处理器对应的缓存都是有效的?
  • 总结

概念

关键字volatile可以说是Java虚拟中提供的最轻量级的同步机制。
​ Java内存模型对volatile专门定义了一些特殊的访问规则。

主要规则

假定T表示一个线程,v和W分别表示两个volatile修饰的变量,那么进行read,load,use,assign,store和write操作的时候需要满足以下条件:

保证可见性

  • 只有当线程T都变量V执行的前一个操作时load时,才能对V执行use操作;通知只有线程T对变量V执行的后一个操作是use时,线程T对变量V才能执行load操作。所以,线程T对变量的use动作和线程T对变量V的read,load动作相关联,必须一起出现。即一个线程在使用一个变量之前必须从主内存中获取到该变量的最新值,用于保证线程T能看得见其它线程对变量V的最新的修改后的值。
  • 只有当线程T对V执行的前一个动作时assign时,对V执行的后一个动作才能时store操作,同时只有当T对V执行的后一个操作时store时,前一个动作才能时assign操作。 所以,线程对变量的assign操作和线程T对变量的store,write操作是相关联的,必须同时出现。即在T的工作内存中,每次修改变量V之后必须立刻同步会主内存,用于保证线程T对V变量的修改能被别的线程看到。

注意:
对于普通的变量来说不一定能保证变量的可见性,因为某个线程在其工作内存中对普通变量做了修改之后并不能保证何时将新的值写回主存中,当另一个线程读取变量值的时候并不能保证主存中的值一定是新的值,有可能是上一个线程修改之前的旧值。


保证有序性

  • 在同一个线程内部,被volatile修饰的变量不会被指令重排序, 保证代码的执行顺序和程序的顺序相同。

不能保证原子性

前面提到了volatile关键字可以保证被操作变量的可见性和有序性,但是为什么不能保证原子性?
其实,对于一个单一的读/写操作,这里的单一指的是某一个操作只由一条指令组成,这种情况下是可以保证原子性的。因为完成一个操作之后将立即将刷新后的值从工作内存存入到主内存中,读取一个变量之前必须从主内存中获取最新的值。
但是对于一些复合操作,是不具有原子性的,例如volatile关键字修饰的变量进行自增的操作就不具备原子性。

解释之前,首先我们来明确几个概念:

内存屏障

什么是内存屏障?

  1. 用来确定一些特定操作的执行顺序
  2. 影响一些数据的可见性。

内存屏障有什么作用?

  1. 命令cpu和编译器必须先执行某个命令或者必须后执行某个命令。
  2. 强制更新一次不同的cpu缓存。

内存屏障和volatile关键字有什么关系?

对于一个被volatile关键字修饰的变量而言:

  1. 读屏障:在对这个变量进行读操作之前,会插入一个读屏障,保证在读取数据前所有的事件已经发生 ,并且任何更新过的数据值是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
  2. 写屏障:在写操作转换后插入一个写屏障指令,保证当前访问这个字段的所有线程将会得到最新值。

多线程环境下volatile关键字是怎样处理工作内存的?

  1. 使用volatile关键字修饰的变量在被修改后会强制立即写入主存中。
  2. 对于多个线程,如果线程2在线程1操作变量时也修改了相同变量的值,会导致线程1中的缓存(工作内存)失效。
  3. (接第二条)如果此时线程1中的缓存失效了,则当线程1再次读取数据时,将从主存中获取数据的最新值。

怎样保证多个处理器对应的缓存都是有效的?

在多个处理器处理的情况下,为了保证缓存的一致性,就实现了缓存一致协议,每个处理器会通过嗅探在总线上的数据来检查自己的缓存是不是过期了,如果已经过期了,就会将当前的缓存设置为无效状态,当处理器对这个数据进行修改操作时,就会重新从系统内存中把数据读到缓存中。

同时,需要明确的一点是,对于一个变量的自增操作,其实伴随着三个步骤:

  • 读取原值到工作内存中
  • 变量自增
  • 将自增之后的值写回主存中

现在来看一个例子:

public class TestVo {
    public static volatile int inc = 0;
    public static void f() {
        for (int j = 0; j < 1000; j++) {
            TestVo.increase();
        }
    }
    public static void increase() {
        inc++;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                TestVo.f();}).start();
        }
        //使当前线程由执行状态变为就绪状态
        //让出cpu的执行时间,让下一个线程执行的时候,该线程可能执行了,也可能没有执行。
        while (Thread.activeCount()>2){
            Thread.yield();
        }
       System.out.println(TestVo.inc);
    }
}
9285

Process finished with exit code 0

8166

Process finished with exit code 0

由上述结果可以看出,当开辟多个线程对一个volatile变量进行自增时,每次运行的结果都是一个小于10000的值,而不是我们预期的按照理论计算得到的10000。
为什么会产生这样的结果?

假设现在inc的值为10,这时线程A将对其进行自增操作,线程A此时读取了inc的值到其工作内存中,值为10,但此时线程A被阻塞了,线程B也要进行自增操作,因为线程A没有对inc的值进行修改,所以B的缓存是有效的,读取到的值也为10。
所以B对inc的值进行自增,自增之后inc的值为11,并将其最新值写回主存,但是线程A的工作内存中已经读取到了inc的值为10,所以A进行了自增之后其值也变成了11。
所以,尽管两个线程对同一个变量都进行了自增操作,但是其自增之后的值还是自增一次之后的效果,而不是两次。

一开始我也有疑问,当线程A进行自增时,不应该从主存中获取最新的值11吗?但其实不是的,因为对于自增操作,读取,自增,写入这三个操作合起来算一个完整的操作,所以,当线程A读取了inc的值时,相当于进入了这个自增操作,其后的自增和写入内存都属于这一个操作里的分操作,在这个完整的操作结束之前,是不能从主存中获取新值的。

现在看例二:

//线程1
boolean volatile stop = false;
while(!stop){
    doSomething();
}
 
//线程2
stop = true;

对于这个例子,同样的,当线程2将stop的值改为true之后,线程1中对stop值的缓存将变得无效,而对于while循环语句中的操作,每次的判断都是一个单一的操作,所以线程1能立即感知到stop的缓存变得无效,并且会从主存中重新获取其最新值。
这也就印证了之前说的,对于单一读/写操作,是满足原子性的。

总结

1.对于单一的读或写操作,volatile是满足原子性的。
2.对于复合操作例如++操作,是不满足原子性的。
3.volatile关键字能够保证数据的可见性和顺序性。

你可能感兴趣的:(并发)