volatile 关键字解析

volatile 关键字解析

薛定谔的汪

关于volatile关键字,一开始学习java基础的时候是见过的,但当时初学java,功底不够,未能理解这个关键字的实现和作用。最近在阅读《深入理解JVM虚拟机》一书时,看到了对volatile关键字的讲解,但对我来说,总觉的不够细致,未能明白其真义和实际场景应用。之后花了一些时间去搜集资料学习,整理如下:

Java内存模型

原来volatile关键字在C语言中就存在,在了解之前有必要知道java的内存模型。

主内存(Main Memory)

主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。

工作内存(Working Memory)

工作内存可以简单理解为每个线程的线程栈。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。

线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。

这样划分是因为直接操作主内存太慢了,工作内存的性能是远远高于主内存的。

工作内存和主内存同步

比如主内存中有一个对象obj, 工作内存也会有一个obj的副本,线程A首先对工作内存中的obj的副本修改,比如obj=null,再把这个结果同步到主内存中,那主内存中的obj变成了null。对于单线程来说这样是没问题的。

但是当线程A执行的过程中,线程B执行System.out.print(obj)时,会得到什么结果呢?

这个答案是不一定的,有可能是null,也有可能是obj未修改前的引用地址。

这是因为如果线程B读到的数据是线程A工作内存已同步到主内存的数据,这样是没问题的,输出null。如果在线程A工作内存同步到主内存之前,线程B就读了主内存,那这样得到的是obj先前的地址。

如何解决?

大家肯定都知道可以使用synchronized同步锁来实现,它会对线程A线程加锁,当A执行完后释放锁,再让线程B获取锁……

但使用synchronized是有些影响程序性能的,这里就可以使用volatile关键字。

volatile关键字介绍

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

线程可见性

使用volatile关键字,它最关键的特性就是保证了使用此关键字修饰的变量具有线程可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

对一个 volatile 变量的读,总能看到任意线程对这个volatile变量最后的写,基于 happens-before

当读一个volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,强制从主内存中读取共享变量。

给共享变量加volatile关键字修饰,如:

static volatile Object obj = new Object();

这样以上例子的线程B输出的就是null了。

禁止进行指令重排序

1
2
3
4
5
6
7
boolean do = true;
//线程A执行
while(do){
doSomething();
}
//线程B
do = false;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程A在运行的时候,会将do变量的值拷贝一份放在自己的工作内存当中。

那么当线程B更改了do变量的值之后,但是还没来得及写入主存当中,线程B转去做其他事情了,那么线程A由于不知道线程B对do变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了因为使用volatile关键字会强制将修改的值立即写入主存。

什么是指令重排?

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。

然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。我们来看看下面的例子:

1
2
3
4
5
6
7
8
9
10
boolean loadComplete= false;
//在线程A中执行
context = loadContext();
loadComplete = true;

//线程B执行
while(!loadComplete){
Thread.sleep(100);
}
doAfterLoadComplete(context);

正常情况下是先loadContext;,然后将loadComplete 设置为true表示已加载完毕,线程B的while循环跳出,指定doAfterLoadComplete(context);,这样是没问题的。

但是当线程A发生指令重排时,如果执行顺序发生变化:

1
2
loadComplete = true;
context = loadContext();

当loadComplete=true时,context还没加载完,线程B就会执行doAfterLoadComplete(context);,那这样肯定是有问题的。

注:真正的指令重排发生在字节码层面,这里只是代码示例;

volatile关键字底层利用CPU内存屏障指令来阻止指令重排。

volatile缺点

volatile虽然能保证变量的可见性,但是不能保证原子性。或者说,对于非原子的操作是不能保证同步的。如count++:

代码实例:

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 TestVolatile {
//定义一个共享变量c
private static volatile int c=0;
public static void main(String[] args) {
//开启100个线程
for (int i = 0; i < 100; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
//每个线程里对c自增1
c++;
}
}
}).start();
}

try {
//主线程休息1秒
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(c);
}
}

正常结果应该是10000,可实际运行了几次,结果都是小于10000 的。

这是因为count++不是一个原子级操作,可以其进行拆分:

1,从主内存中读取静态变量到工作内存

2,进行+1

3,将工作内存变量值同步到主内存。

这样就导致多个线程同时操作的时候,当本线程读取到了count值比如为10,并对其进行加1,这时count变成了11,但是在这个过程中,其他线程已经将count自增改变了很多次,并同步到主内存中。那主内存里count可能已经变成了20,当本线程同步count=11的时候,count又变成了11,这样的话数据自然就不对了。

volatile 使用场景:

1,当只有一个线程对变量进行写,其他线程只是读的时候。

2,变量不要与其他的状态变量共同参与不变约束。(两个都被volatial修饰的变量不要共同限制某个条件)

  • Title: volatile 关键字解析
  • Author: 薛定谔的汪
  • Created at : 2017-11-15 14:46:31
  • Updated at : 2023-11-17 19:37:37
  • Link: https://www.zhengyk.cn/2017/11/15/java/volatile/
  • License: This work is licensed under CC BY-NC-SA 4.0.