在前面的文章中,我们简要介绍了 Java
线程中的一些基本概念,包括 synchronized
锁。synchronized
锁属于重量级的锁,虽然在 JDK
版本迭代中性能不断得到优化,但是相对volatile
关键字成本还是略高。volatile
关键字不会引起线程上下文的切换和调度。
Java
语言规范 volatile
关键字定义
Java
编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
“为了确保共享变量能够准确和一致地更新”,这里就涉及到 Java
内存模型的一些基本概念。
1. Java内存模型基本概念
内存变量访问
Java
内存模型规定所有的变量都存储在主内存中。计算机运行程序时,每条指令都在 cpu
中完成,在运行过程中的数据读写就会涉及到与主内存数据读写的交互,如果这样每条指令都与主内存交互,这个效率会非常低,所以就有了 CPU
高速缓存。CPU
高速缓存为 CPU
独有,只与在该 CPU
运行的线程有关。
每个线程有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
但是这样也带来了多线程中的读取“脏数据”的问题。举个简单例子:
java
代码解读
复制代码
int a=1;
int i=1;
i += a;
在上面的代码中如果两个线程执行 i+=a
操作,运行的结果可能不是我们期望的 3
。这是因为运行的过程中可能存在这样一种情况:
初始时两个线程分别从主内存中读取到
i=1
缓存到自己的内存中,第一个线程执行完毕后,第二个线程中的缓存值i
还是1
,最后写入主内存的结果是2
。
出现这个问题的原因就需要了解并发编程的三大概念:原子性、可见性、有序性。
1. 原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子是世界上的最小单位,具有不可分割性。比如 a=0
;(a
非 long
和 double
类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++
; 这个操作实际是 a = a + 1
;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized
)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java
的 concurrent
包下提供了一些原子类,我们可以通过阅读 API
来了解这些原子类的用法。比如:AtomicInteger
、AtomicLong
、AtomicReference
等。
在 Java
中 synchronized
和在 lock
、unlock
中操作保证原子性。volatile
是无法保证复合操作的原子性
2. 可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。 比如:用 volatile
修饰的变量,就会具有可见性。volatile
修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile
只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0
;之后有一个操作 a++
;这个变量 a
具有可见性,但是 a++
依然是一个非原子操作,也就是这个操作同样存在线程安全问题。
在 Java
中 volatile
、synchronized
和 final
实现可见性。
3. 有序性
即程序执行的顺序按照代码的先后顺序执行。
举个例子:
java
代码解读
复制代码
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个 int
型变量,定义了一个 boolean
类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1
是在 语句2
前面的,那么 JVM
在真正执行这段代码的时候会保证 语句1
一定会在 语句2
前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder
)。
指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
Java
语言提供了 volatile
和 synchronized
两个关键字来保证线程之间操作的有序性,volatile
是因为其本身包含“禁止指令重排序”的语义,synchronized
是由“一个变量在同一个时刻只允许一条线程对其进行 lock
操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。
2. volatile 关键字解析
volatile
可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM
底层volatile
是采用“内存屏障”来实现的。
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile
修饰之后,那么就具备了两层语义:
- 保证不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
Java
语言提供了一种稍弱的同步机制,即 volatile
变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile
类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile
变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile
类型的变量时总会返回最新写入的值。
在访问 volatile
变量时不会执行加锁操作,所以不会造成线程阻塞,所以 volatile
变量是一种比 synchronized
关键字更轻量级的同步机制。
当对非 volatile
变量进行读写的时候,每个线程先从内存拷贝变量到 CPU
缓存中。如果计算机有多个 CPU
,每个线程可能在不同的 CPU
上被处理,这意味着每个线程可以拷贝到不同的 CPU cache
中。
而声明变量是 volatile
的,JVM
保证了每次读变量都从内存中读,跳过 CPU cache
这一步。
2.1 volatile 实现机制
“观察加入 volatile
关键字和没有加入 volatile
关键字时所生成的汇编代码发现,加入volatile
关键字时,会多出一个 lock
前缀指令”,lock
前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3
个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他
CPU
中对应的缓存行无效。
3.2 volatile 性能
volatile
的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
3. 使用 volatile 关键字的场景
synchronized
关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile
关键字在某些情况下性能要优于 synchronized
,但是要注意 volatile
关键字是无法替代 synchronized
关键字的,因为 volatile
关键字无法保证操作的原子性。通常来说,使用 volatile
必须具备以下 2
个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile
变量的这些有效值独立于任何程序的状态,包括变量的当前状态。事实上,我的理解就是上面的 2
个条件需要保证操作是原子性操作,才能保证使用volatile
关键字的程序在并发时能够正确执行。
3.1 场景一:状态标记量
java
代码解读
复制代码
volatile boolean flag = false;
//线程1
while(!flag){
doSomething();
}
//线程2
public void setFlag() {
flag = true;
}
根据状态标记,终止线程。
3.2 场景二:单例模式中的double check
java
代码解读
复制代码
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}