相识final关键字在Java并发编程领域的作用吗?

十念  金牌会员 | 2024-10-8 18:08:08 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 886|帖子 886|积分 2658

在Java并发编程领域,final关键字扮演着一个至关重要的角色。虽然许多同学认识final用于修饰变量、方法和类的基本用法,但其在并发环境中的应用和原理却常常被忽视。final关键字不但仅是一个简单的修饰符,它在多线程编程中确保对象状态的可见性和稳定性,这对于构建线程安全的应用至关重要。本文将深入探讨final关键字的作用,揭示其在Java并发编程领域中的重要性及实现原理。
final域重排序规则

Java内存模子为了能让处置惩罚器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说Java内存模子就是 弱内存数据模子。同时,处置惩罚器和编译为了性能优化会对指令序列有编译器和处置惩罚器重排序。
而final能够做出如下保证:当创建一个对象时,使用final关键字能够使得另一个线程不会访问到处于“部门创建”的对象,否则是会大概发生的。这是由于,当用作对象的一个属性时,final有着如下的语义:
当构造函数竣事时,final范例的值是被保证其他线程访问该对象时,它们的值是可见的
对于final域,编译器和处置惩罚器要遵守两个重排序规则。

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给个引用变量,这两个利用之间不能重排序。
  • 初次读一个包罗final域的对象的引用,与随后初次读这个final域,这两个利用之间不能重排序。
为什么是必须的

使用final是所谓的安全发布(safe publication)的一种方式,这里 发布(publication)意味着在一个线程中创建它,同时另一个线程在之后的某时刻可以引用到该新创建的对象。存在如下场景:当JVM调用对象的构造函数时,它必须将各成员赋值,同时存储一个指向该对象的指针;但就像其他普通数据写入一样,这大概是乱序的。
而final可以防止此类事情的发生:假如某个成员是final的,JVM规范做出如下明确的保证:一旦对象引用对其他线程可见,则其final成员也必须精确的赋值了。
final域为基本范例

先看一段示例性的代码:
  1. public class FinalDemo {
  2.     private int a;  //普通域
  3.     private final int b; //final域
  4.     private static FinalDemo finalDemo;
  5.     public FinalDemo() {
  6.         a = 1; // 1. 写普通域
  7.         b = 2; // 2. 写final域
  8.     }
  9.     public static void writer() {//线程A执行
  10.         finalDemo = new FinalDemo();
  11.     }
  12.     public static void reader() {//线程B执行
  13.         FinalDemo demo = finalDemo; // 3.读对象引用
  14.         int a = demo.a;    //4.读普通域
  15.         int b = demo.b;    //5.读final域
  16.     }
  17. }
复制代码
假设线程A在执行writer()方法,线程B执行reader()方法。
写final域重排序规则

写final域的重排序规则 禁止对final域的写重排序到构造函数之外,这个规则的实现重要包罗了两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处置惩罚器把final域的写重排序到构造函数之外。
再分析writer方法,虽然只有一行代码,但实际上做了两件事情:

  • 构造了一个FinalDemo对象;
  • 把这个对象赋值给成员变量finalDemo。
存在的一种大概执行时序图,如下:

由于a,b之间没有数据依赖性,普通域(普通变量)a大概会被重排序到构造函数之外,线程B就有大概读到的是普通变量a初始化之前的值(零值),这样就大概出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够精确赋值,线程B就能够读到final变量初始化后的值。
因此,写final域的重排序规则可以确保:在对象引用为恣意线程可见之前,对象的final域已经被精确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有大概就是一个未精确初始化的对象finalDemo。
读final域重排序规则

读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包罗的final域,JMM会禁止这两个利用的重排序。(注意,这个规则仅仅是针对处置惩罚器),处置惩罚器会在读final域利用的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处置惩罚器不会重排序这两个利用。但是有一些处置惩罚器会重排序,因此,这条禁止重排序规则就是针对这些处置惩罚器而设定的。
read()方法重要包罗了三个利用:

  • 初次读引用变量finalDemo;
  • 初次读引用变量finalDemo的普通域a;
  • 初次读引用变量finalDemo的final域b;
假设线程A写过程没有重排序,那么线程A和线程B有一种的大概执行时序为下图:

读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的利用。而final域的读利用就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种环境。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包罗这个final域的对象的引用。
final域为引用范例

对final修饰的对象的成员域写利用

针对引用数据范例,final域写针对编译器和处置惩罚器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个利用是不能被重排序的。注意这里的是“增加”也就说前面对final基本数据范例的重排序规则在这里照旧使用。这句话是比较拗口的,下面结合实例来看。
  1. public class FinalReferenceDemo {
  2.     final int[] arrays;
  3.     private FinalReferenceDemo finalReferenceDemo;
  4.     public FinalReferenceDemo() {
  5.         arrays = new int[1];  //1
  6.         arrays[0] = 1;        //2
  7.     }
  8.     public void writerOne() {
  9.         finalReferenceDemo = new FinalReferenceDemo(); //3
  10.     }
  11.     public void writerTwo() {
  12.         arrays[0] = 2;  //4
  13.     }
  14.     public void reader() {
  15.         if (finalReferenceDemo != null) {  //5
  16.             int temp = finalReferenceDemo.arrays[0];  //6
  17.         }
  18.     }
  19. }
复制代码
针对上面的实例程序,线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种环境来讨论

由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
对final修饰的对象的成员域读利用

JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入大概看到大概看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。假如可见的,可使用锁或者volatile。
关于final重排序的总结

按照final修饰的数据范例分类:

  • 基本数据范例:

    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
    • final域读:禁止初次读对象的引用与读该对象包罗的final域的重排序。

  • 引用数据范例:

    • 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序

final的实现原理

写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的利用前插入一个LoadLoad屏障。
很故意思的是,假如以X86处置惩罚为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的利用重排序,所以在X86处置惩罚器中,读final域必要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入照旧得看是什么处置惩罚器
“溢出”带来的重排序题目

上面对final域写重排序规则可以确保:在使用一个对象引用的时间该对象的final域已经在构造函数被初始化过了。但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:
如下所示,溢出意味着,构造函数FinalReferenceEscapeDemo()还未执行完,由于2的执行,导致referenceDemo就已经不为null了
  1. public class FinalReferenceEscapeDemo {
  2.     private final int a;
  3.     private FinalReferenceEscapeDemo referenceDemo;
  4.     public FinalReferenceEscapeDemo() {
  5.         a = 1;  //1
  6.         referenceDemo = this; //2
  7.     }
  8.     public void writer() {
  9.         new FinalReferenceEscapeDemo();
  10.     }
  11.     public void reader() {
  12.         if (referenceDemo != null) {  //3
  13.             int temp = referenceDemo.a; //4
  14.         }
  15.     }
  16. }
复制代码
大概的执行时序如图所示:

假设一个线程A执行writer方法,另一个线程执行reader方法。由于构造函数中利用1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时间引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化乐成。但是,引用对象“this”逸出,该代码依然存在线程安全的题目。
使用 final 的限制条件和局限性

当声明一个 final 成员时,必须在构造函数退出前设置它的值。
  1. public class MyClass {
  2.   private final int myField = 1;
  3.   public MyClass() {
  4.     ...
  5.   }
  6. }
复制代码
或者
  1. public class MyClass {
  2.   private final int myField;
  3.   public MyClass() {
  4.     ...
  5.     myField = 1;
  6.     ...
  7.   }
  8. }
复制代码
将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。
下面的方法仍然可以修改该 list。
  1. private final List myList = new ArrayList();
  2. myList.add("Hello");
复制代码
声明为 final 可以保证如下利用不正当
  1. myList = new ArrayList();
  2. myList = someOtherList;
复制代码
假如一个对象将会在多个线程中访问而且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。
" 其他方式 " 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。
final byte
  1. byte b1=1;
  2. byte b2=3;
  3. byte b3=b1+b2;//当程序执行到这一行的时候会出错,因为b1、b2可以自动转换成int类型的变量,运算时java虚拟机对它进行了转换,结果导致把一个int赋值给byte-----出错
复制代码
假如对b1 b2加上final就不会出错
  1. final byte b1=1;
  2. final byte b2=3;
  3. byte b3=b1+b2;//不会出错,相信你看了上面的解释就知道原因了。
复制代码
关于作者

来自一线程序员Seven的探索与实践,一连学习迭代中~
本文已收录于我的个人博客:https://www.seven97.top
公众号:seven97,欢迎关注~

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

十念

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表