‘壹’ 我们一起学并发编程:Java内存模型(六)final的内存语义
上篇介绍了锁和volatile的内存语义,本文讲述的是final的内存语义,相比之下,final域的读和写更像是普通变量的访问。
1、final域的重排序规则final对于final域编译器和处理器遵循两个重排序规则
在构造函数内对一个final域的写入,与随后把这个对象的引用赋值给另一个引用变量,这两个操作之间不能重排序
初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
读起来相对拗口,用代码来说明上面两种重排序规则:
packagecom.lizba.p1;/***<p>**</p>**@Author:Liziba*@Date:2021/6/1120:37*/publicclassFinalExample{/**普通变量*/inti;/**final变量*/finalintj;/**对象引用*/staticFinalExampleobj;/***构造函数*/publicFinalExample(){//写普通域this.i=1;//写final域this.j=2;}/***线程A执行writer写方法**/publicstaticvoidwriter(){obj=newFinalExample();}/***线程B执行reader读方法**/publicstaticvoidreader(){//读对象的引用FinalExamplefinalExample=obj;//读普通域inta=finalExample.i;//读final域intb=finalExample.j;}}假设线程A执行writer()方法,线程B执行reader()方法。下面来通过这两个线程的交互来说明这两个规则。
2、写final域的重排序规则写final域的重排序禁止吧final域的写重排序到构造函数之外。通过如下方式来实现:
JMM禁止编译器把final域的写重排序到构造函数之外
编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
现在开始分析writer()方法:
/***线程A执行writer写方法**/publicstaticvoidwriter(){obj=newFinalExample();}构造一个FinalExample类型的对象
将对象的引用赋值给变量obj
首先假设线程B读对象引用与读对象的成员域之间没有重排序,则下图是其一种执行可能
读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意是处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
解释:初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。
编译器遵守间接依赖关系,编译器不会重排序这两个操作
大多数处理器也遵守间接依赖,不会重排序这两个操作。但是少部分处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门针对这种处理器的。
分析reader()方法:
/***线程B执行reader读方法**/publicstaticvoidreader(){//读对象的引用FinalExamplefinalExample=obj;//读普通域inta=finalExample.i;//读final域intb=finalExample.j;}初次读引用变量obj
初次读引用变量obj指向对象的普通域j
初次读引用变量obj指向对象的final域i
假设B线程所处的处理器不遵守间接依赖关系,且A线程执行过程中没有发生任何重排序,此时存在如下的执行时序:
上图B线程中读对象的普通域被重排序到处理器读取对象引用之前,此时普通域i还没有被线程A写入,因此这是一个错误的读取操作。但是final域的读取会被重排序规则把读final域的操作“限定”在读该final域所属对象的引用读取之后,此时final域已经被正确的初始化了,这是一个正确的读取操作。
总结:
读final域的重排序规则可以确保,在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
4、final域为引用类型上面讲述了基础数据类型,如果final域修饰的引用类型又该如何?
packagecom.lizba.p1;/***<p>*final修饰引用类型变量*</p>**@Author:Liziba*@Date:2021/6/1121:52*/{/**final是引用类型*/finalint[]intArray;;/***构造函数*/publicFinalReferenceExample(){this.intArray=newint[1];//1intArray[0]=1;//2}/***写线程A执行*/publicstaticvoidwriter1(){obj=newFinalReferenceExample();//3}/***写线程B执行*/publicstaticvoidwriter2(){obj.intArray[0]=2;//4}/***读线程C执行*/publicstaticvoidreader(){if(obj!=null){//5inttemp=obj.intArray[0];//6}}}如上final域为一个int类型的数组的引用变量。对应引用类型,写final域的重排序对编译器和处理器增加了如下约束:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给另一个引用变量,这两个操作不能重排序。
对于上述程序,假设A执行writer1()方法,执行完后线程B执行writer2()方法,执行完后线程C执行reader()方法。则存在如下线程执行时序:
JMM对于上述代码,可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即写线程C至少能看到数组下标0的值为1。但是写线程B对数组元素的写入,读线程C可能看得到可能看不到。JMM不能保证线程B的写入对读线程C可见。因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
此时如果想确保读线程C看到写线程B对数组元素的写入,可以结合同步原语(volatile或者lock)来实现。
5、为什么final引用不能从构造函数内“逸出”本文一直在说写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化了。那究竟是如何实现的呢?
其实这需要另一个条件:在构造函数内部,不能让这个被构造对象的引用被其它线程所见。也就是对象引用不能在构造函数中“逸出”。
示例代码:
packagecom.lizba.p1;/***<p>*final引用逸出demo*</p>**@Author:Liziba*@Date:2021/6/1122:33*/{finalinti;;(){i=1;//1、写final域obj=this;//2、this引用在此处"逸出"}publicstaticvoidwriter(){();}publicstaticvoidreader(){if(obj!=null){//3inttemp=obj.i;//4}}}假设线程A执行writer()方法,线程B执行reader()方法。这里操作2导致对象还未完成构造前就对线程B可见了。因为1和2允许重排序,所以线程B可能无法看到final域被正确初始化后的值。实际执行的时序图可能如下所示:
总结:
在构造函数返回之前,被构造对象的引用不能为其他线程可见,因为此时的final域可能还没被初始化。而在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。
6、final语义在处理器中的实现举例X86处理器中final语义的具体实现。
在编译器中会存在如下的处理:
写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障
读final域的重排序规则要求编译器在读final域的操作前插入一个LoadLoad屏障
但是,由于X86处理器不会对写-写操作做重排序,所以在X86处理器中,写final域需要的StoreStore屏障会被省略。同样,由于X86处理器不会对存在间接依赖关系的操作做重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。因此,在X86处理器中,final域的读/写不会插入任何内存屏障。
7、JSR-133为什么要增强final的语义在旧的Java内存模型中,一个最严重的缺陷就是现场可能看到final域的值会改变。比如一个线程读取一个被final域的值为0(未初始化之前的默认值),过一段时间再读取初始化后的final域的值,却发现变为了1。因此为了修复此漏洞,JSR-133增强了final语义。
总结:
通过为final增加写和读重排序规则,可以为Java程序员提供初始化安全保障:只要对象正确构造(被构造对象额引用在构造函数中没有“逸出”),那么不需要使用同步原语(volatile和lock的使用)就可以保障任意线程都能看到这个final域在构造函数中被初始化之后的值。
文章总结至《Java并发编程艺术》,下篇总结“happens-before”,敬请关注。
‘贰’ 我们一起学并发编程:Java内存模型(七)happens-before
简介happens-before是JMM的核心概念,理解happens-before是了解JMM的关键。
1、设计意图JMM的设计需要考虑两个方面,分别是程序员角度和编译器、处理器角度:
程序员角度,希望内存模型易于理解、易于编程。希望是一个强内存模型。
编译器和处理器角度,希望减少对它们的束缚,以至于编译器和处理器可以做更多的性能优化。希望是一个弱内存模型。
因此JSR-133专家组设计JMM的核心目标就两个:
为程序员提供足够强的内存模型
对编译器和处理器的限制尽可能少
下面通过一段代码来看JSR-133如何实现这两个目标:
doublepi=3.14;//Adoubler=1.0;//Bdoublearea=pi*r*r//C上述代码存在如下happens-before关系:
Ahappens-beforeB
Bhappens-beforeC
Ahappens-beforeC
这3个happens-before关系中,第二个和第三个是必须的,而第一个是非必须的(A、B操作之间重排序,程序执行结果不会发生改变)。
JMM把happens-before要求禁止的重排序分为下面的两类:
会改变程序执行结果的重排序
不会改变程序执行结果的重排序
JMM对这两种不同性质的重排序,采取了不同的策略:
对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止
对于不会改变程序执行结果的重排序,JMM不做要求(JMM运行)
JMM设计示意图:
总结:
JMM给程序员提供的happens-before规则能满足程序员的需求。简单易懂,具有足够强的内存可见性保证。
JMM对编译器和处理器的束缚尽可能少。遵循的原则是:不改变程序的执行结果(正确同步或单线程执行),编译器和处理器可以任意优化。
2、happens-before的定义起源:
happens-before规则来源于LeslieLamport《Time,》。该论文中使用happens-before来定义分布式系统中事件之间的偏序关系(partialordering),该文中给出了一个分布式算法,能用来将偏序关系扩展为某种全序关系。
Java中的应用:
JSR-133使用happens-before来指定两个操作之间的执行顺序。JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
《JSR-133:》对happens-before关系的定义如下:
如果操作Ahappens-before操作B,那么A操作的执行结果将会对操作B可见,且操作A的执行顺序排在操作B之前——JMM对程序员的承诺
两个操作存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before的顺序来执行。如果重排序不改变程序执行结果(与happens-before)规则一致,那么这种重排序是不非法的(JMM允许这种重排序)。——JMM对编译器和处理器的束缚原则
happens-before和as-if-serial语义:
从上述来看,happens-before和as-if-serial语义本质上是一回事
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不改变
as-if-serial语义给编程者一种单线程是按程序顺序执行的幻境;happens-before关系给编程者一种正确同步的多线程是按照happens-before指定的顺序执行的幻境。
两者的目的都是为了在不改变程序执行结果的前提下,尽可能的提高程序的执行效率。
3、happens-before规则《JSR-133:》定义了如下happens-before规则
程序顺序规则
监视器锁规则
volatile变量规则
传递性
start()规则
join()规则
3.1volatile写-读volatile写-读建立的happens-before关系
分析上图:
1happens-before2和3happens-before4由程序顺序规则产生。由于编译器和处理器遵循as-if-serial语义,也就是说,as-if-serial语义保证了程序顺序规则。因此可以把程序顺序规则看成是对as-if-serial语义的“封装”。
2happens-before3是有volatile规则产生。一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后写入。
1happens-before4是由传递性规则产生的。这里的传递性是由volatile的内存屏障插入策略和volatile的编译器重排序规则来共同保证的。
3.2start()规则假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一个共享变量,线程B在执行后会读取这些共享变量。
start()程序对应的happens-before关系图:
分析上图:
1happens-before2由程序顺序规则产生
2happens-before4由start规则产生
1happens-before4由传递性规则产生
因此线程A执行ThreadB.start()之前对共享变量所做的修改,在线程B执行后都将确保对线程B可见。
3.3join()规则假设线程A执行的过程中,通过执行ThreadB.join()来等待线程B终止;则线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会读这些共享变量。
join()程序的happens-before关系图:
分析上图:
2happens-before4由join()规则产生
4happens-before5由程序顺序规则产生
2happens-before5由传递性规则产生
因此线程A执行操作ThreadB.join()并成功返回,线程B中任意操作都将对线程A可见。
文章总结至《Java并发编程艺术》,下篇总结“双重检查所定与延迟初始化”,敬请关注。