并发编程--volatile

一个问题引发的思考


public static void main(String[] args) throws InterruptedException { 
    Thread thread=new Thread(()->{ 
        int i=0; 
        while(!stop){ 
            i++; 
            //System.out.println("rs:"+i); 
            try {
                Thread.sleep(0); 
            } catch (InterruptedException e) { 
                e.printStackTrace(); 
            } 
          } 
        }); 
    thread.start(); 
    Thread.sleep(1000);
    stop=true;
 }

问题:

  • 如果不加System.out.println("rs:"+i);,线程不会终止,因为变量i在主线程和创建的线程中是不可见的,主线程中修改stop的值,创建的线程中不可见。
  • 如果加了System.out.println("rs:"+i); 线程会终止。为什么呢。

活性失败. JIT深度优化

  • 这里分为两个层面来解答

第一个层面

  • println底层用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存。
  • 因为println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存,可以通过如下代码去证明。
Thread thread=new Thread(()->{ 
    int i=0; 
    while(!stop){
       i++;
       synchronized (JITDemo.class){} 
    } 
});

第二个层面

  • 第三个角度,从IO角度来说,print本质上是一个IO的操作,我们知道磁盘IO的效率一定要比CPU的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。比如我们可以在里面定义一个new File()。同样会达到效果。

Thread.sleep(0) 也能达到上述效果

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3

  • 在这段代码中,我们增加Thread.sleep(0)也能生效,这个我认为是和cpu、以及jvm、操作系统等因素有关系。
  • 官方文档上是说,Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。
  • 编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。但是我认为Thread.sleep(0)导致线程切换,线程切换会导致缓存失效从而读取到了新的值。

volatile关键字(保证可见性)


通过对上述代码查看汇编指令,使用HSDIS工具,具体的使用方法详见压缩文档。
可以看到,使用volatile关键字之后,多了一个Lock指令

0x00000000037028f3: lock add dword ptr [rsp],0h ;*putstatic stop

思考:思考lock汇编指令来保证可见性问题?

什么是可见性


在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。

硬件层面


CPU/内存/IO设备

  • CPU层面增加了高速缓存;
  • 操作系统,进程、线程、| CPU时间片来切换;
  • 编译器的优化 ,更合理的利用CPU的高速缓存。

CPU层面的高速缓存


因为高速缓存的存在,会导致一个缓存一致性问题


13.png
总线锁&缓存锁

总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。

  • 如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。

引入缓存锁的概念。所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。

缓存一致性协议

MSI ,MESI ,MOSI ...
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常
见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI

MESI表示缓存行的四种状态,分别是

  1. M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内
    存中的数据不一致.
  2. E(Exclusive)表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
    3.S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  3. I(Invalid)表示缓存已经失效

引出了MESI的一个优化


Store Bufferes

Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到Store Bufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。

5.png

指令重排序


12.png

我们来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0executeToCPU1,分别由两个不同的CPU来执行。
引入Store Bufferes之后,就可能出现 b==1返回true ,但是assert(a==1)返回false。很多同学肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下。

executeToCPU0(){
    a=1; 
    b=1; 
}

executeToCPU1(){
    while(b==1){
         assert(a==1); 
    } 
}

分析

  • a=1b =1 这两条指令是可以重排序的,如果b=1被执行了,并且被刷新到主内存,这个时候cpu0读到的b=1true,但是a=1这条指令可能还没执行,或者还没刷新到主内存,反正就是没让cpu1可见,这个时候assert(a==1)就判断为false

通过内存屏障禁止了指令重排序


X86memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)

  • Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或
    者写是可见的
  • Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏
    障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  • Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障
    后的读写操作

附图:


11.png
volatile int a=0; 
executeToCpu0(){
    a=1; 
    storeMemoryBarrier()//写屏障,写入到内存 
    b=1; 
    //CPU层面的重排序
    //b=1; 
    //a=1; 
}
executeToCpu1(){ 
    while(b==1){ //true
       loadMemoryBarrier(); //读屏障
       assert(a==1) //false
    }
}

JMM


简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。

JMM是如何解决可见性和有序性问题的


  • 其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
  • 其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。

Volatile的原理

通过javap -v VolatileDemo.class ,我们可以清楚的看到, volatile修饰的成员变量,汇编指令多了一个ACC_VOLATILE,后续可以通过hotspot 源码分析这个指令的作用。

public static volatile boolean stop; 
descriptor: Z flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE

1、可以在JVM源码中看到有一个is_volatile判断是否是volatile访问限定符修饰的,然后再看字节码解释器的部分源码
2、注意有三个细节,首先会判断是否标识了volatile,然后再判断类型,我们这个是int类型所以会调用release_int_field_put,最后插入一道屏障storeload

int field_offset = cache->f2_as_index(); 
if (cache->is_volatile()) { //是否标识了volatile
    if (tos_type == itos) {
        obj->release_int_field_put(field_offset, STACK_INT(-1)); 
    } else if (tos_type == atos) {
         VERIFY_OOP(STACK_OBJECT(-1)); 
        obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
        OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift],   0); 
    } else if (tos_type == btos) { 
        obj->release_byte_field_put(field_offset, STACK_INT(-1)); 
    } else if (tos_type == ltos) { 
        obj->release_long_field_put(field_offset, STACK_LONG(-1));
     } else if (tos_type == ctos) { 
        obj->release_char_field_put(field_offset, STACK_INT(-1)); 
    } else if (tos_type == stos) { 
        obj->release_short_field_put(field_offset, STACK_INT(-1)); 
    } else if (tos_type == ftos) {
         obj->release_float_field_put(field_offset, STACK_FLOAT(-1)); 
    } else { 
        obj->release_double_field_put(field_offset, STACK_DOUBLE(-1)); 
    }
    OrderAccess::storeload(); //全屏障
}

Happens-Before模型

除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。

从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。

传递性规则


a happens-before b , b happens- before c, a happens-before c

程序顺序规则(as-if-serial语义)


  • 不能改变程序的执行结果(在单线程环境下,执行的结果不变.)
  • 依赖问题, 如果两个指令存在依赖关系,是不允许重排序
int a=0; 
int b=0; 
void test(){
     int a=1; a   
     int b=1; b 
     //int b=1; 
     //int a=1;
     int c=a*b; c 
}

a happens -before c ; b happens before c

锁的happen-before原则

同一个锁的unlock操作happen-before此锁的lock操作。

线程启动的happen-before原则

同一个线程的start方法happen-before此线程的其它方法。
start 放法与其它方法可能并没有数据依赖关系,但是显而易见的,为了程序的正确性,我们必须做到这一点。start 方法造成的函数副作用必须对其它方法可见。

public class StartDemo{ 
    int x=0; 
    Thread t1=new Thread(()->{
         //读取x的值 一定是20
         if(x==20){ 
         }
    });
    x=20; 
    t1.start();
 }

线程中断的happen-before原则

对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
interrupt 方法改变的状态必须对后续执行的检测方法可见。

线程终结的happen-before原则

线程中的所有操作都happen-before线程的终止检测。
为了安全的关闭线程,线程中的方法造成的函数副作用必须对线程关闭方法可见

对象创建的happen-before原则

一个对象的初始化完成先于他的finalize方法调用。
单线程下对象的创建于销毁存在数据依赖,该条原则强调的是多线程情况下对象初始化的结果必须对发生于其后的对象销毁方法可见。

Join规则

public class Test{ 
    int x=0; 
    Thread t1=new Thread(()->{ 
        x=200; 
    }); 
    t1.start();
    t1.join(); 
    //保证结果的可见性。 //在此处读取到的x的值一定是200.
 }

volatile变量规则


volatile 修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作.
内存屏障机制来防止指令重排.

public class VolatileExample{
    int a=0; 
    volatile boolean flag=false;
    public void writer(){
        a=1;                       1 
        //修改                      
        flag=true;                 2
    }
    public void reader(){
         //true
         if(flag){                 3 
            //1
            int i=a;               4 
        }
    } 
}

问: 1 happens-before 4 是否成立?
分析:

  • 1 happens-before 2 是否成立?是 (volatile规则)
  • 3 happens-before 4 是否成立? 是 (as-if-serial语义)
  • 2 happens-before 3 是否成立? 是 (volatile规则)
  • 1 happens-before 4 ;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容