说说对于volatile关键字的理解,及的作用
概述
1、我们知道要想线程安全,就需要保证三大特性:原子性,有序性,可见性。
2、被volatile关键字修饰的变量,可以保证其可见性和有序性,但是volatile关键字无法保证对变量操作的原子性。
- 可见性:使用volatile修饰变量,就是告诉JVM,这个变量是共享且不稳定的,每次使用它都需要到主存中进行读取。
- 有序性:保证有序性这块主要是指被volatile修饰的关键字,其可以有效的防止变量的指令重排序(通过插入内存屏障的方式来实现防止指令重排序)。
一、volatile如何保证变量的可见性?
被volatile修饰的变量,线程每次读取这样的变量都会从主内存获取最新的值,用于保证自己可以看见其他线程对于该变量的修改并获取到最新的值;而线程每次修改这样的变量也都会同步回写到主内存中,用来保证其他线程访问该变量时可以看到自己对于变量的修改并获取到最新的值。
二、volatile关键字如何有效防止指令重排序
在Java中,volatile关键字除了可以保证变量的可见性,还有一个重要的作用就是防止JVM的指令重排序。如果我们将变量声明为volatile修饰的变量,在对这个变量进行读写操作的时候,就会插入特定的内存屏障 (lock前缀指令) 的方式来禁止指令重排序。
最典型的例子就是使用双重校验加锁方式创建单例模式,具体代码如下:
public class SingleObject{
//volatile关键字防止指令重排序造成的空指针异常(通过插入特定的内存屏障的方式来禁止指令重排序)
private static volatile SingleObject object;
//私有构造方法
private SingleObject() {}
public static SingleObject getSingleObject() {
//第一次检查防止每次获取bean都加锁,减小锁的锁的粒度,提升性能
if (object == null) {
//加锁,防止第一次创建实例化时,并发线程多次创建对象
synchronized (SingleObject.class) {
//第二次检查判断对象没有实例化,则进行对象的实例化
if (object == null) {
object = new SingleObject();
}
}
}
return object;
}
}
问题一:object为什么要采用volatile关键字修饰?
object = new SingleObject();这段代码其实分为三步执行:
- 1、为object分配内存空间。
- 2、初始化object对象。
- 3、将object指向分配的内存地址。
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getSingleObject()后发现 object不为空,因此返回 object,但此时 object还未被初始化。
问题二:volatile修饰的变量如何有效防止指令重排序?
volatile关键字通过插入lock前缀形式的内存屏障方式来有效的防止指令重排序。即在分配内存和赋值操作后插入lock前缀指令(内存屏障),等这两步(上述1、2步)执行完成后,再执行3返回object,就可以实现防止指令重排序。
指令重拍导致的问题就是在内存中分配内存并赋值之前将object返回导致空指针异常,现在在这两步中间加了一层lock前缀指令(内存屏障),保证返回singleton之前分配内存赋值等操作执行完就可以防止指令重拍造成的问题了。
三、volatile关键字能保证原子性吗?
结论:volatile关键字能保证被修饰变量的可见性,但不能保证对变量操作的原子性。
我们直接上代码演示一个例子:
public class practice3 {
//声明一个被volatile修饰的int型变量
private static volatile int x = 0;
//x自增函数
public static void inc() {
x++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
inc();
}
}).start();
}
//等待2s保证上述程序执行完。
Thread.sleep(2000);
//输出执行后的x的最终值
System.out.println(x);
}
}
输出结果如下:
正常情况下的输出结果是10000,而实际的输出结果却为6984!!!
分析:
为什么会出现上述情况呢,如果说volatile可以保证x++操作的原子性,则每个线程对x变量自增完之后,其他线程可以立即看到修改后的值。10个线程分别进行1000次操作,那么最终x的值应该是10000。
我们可能会误认为x++操作是原子性的操作,其实x++操作是一个复合操作,包括三步:
(1)读取x的值
(2)对x加1
(3)将x的值写回内存
尽管volatile修饰了变量x,但是volatile无法保证上述三步对x操作的原子性,可能出现如下情况:
- 线程1读取了变量x的值,还没来得及对x进行修改,这时线程2也读取了x变量的值,并对x进行了修改(+1),再将自增后的x的值写回了内存。
- 线程2执行完毕后,线程1才进行对x的修改(+1),然后再将修改后的值写回内存。
这样虽然线程1和线程2分别对x执行了自增操作,但实际上x的值只加了1。
如何保证上述代码正确的运行呢?
可以通过synchronized关键字、ReentrantLock锁、AtomicInteger来保证。
方式1:通过synchronized关键字修饰inc方法实现,具体代码如下
public class practice3 {
//声明一个被volatile修饰的int型变量
private static volatile int x = 0;
//通过synchronzied来修饰x自增函数(****)
public synchronized static void inc() {
x++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
inc();
}
}).start();
}
//等待2s保证上述程序执行完。
Thread.sleep(2000);
//输出执行后的x的最终值
System.out.println(x);
}
}
方式2:通过ReentrantLock锁,锁住inc方法实现,具体代码如下
public class practice3 {
//声明一个被volatile修饰的int型变量
private static volatile int x = 0;
static Lock lock = new ReentrantLock();
//使用ReentrantLock独占锁锁住x自增操作
public static void inc() {
lock.lock();
try {
x++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
inc();
}
}).start();
}
//等待2s保证上述程序执行完。
Thread.sleep(2000);
//输出执行后的x的最终值
System.out.println(x);
}
}
方式3:使用AtomicInteger实现,具体代码如下
AtomicInteger是JUC包下的工具类,可以实现原子性操作,即保证线程安全。
public class practice3 {
//声明一个被volatile修饰的int型变量
private static volatile AtomicInteger x = new AtomicInteger();
//x自增函数
public static void inc() {
//获取当前值并+1
x.getAndIncrement();
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
inc();
}
}).start();
}
//等待2s保证上述程序执行完。
Thread.sleep(2000);
//输出执行后的x的最终值
System.out.println(x);
}
}