指令重排序
我们知道cpu的执行速度非常快,而有些指令的硬件动作相较于cpu的执行速度是很慢的,为了提高cpu的利用率,在一些指令的硬件动作执行的过程中,cpu会去执行其他的指令。
比如,从内存中读取某个数据的执行,如果cpu在执行这条指令后,一直等待内存中的数据,对于cpu来说,这个过程太漫长了,所以,为了让cpu利用率更高,会让cpu去执行其他的指令,等内存数据读取完成后,再回过头来,执行这个数据相关的指令。
以上的现象叫做指令重排序,这种机制是为了提高cpu的利用率。当然,相互有依赖关系的指令是不会进行重排序的,不然代码有可能执行出错。
对象的创建过程有三个步骤:
- 在堆区分配内存空间
- 调用构造方法,初始化内存空间中的对象信息
- 将内存空间地址赋值给对象变量
但是,由于指令重排序,cpu不会等待内存空间初始化完成再执行其他指令,也是因为在堆区初始化内存空间所花费的时间对于cpu来说太漫长了,所以cpu向后执行将地址赋值给对象变量的操作。不过,这种现象也会引发一些问题,我们借助单例模式来讲解下:
public class MySingleInstance {
private static volatile MySingleInstance instance;
private MySingleInstance() {
}
public static MySingleInstance getInstance() {
if (null == instance) {
synchronized (MySingleInstance.class) {
if (null == instance) {
instance = new MySingleInstance();
}
}
}
return instance;
}
}
在getInstance方法中,创建对象之前有一个synchronized锁,只有一个线程可以进入synchronized代码块。当此线程执行创建对象的代码后,就会把锁释放,其他线程就可以进入了。
按照正常逻辑来说,第一个线程释放锁的时候,instance已经赋过值不为null了,所以,其他线程进入synchronized代码块也会因为不满足if条件而退出,如此一来,MySingleInstance对象就是单例的。但是,我们知道在创建对象的时候,可能会发生指令重排序,因此会产生特殊情况。
特殊情况是,对象创建步骤发生了指令重排序,即步骤2和3顺序调换执行了。当第一个线程释放锁后,此时,对象创建已经完成了堆区内存分配和将内存空间地址赋值给对象变量instance,但是,这块内存空间还没有初始化。而此时,另一个线程执行到第一个if判断时,发现instance不为null,那么就会拿到这个还未初始化完成的对象去使用,存在未知风险。
解决指令重排序
happens before
指令重排序虽然可以提高cpu的利用率,但是也会带来各种问题。所以,提出了happens before规则。这个规范就是规定了哪些场景下的指令必须是顺序执行的。
- 程序顺序规则:一个线程内,按照代码书写顺序执行。这条规则只能保证有依赖关系的代码一定是顺序执行的。没有依赖关系的代码,由于指令重排序机制,不一定顺序执行。就像上面单例模式的例子中,创建对象时,调用构造方法与地址赋值操作没有依赖关系,所以可能发生指令重排序。
- 管程锁定规则:一个解锁操作必然先执行于后面对同一个锁的加锁操作。
- volatile变量规则:对一个变量的写操作先执行于后面对同一变量的读操作。
- 线程启动规则:Thread对象的start方法先执行于后面对此线程的所有操作。
- 线程终结规则:线程中所有的操作先执行于后面线程的终止检测。我们可以通过Thread.join方法的结束以及Thread.isAlive方法的返回值检测到线程已经终止执行。
- 线程中断规则:对线程interrupt方法的调用先执行于后面被中断线程的代码检测到中断事件的发生。
- 对象终结规则:一个对象的初始化完成先执行于后面对象的finalize方法的开始。
- 传递性:如果操作A先执行于操作B,而操作B先执行于操作C,那么操作A先执行于操作C。
内存屏障
因为指令重排序在某些情况下会出现问题,所以我们想要防止指令重排序。
在JVM层面,除了happens before规则外,还可以通过内存屏障来防止指令重排序。 内存屏障有四种,分别是:
LoadLoad屏障:这个屏障前的所有读操作不能和屏障后的所有读操作进行重排序
LoadStore屏障:这个屏障前的所有读操作不能和屏障后的所有写操作进行重排序
StoreLoad屏障:这个屏障前的所有写操作不能和屏障后的所有读操作进行重排序
StoreStore屏障:这个屏障前的所有写操作不能和屏障后的所有写操作进行重排序
volatile的实现
volatile关键字可以防止指令重排序
其在字节码文件中的实现是在属性前加了一个ACC_VOLATILE修饰符
其在JVM层面的实现是,有关volatile关键字修饰的变量,其读写操作的前后都会加上内存屏障
例如:
LoadLoad屏障 volatile变量读操作 LoadStore屏障
StoreStore屏障 volatile变量写操作 StoreLoad屏障
在单例模式中,对象创建的三个步骤中,最后一步要完成对volatile变量的写操作。由于内存屏障,volatile变量写操作前加了StoreStore屏障,意味着前面的所有写操作都要执行完,才能执行volatile变量的写操作。而调用构造方法就涉及到写操作,所以调用构造方法执行于volatile变量写操作之前,因此,防止指令重排序。
volatile的内存屏障和happens before规则是防止指令重排序的重要手段,有些happens before无法约束的场景,volatile可以进行约束。
例如:
public class Test {
private static int a = 0;
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
a = 1;
flag = true;
});
Thread t2 = new Thread(() -> {
if (flag) {
System.out.println(a);
}
});
t1.start();
t2.start();
}
}
上述代码中,线程t1给a和flag赋值,线程t2执行当flag为true时,打印a的值。线程t1中,a和flag两个赋值操作是没有依赖关系的,所以happens before规则是不能约束这两个操作的执行顺序,可能发生指令重排序。
如果flag赋值操作在a之前执行,那么t2打印出来的a的值可能就是0了。此时,使用volatile修饰flag变量,在flag写操作前有个StoreStore屏障,就可以保证a赋值操作在flag之前进行,确保代码逻辑正常。
volatile对缓存一致性协议的影响
缓存一致性协议由于指令重排序,cpu在将数据写入内存前,先写入store buffer中,然后给其他cpu发送数据状态更新指令。其他cpu接收到指令后,不会立即执行,而是存入invalid queue队列中,然后立即返回响应。这可能会导致其他cpu读取到的是旧数据。
volatile的可见性,使得在volatile变量写操作的时候,将store buffer里的数据都写到内存中,确保其他cpu能够读到最新数据。而volatile变量读操作的时候,将invalid queue的指令都执行一边,将无效的数据标记出来,然后重新去内存中读取。
volatile无法保证线程安全
虽然volatile具有可见性,但依然无法保证线程安全。
因为volatile变量操作不是原子性和有序性的。
从内存中读取数据,将数据进行计算,然后将结果写回内存中,共有三个步骤。
由于volatile变量操作不满足原子性,存在一种情况:从内存中读取到最新数据后,有个中断过来了,此时cpu要去响应中断。要是中断的逻辑中将内存中数据的值修改了,那么等cpu回来,继续执行代码,对读取到的数据进行计算,此时,参与计算的数据已经成为旧数据了。这是不满足原子性导致的线程安全问题。
由于volatile变量操作不满足有序性,就算上述的三个步骤不会被中断,但是现在cpu都是多核的,存在一种情况是两个cpu同时在执行这三个步骤。就算cpu从内存中读取到最新的数据,进行后面的运算。但是,在此过程中,另一个cpu可能已经计算出新的数据写回内存了。这样依然存在线程安全问题。
synchronized的实现
其在字节码文件中有两种表现形式,如果synchronized修饰方法,那么会在方法上加一个ACC_SYNCHRONIZED修饰符;如果synchronized修饰一个代码块,那么会在代码块的字节码指令前加上monitorenter,代码块尾部加上monitorexit。这两个指令代码进入同步代码块和退出同步代码块。其实,认真观察字节码指令会发现有两个monitorexit,另一个的作用是在执行同步代码块发生异常时,退出代码块。
synchronized可以保证可见性。在进入synchronized同步代码块后,会从内存中重新读取数据,以确保读到的是最新数据。
synchronized可以保证原子性。在进入synchronized同步代码块后,执行代码的过程中不会被中断。
synchronized可以保证有序性。被同一把synchronized锁锁住的代码块,即使在多线程执行的情况下,都是串行执行的。所以,其中一个线程从内存中读取数据后,进行运算,再写回内存整个过程中,没有其他线程在和它同时操作数据,影响数据的时效性。