volatile关键字和synchronized关键字的区别

本文介绍了volatile关键字和synchronized关键字的区别

一句话概括

volatile保证了线程间的可见性和有序性

synchronized保证了线程间的原子性(同时保证了可见性和有序性)

Java内存模型

首先,为了说明两者的区别,先要引出一个概念——Java内存模型(JMM),该模型用于屏蔽各种硬件和操作系统带来的内存访问的差异,定义了程序中各个变量**(不包括局部变量和方法参数,因为这些是线程私有的)的访问规则。

JMM规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

需要注意的是,这里Java内存模型中的主内存,工作内存和Java内存区域中的Heap,Stack,方法区等是没有关系的,如果非要类比,主内存可以对应Heap中的对象实例的数据部分,工作内存对应JVM Stack中的部分区域。

volatile语义

volatile关键字可以说是Java虚拟机提供的最轻量级的同步机制。

当一个变量定义为volatile时,它将具备两种特性:

第一,保证此变量对所有线程的可见性。这里的可见性指的是当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量不能做到这一点,普通变量的值在线程间传递都需要通过主内存来完成。

第二,禁止指令重排序优化。

举例说明

如下图所示,有两个线程t1和t2,它们都需要使用对象A中的变量flag,这两个线程都会从主内存中copy一份对象A的变量flag,放到它们各自的线程工作内存中,然后在运行期间直接使用这份copy。

当t2线程需要修改flag的值,它会先把自己工作区的flag修改然后存入主内存中,但是t1并不会去访问heap中的flag,而是依旧使用自己工作区的copy。

当volatile关键字作用到变量flag上,将会强制所有线程都去主线程中读取变量flag的值。

img

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 示例代码中有两个线程,主线程和main方法中启动的线程t1
*/
public class T {
/*volatile*/ boolean flag = true; //对比一下有无volatile的情况下,整个程序运行结果的区别

public void m() {
System.out.println("m start");
while(flag) {

}
System.out.println("m end!");
}

public void shutdown() {
flag = false;
}

public static void main(String[] args) {
T t = new T();

new Thread(t::m, "t1").start();

try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 主线程更改flag的值
shutdown();
}
}

以上代码开启了一个线程t1,该线程执行m方法,如果不加volatile关键字,在主线程中更改flag的值,并不会使循环停止,这是由于t1线程执行时使用的是来自主线程的拷贝,并不会在执行时访问主线程,而volatile关键词使得主线程对flag的修改对于线程t1来说是可见的,t1会刷新flag的值并结束循环。

使用场景

由于volatile变量只能保证可见性,在不符合以下两条规则的场景中,我们仍需要通过加锁(使用synchronizedjava.util.concurrent中的Atomic类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

前面举的列子就很适合使用volatile变量来控制并发,当shutdown()被调用时,能保证所有线程中的m()方法立即停止。

大多数情况下使用volatilesynchronized开销低,选择的唯一依据就是我们是否在保证可见性和有序性的基础上,还需要保证原子性。