Volatile 这个关键字可能很多朋友都听说过,但是可能不敢用,毕竟这个关键字非常不好控制,干脆不用为好。Volatile在一般的多线程编程里面算是比较尴尬的关键字了。本人也不想说(抄)太多的底层原理,相信很多人也不愿意看,只想知道怎么用,但是大概简单的了解也是必须的,这使得我们很容易的理解,并正确的使用。
一.Java内存模型
在java中,线程之间的共享变量是存储在主内存中的,每个线程都有一个属于自己的私有的本地内存,其中存放着主内存中所有线程共享的变量的值的拷贝。内存模型图如下
图片.png现在假设本地内存A和本地内存B存着主内存中的共享变量x的副本。假设初始化这三个内存中的x值都是0。现在线程A和线程B同时执行 x=x+1;那么我们希望两个线程执行完之后x的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取x的值存入各自的本地内存当中,然后线程A进行加1操作,然后把x的最新值1写入到内存。此时线程B的本地内存中还是0,读取x=0后进行加1操作之后,x的值为1,然后线程B把x的值写入内存。最终的结果x=1;这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
1.原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子,请分析以下哪些操作是原子性操作:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
- 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
- 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入本地内存,虽然读取x的值以及 将x的值写入本地内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
- 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它必须去主内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢? 看下面的代码
public class ReorderExample {
int a = 0;
boolean flag = false;
public void writer(){
a = 1; //1
flag = true; //2
}
public void reader(){
if (flag) {
int i = a;
//other
}
}
}
线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;
所以在这里多线程的语义被重排序破坏了。
二.深入剖析volatile关键字
1.声明为 volatile 变量有以下保证:
- 其他线程对volatile变量的修改,可以即时反应到当前线程中
- 确保当前线程对volatile变量的修改,能即时写回主内存中,并别其他线程可见
- 使用 volatile 声明的变量,编译器会保证其有序性
看以下代码:
public class VolatileTest extends Thread{
private boolean stop = false;
public void stopMe(){
stop = true;
}
@Override
public void run() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("Thread Stop");
}
public static void main(String[] args) throws InterruptedException {
VolatileTest test = new VolatileTest();
test.start();
Thread.sleep(1000);
test.stopMe();
Thread.sleep(1000);
}
}
这是很典型的一段代码,上面的线程会被停止吗?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程,这是有隐藏bug的代码。
在前面已经解释过,每个线程在运行过程中都有自己的本地内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的本地内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的本地内存中缓存变量stop的缓存行无效;
第三:由于线程1的本地内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的本地内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主内存地址被更新之后,然后去对应的主内存读取最新的值。
那么线程1读取到的就是最新的正确的值。这个线程就能确保一定能停下来。
再来看重排序的问题,看回这段代码:
public class ReorderExample {
int a = 0;
boolean flag = false;
public void writer(){
a = 1; //1
flag = true; //2
}
public void reader(){
if (flag) {
int i = a;
//other
}
}
}
线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;所以在这里多线程的语义被重排序破坏了。
但是当用volatile声明flag变量的时候:
- 线程A写一个volatile变量的时候,会把写之前对共享变量所做的修改写到主内存中,并且对其他线程可见,并通知线程B去主内存中读数据。那么就不会出现上面那种由于重排序破坏了多线程的语义。并且volatile会保证有序性。
2.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关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
- 状态标记量
public class VolatileTest extends Thread{
private volatile boolean stop = false;
public void stopMe(){
stop = true;
}
@Override
public void run() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("Thread Stop");
}
public static void main(String[] args) throws InterruptedException {
VolatileTest test = new VolatileTest();
test.start();
Thread.sleep(1000);
test.stopMe();
Thread.sleep(1000);
}
}
- 防止重排序对多线程语义破坏
public class ReorderExample {
int a = 0;
boolean volat flag = false;
public void writer(){
a = 1; //1
flag = true; //2
}
public void reader(){
if (flag) {
int i = a;
//other
}
}
}
网友评论