OK,文章开头我先给定一个内味:volatile是一个特征修饰符(type specifier)volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。 [百度百科].
接下来就让我们炮打司令部—三共识:
可见性简单解释:线程A修改了内存某值,对于其他线程B、C、D…而言,如果其中线程B也在对这个值进行相应操作,那线程B就应知道自己拿到的值不是最新的,那他就应该去重新读最新的这个值,然后完成自己的操作。
要讲清可见性,我们得先来一点计算机常识:计算机组成原理中内存是分多层次的、Register、L1、L2、L3…,来提升性能。
在多核CPU的情况如下图,数据一般依次经过内存->L3->L2->L1->寄存器->ALU。在ALU中完成计算,再次写回内存。
而在Cache三级缓存中我们又划分了Cacheline作为Cache的基本读取单位,Cacheline是为在性能和时间间折中出现:
缓存行越大,局部性空间效率越高,但读取时间慢;
缓存行越小,局部性空间效率越低,但读取时间快;
取一一个折中值,目前多用:64字节
接下来就是volatile可见性实现的核心部分:讲解依据下图,
这一部分是针对可见性带来的问题的优化:
先来整一段代码:
class volatile {
public static long COUNT = 1_0000_0000L;
private static class T{
public volatile long x=0l;
}
public static T[] arr=new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args)throws Exception {
CountDownLatch latch=new CountDownLatch(2);
//t1线程对静态数组中的arr[0].x进行循环赋值
Thread tA=new Thread(()->{
for (long i=0;i<COUNT;i++){
arr[0].x=i;
}
latch.countDown();
});
//t2线程对静态数组中的arr[0].x进行循环赋值
Thread tB=new Thread(()->{
for (long i=0;i<COUNT;i++){
arr[1].x=i;
}
latch.countDown();
});
final long start =System.currentTimeMillis();
tA.start();
tB.start();
latch.await();
System.out.println("time cinsuming:"+(System.currentTimeMillis() - start)/1000.000 +"S");
}
}
执行代码我们发现平均运算时间在:2.5s左右,计算机性能一般情况下。
对代码进行一下改良呢:
public class volatile {
public static long COUNT = 1_0000_0000L;
//变化之处在这,对静态内部类T的成员变量进行了改变
private static class T{
public volatile long p1,p2,p3,p4,p5,p6,p7;
public volatile long x=0l;
public volatile long p9,p10,p11,p12,p13,p14,p15;
}
public static T[] arr=new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args)throws Exception {
CountDownLatch latch=new CountDownLatch(2);
Thread tA=new Thread(()->{
for (long i=0;i<COUNT;i++){
arr[0].x=i;
}
latch.countDown();
});
Thread tB=new Thread(()->{
for (long i=0;i<COUNT;i++){
arr[1].x=i;
}
latch.countDown();
});
final long start =System.currentTimeMillis();
tA.start();
tB.start();
latch.await();
System.out.println("time cinsuming:"+(System.currentTimeMillis() - start)/1000.000 +"S");
}
}
那仅仅是两行代码怎么能提升这么多运算效率呢?这就是一种伪共享,或者是一种伪对齐思路。还是用图唠叨:
因为数组在进行内存分配时是连续分配的,所以T[0]和T[1]是相连的,那么就将图中的x代表T[0].x ,y代表T[1].x;
1.ThreadA只对x进行操作,ThreadB只对y进行操作。但是读取内存时是按照一定单位读取的,所以相连的x,y就当作是一个cacheline读入了cache中,
2.某时ThreadA先把x给修改了,并写回内存。触发ThreadB中的cacheline无效,可是ThreadB核读到的就是y的新值,根本就不需要在读呀。但是莫得办法,ThreadB核还是得重新去读内存。时间就在这浪费
那为什么加了两行代码,在x前后加7个long就解决了呢?
出现上面现象是因为x,y出现在了同一cacheline上,那么让他们不在一起,个去个的核处理不就行了。
,因为我们知道一般cacheline是64字节,而long是8字节。64/8=8,那我们就在数据前后都加上7的long,这样不管cpu怎么读64字节,x、y都不可能在同一cacheline出现,所以这就节省了很多时间。向上面的情况,当然一般来说只需要在前或者在后加上相应字节进行填充就好,不一定需要前后都加。
由于volatile的MESI缓存一致性协议需要不断的从主内存嗅探和CAS不断循环无效交互导致总线带宽达到峰值。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~
看完可见性我们可以脱口而出这个结论:volatile这个东西没有原子性。
在这一啪中,最闪的就是—++问题
先来一段code:
class Volatile_Atomicity {
public static volatile int x;
public static void main(String[] args)throws Exception {
CountDownLatch latch=new CountDownLatch(2);
new Thread(()->{
for (int i=0;i<20000;i++){
x++;
}
latch.countDown();
}).start();
new Thread(()->{
for (int i=0;i<20000;i++){
x++;
}
latch.countDown();
}).start();
latch.await();
System.out.println("瞅瞅x值:"+x);
}
}
当这个阈值 i 较小时不明显,调大以后就比较明显,因为线程2还没启动起来,有可能线程1跑完了:
给出解释 :
尽管volitale保证了可见性,但是对于多个线程同时对某一变量进行i++或者++i操作时,仍然无法保证其运算得到正常的结果,本质原因还是i++ 和 ++i不是原子操作。结合可见性图解,当线程1和线程2进行 i++ 操作时,
首先,i++并不是原子操作,操作是拆分为3个步的:
1.把数据从主内存加载到缓存。
2.在缓存中命中数据,传到ALU执行i++操作。
3.将i的新值刷新到主内存。
那么进行如下过程,则会发生线程安全问题:
线程A,线程B都取到最新i值,并都执行到步骤2,都已经从cache中命中数据,传到了工作的内存,此时不管哪个线程先完成,置其他核cacheline无效,都达不到最终效果,因为我需要读的数,我读到了,不用再去cache中找。这时明明是执行了两次的 ++ 操作,但内存中的值还是 i+1,只是刷新两次而以。这是一种极端情况,因为一般cpu运算都特别快,但出问题都是在调度线程的核自以为读到最新值并传入工作的内存时,出现该线程的不正常停止或其他核运算更快,导致主内存值已刷新。
最后:自增操作不是原子性操作,而且volatile不能保证对变量的任何操作都是原子性的。
不是说volatile是缩水的synchronized吗?难道不具有安全性了吗?
这个问题我就在简单介绍有序性后再做讨论。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~
volatile是禁止指令重排序优化。由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。
先来一段简单代码:
class Test8_22 {
static class T{
int x=8;
}
public static void main(String[] args) {
T t=new T();
}
}
我们编译以后,看看字节码文件(主要看main方法中的new):
public static main([Ljava/lang/String;)V
L0
LINENUMBER 7 L0
NEW Test8_22$T //1.内存申请T对象大小的空间
DUP
INVOKESPECIAL Test8_22$T. ()V //2.完成初始化
ASTORE 1 //3.将内存中的地址,赋予引用
L1
LINENUMBER 8 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE t LTest8_22$T; L1 L2 1
MAXSTACK = 2
MAXLOCALS = 2
所以如上注释new操作一般是分三步的,可能第2步不胜了解,那就是在第一步完成后,类成员int x只是分配了一个4字节的地址,他的值还是0,并没有赋值。只有在完成第2步后进行初始化,并调用构造方法。所以第1步还是半初始化状态。
但是这还仅仅是到了字节码,离真正的底层还有:源码->编译器优化重排序->指令级并行重排序->内存系统重排序->最后执行的指令序列。
并且越往下走,指令的优化就愈加重要,所以我们new的这三步,到最后可能什么顺序都有,最可怕的就是在单列模式下重排序成了:3-1-2。
所以我们一般将单列模式写成双重检查模式(double check):
class Singleton{
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();//加了volatile的变量严格按顺序执行,不会先执行3将一个null的值给引用对象。
}
}
return instance;
}
}
通过volatile的修饰去避免当重排序成了3-1-2的时候:threadA先执行了 instance = new Singleton();但是是先执行的3指令,将一个null赋给了引用对象。这样等ThreadA走完同步代码块,ThreadB进来instance==null发现是ture那么ThreadB又会去new一个对象,破坏了单列模式。
更明显的无序性案列在网上有许多。譬如《 volatile看完你就明白了》—无序性的例子这一节就罗列出了不少好案列。文章中的代码想跑出相应的结果可能需要挺多次尝试。
那volatile是怎么做到的呢?—内存屏障
这种内存屏障(JVM级别,不是CPU级别)将显示的告诉后面的编译器禁止指令重排序。
内存屏障插入策略:
1)在每个volatile写操作前插入一个StoreStore屏障。
2)在每个volatile写操作后插入一个StoreLoad屏障。
3)在每个volatile读操作后插入一个LoadLoad屏障。
4)在每个volatile读操作后插入一个LoadStore屏障。
这是一种JMM规范的实现,同时volatile的禁止重排序并不局限于两个volatile 的属性操作不能重排序,而且是volatile 属性操作和它周围的普通属性的操作也不能重排序,禁止规则如下:
1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
依据上面可知volatile提供了happens-before 保证,总的来说就是:对volatile 变量v的写入happens-before 所有其他线程后续对v的读操作。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~
安全性探讨:
那安全性用法呢?这就附上:
依据可见性--------可以成为信号量在线程中进行通讯,但是这中信号量得安全性使用有局限性:
class Test8_22 {
public static volatile boolean flag=true;
public static void main(String[] args) throws Exception {
CountDownLatch latch=new CountDownLatch(2);
new Thread(()->{
for (int i=0;i<20000;i++){
reversal();
}
latch.countDown();
}).start();
new Thread(()->{
for (int i=0;i<20000;i++){
reversal();
}
latch.countDown();
}).start();
latch.await();
System.out.println(flag);
}
public static void reversal(){
flag=!flag;///每一次取反,都依赖上一次
}
}
进行过偶数次取反,应该还是true的,但返回的是错误的false。
因为这种volatile信号量的操作,每一次都依赖于前一次,但volatile不具原子性导致特别容易出错。
那他怎么做信号量?所以我们要打破这种修改依赖上一次的行为。如下:
class Test8_22 {
public static volatile boolean flag=true;
public static void main(String[] args) throws Exception {
CountDownLatch latch=new CountDownLatch(2);
new Thread(()->{
for (int i=0;i<20000;i++){
reversal();
}
latch.countDown();
}).start();
new Thread(()->{
for (int i=0;i<20000;i++){
reversal();
}
latch.countDown();
}).start();
latch.await();
System.out.println(flag);
}
public static void reversal(){
flag=true;//取值单一指向。
}
}
这样是安全的,flag变量是取值无须参考上一次的。可是这样的信号量效率是极低的。不过利用这个点还是可以来一个简单的触发器:
volatile boolean flag = false;
//线程A:
{
do some things;
flag = true; //当完成时,或者达到条件时
}
//线程B:
{
while(!flag ){
sleep();//减少空循环次数,提高效率;若实时性要求高,可以适当减少sleep时间
}
do some things;
}
依据有序性--------这里最常见的就是双重检查单例
总结:
OK,文章结尾再来品一品☕内味:volatile是一个特征修饰符(type specifier)volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略(有序性),且要求每次直接读值(可见性)。
Fine任务完成,向三连长致敬了。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~
参考资料:
可见性小节—《马士兵教育-多线程与高并发》视频
总结------------《java并发核心》mooc视频课