掌握 volatile & synchronized
1、关键字:volatile
1.1 并发编程3大问题
首先来回顾一下并发编程中的3大问题:原子性、可见性、有序性!
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响!
可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值!
有序性
程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)!
1.2 volatile的内存语义
volatile是Java虚拟机提供的轻量级的同步机制!
volatile的3大特性:
原子性: 对任意单个volatile变量的读/写具有原子性,但类似于i++这种复合操作不具有原子性(基于这点,我们通常会认为volatile不具备原子性)。
虽然volatile仅仅保证对单个volatile变量的读/写具有原子性,但是可以借助锁的互斥执行的特性来确保对整个临界区代码的执行具有原子性。
注意:通常面试时,最好不要提volatile的内存语义包含原子性,这个点仅作为了解即可!
可见性: 保证被volatile修饰的共享变量对所有线程可见,即当一个线程修改了变量的值,其他线程可以立马得到这个修改的值!
有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性!
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
volatile写-读内存语义:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
1.3 volatile的3大特性
(1)volatile无法保证原子性
public class VolatileVisibility {
public static volatile int i =0;
public static void increase(){
i++;
}
}
在并发场景下,变量i的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成!
如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。
(2)volatile保证可见性
示例代码:
public class VolatileVisibilitySample {
volatile boolean initFlag = false;
public void save() {
this.initFlag = true;
String threadname = Thread.currentThread().getName();
System.out.println("线程:" + threadname + ":修改共享变量initFlag的值");
}
public void load() {
String threadname = Thread.currentThread().getName();
while (!initFlag) {
//线程在此处空跑,等待initFlag状态改变
}
System.out.println("线程:" + threadname + ":嗅探到initFlag的值被改变");
}
public static void main(String[] args) {
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(() -> {
sample.save();
}, "threadA");
Thread threadB = new Thread(() -> {
sample.load();
}, "threadB");
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
结果:
线程:threadA:修改共享变量initFlag的值
线程:threadB:嗅探到initFlag的值被改变
上例可见:线程A改变initFlag属性之后,线程B马上感知到!
volatile可见性实现原理
JMM内存交互层面实现:
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的!即使用时必须从主内存刷新,修改后必须立即同步回主内存,由此保证volatile变量操作对多线程的可见性。
硬件层面实现:
通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效!
lock前缀指令的作用
- 确保后续指令执行的原子性。在Pentium系列及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
- LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
- LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
(3)volatile禁止指令重排序
Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
前面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
下图是JMM针对编译器制定的volatile重排序规则表:
由上图不难看出volatile禁止重排序的场景:
第二个操作是volatile写,不管第一个操作是什么都不会发生重排序;这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后!
第一个操作是volatile读,不管第二个操作是什么都不会发生重排序 ;这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前!
第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序 ;
volatile禁止指令重排序实现原理
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细介绍过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
硬件层的内存屏障
Intel硬件提供了一系列的内存屏障,主要有:
lfence,是一种Load Barrier 读屏障
sfence, 是一种Store Barrier 写屏障
mfence, 是一种全能型的屏障,具备ifence和sfence的能力
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,JVM中提供了四种内存屏障指令:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕 |
LoadStore | Load1; LoadStore; Store2 | 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕 |
StoreStore | Store1; StoreStore; Store2 | 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见 |
StoreLoad | Store1; StoreLoad; Load2 | 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见 |
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化,但是如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化!
1.4 JMM内存屏障插入策略
上面主要介绍了volatile的重排序规则以及内存屏障,下面介绍具体内存屏障的在JMM中的插入策略(时机)!
JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile写插入内存屏障示意图:
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。
因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。
为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,插入一个StoreLoad屏障。
volatile读插入内存屏障示意图:
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障!
下面通过具体的示例代码进行说明:
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。
以X86处理器为例,上图中除最后的StoreLoad屏障外,其他的屏障都会被省略。
1.5 总结
从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序。
从保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。
从原子性这一点来看,由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。
在禁止重排序这一点上,volatile也是非常有用的! 比如常见的单例模式,其中有一种实现方式是“DCL双重锁单例模式”:
public class Singleton {
private static Singleton instance; // 不使用volatile关键字
private Singleton() {
}
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock();
正常情况下应该分为以下3步完成 (伪代码) :
memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instanc e!=null
但是由于步骤1
和步骤2
间可能会重排序,如下:
memory=allocate(); //1.分配对象内存空间
instance=memory; //3.设置instance指向刚分配的内存地址,此时instanc e!=null,但是对象还没有初始化完成!
instance(memory); //2.初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成, 也就造成了线程安全问题。
那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可:
//禁止指令重排优化
private volatile static DoubleCheckLock instance;
所以,volatile的禁止重排序功能还是非常有用的!
2、关键字:synchronized
2.1 说说你对 synchronized 的理解
synchronized
关键字解决的是多个线程之间访问资源的同步性,它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行!
另外,在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。
为什么呢?
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换时间成本相对较高。
庆幸的是从 JDK.16开始,官方从 JVM 层面对进行了大量的 synchronized
的优化,所以现在的 synchronized
锁效率也提升了很多。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
所以,你会发现目前,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized
关键字。
2.2 你平时怎么使用 synchronized?
synchronized 关键字最主要的三种使用方式:
1. 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁。
synchronized void method() {
//业务代码
}
2. 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
因为静态成员不属于任何一个实例对象,是类成员。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
synchronized static void method() {
//业务代码
}
3. 修饰代码块: 指定加锁对象,对给定对象/类加锁。synchronized(this|object)
表示进入同步代码库前要获得给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 当前 class 的锁
synchronized(this) {
//业务代码
}
总结:
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁synchronized
关键字加到实例方法上是给对象实例上锁- 尽量不要使用
synchronized(String a)
,因为在 JVM 中,字符串常量池具有缓存功能
下面以一个常见的面试题为例讲解一下 synchronized
关键字的具体使用:双重检验锁单例模式!
public class Singleton {
private static Singleton instance; // 不使用volatile关键字
private Singleton() {
}
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
2.3 说说 synchronized 的底层原理
synchronized 关键字底层原理属于 JVM 层面的知识!
1. synchronized 同步代码块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过 JDK 自带的 javap
命令查看类的字节码信息:
首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class
。
从上面我们可以看出:synchronized
同步代码块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
当执行
monitorenter
指令时,线程试图获取锁也就是获取 对象监视器monitor
的持有权。在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个
ObjectMonitor
对象。另外,
wait/notify
等方法也依赖于monitor
对象,这就是为什么只有在同步代码块或者方法中才能调用wait/notify
等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
- 在执行
monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
- 对象锁的的拥有者线程才可以执行
monitorexit
指令来释放锁。在执行monitorexit
指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
- 如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
2. synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized
修饰的方法并没有使用 monitorenter
指令和 monitorexit
指令,取得代之的却是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标识来辨别一个方法是否为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM 会尝试获取实例对象的锁;如果是静态方法,JVM 会尝试获取当前 class 的锁!
2.4 synchronized 与 volatile的区别
volatile
解决的是内存可见性问题,会使得所有对 volatile
变量的读写都直接写入主存,即 保证了变量的可见性。
synchronized
解决的是执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让被 synchronized
关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized
还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得拥有这个锁的线程的所有操作都 happens-before
于随后获得这个锁的其他线程的操作!
两者的区别主要如下:
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
- volatile 仅能使用在变量级别;synchronized 则可以使用在 变量、方法、类级别
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化
2.5 总结
synchronized
同步代码块的实现依靠 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的却是使用 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取!
volatile
本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile
仅能使用在变量级别;synchronized 则可以使用在 变量、方法、类级别。
volatile
仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性。
volatile
不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
volatile
标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。