一、什么是线程安全
多个线程不管以何种方式访问共享变量,并且不需要进行同步,都能表现正确的行为。这就是线程安全。
呃,这和 JMM(Java Memory Model)有什么联系呢?
这里就需要知道为什么会产生线程不安全。
发生线程不安全的本质实际上是主内存和工作内存中的数据不一致,或者发生了重排序所导致。
什么是主内存,什么是工作内存,什么又是重排序呢?这就牵涉到 JMM 了。
二、Java 内存模型——JMM
Java 内存模型(Java Memory Model,JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例变量、静态变量和构成数组对象的元素)的访问方式。
需要注意的是别把 JMM 和 JVM 搞混了,JVM 是 Java 虚拟机(Java Virtual Machine)。
JMM 定义了每个线程和主内存之间的抽象关系:线程之间的共享变量存储在“主内存”中,而每个线程都有一个私有的“工作内存”,工作内存中存储了该线程以读/写共享变量的副本。
上图描述了一个多线程执行场景。
线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。
JMM 规定:线程不能直接读写主内存的共享变量,每个线程都有自己私有的工作内存,线程需要读写主内存的共享变量时,首先需要将该变量拷贝一份副本到自己工作内存,然后在自己的工作内存中对该变量进行所有操作,完成操作之后再将结果同步至主内存。
这么说起来有点抽象,下面举一个例子。
例如 2 条线程,循环 1000 次,分别给 x 值加 1,若不加任何处理,其最终结果很可能会小于 2000。
A、B 线程分别读取主内存中 x 的值,并把 x 的值复制到自己的工作内存中,此时,A、B 线程的工作内存的值都是 0。
然后,每条线程对自己工作内存中的值进行自加操作,操作完成之后再写回主内存,这时候就出问题了。站在线程 A、B 的角度看是没问题的,但是站在主内存的角度看,结果就不对了。A、B 线程只把自己的值写回主内存,而没有考虑到其他线程在该期间内也对主内存中的值做了修改。这就是线程不安全的原因之一。
三、volatile
为了解决上述问题,volatile 关键字就闪亮登场了。
被 volatile 关键字描述变量的操作具有可见性和有序性(禁止指令重排)。
1、可见性
针对 volatile 修饰的变量 Java 虚拟机有特殊的约定:
-
当一条线程读取被 volatile 修饰的变量时,JMM 会把该线程的工作内存中对应的变量值置为无效,必须从主内存中读取;
-
当一条线程写入被 volatile 修饰的变量时,JMM 会把该线程的工作内存中对应的值立即刷新到主内存;
从而避免出现数据脏读的现象,保证数据的“可见性”。
2、有序性
在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。那么什么情况下,不能进行重排序了?下面就来说说数据依赖性。有如下代码:
1 | double pi = 3.14 // A |
这是一个计算圆面积的代码,由于 A、B 两句之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。
因此可以执行顺序可以是 A->B->C 或者 B->A->C 执行最终结果都是3.14,即 A 和 B 之间没有数据依赖性。但是 C 一定在 A、B 执行完之后才能执行。因此,重排序有以下两个特点:
-
重排序操作不会对存在数据依赖关系的操作进行重排序。
-
重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
例如下面的代码,A 线程执行changeStatus()
,B 线程执行run()
,能保证输出一定等于 3 吗?
不一定,因为 1 和 2 之间不存在数据依赖关系,因此编译器和处理器可能会对指令进行重排序,若 A 线程先执行了 2,还未执行 1,此时 B 线程执行了 3 和 4,这就会时输出的值等于 2。
1 | public class TestVolatile { |
使用 volatile 关键字修饰共享变量便可以禁止这种重排序。若用 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
3、不保证原子性
在访问 volatile 变量时不会执行加锁操作,也就不会使执行线程阻塞,因此 volatile 是一种比 sychronized 关键字更轻量级的同步机制,可以保证可见性,保证不被重排序,但是,不能保证线程安全,也不保证原子性。
为什么 volatile 不能保证原子性?
以 i++ 为例,其包括读取、操作、赋值三个操作,下面是两个线程的操作顺序。
假如说线程 A 在做了 i+1,但未赋值的时候,线程B就开始读取 i,那么当线程 A 赋值 i=1,并回写到主内存,而此时线程 B 已经不再需要 i 的值了,而是直接交给处理器去做 +1 的操作,于是当线程 B 执行完并回写到主内存,i 的值仍然是 1,而不是预期的 2。也就是说,volatile 缩短了普通变量在不同线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。
四、总结
所以,结论是 volatile 是一种轻量级的同步机制,可以保证共享变量对所有线程的可见性,禁止指令重排序优化,但不保证原子性,像 num++ 这种复合操作,volatile 无法保证其原子性。
当然,像 num++ 这种操作可以通过 CAS 的方式来保证原子性。
如想保证线程安全和原子性,还是需要使用锁。锁可以保证可见性、有序性、原子性。