楔子
小七今天在看某篇博客的时候,发现了下面这段生成单例的代码,粗看没有什么问题,但是却有一个隐藏的大坑,共享变量instance没有加 volatile关键字修饰,有一定几率造成我们获取到多个对象,代码如下:
public class SingleInstance{
private static SingleInstance instance = null;
private SingleInstance(){}
public static SingleInstance getInstance(){
if(instance == null){
synchronized(SingleInstance.class){
if(instance == null){
instance = new SingleInstance();
}
}
}
return instance;
}
}
从多线程的使用说起
首先我们执行以下代码(代码一)
public class Test {
static int flag = 0;
public static void main(String[] args) {
// 线程一
new Thread(() -> {
int localFlag = flag;
while(true) {
//
if(localFlag != flag) {
System.out.println("读取到了修改后的标志位:" + flag);
localFlag = flag;
}
}
}).start();
// 线程2
new Thread(() -> {
int localFlag = flag;
while(true) {
System.out.println("标志位被修改为了:" + ++localFlag);
flag = localFlag;
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
执行结果:
从以上结果我们可以知道,线程一只有在刚启动的时候,感知到了标志位的修改,后面再也没有感知到标志位的变动。为什么会有这种情况发生呢?这就不得不谈Java的线程模型了。
Java内存模型
在讨论Java内存模型之前,我们先明确几个概念
1、本地内存:每个线程自己的内存,存放了共享变量的副本,每一个线程都会优先从本地内存里面取值使用。
2、主内存:存放了很多共享变量。
3、操作:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
JMM流程图
这样的内存模型会有什么问题呢?因为优先使用自己的本地内存,那么多个线程之间其实是隔离开来的,也就是相互之间并不可见,所以我们执行代码一的时候,才会出现一个线程修改了共享变量,另外一个线程是完全不知道的。
volatile是如何保证可见性的
通过Java内存模型我们可以知道,线程之间不可见的原因主要是因为,他们自身的本地内存是相互不可见的,但是如果他们都能从主内存中同步数据是不是就解决这个问题了呢?
如果加了volatile关键字的话,那么内存的执行大概会走以下几步:
1、线程一assign之后,会立马执行store + write,刷回到主内存里去;
2、线程二中的本地内存会立马过期;
3、线程二从自己的内存中拿取数据,如果发现过期了,就会从主内存中同步数据;
动图如下:
MESI缓存一致性协议
什么是MESI缓存一致性协议呢?其实这个涉及到CPU多级缓存模型,模型如下
大家看这个是不是和Java内存模型很像?大家也的确可以这么去理解,Java内存模型就是CPU缓存模型的升级,为我们屏蔽掉了一些操作系统的底层而已。
如果加了volatile关键字的话,各个CPU都会对主内存进行嗅探,如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上的线程在读取那个变量的时候,就会重新从主内存加载最新的数据了。
lock指令
对volatile修饰的变量,如果我们执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,然后执行缓存一致性协议,最终实现各个线程之间的可见性。
volatile是如何保证有序性的
happens-before原则
JMM为了满足编译器和处理器的约束尽可能少,它遵循的规则是:只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化,也就是我们常听说的指令重排。但是JMM也不可能拿着我们的代码乱排,他会遵循一些规则。
程序顺序规则
一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
监视器锁规则
拿synchronized举例,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。也就是他这个加锁解锁是不会反过来的。
volatile变量规则
对一个变量的写操作先行发生于后面对这个变量的读操作,也就是说volatile变量写,再是读,必须保证是先写,再读。
传递规则
如果A happens-before B,且B happens-before C,那么A happens-before C。
线程启动规则
Thread对象的start()方法先行发生于此线程的每个一个动作。
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
线程终结规则
线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
对象终结规则
一个对象的初始化完成先行发生于他的finalize()方法的开始。
happens-before规则有很多,但是我们只需要知道指令重排是有规则的即可,他们大多是符合我们现实中的逻辑的。
内存屏障
前面我们讲了happens-before原则,内存屏障可以看做是他具体的某些实现。
对于volatile修饰的变量,都会加入内存屏障,有了这些屏障,就不会执行指令重排。
指令重排代码示例
public class ReorderTest {
private static int a = 0;
private static int b = 0;
private static int x = 0;
private static int y = 0;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
for(;;) {
i++;
a=0;b=0;x=0;y=0;
//创建2个CyclicBarrier对象,执行完后执行当前类的run方法
CyclicBarrier cb = new CyclicBarrier(2);
Thread t1 = new Thread(new Runnable() {
public void run() {
try {
cb.await();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
a=1;
y=b;
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
try {
cb.await();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
b=1;
x=a;
}
});
t1.start();
t2.start();
//t.join()方法只会使主线程(或者说调用t.join()的线程)
//进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程。
t1.join();
t2.join();
String result = "第"+i+"次执行结果x="+x+";y="+y;
if(x==0&&y==0) {
System.out.println("发生了指令重排");
System.out.println(result);
break;
}else {
System.out.println(result);
}
}
}
}
结果
volatile为什么不保证原子性
我们先来看下面这个现象,假设线程1和线程2中都存在i++操作,代码如下
public class Test {
static volatile int flag = 0;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 10; i++) {
flag++;
}
}).start();
new Thread(() -> {
for (int i = 0; i < 5; i++) {
flag++;
}
}).start();
System.out.println(flag);
}
}
结果
如果volatile保证原子性的话,flag应该为15才对,但是最终结果却为10。这是因为i++的计算,与缓存的同步,其实可以看做是两个步骤。举个例子,在线程1刷回主存的时候,线程2已经计算出结果了,那么就算这个时候线程2的本地内存过期了,又有什么意思呢?
再次回到多线程的使用
这次让我们为flag添加关键字volatile之后,运行代码
public class Test {
static volatile int flag = 0;
public static void main(String[] args) {
new Thread(() -> {
int localFlag = flag;
while(true) {
if(localFlag != flag) {
System.out.println("线程一===>读取到了修改后的标志位:" + flag);
localFlag = flag;
}
}
}).start();
new Thread(() -> {
int localFlag = flag;
while(true) {
System.out.println("线程二===>标志位被修改为了:" + ++localFlag);
flag = localFlag;
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
运行结果
由以上结果我们可以可知两个线程之间彼此可见了。