首先并发编程有三大特性: 可见性
,有序性
,原子性
。volatile关键字实现了前面两个特性。那么它是如何实现这两个特性的呢?
可见性
可见性主要是让缓存,直接写穿透到主存中。`let‘s fire the hole `,洞挖深一点。
缓存说到他的本质,就是存储器,而且是成本较高的sram,静态随机存储器,所具备的晶体管数目一般是6个,而dram一般只有一个晶体管,一个电容用于充放电。而物理内存是经过dram优化之后的sdram ,同步动态随机存储器。
这里还涉及到两个原理,一个是时间局部性原理
,意思是被引用的位置,在不久的将来,还会再次被引用,也就是我们通常所说的缓存命中
。空间局部性原理
,被引用的位置,在不久的将来,他的附近内存位置还会被引用。也就是一般缓存行填充64个字节。
实际上cpu内部不止一层缓存,一般还有二级缓存。L3一般是在片外,当然L3就属于多个芯片共享的了。结构图如下:
上图中提到了,机器周期,这个就具体不多说了。我是通信工程本科出身。一般都有玩过51单片机,玩过的都知道。机器周期知识传送门:https://blog.csdn.net/cll_caicai/article/details/79163590
缓存内部结构原理
let‘s fire the hole ,洞再挖深一点。
cpu是如何寻址的呢?如何从下层的L3,传到L2,传到L1,数据拷贝有重复吗? 为了整体的L1-L3缓存的命中率,应该是不重复的。到最后到内存装载 的数据可能就5% 。也就是命中率有95%。看下图,缓存的每一行都有一个唯一性标志位
,还有一个失效标志位
(这里我开始还以为是异步刷新的,但是想了一下不对啊,如果是异步的,等其他核(L2)或者其他芯片(L3)看到是失效,又得重新加载一遍),以我们常见的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 *策略,直接写到缓存。
看如下代码, 主线程把共享变量改成true .然后到程序结束大概花了151ms .但是如果立马可见的话,执行 test.stopRequested = true;一条执行需要花151ms吗?
import java.util.concurrent.TimeUnit;
public class VolatileTest3 {
public boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
VolatileTest3 test = new VolatileTest3();
long time1 = System.currentTimeMillis();
for(int i=0;i<10;i++) {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
// TODO Auto-generated method stub
int count = 0;
while (!test.stopRequested) {
System.out.println(count);
}
System.out.println("time5="+System.currentTimeMillis());
}
});
backgroundThread.start();
}
long time2 = System.currentTimeMillis();
System.out.println("启动线程"+(time2-time1));
TimeUnit.SECONDS.sleep(1);
System.out.println("time3="+System.currentTimeMillis());
test.stopRequested = true;
long time4 = System.currentTimeMillis();
System.out.println("time4="+System.currentTimeMillis());
}
}
执行结果图:
time3=1544241577773
0
0
time4=1544241577924
time5=1544241577924
0
0
0
0
0
0
0
time5=1544241577925
time5=1544241577925
time5=1544241577925
time5=1544241577925
time5=1544241577925
time5=1544241577925
time5=1544241577925
time5=1544241577924
time5=1544241577925
改成volatile变量结果如图
time3=1544242199911
0
time4=1544242200079
time5=1544242200079
0
time5=1544242200079
0
0
time5=1544242200079
time5=1544242200079
0
0
time5=1544242200080
0
0
time5=1544242200080
0
time5=1544242200080
0
time5=1544242200080
time5=1544242200080
time5=1544242200079
相差168ms...
疑问???
**what the fuck **,居然可见的时间比普通变量还晚? 有谁能够解释一下??可能是volatile还需要申请锁缓存的时间吧。谁能够提供一个volatile可见性的反例
有序性
《深入理解Java虚拟机》中有这句话“”“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”“”,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;至于什么是内存屏障,不做深入了解。只需要知道是CPU Out-of-order execution 和 compiler reordering optimizations。用于对内存操作的顺序限制。
我的理解就是,同步锁是控制不同线程,对内存堆中的对象串行访问。假如是volatile,说明是不同cpu内核,串行访问缓存,加了一把缓存锁。
原子性
举个反例子说明volatile不具有原子性。代码如下:
/**
* 这个实例说明,volatile不具备原子性
*/
public class VolatileTest {
private static volatile int count=0 ;
private static CountDownLatch start = new CountDownLatch(10);
/**
* 这里是个复合操作,读取count变量,计算,然后写回去
* */
public void increase(){
count ++;
}
public void getCount(){
System.out.println(count);
}
public static void main(String[] args) throws InterruptedException{
VolatileTest test = new VolatileTest();
for(int i=0;i<10;i++){
new Thread(new Task(start,test)).start();
}
start.await();
test.getCount();
}
}
class Task implements Runnable{
private CountDownLatch latch;
private VolatileTest test ;
public Task(CountDownLatch start,VolatileTest test){
this.latch = start;
this.test = test;
}
@Override
public void run() {
System.out.println("countDown===");
for(int j=0;j<1000;j++)
test. increase();
latch.countDown();
}
}
结果是9900多,是一个不定值。也有时候到10000是正确值。
假如 public void getCount() 这个方法,加synchronized
,就可以得到正确结果。
volatile使用优化
由于在多核环境下,交换数据的基本单位是一个缓存行64个字节。假如说一个对象,总共占用256个字节
,然后volatile long变量占用8个字节,假如说线程A,对变量修改,内核会对缓存行设置成invalid。另外的内核假如想访问相邻的字段。就需要重新加载这一缓存行。所以在jdk8中加了@contended
注解,能够标记class,表示类里面每个字段的变量值,都会在不同的缓存行中。
也可以标记在字段上,@sun.misc.Contended("group1")
表示,这个几个字段同一组的在一个缓存行中。jvm需要添加参数-XX:-RestrictContended才能开启此功能 。如果不开启只能采用padding的方式,填充。