volatile
我们都知道volatile可以解决线程间变量可见性问题。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
使用hsdis工具打印出汇编指令,可以发现,加了volatile修饰之后打印出来的汇编指令多了下面一行:
lock是一种控制指令,在多处理器环境下,lock汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的效果。
CPU处理器
在了解volatile之前,我们需要先了解下线程的相关知识。
线程是CPU调度的最小单元,线程设计的目的是为了更充分的发挥CPU的处理能力。CPU在与内存中的数据进行交互的时候,需要读取数据、存储结果等I/O操作,然而I/O操作都是比较耗时的操作。CPU的运算速度是最快的,为了能够快速的运算,CPU增加了缓存,先将内存中的数据加载到缓存,这样CPU在运算的时候,就不需要与内存进行交互,这样CPU才能快速的进行运算。
一般CPU有L1、L2、L3三级缓存,大致结构如下:
L1和L2缓存都为各个CPU内核独有的高速缓存,每个CPU内核先将需要用到的数据缓存在CPU高速缓存中,CPU直接从高速缓存中读取数据进行运算,运算完成后再写入高速缓存中,最后把高速缓存中的数据同步到主内存中。
如今电脑的CPU都有多个内核,每个线程可能会运行在不同的CPU内核中,由于每个CPU内核的高速缓存是独立的,不同内核中的高速缓存都是从内存中缓存数据,这样多个线程经过数据处理后,内核中的高速缓存中的数据可能会出现不一致的情况,也就是缓存不一致问题。
为了解决缓存不一致问题,CPU提供了两种解决办法:
- 总线锁
- 缓存锁
总线锁
总线锁就是将内核与主内存的交互中增加一把锁,在多内核CPU下,当其中一个内核要对主内存中的共享数据进行操作的时候,发出一个LOCK信号,这样其它内核就无法访问到主内存中的共享数据。
总线锁把内核与主内存之间的通信锁住了,在锁定期间,其他内核不能操作其主内存对应的共享数据,我们知道锁的对性能的影响是比较大的,所以CPU引入了另外一种方式:缓存锁。
MESI(Modify Exclusive Shared Invalid)
缓存锁是利用缓存一致性协议来实现的,一个内核的缓存回写到主内存时,会导致其他内核中的缓存失效。
MESI是一种比较常用的缓存一致性协议,MESI表示缓存行的四种状态:
- M(Modify):主内存中的共享数据只存在在当前内核的缓存中,并且缓存中的数据被修改。
- E(Exclusive):主内存中的共享数据只存在当前内核缓存中,并且没有被修改。
- S(Shared):主内存中的共享数据存在多个内核缓存中,并且没有做过修改。
- I(Invalid):内核中的缓存失效。
在MESI协议中,每个内核的缓存不仅要进行读写操作,还需要监听其它CPU内核的读写操作:
- CPU读请求:缓存处于M、E、S状态都可以被读取,在I状态时,CPU内核只能从主内存中读取数据。
- CPU写请求:缓存处于M、E状态才可以被写,对于S状态的写,需要将其他CPU内核缓存置为无效。
由于上面锁机制的存在,CPU内核对于主内存数据的操作流程如下:
MESI协议带来的问题
MESI协议就是各CPU内核缓存的状态是通过发送消息来传递的。
如果CPU内核要将缓存中的数理写入主内存,要发送一个失效的消息给其它拥有该数据缓存的CPU内核,并且等待其它CPU内核的回执消息,这显然是一个同步操作。同步操作就意味着CPU内核在发送消息的时候处于阻塞状态,为了避免阻塞带来的资源浪费,CPU中内核与缓存中间加入了store buffer。
如上图,CPU内核在写入数据时,先将数据写入到 store buffer中,同时给其它CPU内核发送消息,然后继续执行其他CPU指令(发消息为异步操作) ,等到所有持有相同主内存数据的CPU内核发送过来的响应消息时,再将store buffer中的数据存储到缓存行中,最后将缓存行数据同步到主内存。
由于加入了store buffer,使发送消息成了异步操作,CPU内核可以继续执行其他指令,提高了CPU内核的处理效率,这样本来排在后面的指令被提前执行了。CPU这样的乱序执行也叫做指令重排序。
我们通过下面一个简单的示例来看一下指令重排序带来的问题。
- cpu内核0、cpu内核1都加载了共享变量value的值到各自的缓存中,默认为0;
- cpu内核0将value=10写入到store buffer,通知cpu内核1【因为cpu内核1中也使用了value】
- cpu内核0继续执行flag = true,这时候cpu内核1还没有发送响应消息
- cpu内核1读取flag的值为true,value仍然为0
- cpu内核1发送响应消息
- cpu内核0将store buffer的数据写入到缓存,然后将缓存中value=10写入到主内存
public class Demo {
int value;
boolean flag;
void cpu0(){
value = 10;//S->I状态,将value写入store bufferes,通知其他CPU当前value的缓存失效
flag = true;//E状态
}
void cpu1(){
if (flag){//true
System.out.println(value == 10);//可能为false
}
}
}
上面是我们的代码逻辑,但是硬件却不能知道软件中代码的前后关系,很难去解决这种问题。因此CPU就提供了一个叫做内存屏障(Memory Barrier,Intel称之为 Memory Fence)的东西。然后我们可以通过代码来决定在哪里插入内存屏障来禁止指令重排序。
内存屏障
当加入入内存屏障,CPU内核会在执行到屏障前,将store buffer中的数据同步到主内存,这样屏障之后的读或者写都是可见的,因为数据都已经同步到主内存,而主内存对所有CPU内核都是可见的。
通过加入内存屏障,可以防止指令重排序。内存屏障的作用就是通过防止 CPU内核对主内存的乱序访问,以此来保证共享数据在多线程并行执行下的可见性,但是这个内存屏障怎么使用呢?
回到最开始我们讲 volatile关键字的代码,这个关键字会生成一个 lock 的汇编指令,这个就相当于实现了一种内存屏障。
JAVA内存模型
在JAVA中,定义了一种抽象的内存模型(JMM)来规范并控制重排序,以此解决可见性问题。
JMM全称是Java Memory Model(Java内存模型)。其最核心的价值就是解决可见性和有序性问题。JMM通过定义共享内存在多线程环境下的读写操作规范,以此来保证指令的正确性,以及并发场景下的可见性。
JMM如何解决可见性问题
JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量。
如上图所示,这3个内存中的变量x值都为0。
- 线程1更新本地内存中x的值为1,将x=1更新到主内存中,然后通知线程2
- 线程2收到通知,重新到主内存中读取x的值,此时线程2的本地内存的x=1
本质上是线程1在向线程2发送消息。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序提供内存可见性保证。
编译器的指令重排序
我们知道Java程序需要先进行编译,然后才能运行,为了提高程序的性能,编译器会对指令进行优化,可能会将指令进行重排序,编译器会遵守happens-before规则和as-ifserial语义,并不会影响程序的运行结果。
在插入了内存屏障的地方,编译器将禁止指令重排序,以此来保证可见性。正是因为volatile的这个特性,所以单例模式中可以通过volatile关键字来解决双重检查锁(DCL)写法中所存在的问题。
happens-before规则
happens-before表示的是前一个操作的结果对于后续操作是可见的,它用来表达多个线程之间对于内存的可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程,详细happens-before规则,请自行查找资料。
其实就是前后代码的一个关系,编译器通过遵守这个规则,从而不会随意的对指令进行重排序。
总结
并发编程三大特性:
- 原子性
- 可见性
- 有序性
volatile通过内存屏障禁止指令重排序,从而实现可见性和有序性。编译器在生成字节码时,会在指令序列中插入内存屏障来禁止指令重排序。