Java内存模型final的内存语义
作者:李子捌 发布时间:2023-06-05 08:02:25
上篇并发编程之Java内存模型volatile的内存语义介绍了volatile
的内存语义,本文讲述的是final
的内存语义,相比之下,final
域的读和写更像是普通变量的访问。
1、final域的重排序规则final
对于final
域编译器和处理器遵循两个重排序规则
在构造函数内对一个
final
域的写入,与随后把这个对象的引用赋值给另一个引用变量,这两个操作之间不能重排序初次读一个包含
final
域的对象的引用,与随后初次读这个final
域,这两个操作之间不能重排序。
用代码来说明上面两种重排序规则:
package com.lizba.p1;
/**
* <p>
*
* </p>
*
* @Author: Liziba
* @Date: 2021/6/11 20:37
*/
public class FinalExample {
/** 普通变量 */
int i;
/** final变量 */
final int j;
/** 对象引用 */
static FinalExample obj;
/**
* 构造函数
*/
public FinalExample() {
// 写普通域
this.i = 1;
// 写final域
this.j = 2;
}
/**
* 线程A执行writer写方法
*
*/
public static void writer() {
obj = new FinalExample();
}
/**
* 线程B执行reader读方法
*
*/
public static void reader() {
// 读对象的引用
FinalExample finalExample = obj;
// 读普通域
int a = finalExample.i;
// 读final域
int b = finalExample.j;
}
}
假设线程A执行writer()
方法,线程B执行reader()
方法。下面来通过这两个线程的交互来说明这两个规则。
2、写final域的重排序规则
写final域的重排序禁止吧final域的写重排序到构造函数之外。通过如下方式来实现:
JMM禁止编译器把
final
域的写重排序到构造函数之外编译器会在final域的写之后,构造函数return之前,插入一个
StoreStore
屏障。这个屏障禁止处理器把final
域的写重排序到构造函数之外。
现在开始分析writer()方法:
/**
* 线程A执行writer写方法
*
*/
public static void writer() {
obj = new FinalExample();
}
构造一个
FinalExample
类型的对象将对象的引用赋值给变量
obj
首先假设线程B读对象引用与读对象的成员域之间没有重排序,则下图是其一种执行可能
线程执行时序图:
3、读final与的重排序规则
读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final
域,JMM
禁止处理器重排序这两个操作(注意是处理器)。编译器会在读final
域操作的前面插入一个LoadLoad
屏障。
解释:初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。
编译器遵守间接依赖关系,编译器不会重排序这两个操作
大多数处理器也遵守间接依赖,不会重排序这两个操作。但是少部分处理器允许对存在间接依赖关系的操作做重排序(比如
alpha
处理器),这个规则就是专门针对这种处理器的。
分析reader()方法:
/**
* 线程B执行reader读方法
*
*/
public static void reader() {
// 读对象的引用
FinalExample finalExample = obj;
// 读普通域
int a = finalExample.i;
// 读final域
int b = finalExample.j;
}
初次读引用变量obj
初次读引用变量obj指向对象的普通域j
初次读引用变量obj指向对象的final域i
假设B线程所处的处理器不遵守间接依赖关系,且A线程执行过程中没有发生任何重排序,此时存在如下的执行时序:
线程执行时序图:
上图B线程中读对象的普通域被重排序到处理器读取对象引用之前, 此时普通域i还没有被线程A写入,因此这是一个错误的读取操作。但是final域的读取会被重排序规则把读final域的操作“限定”在读该final域所属对象的引用读取之后,此时final域已经被正确的初始化了,这是一个正确的读取操作。
总结:
读final域的重排序规则可以确保,在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
4、final域为引用类型
上面讲述了基础数据类型,如果final域修饰的引用类型又该如何?
package com.lizba.p1;
/**
* <p>
* final 修饰引用类型变量
* </p>
*
* @Author: Liziba
* @Date: 2021/6/11 21:52
*/
public class FinalReferenceExample {
/** final是引用类型 */
final int[] intArray;
static FinalReferenceExample obj;
/**
* 构造函数
*/
public FinalReferenceExample() {
this.intArray = new int[1]; // 1
intArray[0] = 1; // 2
}
/**
* 写线程A执行
*/
public static void writer1() {
obj = new FinalReferenceExample(); // 3
}
/**
* 写线程B执行
*/
public static void writer2() {
obj.intArray[0] = 2; // 4
}
/**
* 读线程C执行
*/
public static void reader() {
if (obj != null) { // 5
int temp = obj.intArray[0]; // 6
}
}
}
如上final
域为一个int类型的数组的引用变量。对应引用类型,写final
域的重排序对编译器和处理器增加了如下约束:
在构造函数内对一个
final
引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给另一个引用变量,这两个操作不能重排序。
对于上述程序,假设A执行writer1()方法,执行完后线程B执行writer2()方法,执行完后线程C执行reader()方法。则存在如下线
程执行时序:引用型final的执行时序图
JMM对于上述代码,可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即写线程C至少能看到数组下标0的值为1。但是写线程B对数组元素的写入,读线程C可能看得到可能看不到。JMM不能保证线程B的写入对读线程C可见。因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
此时如果想确保读线程C看到写线程B对数组元素的写入,可以结合同步原语(volatile
或者lock
)来实现。
5、为什么final引用不能从构造函数内“逸出”
本文一直在说写final
域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化了。那究竟是如何实现的呢?
其实这需要另一个条件:在构造函数内部,不能让这个被构造对象的引用被其它线程所见。也就是对象引用不能在构造函数中“逸出”。
示例代码:
package com.lizba.p1;
/**
* <p>
* final引用逸出demo
* </p>
*
* @Author: Liziba
* @Date: 2021/6/11 22:33
*/
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample() {
i = 1; // 1、写final域
obj = this; // 2、this引用在此处"逸出"
}
public static void writer() {
new FinalReferenceEscapeExample();
}
public static void reader() {
if (obj != null) { // 3
int temp = 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域在构造函数中被初始化之后的值。
来源:https://juejin.cn/post/7017979592119943198
猜你喜欢
- 前言:由于项目需求,短信验证码的接口需要换成阿里大于的,但是尴尬的发现阿里大于的jar包没有maven版本的,于是便开始了一上午的 * 引包之
- 按行读取文件package test; import java.io.*; import java.util.*; public class
- 近期,Apache SkyWalking 修复了一个隐藏了近4年的Bug - TTL timer 可能失效问题,这个 bug 在 SkyWa
- 本文介绍了Flutter 实现下拉刷新上拉加载的示例代码,分享给大家,具体如下:效果图 使用方法添加依赖depende
- 类必须先定义才能使用。类是创建对象的模板,创建对象也叫类的实例化。下面通过一个简单的例子来理解Java中类的定义:public class
- 一、闭包的定义。有很多不同的人都对闭包过进行了定义,这里收集了一些。# 是引用了自由变量的函数。这个函数通常被定义在另一个外部函数中,并且引
- 简介optional类是java8中引入的针对NPE问题的一种优美处理方式,源码作者也希望以此替代null。历史1965年,英国一位名为To
- 二分查找又称折半查找,它是一种效率较高的查找方法。折半查找的算法思想是将数列按有序化(递增或递减)排列,查找过程中采用跳跃式方式查找,即先以
- google benchmark已经为我们提供了类似的功能,而且使用相当简单。具体的解释在后面,我们先来看几个例子,我们人为制造几个时间复杂
- 弃用内容先来纠正一个误区。主要之前在版本更新介绍的时候,存在一些表述上的问题。导致部分读者认为这次的更新是Datasource本身初始化的调
- 本文主要和大家分享介绍了关于Java JDK * 使用的相关内容,分享出来供大家参考学习,下面来一起看看详细的介绍:前言代理是一种常用的
- NDK部分1、下载ndk这里就一笔带过了。2、解压ndk不要解压,文件权限会出错。执行之,会自动解压,然后mv到想放的地方。我放到了”/us
- 一、题目描述题目:模拟一个简单的银行系统,使用两个不同的线程向同一个账户存钱。实现:使用特殊域变量volatile实现同步。二、解题思路创建
- 本文为大家分享了JAVA语言课程设计:连连看小游戏,供大家参考,具体内容如下1.设计内容界面中有5*10的界面,图中共有6种不同的图片,每两
- 前言在淘宝内网里看到同事发了贴说了一个CPU被100%的线上故障,并且这个事发生了很多次,原因是在Java语言在并 * 况下使用HashMap
- springboot多模块化整合mybatis,mapper自动注入失败问题启动类添加@MapperScan或@ComponentScan,
- 一、Bundle进行IPC介绍四大组件中的三大组件(Activity、Service、Receiver)都是支持在Intent中传递Bund
- tcp客户端示例#include <errno.h> #include <sys/socket.h> #includ
- 这篇文章主要介绍了SpringBoot FreeWorker模板技术解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考
- IDEA 2020 源生是不支持中文的,感谢捷克工程师(可能是由国人实现)对我大天朝程序员的“照顾”,且不说这个必要性到底有多大,但从侧面体