volatile关键字

一、相关知识

1.1 内存可见性

线程对共享变量修改的可见性:当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。

volatile使用Lock前缀的汇编指令禁止线程本地内存缓存,保证不同线程之间的内存可见性,这一点可以从如下的Java代码和汇编代码中看出。

Java代码如下:

//instance是volatile变量
Singleton volatile instance = new Singleton();  

转变成汇编代码如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,这个变量所在缓存行的数据会立即写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

Lock前缀的指令在多核处理器下会引发了两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存,处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致

1.2 从内存的角度理解volatile

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

volatile写-读的内存语义:

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

1.3 JMM针对编译器制定volatile重排序规则表

在这里插入图片描述

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

内存屏障
重排序可能会导致多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
在这里插入图片描述
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令列中插入内存屏障来禁止特定类型的处理器重排序
下面是基于保守策略的JMM内存屏障插入策略:

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

从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能破坏volatile的内存语义(内存可见性),这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

二、内存可见性的一个例子

假如不使用volatile关键字:

在下面的程序中,main线程将启动的线程中的RunThread中的共享变量设置为false,从而想让RunThread中的while循环结束。

但是当我们执行这段代码时,RunThread线程并不会停止,从而出现了死循环。

import java.util.*;

public class test {

    public static void main(String[] args) {
        try {
            RunThread thread=new RunThread();
            thread.start();
            thread.sleep(1000);
            thread.setRunning(false);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
    }

}

class RunThread extends Thread{
    private boolean isRunning=true;

    public boolean isRunning(){
        return isRunning;
    }

    public void setRunning(boolean isRunning){
        this.isRunning=isRunning;
    }

    @Override
    public void run(){
        System.out.println("进入到run方法");
        while(isRunning==true){

        }
        System.out.println("线程执行完毕");
    }
}

结果:
在这里插入图片描述

这是因为现在有两个线程,一个是main线程,另一个是RunThread。它们都试图修改第三行的isRunning变量。按照Java内存模型,main线程将isRunning读取到本地线程内存空间,修改后,再刷新回主内存。

但是线程实际上一直在私有堆栈中读取isRunning变量,因此,RunThread线程无法读到main线程改变时的isRunning变量。从而出现了死循环,导致RunThread无法终止。这种情形被称为”活性失败“。

解决方法就是在上面定义isRunning时,加上volatile关键字,也就是修改第19行为:

volatile private boolean isRunning = true;

三、volatile的非原子性

对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性,因为本质上volatile++是读、写两次操作。

示例代码如下:

import java.util.*;

public class test {

    public static void main(String[] args) {
        MyThread[] myThreads=new MyThread[100];
        for(int i=0;i<100;i++){
            myThreads[i]=new MyThread();
        }
        for(int i=0;i<100;i++){
            myThreads[i].start();
        }
    }

}

 class MyThread extends Thread{
    public volatile static int count;

    private static void addCount(){
        for(int i=0;i<100;i++){
            count++;
        }
        System.out.println("count="+count);
    }

    @Override
     public void run(){
        addCount();
    }
 }

执行结果:
在这里插入图片描述

期望的正确结果应该是100*100=10000,但是,实际上count并没有达到10000,原因是volatile修饰的变量不能保证原子操作。

比如,假设某一时刻线程1将count的值取出来,放置在CPU缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改count,因此其他线程不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取count值并将其自增1并存入主内存。此时由于线程2修改了count的值,实时的线程1中的count值缓存失效,重新从主内存中读取,变为count+1。接下来线程1恢复,将自增后的A寄存器值cout+1赋值给cpu缓存count,这样就出现了线程安全问题。

https://www.zhihu.com/question/329746124

对于复合操作,可以:

  • 同步块技术(锁)
  • Java concurrent包(原子操作类,比如AtomicInteger类)

四、例子

并发编程有三大特性:可见性、有序性和原子性

4.1 volatile满足可见性和有序性

这里要提到volatile变量规则(happens-before规则):对一个volatile域的写,happens-before于后续对这个volatile域的读。这意味着,如果一个变量声明成是volatile的,那么当读这个变量时,总是能读到它的最新值,这里最新值是指不管其他哪个线程对该变量做了写操作,都会立即被更新到主存里,我们也可以从主存里读到这个刚写入的值。也就是说volatile变量可以保证可见性以及有序性

以下面的代码为例:

int a = 0;
bool flag = false;

public void write() {
	a = 2; //1
	flag = true; //2
}

public void multiply() {
	if (flag) { //3
		int ret = a * a;//4
	}
}

这段代码不仅仅受到重排序的困扰,即使1、2没有重排序。3也不会那么顺利的执行的。假设还是线程1先执行write操作,线程2再执行multiply操作,由于线程1是在工作内存里把flag赋值为true,不一定立刻写回主存,所以线程2执行时,multiply再从主存读flag值,仍然可能为false,那么括号里的语句将不会执行。

但如果改成下面的形式:

int a = 0;
volatile bool flag = false;

public void write() {
	a = 2; //1
	flag = true; //2
}

public void multiply() {
	if (flag) { //3
		int ret = a * a;//4
	}
}

那么线程1先执行write,线程2再执行multiply。根据happens-before原则,这个过程会满足以下3类规则:

  1. 程序顺序规则:1 happens-before 2;3 happens-before 4
  2. volatile规则:2 happens-before 3
  3. 传递性规则:1 happens-before 4

4.2 volatile不满足原子性

volatile限制对变量的读/写具有原子性,但是对于类似volatile+=这样的复合操作就无能为力了。

如下面的例子:

public class Test {
	public volatile int inc = 0;

	public void increase() {
		inc++;
	}

	public static void main(String[] args) {
		final Test test = new Test();
		for(int i=0;i<10;i++){
			new Thread(){
				public void run() {
					for(int j=0;j<1000;j++)
						test.increase();
				};
			}.start();
		}
		while(Thread.activeCount()>1) //保证前面的线程都执行完
			Thread.yield();
		System.out.println(test.inc);
	}
}

按道理来说结果是10000,但是运行下很可能是个小于10000的值。

这里的inc++是一个复合操作,包括取inc的值,对其自增,然后再写回主存。假设线程A,读取了inc的值为10,这时候被阻塞了,因为没有对变量进行修改,触发不了volatile规则。线程B此时也读取inc的值,主存里inc的值仍旧为10,做自增,然后立刻就被写回主存了,为11。此时又轮到线程A执行,由于工作内存里保存的是10,所以继续做自增,再写回主存,11又被写了一遍。所以虽然两个线程执行了两次increase(),结果缺只加了一次。

问题一:volatile不会使缓存行无效的吗?
这里线程A读取到线程B也进行操作之前,并没有修改inc值,所以线程B读取的时候,还是读的10.

问题二:线程B将11写回主存,不会把线程A的缓存行设为无效吗?
线程A的读取操作已经做过了,只有在做读操作的时候,发现自己的缓存行无效,才会去读主存的值,所以这里线程A只能继续做自增了

综上所述,在这种复合操作的情境下,原子性的功能无法维持。但是volatile在上面那种设置flag值的例子里,由于对flag的读/写操作都是单步的,所以还是能保证原子性的。

volatile关键字只保证可见性,所以在以下情况中,需要使用锁来保证原子性:

  • 运算结果依赖变量的当前值,并且有不止一个线程在修改变量的值
  • 变量需要与其他状态变量共同参与不变约束

要想保证原子性,只能借助于synchronized,Lock以及并发包下的atomic的原子操作类了,即对基本数据类型的自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

五、从Java内存模型角度去理解volatile关键字

主内存和工作内存之间的交互操作有八种,和volatile有关的操作为:

  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

对被volatile修饰的变量进行操作时,需要满足以下规则:

  • 线程对变量执行的前一个动作是load时才能执行use,反之只有后一个动作是use时才能执行load。线程对变量的read,load,use动作关联,必须连续一起出现。-----这保证了线程每次使用变量时都需要从主存拿到最新的值,保证了其他线程修改的变量本线程能看到。
  • 线程对变量执行的前一个动作是assign时才能执行store,反之只有后一个动作是store时才能执行assign。线程对变量的assign,store,write动作关联,必须连续一起出现。-----这保证了线程每次修改变量后都会立即同步回主内存,保证了本线程修改的变量其他线程能看到。
  • 有线程T,变量V、变量W。假设动作A是T对V的use或assign动作,P是根据规则2、3与A关联的read或write动作;动作B是T对W的use或assign动作,Q是根据规则2、3与B关联的read或write动作。如果A先与B,那么P先与Q。------这保证了volatile修饰的变量不会被指令重排序优化,代码的执行顺序与程序的顺序相同。

参考文章
参考文章
参考文章

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值