首先并发编程有三大特性: 可见性,有序性,原子性。volatile关键字实现了前面两个特性。那么它是如何实现这两个特性的呢?
首先是可见性。可见性主要是让缓存,直接写穿透到主存中。然后另外的cpu 通过底层的硬件层面的嗅探,可以发现自己cpu本地的缓存已经失效。然后到主存中直接读取。现在让我们来看看,cpu里面的缓存具体和主存如何交互。
说道这里首先需要了解,计算机内部存储器结构。每个核中都有自己的通用寄存器,比如eax,ebx,edx,esi,esp.访问这些寄存器里面的内容,只要一个机器周期,就够了,通常小于1ns..然后是L1,L2的本地core的缓存。 通常在10个机器周期左右。大约10ns. L3级缓存是多core共享的。
以我们常见的X86芯片为例,Cache的结构下图所示:整个Cache被分为S个组,每个组是又由E行个最小的存储单元——Cache Line所组成,而一个Cache Line中有B(B=64)个字节用来存储数据,即每个Cache Line能存储64个字节的数据,每个Cache Line又额外包含一个有效位(valid bit)、t个标记位(tag bit),其中valid bit用来表示该缓存行是否有效;tag bit用来协助寻址,唯一标识存储在CacheLine中的块;而Cache Line里的64个字节其实是对应内存地址中的数据拷贝。根据Cache的结构题,我们可以推算出每一级Cache的大小为B×E×S。
1级大概是32k 或者32K X2 ,2级大概是 256K或者256KX2 ,L3一般是3M左右。当多线程并发访问一段代码的时候,读取变量到本地的core进行计算,然后把数据写入到缓存中,假如没有volatile关键字的话,缓存采用的是write back 策略,直接写到缓存,看如下代码,虽然启用了10个线程进行计数,但是打印出来的count值是0.即使sleep(100),100ms,等所有线程都起来了,也是得到的结果都是不定的,因为无法确定缓存什么时候,换出写到主存中。
1 public class VolatileTest {
2 3 private int count ; 4 public void increase() { 5 count++; 6 } 7 public void getCount(){ 8 System.out.println(count); 9 } 10 public static void main(String[] args) throws InterruptedException{ 11 VolatileTest test = new VolatileTest(); 12 for(int i=0;i<10;i++){ 13 new Thread(){ 14 15 public void run() { 16 for(int j=0;j<1000;j++) 17 test.increase(); 18 } 19 }.start(); 20 } 21 Thread.sleep(100); 22 test.getCount(); 23 } 24 }volatile 作用1 就是一个线程改变了共享变量的值,其它线程马上能看见,就是可见性。比如下面的这段代码。
1 import java.util.concurrent.CountDownLatch; 2 3 public class VolatileTest2 { 4 5 private static volatile boolean status=false ; 6 7 private static CountDownLatch start = new CountDownLatch(1); 8 9 public void setStatusTrue(){ 10 status =true; 11 } 12 public void getStatus(){ 13 System.out.println(status); 14 } 15 public static void main(String[] args) throws InterruptedException{ 16 VolatileTest2 test = new VolatileTest2(); 17 new Thread(new Task2(start,test)).start(); 18 for(int i=0;i<10;i++){ 19 new Thread(new Task1(start,test)).start(); 20 } 21 } 22 } 23 24 class Task1 implements Runnable{ 25 private CountDownLatch latch; 26 private VolatileTest2 test ; 27 public Task1(CountDownLatch start,VolatileTest2 test){ 28 this.latch = start; 29 this.test = test; 30 } 31 @Override 32 public void run() { 33 try{ 34 latch.await(); 35 }catch (Exception e){ 36 } 37 test.getStatus(); 38 } 39 } 40 41 /** 42 * 这个线程吧状态设置成true,然后同步计数器马上变成0.之后,就其它线程马上就能看到status状态为true 43 */ 44 class Task2 implements Runnable{ 45 private CountDownLatch latch; 46 private VolatileTest2 test ; 47 public Task2(CountDownLatch start,VolatileTest2 test){ 48 this.latch = start; 49 this.test = test; 50 } 51 @Override 52 public void run() { 53 test.setStatusTrue(); 54 latch.countDown(); 55 System.out.println("countDown==="); 56 } 57 }
具体的原理,这里涉及到缓存一致性原理,MESI 协议
失效(Invalid)缓存段,要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
共享(Shared)缓存段,它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
独占(Exclusive)缓存段,和S状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个E状态的缓存段,那其他处理器就不能同时持有它,所以叫“独占”。这意味着,如果其他处理器原本也持有同一缓存段,那么它会马上变成“失效”状态。
已修改(Modified)缓存段,属于脏段,它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和E状态一样。此外,已修改缓存段如果被丢弃或标记为失效,那么先要把它的内容回写到内存中——这和回写模式下常规的脏段处理方式一样。
在写入时锁定缓存,称为Exclusive状态,然后同时写入缓存和主存,当读取数据的时候,强行,从主存中读取,并且申请缓存行填充。
2 有序性,这个又如何保证呢?
《深入理解Java虚拟机》中有这句话“”“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”“”,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;至于什么是内存屏障,不做深入了解。只需要知道是CPU Out-of-order execution 和 compiler reordering optimizations。用于对内存操作的顺序限制。
Memory access instructions, such as loads and stores, typically take longer to execute than other instructions. Therefore, compilers use registers to hold frequently used values and processors use high speed caches to hold the most frequently used memory locations. Another common optimization is for compilers and processors to rearrange the order that instructions are executed so that the processor does not have to wait for memory accesses to complete. This can result in memory being accessed in a different order than specified in the source code. While this typically will not cause a problem in a single thread of execution, it can cause a problem if the location can also be accessed from another processor or device.