JMM
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。 其实我们的JAVA程序的执行在内存中是通过一条条指令(编译成字节码)来完成的,而且一行代码往往对应着一到多条指令。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
首先需要明确的一点是:这三个特性是针对多线程而言,单个线程的执行不存在任何问题。
我们都知道volatile可以保证可见性
和有序性
;而synchronized最重要的功能就是保证原子性
,它也可以保证可见性
,并且在一定条件下也可以保证有序性
,笼统的讲synchronized可以保证有序性
是不准确的,稍后会解释。下面我将从JMM的角度解释一下volatile和synchronized的原理。
原子性
synchronized保证原子性是通过给对象加锁实现的,它实质上是保证了对同一对象加锁的多个线程中临界区代码
的“同步”。
简单粗暴的理解就是同一时间只能有一个线程对同一对象加锁,只有加了锁才有权利执行临界区的代码
,当发生线程上下文切换时,加锁线程并不会释放锁,这就会使后来竞争锁的线程陷入阻塞状态,然后再切换回来继续将加锁线程的临界区代码执行完,所以在线程的角度,一段临界区代码就是一个原子操作
。
可见性
退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
public class TestVisible {
static boolean run = true;
public static void main(String[] args) {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
}
为什么呢?分析一下:
- 初始状态, t 线程刚开始从主内存读取了 run 的值到
工作内存
。 - 因为 t 线程要频繁(如果仅读取几次,不会缓存)从
主内存
中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存
中的高速缓存
中,减少对主存中 run 的访问,提高效率 - 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
看来是JVM好心办了坏事,怎么解决这个问题哪?volatile
和synchronized
都可以。
volatile原理
解决上述问题:
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
class TestVisibleV {
static volatile boolean run = true;
public static void main(String[] args) {
Thread t = new Thread(()->{
// --------------读屏障------------------
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
// --------------写屏障------------------
}
}
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量执行写指令后会加入写屏障,写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 对 volatile 变量执行读指令前会加入读屏障,而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
用volatile修饰共享变量后
- 当多个线程对共享变量进行
写
操作后,会在此处建立一个写屏障
,保证写屏障之前所有对共享变量的改动都同步到内存中,例如主线程t修改run后,会同步到内存中。 - 当多个线程对共享变量进行
读
操作前,会在此处建立一个读屏障
,保证读屏障之后的所有共享变量的读取加载的是主存中最新数据,例如线程t读取run时,读取的都是内存中的最新数据。
synchronized原理
解决上述问题:
class TestVisibleS1 {
static boolean run = true;
public static void main(String[] args) {
Thread t = new Thread(()->{
synchronized (TestVisibleS1.class) {
while(run){
try {
TestVisibleS1.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
// ....
}
}
});
t.start();
sleep(1);
synchronized (TestVisibleS1.class) {
run = false; // 线程t可以停下来
TestVisibleS1.class.notify();
}
}
}
首先主线程sleep,t先获得锁,因为run为true,会进入while等待,此时释放锁,然后主线程获得锁,并改变run,唤醒t,释放锁的同时提交对run的更改。最后wait结束,重新加锁获得run的最新值为false,退出循环,线程死亡。
synchronized
实现可见性的原理与volatile
略有不同
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
其实synchronized是以临界区为单位与主内存中的共享变量进行交互的。注意一点,线程新建时会从主内存加载数据,线程死亡时也会自动提交更改。这类似于加锁和解锁时对于共享变量的操作。
有序性
指令重排序优化
实际上在CPU中,在不改变程序结果的前提下,有些指令的各个阶段可以通过重排序和组合来实现指令级并行。
例如:
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
显然指令重排在单线程的环境下没有任何问题,不会影响结果,但在多线程下却有隐患,例如:
boolean ready = false;
int num = 0;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
其实还有一种情况:
- 情况4:线程2 先执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
volatile原理
解决方法:volatile 修饰的变量,可以通过读屏障
和写屏障
保证指令重排在多线程环境下的正确性(一些教程里说“禁止指令重排”是不严谨的,我稍后会介绍)。
写屏障
会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后读屏障
会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
注意:保证写屏障
之前的代码所对应的指令不会重排序到写屏障之后,并不是禁止重排序,写屏障
之前的代码所对应的指令还是可以重排序的。
volatile boolean ready = false;
int num = 0;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
// --------------读屏障------------------
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
// --------------写屏障------------------
ready = true;
}
从上例可以看出,对ready加上volatile后,线程2对ready的写操作一定在最后。
注意,volatile不能解决指令交错,指令交错必须由synchronized解决(原子性):
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
synchronized原理
前面讲synchronized在一定条件下可以保证有序性
,这个条件是什么哪?就是必须把共享变量的所有读写操作都包含在临界区内。
仔细一想就会明白为什么了,其实跟读写屏障的作用差不多,synchronized的临界区其实就是一个屏障,它可以保证临界区内代码的指令的有效性,即使在临界区内产生了指令的重排,但期间并没有其他线程可以访问临界区内所使用的共享变量(因为被锁住了),所以可以保证有序性。
下面这个例子很好的体现了上述的诸多特性。
应用之double-checked locking 问题
如果是下面这样的设计:
final class Singleton {
// 私有构造方法
private Singleton() {
//...
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) { // t1,t2线程可能同时进入,创建多个实例
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
我们知道上面的代码有很大的问题,不能保证多线程安全,可能多个线程同时进入,创建了多个实例,所以要在getInstance
加上synchronized
,因为把锁加在静态方法上等价于把锁加在类对象上,所以可以写成下面这种形式:
final class Singleton {
// 私有构造方法
private Singleton() {
//...
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
}
这样不存在线程安全问题了,但是也不好,因为每次调用getInstance
方法都要尝试加锁,会影响程序的性能,,我们改一下,尝试将锁的范围缩小,能不能做到只在第一次调用的时候加锁,有人想到了,就是dcl模式,也就是两次检查:
final class Singleton {
// 私有构造方法
private Singleton() {
//...
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null ) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
但是上面的看似完美的代码真的没有问题吗,可能有人说没有问题啊,synchronized可以保证原子性、可见性和有序性啊,别忘了我们之前说的有序性的条件,这里满足吗?
没错,上面的代码并不能满足有序性,因为第一个if (INSTANCE == null )
涉及到对共享变量的读操作,但并不在synchronized里。
存在这样一种特殊情况,线程1第一个调用了getInstance
方法,并对类对象加了锁,但它内部临界区代码进行了指令重排,先对INSTANCE
赋了值(对象的引用地址),然后再调用构造方法;此时线程2也调用了getInstance
方法,很显然INSTANCE
不是null,线程2就直接返回了一个尚未进行构造的对象,并且很可能开始使用了,这就产生了违反有序性的问题。
解决办法也很简单,在INSTANCE
变量前加上volatile
即可。
final class Singleton {
// 私有构造方法
private Singleton() {
//...
}
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null ) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}