面试官Q1:请谈谈你对volatile关键字的理解?
说实话,volatile这个关键字,笔者在工作中实在是用的不算太多,但是这个问题却在面试中高频率出现,相信大家面试过高级岗位,这个问题大家应该不会陌生,下面我们将从几个方面来看看volatile具体用哪些特征:
volatile能保证内存可见性
volatile不能保证原子性
volatile禁止指令重排序
volatile能保证内存可见性
volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,使用volatile,相对来说效率上还是有所提升的。为了更好的理解,我们来看看下面一段代码:
public class TestVolatile {
public static void main(String[] args) {
ThreadDemo td = new ThreadDemo();
new Thread(td).start();
while(true){
if(td.isFlag()){
System.out.println("------------------");
break;
}
}
}
}
class ThreadDemo implements Runnable {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
flag = true;
System.out.println("flag=" + isFlag());
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
从上面代码我们知道这里有两个线程:
一个线程负责写,给flag改值
另外一个线程负责读,读flag的值
我们期望的输出结果是:既打印了flag的值,又打印了while里面的语句,最后程序结束,但是实际上是这样吗?我们看一下运行结果:
只打印了flag的值,而且程序没有结束,那么你觉得这个问题奇怪吗?
这个结论会让人有些疑惑,可以理解。如果在单线程模型里,可以保证程序运行直到结束;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程A的修改,对于线程B来讲,是"不可见"的。也就是说,线程B此时可能无法观测到flag已被修改为true。那么什么是可见性呢?
所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。很显然,上述的例子中是没有办法做到内存可见性的。
为什么出现这种情况呢,我们需要先了解一下JMM?
实际上当程序运行时,JVM会为每一个执行任务的线程独立的分配一个缓存,用于提高程序的执行效率。java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下:
了解了JMM的简单定义后,问题就很容易理解了,对于普通的共享变量来讲,比如我们上文中的flag,当有两个线程T1和线程T2同时操作主线程中的数据的时候,线程T1先从主存中将数据flag读取到自己缓存空间,对数据进行修改,然后将来把修改的数据flag刷新回主存中去。但是在写之前线程T2来了,线程T2比线程T1先读取到主存中的flag数据,线程T2读到的是线程T1还没有刷新回主存之前的数据。产生这种现象的原因是多个线程在操作共享数据时,对于共享数据的操作彼此是不可见的,所以就导致了上述的问题。
那么这种共享变量在多线程模型中的不可见性如何解决呢?
比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,使用同步锁机制sychronized可以保证每次都刷新缓存,但是sychronized枷锁、释放锁、效率低,多个线程操作时,只有一个线程使用,其他线程只能处于等待状态。这时候可以使用volatile关键字,用volatile修饰变量时不会执行加锁操作,因此也就不会使执行线程阻塞,当多个线程进行操作共享数据时,可以保证内存中的数据可见,相较于synchronized是一种较为轻量级的同步策略,我们可以这样理解它的操作就是在主存中进行操作的。我们将上述代码改成volatile修饰就能保证,一个线程修改的值,对另外一个线程是立刻知道的
private volatile boolean flag = false;
但是volatile只能保证内存可见性,不能保证原子性。volatile解决的是变量在多个线程之间的可见性,像i++这种操作volatile无法保证其原子性。
volatile不能保证原子性
什么是原子性?
原子是世界上的最小单位,具有不可分割性。比如 i=0 这个操作是不可分割的,那么我们说这个操作是原子操作。再比如:i++这个操作实际是i = i +1是可分割的,所以它不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作,一个操作是原子操作,那么我们称它具有原子性。在Java中synchronized 和lock、unlock 中操作保证原子性。
我们来看下面一段代码:
1public class TestAtomicDemo {
2 public static void main(String[] args) {
3 AtomicDemo ad = new AtomicDemo();
4 for (int i = 0; i < 10; i++) {
5 new Thread(ad).start();
6 }
7 }
8}
9
10class AtomicDemo implements Runnable{
11 private volatile int i= 0;
12
13 @Override
14 public void run() {
15 try {
16 Thread.sleep(200);
17 } catch (InterruptedException e) {
18 }
19 System.out.println(getSerialNumber());
20 }
21
22 public int getSerialNumber(){
23 return i++;
24 }
25}
上述代码运行结果如下:
1Thread-2 1
2Thread-4 2
3Thread-8 3
4Thread-0 0
5Thread-1 0
6Thread-7 4
7Thread-6 5
8Thread-5 6
9Thread-3 7
10Thread-9 8
线程安全性问题依然存在,即便volatile修饰后,操作在主内存中发生,但是依然改变不了线程安全性问题的发生,那究竟是什么原因呢?
因为i++是一个复合操作,在JVM底层它有三个步骤:
1i++ 的操作实际上分为三个步骤“读-改-写”
2int temp = i;
3i = i + 1;
4i = temp;
那么如何解决i++操作的原子性问题呢?
在JDK1.5以后,可以使用java并发包中的原子操作类,原子操作类是通过循环CAS的方式来保证其原子性的,代码如下:
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class TestAtomicDemo {
4 public static void main(String[] args) {
5 AtomicDemo ad = new AtomicDemo();
6 for (int i = 0; i < 10; i++) {
7 new Thread(ad).start();
8 }
9 }
10}
11
12class AtomicDemo implements Runnable {
13 private AtomicInteger serialNumber = new AtomicInteger(0);
14
15 @Override
16 public void run() {
17 try {
18 Thread.sleep(200);
19 } catch (InterruptedException e) {
20 }
21 System.out.println(Thread.currentThread().getName() + " "
22 + getSerialNumber());
23 }
24
25 public int getSerialNumber() {
26 return serialNumber.getAndIncrement();
27 }
28}
运行结果如下:
1Thread-0 0
2Thread-1 2
3Thread-2 1
4Thread-5 3
5Thread-4 4
6Thread-3 5
7Thread-6 6
8Thread-7 8
9Thread-9 7
10Thread-8 9
无论执行多少遍,都不会再出现线程安全性问题了。
也可以使用 synchronized 和lock、unlock来进行同步处理,这里就不再赘述了。
volatile禁止指令重排序
volatile还有一个特性:禁止指令重排序优化。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。但是重排序也需要遵守一定规则:
①重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
②重排序是为了优化性能,但不管怎么重排序,单线程下程序的执行结果不能被改变
比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了,我们来看一个例子:
1public class TestVolatile {
2 int a = 1;
3 boolean status = false;
4
5 /**
6 * 状态切换为true
7 */
8 public void changeStatus(){
9 a = 2;//1
10 status = true;//2
11 }
12
13 /**
14 * 若状态为true,则running。
15 */
16 public void run(){
17 if(status){//3
18 int b = a+1;//4
19 System.out.println(b);
20 }
21 }
22}
假设线程A执行changeStatus后,线程B执行run,我们能保证在4处,b一定等于3么?
答案依然是无法保证!也有可能b仍然为2。上面我们提到过,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而上例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2。
使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
volatile禁止指令重排序也有一些规则,简单列举一下:
当第二个操作是voaltile写时,无论第一个操作是什么,都不能进行重排序
当地一个操作是volatile读时,不管第二个操作是什么,都不能进行重排序
当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序
总结一下
volatile只能保证变量内存可见性,不能保证原子性。volatile解决的是变量在多个线程之间的可见性,而sychronized、Lock解决的是多个线程之间访问资源的同步性。
转自:https://mp.weixin.qq.com/s/yXS1zWrEbxwmNjIgzUwgnw