Java 语言的每个关键字都设计的很巧妙,金雕玉琢,只有深度钻研其中,才知其中懊悔,本文带领大家一起深入理解 Java 内存模型之 final。

加我微信好友的不要着急,手机没电了,等我借个充电器之后,再一一通过!

与前面介绍的锁和 volatile 相比较,对 final 域的读和写更像是普通的变量访问。对于 final 域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  2. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

下面,我们通过一些示例性的代码来分别说明这两个规则:

public class FinalExample {int i;                            // 普通变量final int j;                      //final 变量static FinalExample obj;public void FinalExample () {     // 构造函数        i = 1;                        // 写普通域        j = 2;                        // 写 final 域}public static void writer () {    // 写线程 A 执行        obj = new FinalExample ();}public static void reader () {       // 读线程 B 执行FinalExample object = obj;       // 读对象引用int a = object.i;                // 读普通域int b = object.j;                // 读 final 域}}

这里假设一个线程 A 执行 writer () 方法,随后另一个线程 B 执行 reader () 方法。下面我们通过这两个线程的交互来说明这两个规则。

# 写 final 域的重排序规则

写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。

  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

现在让我们分析 writer () 方法。writer () 方法只包含一行代码:finalExample = new FinalExample ()。这行代码包含两个步骤:

  1. 构造一个 FinalExample 类型的对象;

  2. 把这个对象的引用赋值给引用变量 obj。

假设线程 B 读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:

在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,读线程 B 正确的读取了 final 变量初始化之后的值。

写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B“看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域 i 的写操作被重排序到构造函数外,此时初始值 2 还没有写入普通域 i)。

# 读 final 域的重排序规则

读 final 域的重排序规则如下:

  • 在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。

reader() 方法包含三个操作:

  1. 初次读引用变量 obj;

  2. 初次读引用变量 obj 指向对象的普通域 j。

  3. 初次读引用变量 obj 指向对象的 final 域 i。

现在我们假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。

读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。

# 如果 final 域是引用类型

上面我们看到的 final 域是基础数据类型,下面让我们看看如果 final 域是引用类型,将会有什么效果?

请看下列示例代码:

public class FinalReferenceExample {final int[] intArray;                     //final 是引用类型static FinalReferenceExample obj;public FinalReferenceExample () {        // 构造函数        intArray = new int[1];              //1        intArray[0] = 1;                   //2}public static void writerOne () {          // 写线程 A 执行        obj = new FinalReferenceExample ();  //3}public static void writerTwo () {          // 写线程 B 执行        obj.intArray[0] = 2;                 //4}public static void reader () {              // 读线程 C 执行if (obj != null) {                    //5int temp1 = obj.intArray[0];       //6}}}

这里 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

  1. 在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

对上面的示例程序,我们假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader () 方法。下面是一种可能的线程执行时序:

在上图中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lock 或 volatile)来确保内存可见性。

# 为什么 final 引用不能从构造函数内“逸出”

前面我们提到过,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面示例代码:

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) {                     //3int temp = obj.i;                 //4}}}

假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且即使在程序中操作 2 排在操作 1 后面,执行 read() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如下图所示:

从上图我们可以看出:在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。

# final 语义在处理器中的实现

现在我们以 x86 处理器为例,说明 final 语义在处理器中的具体实现。

上面我们提到,写 final 域的重排序规则会要求译编器在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 障屏。读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。

由于 x86 处理器不会对写 - 写操作做重排序,所以在 x86 处理器中,写 final 域需要的 StoreStore 障屏会被省略掉。同样,由于 x86 处理器不会对存在间接依赖关系的操作做重排序,所以在 x86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。也就是说在 x86 处理器中,final 域的读 / 写不会插入任何内存屏障!

# JSR-133 为什么要增强 final 的语义

在旧的 Java 内存模型中 ,最严重的一个缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整形 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为了 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变(参考文献 2 中有一个具体的例子,感兴趣的读者可以自行参考,这里就不赘述了)。

为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用),就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。

编辑:业余草
来源:https://www.xttblog.com/?p=4957

 往期推荐 

?

  • 阿里面试官:数据库连接池有必要吗?你对它的底层实现了解过没?
  • 代码,到底该如何分层,才能给人赏心悦目的感觉?
  • 你这代码写得真丑,满屏的try-catch,全局异常处理不会吗?

 

点击

写屏障是什么_面试官为什么问内存模型总离不开final关键字,该如何应对?相关推荐

  1. java 反射获取属性名和值_面试官这样问我Java反射原理,我刚好都会

    上周我投递出了简历,岗位是java后端开发工程师.这周美团面试官给我进行了面试,面试过程中他问了Java的反射原理.(不得不夸一句,美团的效率真高,上午面完一面,晚上二面马上安排上了.) 无论什么Ja ...

  2. redisserver是什么问题_面试官老是问:为什么采用单线程的Redis也会如此之快?...

    Java面试笔试面经.Java技术每天学习一点 公众号Java面试 关注我不迷路 作者:kaito 来源:http://kaito-kidd.com/2020/06/28/why-redis-so-f ...

  3. 为什么重新new两个线程线程号相同_面试官每次问我关于线程间通信方法,我都回答的很糟糕...

    线程的生命周期 废话不多写.首先我们先回顾回顾,理解下线程的生命周期,以及不同的阶段的区别: 新建状态(NEW) 当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时仅由JVM为其分配 ...

  4. 在c#中用mutex类实现线程的互斥_面试官经常问的synchronized实现原理和锁升级过程,你真的了解吗...

    本篇文章主要从字节码和JVM底层来分析synchronized实现原理和锁升级过程,其中涉及到了简单认识字节码.对象内部结构以及ObjectMonitor等知识点. 阅读本文之前,如果大家对synch ...

  5. c++ 不插入重复元素但也不排序_面试官爱问的 10 大经典排序算法,20+ 张图来搞定...

    (给算法爱好者加星标,修炼编程内功) 作者:技术让梦想更伟大 / 李肖遥 (本文来自作者投稿) 冒泡排序 简介 冒泡排序是因为越小的元素会经由交换以升序或降序的方式慢慢浮到数列的顶端,就如同碳酸饮料中 ...

  6. java限制只能修改一次订单_面试官再问你怎么修改订单,就把这篇甩给他

    从下单开始.支付.发货,收货,每一个环节,都少不了更新订单,每一次更新又需要同时更新好几张表. 这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题. 那么如何才能保证订单服 ...

  7. python快速排序最简单写法_面试官:来,这位精神小伙,简简单单写个快速排序吧...

    当你的才华还撑不起你的野心时,你应该静下心去学习 . 面试现场,终于到了紧张刺激的手撕代码环节,你忐忑的心情随着考官的一句话归于平静. 你简直不敢相信眼前这个穿着格子衬衫,牛仔裤角有些发白,头发在风中 ...

  8. qps是什么意思_面试官:说说你之前负责的系统,QPS 能达到多少?

    被面试官经常问到之前开发的系统接口 QPS 能达到多少,经常给不出一个数值,支支吾吾,导致整体面试效果降低? 原因基本是一些公司中,做完功能测试就完了,压根不会有性能测试这一步,或者说并发量较少,没有 ...

  9. 307 跳转会携带请求方法吗_面试官:GET和POST两种基本请求方法有什么区别

    点击上方蓝色"后端面试那些事儿",选择"设为星标" 学最好的别人,做最好的我们 来源:r6d.cn/j26B GET和POST是HTTP请求的两种基本方法,要说 ...

最新文章

  1. 2018-3-31 ELM极限学习机--笔记(黄广斌PPT,以及极限学习机原理介绍的部分,但是忘记网址了)
  2. @Configuration与@Component区别
  3. 通过 39 个 问答方式快速了解学习 Git
  4. 脱机下载至校验成功的脚本
  5. 【hadoop】Apach Hdoop 安全模式
  6. java rowmapper 通用实现_必经之路!各大网站力推Java代码优化:77案例+28技巧
  7. linux新建用户不显示,linux系统无法添加用户帐号的原因分析
  8. pysimplegui 显示 html,PySimpleGUI 的第一个桌面软件
  9. 常用的MySQL命令大全
  10. Ubuntu14.04搭建Oracle instantClient 11.2.0.4并配置cx_Oracle5.1.2
  11. redis/memcached可视化客户端工具TreeNMS
  12. echarts中geoCoordMap世界地图国家及中国城市的经纬度数组整理
  13. 20190301 小中大
  14. 阿里云服务器迁移内容
  15. 用java实现编译器之代码实现Thompson构造:在简单NFA的基础上构造更复杂的NFA
  16. VMware宿主机与虚拟机之间的文件夹相互共享(看图操作)
  17. python运维自动化脚本案例-python自动化运维脚本范例
  18. 海淀区第九届单片机竞赛获奖名单_2014年北京市中小学生单片机获奖名单-获奖名单...
  19. HTB----Heist(Hard)
  20. 运放(二)-深度负反馈电路放大倍数分析

热门文章

  1. Java代码的执行顺序
  2. 乘方运算中的“次幂”和“次方”有什么区别?
  3. HH SaaS电商系统服务商品在移动端下单结算的交互设计
  4. linux内核cpu负载计算,CPU 负载 — The Linux Kernel documentation
  5. 递归算法 流程图_什么是算法?如何学习算法?算法入门的学习路径
  6. d3js mysql_D3.js入门指南
  7. C语言 | 赋值与运算符
  8. 新手学习C语言编程的8个致命错误,你中招了吗?
  9. 今晚课题:2019-3-23
  10. java第二章复习_JAVA第二章知识点