volatile 的可见性
volatile 的可见性作用
关于 volatile 的可见性作用,必须意识到:
- 被 volatile 修饰的变量对所有线程总是立即可见的;
- 对 volatile 变量进行写操作,能立即反映到其他线程中;
- 但是,对 volatile 变量运算操作在多线程环境中,并不能保证安全性。
场景一
public class volatileVisibility {
public static volatile int value = 0;
public static void increase() {
value++;
}
}
分析:
- 若存在多线程同时调用 increase 方法,上面代码便会出现线程安全问题,因为 value++ 这个操作并不具备 原子性。
- value++ 是先读取值,再写回一个新值,相当于把原来的值加上 1 分为两个步骤来完成。
- 若第二个线程在第一个线程读取旧值和写回新值之间,读取 value 的值,第二个线程就会与第一个线程看到同一个值,并执行相同的 加1 操作,进而引发了线程安全问题。
因此,increase() 方法必须加上 synchronized 关键字修饰,以保证线程安全。
注意:
- synchronized 解决的是执行控制问题,它会阻止其他线程获取当前对象的 监控锁。使得当前对象中被 synchronized 保护的代码块无法被其他线程访问,也就无法并发执行。
- 更重要的是,synchronized 关键字还会创建一个内存屏障,内存屏障指令保证了所有cpu结果都会刷新到主内存中,从而保证了操作内存的可见性。同时使得先获得锁的线程的所有操作都 happens-before 于随后获得该锁的线程的操作。一旦使用 synchronized 关键字修饰方法后,由于 synchronized 本身也具备与 volatile 相同的 可见性 特性。因此,在这种情况下,完全可以省去 volatile 修饰。
更改为如下代码
public class volatileVisibility {
public static int value = 0;
public synchronized static void increase() {
value++;
}
}
场景二
使用 volatile 修饰,可以达到线程安全的目的
public class volatileVolatileSafe {
volatile boolean shutdown;
public void close() {
shutdown = true;
}
public void dowork() {
while (!shutdown) {
System.out.println("safe......");
}
}
}
代码分析:
- 由于对 boolean 变量,即 shutdown 的值的修改 属于原子操作,因此可以使用 volatile 修饰shutdown变量,使得对该变量的修改对其他线程立即可见,从而达到线程安全的目的。
volatile 变量为何立即可见
- 当写一个 volatile 变量时,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中;
- 当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效.
内存屏障
是一个 cpu 指令。作用有以下两个:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
volatile 如何禁止重排优化
- 通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化
- 强制刷出各种 cpu 的缓存数据,因此任何 cpu 上的线程都能读取到这些数据的最新版本
总之,volatile 变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
单例的双重检测实现
面试常要求,即常见的实现线程安全的单例写法。
public class Singleton {
private static Singleton instance;
private Singleton () {}
public static Singleton getInstance () {
// 第一次检测
if (instance == null) {
// 同步
synchronized (Singleton.class) {
if (instance == null) {
// 多线程环境下,可能会出现问题的地方
instance = new Singleton();
}
}
}
return instance;
}
}
代码分析
- 通过引入 synchronized 代码块,试图解决 多线程请求单例时,带来的重复创建单例的隐患。
- 这段代码看似没有多大问题,但是在多线程环境下,依然会有隐患。原因是:某一个线程执行到 第一次检测时,instance 不为空时,instance 引用的对象可能还没完全初始化,因为 instance = new Singleton() 这步可以分为下面三步完成,伪代码如下:
// 伪代码
memory = allocate();// 1.分配对象内存空间
instance(memory);// 2.初始化对象
instance = memory; // 3. 设置instance 指向刚分配的内存地址,此时 intance != null
这时,可能会发生指令重排序,变成下面伪代码。
// 伪代码
memory = allocate();// 1.分配对象内存空间
instance = memory; // 3. 设置instance 指向刚分配的内存地址,此时 intance != null,但是对象还没有初始化完成!
instance(memory);// 2.初始化对象
第三步提前,而第二步延后了。
- 这时因为步骤2 和步骤3 之间不存在数据依赖的关系,而且无论排前排后,执行的结果在单线程中并没有改变。因此这种重排序优化是允许的。但是指令重排,只能保证串行语义的一致性,即单线程语义的一致性。但并不会关心多线程的语义一致性。
- 所以当一个线程访问 instance 不为空时,由于实例未必已经完成初始化,就造成了线程的安全问题。那么该如何解决?
解决方法:
- 使用 volatile 去禁止 instance 变量执行指令重排序即可。就是不能将 第2步和第3步颠倒过来。
修改后代码如下:
public class Singleton {
// 禁止指令重排优化
private volatile static Singleton instance;
private Singleton () {}
public static Singleton getInstance () {
// 第一次检测
if (instance == null) {
// 同步
synchronized (Singleton.class) {
if (instance == null) {
// 多线程环境下,可能会出现问题的地方
instance = new Singleton();
}
}
}
return instance;
}
}
volatile 和 synchronized 的区别
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止。
- volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法和类级别
- volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量修改的可见性和原子性
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化