要深刻理解volatile这个关键字的用法及作用,需要补充以下知识:
1、 内存访问操作/指令执行操作的乱序:假设每个CPU都分别运行着一个会触发内存访问操作的程序。那么对于这样一个CPU,其内存访问顺序是非常松散的,在保证程序上下文逻辑关系的前提下,CPU可能乱序执行内存操作。此外,编译器也可以将它输出的指令安排成任何它喜欢的顺序,只要保证不影响程序表面的执行逻辑。这里就涉及到了两次可能发生指令重排的情况:一个是编译的时候,由编译原理的知识知道,编译器会对代码进行优化,这一步就涉及到指令重排,当然,编译完成之后的目标代码中指令的顺序就是确定的,不同线程执行该代码的顺序是一样的;另一个就是CPU在执行具体的指令的时候,也会因为计算机当前的状态(比如寄存器的占用情况、ALU的使用情况,cup缓存层的存在等原因)的不同导致指令最终的执行顺序发生变化(实际上,cpu本身并不会对指令进行重排,它本身是按照编译后的顺序来执行指令的,只是由于执行不同的指令需要的时间长短不同,以及缓存层的存在,再加上CPU执行指令的流水线并不是串行化等因素,那么就有可能出现排在靠前位置的指令还没执行完,而排在靠后的指令已经执行完了的情况,这一情况就是所谓的CPU执行指令的乱序,具体原因后面会更详细地解释),尽管这个变化可能不影响最终结果的正确性。
2、 CPU指令执行乱序的原因:现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取址,译码,访存,执行,写回等若干个阶段。又因为指令流水线并不是串行化的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”前的阶段上。相反,流水线中的多个指令是可以同时处于同一个阶段的,只要CPU内部相应的处理部件未被占满。比如说CPU只有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段,而两条加法指令在“执行”阶段就只能串行工作。这样一来,乱序就可能产生了。比如一条加法指令出现在一条除法指令的后面,但由于除法的执行时间很长,在它执行完之前,加法可能就先执行完了。再比如两条访存指令,可能由于第二条指令中了cache(或其它原因)而导致它先于第一条指令完成。
3、 CPU执行指令乱序进一步说明:一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序,CPU总是顺序地去内存里取指令,然后将其顺序地放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。指令流水线除了在资源不足的情况下会卡住之外(如前所述的一个加法器应付两条加法指令),指令之间存在的相互依赖才是导致流水线阻塞的主要原因。当然,CPU的乱序执行并不是任意地乱序,而必须保证上下文依赖逻辑的正确性。比如:a++;b=f(a);由于b=f(a)这条指令依赖于第一条指令(a++)的执行结果,所以b=f(a);将在“执行”阶段之前被阻塞,直到a++的执行结果被生成出来。
另外一个CPU执行乱序的示例如下:
对于处理器A和处理器B都是按顺序分别执行A1和A2,以及B1和B2指令。以处理器A为例,为了提高执行效率,处理器执行A1只会将a=1写到缓冲区,紧接着就回执行A2指令,然后在适当的时候,才会将缓冲区中的数据回写主内存,即A3操作。所以尽管从处理器A的角度来看,执行顺序是A1->A2,但从内存操作实际发生的顺序来看确是A2->A1。因为A1操作只写了缓冲区,实际上直到处理器A执行完A3将缓冲区刷新到主存后,写操作A1才算真正执行完成。从这个角度也可以看出前面提到的,cpu不会刻意调整指令执行顺序,它本身是按照编译后的顺序来执行指令的,只是由于执行不同的指令需要的时间长短不同,以及缓存层的存在,再加上CPU执行指令的流水线并不是串行化的等因素导致cpu的最终执行效果是“顺序流入,乱序流出”。
4、 编译器指令重排(代码优化)的原理:如果两条有依赖关系(像刚刚列举的a++;b=f(a);)的指令挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久(这个“很久”是对计算机而言哈)。而编译器的乱序,作为编译优化的一种手段,则试图通过指令重排,在这两条指令之间插入其他指令,将这两条指令拉开一定的距离,以保证后一条指令执行的时候前一条指令结果已经得到了,那么也就不需要阻塞等待了。所以相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整,但是编译器所进行的调整也必须保证上下文的依赖逻辑,即存在依赖关系的指令顺序不能调整。
5、 内存屏障:正如上面所说,不存在依赖关系的内存操作会被按随机顺序有效得到执行,但这在CPU与CPU(多核)交互时或CPU与IO设备交互(一般IO比较耗时)时,这可能成为问题。我们需要一些手段来干预编译器和CPU对指令顺序的影响,而内存屏障就是这样的干预手段。它们能保证处于屏障两边的内存操作满足部分有序(“部分有序”的意思是,内存屏障之前的操作都会先于屏障之后的操作,但是如果几个操作出现在屏障的一边,则不保证它们有序)。这样的强制措施是非常重要的,因为系统中的CPU和其它设备可以使用各种各样的策略来提高性能,包括对内存操作的乱序、延迟和合并执行、预取、投机性的分支预测和各种缓存……内存屏障就是用于禁用或者抑制这些策略,使代码能够清楚地控制多个CPU和/或设备的交互。操心系统中存在各式各样的内存屏障,不同的内存屏障涉及到了各种复杂的实现,这里不过多地讲了,但关于内存屏障还要记住的一点就是:在内存屏障之前出现的内存访问不保证在内存屏障指令完成之前完成,内存屏障相当于在该CPU的访问队列中画了一条线,使得相关访存类型的请求不能相互跨越(用于实现内存屏障的指令,其本身并不作为参考对象,其两边的访存操作才被当作参考对象,所以屏障指令执行完成并不表示出现在屏障之前的所有访存操作都已经完成,但如果屏障之后的某一个访存操作已经完成,则屏障指令之前的所有访存操作必定都已经完成了)。
上面介绍了这么多,有人可能要纳闷了,这和volatile有什么关系呢?先别慌,背景知识介绍得还不够,还得继续介绍:
6、 并发编程中的三个概念:原子性、可见性和有序性。
7、 原子性:即一个操作或者多个操作要么全部执行并且执行的过程中不会被任何因素打断,要么就都不执行。比如在处理64位的数据时,会先后给高4个字节和低4个字节赋值,如果这两个操作不具有原子性,那么就可能导致最后的结果出错。
8、 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其它线程能够立即看得见修改的值。我们假设有多个线程并发访问进程中的同一个变量,如果一个线程对该变量的修改不能立即被另一个线程知道,那么另一个线程还是对修改前的值进行操作,那么最后得到的结果必然跟预期的结果不一样。
9、 有序性:即程序执行的顺序按照代码的先后顺序执行。由前面的讨论知道,指令的执行存在乱序现象,那么乱序现象尽管不会影响单个线程的正确执行,但却会影响多个线程并发执行。
到这里我们可以知道,要想并发程序正确地执行,必须要保证程序的原子性、可见性以及有序性。只要有一个没有被保证,就可能会导致程序运行不正确。下面介绍Java内存模型为我们提供了哪些保证以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序等问题。
10、 Java原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。注意:Java内存模型只保证了基本读取和赋值(像x=1;是原子性操作,而y=x;不是原子性操作,因为它实际上包含了两个原子性操作:第一,读取x的值;第二,将x的值写入cpu缓冲区)是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
11、 Java可见性:对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
12、 Java有序性:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在Java里面,可以通过volatile关键字来保证一定程度的“有序性”(具体原理后面会进一步介绍)。
13、 volatile的原理和实现机制(下面这段话摘自《深入理解Java虚拟机》):
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。lock前缀指令实际上相当于一个内存屏障(也称内存栅栏,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用),内存屏障会提供3个功能:
1)、它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;
2)、它会强制将对缓存的修改操作(操作完成后)立即写入主存;
3)、如果是写操作,它会导致其他CPU中对应的缓存行无效。
到这里我们可以看到volatile关键字的作用主要有两个,即保证程序的“可见性”和一定程度的“有序性”。那么它具体是怎么做到的呢?还是以刚刚的示例来介绍:
在这个示例中,我们假设变量a和b都被volatile修饰,那么处理器A和处理器B在执行A1和B1操作时,对于处理器A来说,一定是执行完A1和A3,然后才去执行A2;对于处理器B来说,则是执行完B1和B3,然后才执行B2。此外,处理器A和B分别在执行完A3和B3之后,立即将让其它处理器中对a和b的值失效。当有其他线程需要读取该变量时,检测到缓存行无效后,它会去主存中读取新值。
另外,在Java里面,volatile关键字还能保证一定的“有序性”。volatile的底层原理就是用内存屏障来实现的。
注意:虽然volatile可以保证程序的可见性,但实际上可见性并不能保证线程同步,主要原因是很多指令不具有原子性,也就是说volatile不能完全替代synchronized和lock。具体原因看下面的例子:
public class Test{
public volatile int inc = 0;
public void increase(){
inc++;
System.out.println(inc);
}
}
现在假设有两个线程1和线程2并发调用increase()函数。我们知道,“inc++;”这条指令实际上对应了三个子操作:读取、自增和写入缓冲区+主存并使缓存失效,而且这三个子操作中,只有读取操作会在执行前去检查缓存行是否有效。
整个执行过程可能会出现如下情形:线程1读取到inc的值之后就被阻塞了,然后线程2运行,线程2也去读取变量inc的值,由于线程1只是对变量inc进行读取操作,并没有对变量进行修改操作,所以线程2读取到的值为0。然后线程2执行完自增操作,并立即被写回主存。这时线程2被阻塞了,线程1恢复执行。这时因为线程1的自增指令只执行了一步,所以它会继续执行自增动作,然后写入缓冲区,最后写回主存。线程1和线程2操作完之后inc的值为1,而不是预想的2。
这里你可能会疑惑,你会觉得根据前面的说法,当线程2完成自增操作并写回主存后不是会使得其他线程中对该变量的缓存无效么,那为什么线程1没有去重新读取呢?你可以这样理解:线程1的自增操作指令“inc++;”虽然对应了三个子操作,但是这三个子操作是被当作一个整体来执行的,也就是说,在当前假设的情形下,线程2对inc变量的修改操作只能影响线程1中“inc++;”这条指令后面的指令,因为这条指令的读取子操作已经完成,后面两个子操作是不会检查缓存行是否有效的,所以不受影响。为了更清晰地理解这个过程,我们再看下面一个例子:
public class Test{
public volatile int inc = 0;
public void increase(){
inc++;
System.out.println(inc);
}
public void decrease(){
inc--;
System.out.println(inc);
}
}
这个例子中线程1调用increase()函数,线程2调用decrease()函数。假设线程1读取到了inc的值之后被阻塞了,线程2调用decrease()方法,执行完“inc--;”指令之后也被阻塞了,这个时候线程2中inc变量的副本的值为-1。然后和上面例子的情况一样,线程1继续执行自增和写入缓冲区+主存两个子操作,然后将最后的结果1写回主存并使得线程2中对inc的缓存行无效。然后线程1继续执行输出语句,输出为1。接着是线程2执行输出语句,这时因为检测到inc缓存无效,所以它会重新去主存中读取新的值并输出,所以最后的输出为1。
总结:到这里我们可以发现,volatile只能保证程序的“可见性”和一定程度的“有序性”,但相比之下,反而是一定程度的“有序性”显得更加重要,因为volatile对那些不能改变执行顺序的指令进行了限定,使得这些会影响程序正确性的“关键”指令得以按照特定的顺序执行,从而在一定程度上保证了程序运行的正确性,而那些位于两个内存屏蔽指令之间的指令因为其顺序的改变不影响程序的正确执行,因而不必理会。
补充:这个问题涉及到Java的内存模型,甚至更进一步地说,涉及到计算机硬件执行机器指令的原理,所以强烈建议阅读下面几篇博文:
1、http://blog.chinaunix.net/uid/9918720.html
2、http://blog.csdn.net/jiang_bing/article/details/8629425
3、http://www.cnblogs.com/dolphin0520/p/3920373.html
4、https://www.jianshu.com/p/6745203ae1fe 关于volatile、MESI、内存屏障、#Lock
5、https://www.cnblogs.com/xrq730/p/7048693.html 就是要你懂Java中volatile关键字实现原理
6、https://blog.csdn.net/bytxl/article/details/50275377 cache为什么分为i-cache和d-cache以及Cache的层次设计
7、https://www.cnblogs.com/god-of-death/p/7852394.html C/C++ Volatile关键词深度剖析
8、https://zhuanlan.zhihu.com/p/53795411 volatile和CAS,看完这篇没人能难住你
9、https://www.jianshu.com/p/ef8de88b1343 并发关键字volatile(重排序和内存屏障)
10、https://blog.csdn.net/dashuniuniu/article/details/50347149 基于栈的虚拟机 VS 基于寄存器的虚拟机 重要
11、https://www.cnblogs.com/yanl55555/p/13334713.html?utm_source=tuicool JVM执行引擎
12、https://blog.csdn.net/vtopqx/article/details/78364685 计算机内存模型概念
13、https://mp.weixin.qq.com/s/rXdd7zEJxY4SBSSAg5Dw3w 深入理解JVM字节码执行引擎
14、https://www.shangmayuan.com/a/1abadbba31fb4b3c89c485e7.html CPU的三种虚拟化机制
15、https://zhuanlan.zhihu.com/p/69629212 虚拟化技术
16、https://zhuanlan.zhihu.com/p/69625751 虚拟化技术 - CPU虚拟化
17、https://blog.csdn.net/weixin_42073629/article/details/104742620 理解CPU高速缓存的工作原理
18、https://www.cnblogs.com/icanth/archive/2012/06/10/2544300.html LINUX内核之内存屏障
19、https://ifeve.com/linux-memory-barriers/ Linux内核的内存屏障
20、https://mos86.com/70306.html CPU缓存何时刷新回主内存:缓存只有在缓存控制器无法将新的缓存块放在已占用的空间中时,才会被刷新回主内存。先前占用空间的块被删除,其值被写回主存储器。