电子说
volatile
是一个轻量级的synchronized
,一般作用于 变量 ,在多处理器开发的过程中保证了内存的可见性。相比于synchronized
关键字,volatile
关键字的执行成本更低,效率更高。
并发编程的三大特性为可见性、有序性和原子性。通常来讲
volatile
可以保证可见性和有序性。
volatile
可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。volatile
会通过禁止指令重排序进而保证有序性。volatile
修饰的变量的读写是可以保证原子性的,但对于i++
这种复合操作并不能保证原子性。这句话的意思基本上就是说volatile
不具备原子性了。Java的内存模型如下图所示。
这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?,
该问题Java内存模型是通过synchronized
关键字和volatile
关键字就可以解决。
计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。具体可以看下面这个例子。
int a = 1;
int b = 2;
int a1 = a;
int b1 = b;
int a2 = a + a;
int b2 = b + b;
......
像这段代码,不断地交替读取a和b,会导致寄存器频繁交替存储a和b,使得代码性能下降,可对其进入如下重排序。
int a = 1;
int b = 2;
int a1 = a;
int a2 = a + a;
int b1 = b;
int b2 = b + b;
......
按照这样的顺序执行代码便可以避免交替读取a和b,这就是重排序的意义。
指令重排序一般分为编译器优化重排、指令并行重排和内存系统重排三种。
注:简单解释下数据依赖性:如果两个操作访问了同一个变量,并且这两个操作有一个是写操作,这两个操作之间就会存在数据依赖性,例如:
a = 1;
b = a;
如果对这两个操作的执行顺序进行重排序的话,那么结果就会出现问题。
其实,这三种指令重排说明了一个问题,就是指令重排在单线程下可以提高代码的性能,但在多线程下可以会出现一些问题。
前面已经说过了,在单线程程序中,重排序并不会影响程序的运行结果,而在多线程场景下就不一定了。可以看下面这个经典的例子,该示例出自《Java并发编程的艺术》。
class ReorderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1; // 操作1
flag = true; // 操作2
}
public void reader(){
if(flag){ // 操作3
int i = a + a; // 操作4
}
}
}
假设线程1先执行writer()
方法,随后线程2执行reader()
方法,最后程序一定会得到正确的结果吗?
答案是不一定的,如果代码按照下图的执行顺序执行代码则会出现问题。
操作1和操作2进行了重排序,线程1先执行flag=true
,然后线程2执行操作3和操作4,线程2执行操作4时不能正确读取到a
的值,导致最终程序运行结果出问题。这也说明了在多线程代码中,重排序会破坏多线程程序的语义。
区别:
相同点:happens-before和as-if-serial的作用都是在不改变程序执行结果的前提下,提高程序执行的并行度。
前面已经讲述
volatile
具备可见性和有序性两大特性,所以volatile
的实现原理也是围绕如何实现可见性和有序性展开的。
导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,例如上面所说线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?
volatile
可以保证内存可见性的关键是volatile
的读/写实现了缓存一致性,缓存一致性的主要内容为:
那缓存一致性是如何实现的呢?可以发现通过volatile
修饰的变量,生成汇编指令时会比普通的变量多出一个Lock指令,这个Lock指令就是volatile
关键字可以保证内存可见性的关键,它主要有两个作用:
前面提到重排序可以提高代码的执行效率,但在多线程程序中可以导致程序的运行结果不正确,那
volatile
是如何解决这一问题的呢?
为了实现volatile
的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。
Java内存模型把内存屏障分为4类,如下表所示:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 保证Load1数据的读取先于Load2及后续所有读取指令的执行 |
StoreStore Barriers | Store1;StoreStore;Store2 | 保证Store1数据刷新到主内存先于Store2及后续所有存储指令 |
LoadStore Barriers | Load1;LoadStore;Store2 | 保证Load1数据的读取先于Store2及后续的所有存储指令刷新到主内存 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 保证Store1数据刷新到主内存先于Load2及后续所有读取指令的执行 |
注:StoreLoad Barriers同时具备其他三个屏障的作用,它会使得该屏障之前的所有内存访问指令完成之后,才会执行该屏障之后的内存访问命令。
Java内存模型对编译器指定的volatile
重排序规则为:
volatile
读时,无论第二个操作是什么都不能进行重排序。volatile
写时,无论第一个操作是什么都不能进行重排序。volatile
写,第二个操作为volatile
读时,不能进行重排序。根据volatile
重排序规则,Java内存模型采取的是保守的屏障插入策略,volatile
写是在前面和后面分别插入内存屏障,volatile
读是在后面插入两个内存屏障,具体如下:
volatile
读:在每个volatile
读后面分别插入LoadLoad屏障及LoadStore屏障(根据volatile重排序规则第一条),如下图所示LoadLoad屏障的作用:禁止上面的所有普通读操作和上面的volatile
读操作进行重排序。
LoadStore屏障的作用:禁止下面的普通写和上面的volatile
读进行重排序。
volatile
写:在每个volatile
写前面插入一个StoreStore屏障(为满足volatile
重排序规则第二条),在每个volatile
写后面插入一个StoreLoad屏障(为满足voaltile
重排序规则第三条),如下图所示volatile
写重排序volatile
写与下面可能出现的volatile
读/写重排序。因为Java内存模型所采用的屏障插入策略比较保守,所以在实际的执行过程中,只要不改变
volatile
读/写的内存语义,编译器通常会省略一些不必要的内存屏障。
代码如下:
public class VolatileBarrierDemo{
int a;
volatile int b = 1;
volatile int c = 2;
public void test(){
int i = b; //volatile读
int j = c; //volatile读
a = i + j; //普通写
}
}
指令序列示意图如下:
从上图可以看出,通过指令优化一共省略了两个内存屏障(虚线表示),省略第一个内存屏障LoadStore的原因是最后的普通写不可能越过第二个volatile
读,省略第二个内存屏障LoadLoad的原因是下面没有涉及到普通读的操作。
volatile
只能保证可见性和有序性,但可以保证64位的long
型和double
型变量的原子性。
对于32位的虚拟机来说,每次原子读写都是32位的,会将long
和double
型变量拆分成两个32位的操作来执行,这样long
和double
型变量的读写就不能保证原子性了,而通过volatile
修饰的long
和double
型变量则可以保证其原子性。
volatile
主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。synchronized
主要是解决多个线程访问资源的同步性。volatile
作用于变量,synchronized
作用于代码块或者方法。volatile
仅可以保证数据的可见性,不能保证数据的原子性。synchronized
可以保证数据的可见性和原子性。volatile
不会造成线程的阻塞,synchronized
会造成线程的阻塞。全部0条评论
快来发表一下你的评论吧 !