欢迎大家关注我的公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。
一、概念
volatile关键字表示被修饰的变量的值容易变化( 即被其他线程更改 )。因而不稳定。
volatile变量的不稳定性意味着对这种变量的读和写操作都必须从高速缓存或者主内存。(也是通过高速缓存读取)中读取,以读取变量的相对新值。因此,volatile变量不会被编译器分配到寄存器进行存储,对 volatile 变量的读写操作都是内存访问( 访问高速缓存相当于主内存 )操作。
volatile关键字常被称为轻量级锁 ,其作用与锁的作用有相同的地方:保证可见性和有序性。并不保证操作的原子性;其次,volatile 关键字的使用不会引起上下文切换( 这是 volatile 被冠以 “轻量级” 的原因 )。因此,volatile 更像是一个轻量级简易( 功能比锁有限 )锁。
二、内存可见性
1、概念
内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态。
JVM内存模型:主内存和线程独立的工作内存
Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),线程只能访问自己的工作内存,不可以访问其它线程的工作内存。
工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。
2、失效数据
以下是一个简单的可变整数类:
public class MutableInteger {
private int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}
MutableInteger
不是线程安全的,因为get
和set
方法都是在没有同步的情况下进行的。如果线程1调用了set
方法,那么正在调用的get
的线程2可能会看到更新后的value值,也可能看不到。
解决方法很简单,将value
声明为volatile
变量:
private volatile int value;
3、Java变量的读写
Java通过几种原子操作完成工作内存和主内存的交互:
-
lock:作用于主内存,把变量标识为线程独占状态。
-
unlock:作用于主内存,解除独占状态。
-
read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
-
load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
-
use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
-
assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
-
store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
-
write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。
4、volatile如何保持内存可见性
volatile的特殊规则就是:
- read、load、use动作必须连续出现。
- assign、store、write动作必须连续出现。
所以,使用volatile变量能够保证:
- 每次读取前必须先从主内存刷新最新的值。
- 每次写入后必须立即同步回主内存当中。
也就是说,volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量value的最新修改,对线程2是可见的。
三、防止指令重排
1、概念
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序员带来问题。在基于偏序关系的Happens-Before
内存模型中,指令重排技术大大提高了程序执行效率,但同时也引入了一些问题。
2、一个指令重排的问题——被部分初始化的对象
懒加载单例模式和竞态条件
一个懒加载的单例模式实现如下:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //这里存在竞态条件
instance = new Singleton();
}
return instance;
}
}
竞态条件会导致instance
引用被多次赋值,使用户得到两个不同的单例。]
DCL和被部分初始化的对象
为了解决这个问题,可以使用synchronized关键字将getInstance方法改为同步方法;但这样串行化的单例是不能忍的。所以我猿族前辈设计了DCL(Double Check Lock,双重检查锁)机制,使得大部分请求都不会进入阻塞代码块:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}
“看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance
不为null时,仍可能指向一个"被部分初始化的对象"。
问题出在这行简单的赋值语句:
instance = new Singleton();
它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:
memory = allocate(); //1:分配对象的内存空间
initInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址(此时对象还未初始化)
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个"被部分初始化的对象"。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。
解决这个该问题,只需要将instance声明为volatile变量:
private static volatile Singleton instance;
也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个”单例(未完成初始化)。
然而,许多面试书籍中,涉及懒加载的单例模式最多深入到DCL,却只字不提volatile。这“看似聪明”的机制,对于考查并发的面试官而言,单例模式的实现就是一个很好的切入点,看似考查设计模式,其实期望你从设计模式答到并发和内存模型。
3、volatile如何防止指令重排
volatile关键字通过“内存屏障”来防止指令被重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
四、总结
1、volatile是轻量级同步机制
相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。
volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatilei变量是一种比synchronized关键字更轻量级的同步机制。
2、volatile使用建议
使用建议:在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。
3、volatile和synchronized区别
-
volatile不会进行加锁操作
volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。 -
volatile变量作用类似于同步变量读写操作
从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。 -
volatile不如synchronized安全
在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。 -
volatile无法同时保证内存可见性和原子性
加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。
4、当且仅当满足以下所有条件时,才应该使用volatile变量
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量没有包含在具有其他变量的不变式中。
总结:在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。