暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

volatile,锁,final的内存语义

面向对象的隔壁老王 2017-11-30
396

1.volatile内存语义的实现


        被volatile修饰的变量除了是线程可见的(写操作到工作内存中之间刷新到主内存,读操作时把工作内存清空,之间读取主内存到工作内存),volatile还可以限制编译器重排序和处理器重排序。下表是JMM针对编译器制定的volatile重排序规则表。





       从上表可以可以简单总结一下规律

  • 只要第二个操作是volatile写,第一个操作是什么都不能与之重排序

  • 只要第一个操作是volatile读,第二个操作是什么都不能与之重排序

  • 第一个操作是volatile写,第二个操作是volatile读,两个操作不能进行重排序


        为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列插入内存屏障来禁止特定类型的处理器重排序

内存屏障的类型,如下表



        JMM为volatile插入内存屏障的策略:

        


        volatile写插入内存屏障的示意图:




        由上图可以看出StoreStore屏障可以确保在volatile写操作之前,普通的写操作已经对任意处理器可见了。因为StoreStore屏障可以保证上面的普通写操作已经在volatile写之前刷新到主内存中。

        volatile读插入内存屏障的示意图:

        



        由上图可以看出,在volatile读后面插入LoadLoad屏障,LoadStore屏障确保后面的普通读写操作与上面的volatile读不会发生重排序。


        上述的volatile的内存语义都是JDK1.5后的加强版,之前没有限制volatile对普通读写的重排序限制。在JDK1.5之后,严格限制了volatile的写/读要和锁的释放和获取具有相同的内存语义。


2.锁的内存语义


        锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。


2.1锁释放获取的happens-before关系


        下面是锁释放和获取的代码



        假设线程A执行writer方法,线程B随后执行reader方法,则这个过程包含的happens-before关系可以分为3类:

  • 根据程序次序规则:1先于2,2先于3,4先于5,5先于6

  • 根据监视器锁规则:3先于4

  • 根据传递性规则:2先于5

        转化成示意图如下:



        如上图所示,由于2先行5,所以线程A中的共享变量在A释放锁后,线程B在获得同一把锁后立马可见。


2.2锁获取和释放的内存语义


        当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的程序为例,A线程释放后,共享数据的状态如下图:




        当线程获取锁时,JMM会把该线程对象的本地内存设为无效。从而使锁保护的临界区代码必须从主内存中读取共享变量。下图是示意图




    这里我们可以看出锁释放和volatile的写具有相同的内存语义,锁的获取和volatile的读具有相同的内存语义。


2.3 CAS操作的内存语义


        通过分析CAS的源码(这部分分析详细见《Java并发编程的艺术》)发现CAS的内存语义同时具有volatile的读和写语义。因为CAS调用了本地的方法,本地方法在解析成汇编码的时候,发现指令前多加了一个lock前缀。根据Intel的手册对lock前缀的描述:

  • 利用总线锁定(老处理器,性能低)和缓存锁定(现代处理器,性能相当较高)来确保对内存的读-改-写操作是原子执行的。

  • 禁止该指令,与之前和之后的读和写指令重排序

  • 把写缓冲区的所有数据刷新到内存中

根据2,3点我们就可以看出CAS操作同时具有volatile读写的两个内存语义。

通过对ReentrantLock的源码分析(这部分分析详细见《Java并发编程的艺术》),公平锁和非公平锁的实现如下:




现在我们有两种方式来实现锁的释放或获取的内存语义了。

  • 利用volatile变量的写-读所具有的内存语义

  • 利用CAS所附带的volatile的读和写内存语义

由于CAS具有volatile的读和写内存语义,现在Java多线程通信的方式有下面四种




        如果我们看了concurrent包的源码,我们会发现一个通用化的实现模式:


        首先,将共享变量声明为volatile

        然后,使用CAS的原子条件更新来实现线程之间的同步

        同时,配合以volatile的读写和CAS具有的内存语义来实现线程之间的通信。


3.final的内存语义


        对于final变量的重排序,编译器和处理器要遵循以下两个规则:

        



        也就是说final变量的赋值不会被重排序到构造器以外,和在使用对象引用的时候,final变量肯定是初始化过的(这个初始化不是指默认值的初始化,是手动的初始化)。

        通过例子来说明这两个原则

        

        第一种情况假设线程B获取对象引用和读普通成员变量没有发送重排序,下面是这种情况的序列图:

        



        构造方法中普通变量逸出了构造方法,所以可能会引起脏读。

        第二种情况是,线程B中的引用对象赋值操作,和读取普通变量的操作可能发生重排序。

     

        这种情况是线程B读取普通变量的操作被重排序,线程A还没有对普通变量初始化,线程B就开始读,因此造成的脏读。

        所以说普通变量和final变量的区别就在于,final变量可以保证线程在使用包含这个final变量的对象引用时,final变量已经初始化过了。但是这个保证也是有前提的,那就是对象的引用不能从构造函数中“逸出”,我们看下面的情况:


这个程序可能会发生下面的情况:


所以在日常编程时一定要注意,不要让对象引用在构造方法中逸出,很危险。


文章转载自面向对象的隔壁老王,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论