volatile是JVM提供的一个轻量级的同步机制,除了能能够解决“JVM对于long/double的误读操作”之外,还可以实现如下的两个操作。
volatile 关键字修饰的变量可以对所有线程都是可见的
volatile 关键字可以实现禁止指令重排序
下面我们就来理解一下这两个关键用法。
原子性
在理解指令重排之前,首先来介绍一下原子性,因为指令重排序的排序对象就必须是原子性的操作指令。在Java语言中,并不是所有的语句都是原子性的。
例如现在有一个变量number,对变量的number进行赋值操作number=10 就是一个原子性的操作;但是如果有这样一个操作 int count = 50,那么这个操作就不是一个原子性的操作。因为这个语句会在最终执行的时候被拆分成如下的两条语句
int count ;
count= 50;
所谓的指令重排序是指JVM为了提高运行效率,会对代码进行一个额外的优化。例如会对已经编写好的代码进行重排序。重排序所实现的优化不会影响单线程程序的执行结果。
int number =120;
int count;
count = 20;
int allnumber = count * number;
这里需要说明的是指令重排序并不会影响单线程程序的执行结果,所以这四句代码的执行顺序可以是1、2、3、4,也可以是1、3、2、4,也可以是 2、1、3、4等等,因为这几种的操作最终的执行结果都是一样的。
并且根据上面的分析,也可以知道第一句代码和第四句代码都是非原子性的操作。
了解完原子性和指令重排之后,我们来看一下指令重排是如何影响到单例设计模式的。
单例设计模式
在单例设计模式中有一种实现方式叫做双重检测锁方式,代码如下。
public class Singleton {
private static Singleton instance = null;//多个线程共享instance
private Singleton() {}
public static Singleton getInstance() {
if (instance == null){
synchronized(Singleton.class){
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在上述代码中,根据上面的分析第八行代码也不是一个原子性操作。JVM在执行的时候会将这条语句分为三个原子步骤。
1、分配内存地址、内存空间
2、使用构造方法将对象进行实例化
3、将实例对象赋值为步骤一分配的内存地址
由于会出现指令重排,所以第八行的内部操作可能是1、2、3也可能是 1、3、2;如果是后者那么当某个线程在执行第八行代码的时候刚刚执行完3的操作,但是还没有执行完2的操作,这个时候虽然Instance已经被赋值了,但是还没有进行实例化。而这个时候正好另一个线程抢占到了CPU执行时间片,并且执行到了第五行,判断到instance不为null。所以这个之后线程B会立即返回instance对象,但是此时的Instance还在线程A中没有实例化对象,所以在后续使用Instance的时候就会报错。为了避免这种情况的发生,我们就可以在instance上加上volatile关键字。
private volatile static Singleton instance = null;
这样一来,才真正实现了单例模式。实际上,volatile 是通过内存屏障来防止指令重排的,其具体的实现步骤如下
1、在volatile 写操作之前,插入一个StoreStore的屏障
2、在volatile 写操作之后,插入了一个StoreLoad的屏障
3、在volatile 读操作之前,插入了一个LoadLoad屏障
4、在volatile 读操作之后,插入一个LoadStore 屏障
这里需要注意的是虽然volatile关键字修饰的变量具有可见性,但是其并不具有原子性,所以volatile不是线程安全的操作。需要注意的就是不要将原子性与指令重排两个概念相互混淆。
原子性是指某一条语句不可再拆分,而指令重排序是指某一条语句内部的多个指令的执行顺序。我们可以通过如下的例子来证明volatile是线程不安全的。
volatile是非线程安全的
代码如下
public class TestVolatile {
public static volatile int num = 0;
// 测试主类
public static void main(String[] args) throws Exception {
// 创建100个线程,模拟并发访问。
for (int i = 0; i <100; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i <20000; i++) {
num++;
}
}
}).start();
}
Thread.sleep(5000);//休眠5秒
System.out.println(num);
}
}
如上代码所示,当num=0的时候,创建了100个线程并发访问,并且在每个线程中都会执行20000次的num++操作。如果volatile是线程安全的,那么最终的执行结果应该是2000000,但是实际上执行结果并不是。
从这个例子可以看出 volatile并不是线程安全的。因为num++操作并不是一个原子性的操作。也就是说通过volatile修饰了num变量并没有使得该变量变成原子性的。所以会造成num++操作会被多个线程同时执行。最终就会出现结果中展示的情况。
如何保证原子性
为了能够保证原子性的操作,我们可以使用并发包中提供的原子类。进行操作,代码如下。
public class TestAtomicInteger {
public static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
for (int i = 0; i <100; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i <20000; i++) {
num.incrementAndGet() ;// num自增,功能上相当于int类型的num++操作
}
}
}).start();
}
Thread.sleep(5000);//休眠5秒
System.out.println(num);
}
}
这样通过上面这种方式就可以实现原子性的操作。这里我们来研究一下AtomicInteger是如何保证原子性的。查看源码如下
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
这个方法能够保证原子性操作的核心,就是它的实现是采用了CAS算法,通过CAS算法可以保证变量的原子性操作。
总结
上面我们介绍了关于原子性操作,以及指令重排的相关内容,并且以具体的例子来验证了两种操作的区别,在实际使用过程中一定要理解原子性与指令重排之间的关系,才能更好的保证在多线程场景下的线程安全问题。