Java并发深度总结:volatile关键字

不乱于心,不困于情。不畏将来,不念过往。

1. volatile概述

volatile 是一个类型修饰符,表示易变。线程访问共享变量时,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。

Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

由于volatile与Java内存模型关系密切,因此需要掌握Java内存模型的相关知识:Java并发深度总结:JMM(Java内存模型)概述

2. volatile的特性

  • 可见性:volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 有序性:volatile 通过禁止指令重排序,可以保证有序性。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于i++这种复合操作不具有原子性。

3. volatile 的内存语义

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果。

  • volatile 写的内存语义:
      当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
  • volatile 读的内存语义:
      当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

4. 可见性

4.1 多线程的内存可见性问题

class Test1 {

    private static boolean flag = true;

    public static void main(String[] args) throws Exception {

        Thread t1 = new Thread(() -> {
            while (flag) {  
            }
            System.out.println("end...");
        });

        Thread t2 = new Thread(() -> {
            flag = false;
            System.out.println("t2 set flag=false");
        });

        t1.start();
        Thread.sleep(100);
        t2.start();
    }
}

上面的程序,t1会先行执行,进入while循环里,t2线程改变flag的值,当你运行后,你会发现t1线程永远不会跳出循环。

这是因为每个线程是不会直接去内存中访问共享变量的,而是先把共享变量的副本放到自己的工作内存中,每个线程都会有自己的工作内存,并且是私有的,一个线程不能访问另一个线程的工作内存,一般来说,线程的工作内存为CPU高速缓存或高速寄存器,其存取速度远大于主内存的存取速度。

在这里插入图片描述
线程t1工作内存中flag的副本的值为true,t1线程并不知道t2线程对flag共享变量进行了修改,所以就无法退出循环。

4.2 可见性问题的解决

使用 volatile 修饰共享变量 flag 就可以解决多个线程之间的内存可见性问题。

4.3 volatile 可见性实现原则

用volatile修饰变量时,生成的汇编指令会比普通的变量声明会多一个Lock指令。那么Lock指令会在多核处理器下会做两件事情:

  • 将当前处理器缓存的数据直接写回到系统内存中
  • 这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效

volatile变量之所以能保证其自身的可见性,是因为对volatile变量的读写是符合缓存一致性协议的,缓存一致性协议的核心内容:

  1. 当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态。
  2. 其他CPU通过嗅探在总线上传播的数据,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

相当于线程操作 volatile 变量都是直接操作主存中的数据。

5.有序性

5.1 重排序概述

在执行程序时,为了使得处理器内部的运算单元能尽量被充分利用,为了提高性能,处理器可能会对输入代码进行乱序执行优化,并会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

5.2 重排序分类

重排序分3种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用 并行技术 来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变对应机器指令的执行顺序。
  • 内存系统的重排序:处理器使用 缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。(导致的可见性问题也可以通过 MESI 协议解决)
    在这里插入图片描述

5.3 重排序的条件

重排序不是随意重排序,它需要满足以下两个条件:

  1. 编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。.

可以重排序的例子:

// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );

不可重排序:
在这里插入图片描述

  1. 满足as-if-serial语义:所有的动作都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身(单线程下的执行)的应有结果是一致的,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。注意这对多线程无效。

5.4 指令级重排序

现代处理器一般采用流水线来执行指令,一个指令的执行被分成:取指、译码、执行、访存、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
在这里插入图片描述
流水 CPU 可以在一个时钟周期内,同时运行多条指令的不同阶段(如上图中,T1时刻CPU分别在执行四条指令的不同阶段),本质上,流水线技术并不能缩短单条指令的执行时间,但流水CPU在时间上实现了并行,变相得提高了CPU指令的吞吐率。

流水CPU为了并行执行不同指令的不同阶段,指令的执行次序就有可能被调整。

简单来说,在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行。

5.5 编译器优化重排序

编译器通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。

例如:

//优化前
int x = 1;
int y = 2;
int a1 = x * 1;
int b1 = y * 1;
int a2 = x * 2;
int b2 = y * 2;
int a3 = x * 3;
int b3 = y * 3;

//优化后
int x = 1;
int y = 2;
int a1 = x * 1;
int a2 = x * 2;
int a3 = x * 3;
int b1 = y * 1;
int b2 = y * 2;
int b3 = y * 3;

像下面这段代码这样,交替的读x、y,会导致寄存器频繁的交替存储x和y,最糟的情况下寄存器要存储3次x和3次y。如果能让x的一系列操作一块做完,y的一块做完,理想情况下寄存器只需要存储1次x和1次y。

5.6 重排序在多线程的问题

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因)

但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。因此多线程下要程序员额外保证正确的代码顺序。

class Reorder{

    int num = 0;
    boolean ready = false;

    // 线程1 执行此方法
    public void actor1(Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    // 线程2 执行此方法
    public void actor2() {
        num = 2;
        ready = true;
    }
}

class Result{
    int r1;
}

问:r.r1 的值有几种情况?

  • r.r1=1:线程1先获得CPU执行权,此时ready为false,因此r.r1=1;线程2执行完num=2后切换到线程1,则r.r1=1。
  • r.r1=4:线程2执行完后线程1执行,ready = true,r.r1=4

以上情况很容易就能看出来,但其实还有r.r1=0的情况。

  • 由于actor2()的两条语句不存在数据依赖,因此可能发生重拍序现象。(ready = true重排序到num = 2语句之前)此时:线程2 先执行 ready = true,然后线程1执行,进入 if 分支,r.r1=0,再切回线程2 执行 num = 2。

因此r.r1可能的情况有3种,分别是:0、1、4

5.7 重排序的解决

volatile 修饰的变量,可以禁用指令重排。

6.volatile的实现原理:内存屏障

6.1 内存屏障概述

内存屏障(memory barrier)是一种CPU指令。由于CPU指令可能会有重排序导致乱序执行,使用内存屏障指令之后,对该指令之前和之后的内存CPU读写内存的操作, 产生一种顺序的约束,能够确保一些特定操作执行的顺序,在一定范围内保证指令执行的有序性。

6.2 Java内存屏障作用

Java内存屏障主要有Load和Store两类。

保证可见性

  • 对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
  • 对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

保证有序性

  • 写屏障(Store Barrier)会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障(Load Barrier)会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

6.3 JMM内存屏障插入策略

Java编译器会在生成指令序列时在适当的位置会插入内存屏障指令来禁止特定类型的指令在屏障前后重排序。Java内存模型采用保守的屏障插入策略,volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

volatile 写 :

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

volatile 读 :

  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

在这里插入图片描述

6.3.1 volatile写内存屏障
  • 每个volatile写操作的前面插入一个StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
  • 每个volatile写操作的后面插入一个StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序。
    在这里插入图片描述

对于StoreStore屏障来说:在volatile写指令执行时,volatile写之前的普通写都已经执行完毕,并且将缓存的数据写回到了主内存中,那么,volatile写之前的普通写对其他线程也是可见的。

public void actor2(Result r) {
 num = 2;	
 // StoreStore含义:1.禁止num写与ready写重排序 2.确保num=2写入主存,对其他处理器可见
 ready = true; // ready 是 volatile 变量 : volatile 写 ,ready同步到主存
 // StoreLoad 防止上面的ready 写与下面可能有的volatile读/写重排序。
}

对于StoreLoad屏障来说,此屏障用于volatile写与下面可能有的volatile读/写重排序。因为编译器常常无法准确判断出volatile写后是否需要插入一个StoreLoad屏障(如:volatile写后立即renturn),为了保证正确性,JMM采取保守策略,因此每个volatile写操作的后面插入一个StoreLoad屏障。

6.3.2 volatile读内存屏障
  • 每个volatile读操作的后面插入一个LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序;
  • 每个volatile读操作的后面插入一个LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序。
    在这里插入图片描述

对于LoadLoad屏障来说,该屏障下的所有普通读都不能先于volatile读之前执行,并且屏障下面的普通读都是从主存加载的最新数据。

对于LoadStore屏障来说,该屏障下所有的普通写都不能先于volatile读之前执行。

6.4 编译器对内存屏障插入策略的优化

由于JMM的内存屏障插入策略非常保守,因此在实际执行时,只要不改变volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

public class VolatileBarrierDemo {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    public void readAndWrite() {
        int i = v1;//第一个volatile读
        int j = v2;//第二个volatile读
        a = i + j;//普通写
        v1 = i + 1;//第一个volatile写
        v2 = j * 2;//第二个volatile写
    }
}

在这里插入图片描述

  • 省略第一个volatile读下的loadstore屏障:因为第一个volatile读下的下一个操作是第二个volatile的读,并不涉及到写的操作(也就是store)。所以可以省略。
  • 省略第二个volatile读下的loadload屏障:因为第二个volatile读的下一个操作是普通写,并不涉及到读的操作(也就是load)。所以可以省略
  • 省略第一个volatile写下的storeload屏障:因为第一个volatile写的下一个操作是第二个volatile的写,并不涉及到读的操作(也就是load)。所以可以省略。

7. volatile的注意事项

7.1 volatile的使用范围

  • volatile只可修饰成员变量(静态的、非静态的)。

  • 多线程并发下,才需要使用它。

7.2 正确使用 volatile变量的条件

  1. 对变量的写操作不依赖于当前值。
volatile int a  = 0;
  
//在多线程情况下错误,在单线程情况下正确的方式
public void doSomeThingA() {
//在单线程情况下,不会出现线程安全的问题,正确
//在多线程情况下,a最终的值依赖于当前a的值,错误
	 a++;     
}

//正确的使用方式
public void doSomeThingB() {
	//不管是在单线程还是多线程的情况下,都不会出现线程安全的问题
	if(a==0){
	 a = 1;
	}
}
  1. 该变量没有包含在具有其他变量的不变式中。
volatile static int start = 3;
volatile static int end = 6;

//线程A执行如下代码:
while (start < end){
  //do something
}

//线程B执行如下代码:
start+=3;
end+=3;

这种情况下,一旦在线程A的循环中执行了线程B,start有可能先更新成6,造成了一瞬间 start == end,从而跳出while循环的可能性。

7.3 不能保证复合操作的原子性

volatile可以保证其他线程获得到变量的最新值(其它线程在使用的时候会读内存然后load到自己工作内存,如果这时候其它线程进行了修改,本线程的volatile变量状态会被置为无效,然后从主存重新读取),有序性的保证也只是保证了本线程内相关代码不被重排序,volatile并不能解决指令交错的问题,所以对于volatile的复合操作(如volatile++),仍然不是原子的操作,可以通过加锁或使用原子操作类保证复合操作的原子性。

7.4 保证long、double变量赋值的原子性

对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,对long、double变量的读写会被分离成两个32位的操作来执行(有时称为字撕裂),此时就有可能造成不正确的执行结果。使用volatile关键字,就可以保证long、double变量赋值的原子性。

大部分的JVM厂家都会对long、double变量的读写进行特殊处理,以保证他们的读写是原子性的,当然你不应该依赖于平台相关的特性,言外之意,对于long、double变量,在多线程环境下还是加上volatile关键字比较好。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值