Java并发编程之volatile关键字的理解与使用

摘要

  1. 开头先概括volatile关键字在并发编程中有两个作用:
    • 保证变量的可见性。具体是指:当某个共享变量被多个线程访问,假如该变量被某一个线程修改了,那其他线程可以立即看到被修改后的值。
    • 保证对volatile变量进行读/写操作的那一行代码的顺序不变。
  2. 若要理解Java并发编程,就要先理解Java内存模型和并发编程的三个概念。所以本文的思路是按以下顺序进行的:
    • 一、Java内存模型
    • 二、并发编程的三个问题,以及这三个问题和volatile的关系
    • 三、volatile不保证原子性

一、 Java内存模型

Java内存模型规定所有的共享变量都存在于主内存中,每个线程都有自己的工作内存,每个线程所使用的共享变量是从主存中复制过来的。线程对共享变量的操作(读取、修改)都是在工作内存中进行的。而且线程之间不能直接访问对方的工作内存,线程间变量值的传递是通过主内存完成的。
Java内存模型图

二、 并发编程的三个问题,以及这三个问题和volatile的关系

要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

1.原子性

定义:一个操作或多个操作要么全部执行,要么全部都不执行,不存在第三种情况,即不存在执行到中途就中断的情况。

例子1:银行转账
A账户向B账户转100元,包括两个操作:A账户减少100元,B账户增加100元,要保证这两个操作要么全部执行,要么全都不执行。假如A账户减少了100元,操作中断了(例如停电了),但是B账户没有增加100元。所以要保证A-100和B+100这两个操作具有原子性。

分析以下语句是否具有原子性:
前提:x、y是共享变量,假如x、y是局部变量那就不会有并发编程的原子性问题了,因为每个线程都有自己的一份局部变量

    x = 1;   //1
    y = x;   //语句2
    x++;     //语句3
    x = x+1; //语句4

只有语句1具备原子性,语句1直接将数值1赋值给x。

语句2包含三个操作:1)从主内存中读取x的值到工作内存,2)将x的值赋给y,3)将y的值写到主内存。这三个操作不具备原子性。

语句3和语句4一样,包含三个操作:1)从主内存中读取x的值,2)将x的值加1,3)将x的值写到主内存中。

语句2、3、4都包含三个操作,这三个操作不具有原子性,但每一步操作本身具有原子性,也就是说Java只保证了基本读取和赋值是原子性操作。如果要实现更大范围的原子性操作,可以通过synchronized和Lock来实现,synchronized和Lock能够保证该代码块中的代码被某个线程执行完,才轮到下一个线程执行该代码块中的代码。

volatile是否可以解决原子性问题:volatie不能解决原子性问题。

2. 可见性

定义:当某个共享变量被多个线程访问,假如该变量被某一个线程修改了,那其他线程可以立即看到被修改后的值。

例子2

//共享变量
boolean flag = true;

//线程1执行的代码
while(flag) { //语句1
    doSomething(); //语句2
}

//线程2执行的代码
flag = false; //语句3

该段代码的意图是当线程2将标记变量flag赋值为false时,线程1就停止执行doSomething( )。

当线程2执行flag=false,会先从主存中读取flag的值,然后修改为false,但是flag=false什么时候被写入主存是不确定的,当线程1去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性,所以这时候线程1有可能还是会执行doSomething( )操作。

这就是可见性问题,线程2修改了共享变量flag的值后,线程1没有立即看到修改后的值。

volatile是否可以解决可见性问题
volatile可以解决可见性问题,如何修改例子2,使其可以保证共享变量的可见性?
使用volatile关键字修饰共享变量就可以保证变量的可见性。

当修改被volatile修饰的变量,就会发生以下两个操作:
1)只要共享变量flag被volatile修饰了,线程2修改了flag=false,就会立即强制写到主内存中。

2)当线程2线程修改volatile变量flag,那线程1工作内存中缓存的flag变量就会失效,所以当线程1 再次读取 flag的值时,就会到主存中读取最新的值。注意边界情况:假如执行了语句1,即已经读取了flag的值,然后切换到线程2执行语句3,修改flag=false,当切换到线程1时,并不会再次去读取flag的值,因为已经读取过了,只能等到下次执行时才会读取最新的flag=false。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3. 有序性

定义:因为处理器为了提高程序运行效率,可能会对输入代码进行指令重排序,所以程序中各语句的执行顺序不一定和你书写的文本代码的顺序一致。

虽然进行了指令重排,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的;但是会影响到多线程并发执行的正确性,见如下例子2。

例子3

//共享变量
Object obj = null;
boolean flag = true;

//线程1执行的代码
obj = initObj( );//语句1
flag = false;//语句2

//线程2执行的代码
while(flag) {
    //.....
}
doSomething(obj);//语句3

语句1和语句2没有数据依赖性,所以语句2可能比语句1先执行,线程1先执行了语句2,然后切换到线程2,线程2就会执行语句3,但是obj变量并没有初始化,所以程序就报错了。

volatile是否可以解决有序性问题:
volatile可以解决有序性问题,如何修改例子3,使其可以保证指令执行的顺序?
使用volatile关键词修饰flag变量,保证对volatile变量进行读/写操作的那一行代码的顺序不变。

如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。(保证可见性)

Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。(保证有序性)

举例说明内存屏障

//x、y为非volatile变量
//flag为volatile变量

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

三、 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进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量inc的值为10,

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,也不会导致主存中的值刷新,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过AtomicInteger。

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值