volatile关键字详解

简介

  • volatile的本义是易变的、不稳定的。volatile关键字修饰的变量随时可能被其他线程修改。当volatile变量的值被修改时,主存中值的更新会使缓存中的值失效(非volatile变量不具备这样的特性,非volatile变量的值会被缓存,线程A更新了该值,但线程B读到的值可能并不是最新的值,而是缓存的值),所以被volatile修饰的变量能够保证每个线程能够获取该变量的最新值

特性

volatile具有可见性有序性,不具备原子性。

Java内存模型

在了解各种特性前,需要知道Java的内存模型。

Java内存模型规定所有的变量都要存储在主内存中,而每个线程都拥有属于自己的工作内存,线程的工作内存中保存了该线程所使用到的变量(这些变量是从主内存拷贝而来)。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不同线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

因为这种内存模型,便产生了多线程编程中的数据脏读等问题。例如执行下面程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class VolatileTest {
private int a = 0;

private void add() {
a++;
System.out.println(Thread.currentThread().getName() + ": a = " + a);
}

public static void main(String[] args) {
VolatileTest volatileTest = new VolatileTest();

new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
volatileTest.add();
}
}
}, "Thread A").start();

new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
volatileTest.add();
}
}
}, "Thread B").start();
}
}

其中一次的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread B: a = 2
Thread A: a = 1
Thread B: a = 3
Thread A: a = 4
Thread B: a = 5
Thread B: a = 7
Thread B: a = 8
Thread B: a = 9
Thread B: a = 10
Thread B: a = 11
Thread B: a = 12
Thread B: a = 13
Thread A: a = 6
Thread A: a = 14
Thread A: a = 15
Thread A: a = 16
Thread A: a = 17
Thread A: a = 18
Thread A: a = 19
Thread A: a = 20

可以看出线程读取到的结果可能不是最新的

原子性

定义

原子性:指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

实例

  1. 生活上一个很经典的例子:银行转账问题。比如账户A向账户B转1000元,该事件包含两个操作:从账户A减去1000元,往账户B增加1000元。如果这两个操作不具备原子性,会出现什么问题呢?可能账户A减少了1000元,但是这时操作突然停止了。这样就会造成账户A的钱没有了,但账户B没有收到钱的问题。所以这两个操作必须具备原子性才能保证不出现意外。
  2. 在并发编程中,假设在线程A中执行赋值语句i = 10,暂且假设为一个32位的变量赋值包含两个操作:为低16位赋值,为高16位赋值。那么如果这两个操作不具备原子性,就有可能刚为低16位赋完值,线程A突然被中断,此时线程B要读这个值,那么线程B读取到的值就是错误的。所以这两个操作也必须具备原子性才能保证不出现意外。

Java中的原子性

在Java中,原子性操作包括:

(1)对基本数据类型变量的读取赋值(而且必须是将数字赋给某个变量,变量之间的相互赋值不是原子操作)操作。

例如,对于下面的操作

1
2
3
4
int x, y;
x = 10; //(1),是原子性操作
y = x; //(2),不是原子性操作
x = x + 1; //(3),不是原子性操作

只有(1)是原子性操作,因为(1)是将数字赋给某个变量。(2)包含两个操作:读取x,将x赋给y。虽然两个操作都是原子操作,但合起来就不是原子操作了。(3)包含了三个操作:读取x,进行加1操作,将新的值赋给y,同理,并不是原子操作。

如果要实现更大范围的原子性,可以通过synchronized和Lock来实现,synchronized和Lock保证了同一时刻只能由一个线程执行代码块,自热就保证了原子性。

可见性

定义

可见性:当多个线程访问同一个变量时,假如线程1修改了该变量,其他线程能够立即读取到线程1修改后的变量的值。

实例

由于Java的内存模型,导致了一些可见性问题。例如某个共享变量一开始在主内存中的值是10,该值拷贝到了每个线程的工作内存中。线程A修改该值为15,并将其加载到线程A的工作内存中。此时线程B要读取该值,但它读取到的值仍为10,因为线程A修改后的值还没有写入主内存,或者即使线程A已经将修改后的值写入主内存,但线程B的工作内存还没有向主内存拷贝该值的话,线程B的工作内存中的值就还是旧的10。

Java中的可见性

  • Java提供了volatile关键字来保证可见性。对于被volatile修饰的变量,在变量修改后会立即更新到主存,并且当其他线程读取该值时,也会去主存中读取新值。(而普通变量无法保证可见性)
  • 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此保证了可见性。

有序性

定义

有序性:程序执行时按照代码书写的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序并不会影响单线程的执行,却会影响多线程并发执行的正确性。

例子

举个简单的例子,看下面的代码

1
2
3
int x, y;
x = 10; //语句1
y = 20; //语句2

可以看到,语句1是在语句2前面的,那么JVM在真正执行的时候也会保证语句1在语句2前面吗?不一定。因为这里可能发生指令重排序。什么是指令重排序?

一般来说,处理器为了提高运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序会同代码顺序一样,但它会保证程序最终的执行结果和代码顺序执行的结果是一样的

然而,需要注意的是,虽然指令重排序不会影响当个程序的执行,但是它会影响多线程并发执行的正确性。看下面的例子:

1
2
3
4
5
6
7
8
9
10
//线程1:

context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingWithContext(context); //语句3

假如线程1先执行了语句2,然而在线程1还没执行语句1时,线程2却以为已经准备好了就跳出了循环,开始执行语句3,此时由于context还没有初始化,就可能会导致程序出错。

Java中的有序性

  • 在Java中,可以通过volatile关键字来保证一定的有序性,或者通过synchronized和Lock来保证有序性。
  • Java内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也被称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么就不能保证它们的有序性,JVM可以随意地对它们进行重排序。

happens-before原则

下面就来介绍下happens-before原则(先行发生原则):

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作(这个原则只能保证在单线程下的程序结果和代码顺序执行的结果一致,并不能保证在多线程下执行的正确性)
  2. 锁定规则:一个unLock操作先行发生于位于后面的对同一个锁的lock操作(也就是说对于同一个锁,如果该锁处于锁定状态,必须先释放该锁才能再次进行锁定操作)
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作(如果在代码中一个线程先去写一个变量,然后另一个线程再去读取,那么写入操作肯定会先行发生于读取操作)
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束,通过Thread.isAlive()的返回值手段检测到线程是否已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

valotile如何确保可见性

前面说过valotile关键性修饰的变量是保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了该变量,其他线程能够立即知道这种改变。

那么valotile是如何保证这种可见性的呢?

在生成汇编代码时,valotile修饰的变量在进行写操作时会多出Lock前缀的指令,这个指令在多核处理器下主要有这两个方面的影响:

  1. 将当前处理器缓存行的数据写回系统内存
  2. 其他CPU缓存了该内存地址的数据无效

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将该缓存行设置成无效状态

所以使用valotile修饰的变量A被修改后的过程如下:

  1. 当线程1修改了A后,修改后的值会立即写入主存中,并导致其它线程的工作内存中缓存该变量A的缓存行失效。
  2. 当其他线程,例如线程2读取变量A的时候,发现其工作内存中变量A的缓存行失效,线程2就会去主存中读取变量A,这样读取到的值就是最新的。

valotile如何确保有序性

由于valotile关键字能禁止指令重排列,所以valotile能在一定程度上保证有序性。

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

valotile关键字禁止指令重排列有两层意思:

  1. 当程序执行到valotile变量的读操作或写操作的时候,在其前面的操作肯定已经全部进行,并且结果对后面的操作可见;在其后面的操作肯定还没有进行
  2. 在进行指令优化时,不能将valotile变量前面的语句放到其后面执行,也不能将valotile变量后面的语句放到前面执行。

举个的例子:

1
2
3
4
5
6
7
//假设x,y是普通变量,flag是volatile变量

x = 10; //语句1
y = 15; //语句2
flag = 20; //语句3
x = 25; //语句4
y = 30; //语句5

由于valotile的有序性,所以语句3一定是在语句1和语句2之后进行,并且语句1和语句2的结果对语句3可见,而语句4和语句5会在语句3之后进行。但语句1和语句2哪个先执行是不确定的,语句4和语句5同理。

对于之前的例子:

1
2
3
4
5
6
7
8
9
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingWithContext(context); //语句3

假如inited是valotile变量,那么就可以保证执行到语句2时,语句1已经执行完毕,并且语句1的结果可见,那么语句3就肯定不会出现问题了。

valotile不能确保原子性

先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class VolatileTest {
private volatile int num = 0;

private void increase() {
num++;
}

public static void main(String[] args) {
VolatileTest volatileTest = new VolatileTest();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
volatileTest.increase();
}
}
}).start();
}

while (Thread.activeCount() > 2) { //保证前面的线程全部执行完毕
Thread.yield();
}
System.out.println(volatileTest.num);
}
}

例子中新建了10个线程,每个线程自增1w次,那么输出结果应该是10w才对。但运行多次后发现,有时候结果是10w,但有时候可能只有9w多。

为什么会这样呢?volatile确实可以保证可见性。但问题是出在自增操作上,自增操作并不是原子操作,volatile无法保证对volatile变量的任何操作都是原子性的

假如某个时刻变量num的值为10,此时线程1对变量进行自增操作,线程1先读取了变量num的值,然后线程1被阻塞了;

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

然后线程1接着进行加1操作,由于之前已经读取了num的值,所以现在直接进行加1和赋值操作,然后将11写入工作内存,最后写入主存。

可以看到两个线程都进行了一次自增操作,但num只增加了1。

那怎么解决这个问题呢?

  1. 可以通过synchronized或Lock进行加锁,来保证自增操作的原子性。

    1
    2
    3
    4
    5
    private int num = 0;

    private synchronized void increase() {
    num++;
    }
  2. 使用原子操作类(在java.util.concurrent.atomic包下),保证自增操作的原子性。

    1
    2
    3
    4
    5
    private AtomicInteger num = new AtomicInteger(0);

    private void increase() {
    num.incrementAndGet();
    }

应用场景

volatile在某些时候性能是由于synchronized的,但是volatile并不能替代synchronized,因为volatile无法保证操作的原子性。使用volatile变量必须具备以下两个条件:

  1. 对变量的写操作不依赖与当前值(例如自增操作就不符合这个条件)
  2. 该变量没有包含在具有其他变量的不变式中

下面列举几个使用volatile的场景

  1. 状态标记量
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    volatile boolean flag = false;
    //线程1
    while(!flag){
    doSomething();
    }

    //线程2
    public void setFlag() {
    flag = true;
    }

线程1可根据状态标记,及时终止操作

  1. Double Check Lock(DCL)实现单例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class Singleton {
    private volatile static Singleton sSingleton = null;

    private Singleton() {
    //...
    }

    public static Singleton getInstance() {
    if (sSingleton == null) {
    synchronized (Singleton.class) {
    if (sSingleton == null) {
    sSingleton = new Singleton();
    }
    }
    }
    return sSingleton;
    }

    }

为什么要用volatile来修饰sSingleton呢?

原因在于sSingleton = new Singleton()这条语句,这条语句并不是一个原子操作,它大致做了以下这3件事:

  1. 给sSingleton分配内存
  2. 调用Singleton的构造函数来初始化成员变量
  3. 将sSingleton对象指向分配的内存空间(执行完这一步后,sSingleton就不为null了)

但是由于编译器的指令重排序,上面第2和第3步的顺序是不能保证的。最终的执行顺序可能是1-2-3也可能是1-3-2。如果是1-3-2,那么假如线程A刚好执行完3时(此时sSingleton不为null),切换到另一个线程B,线程B刚好执行到第一个if(sSingleton == null)判断语句(注意是第一个,线程B没有获得锁是执行不到第二个if判断的),此时由于sSingleton不为null,所以直接返回还没初始化的sSingleton,所以报错。

但是sSingleton如果用volatile修饰的话,由于volatile可以禁止指令重排序,此处是sSingleton的写操作,所以可以保证在写操作前的所有指令一定会在volatile写操作之前完成,那么也就保证了sSingleton = new Singleton()这条语句的执行顺序是1-2-3

参考

-------------    本文到此结束  感谢您的阅读    -------------
0%