了解 final 关键字在 Java 并发编程领域的作用吗?
在 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 域为基本类型
先看一段示例性的代码:
假设线程 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 基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看。
针对上面的实例程序,线程 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 了
可能的执行时序如图所示:
假设一个线程 A 执行 writer 方法,另一个线程执行 reader 方法。因为构造函数中操作 1 和 2 之间没有数据依赖性,1 和 2 可以重排序,先执行了 2,这个时候引用对象 referenceDemo 是个没有完全初始化的对象,而当线程 B 去读取该对象时就会出错。尽管依然满足了 final 域写重排序规则:在引用对象对所有线程可见时,其 final 域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。
使用 final 的限制条件和局限性
当声明一个 final 成员时,必须在构造函数退出前设置它的值。
或者
将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。
下面的方法仍然可以修改该 list。
声明为 final 可以保证如下操作不合法
如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。
" 其他方式 " 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。
final byte
如果对 b1 b2 加上 final 就不会出错
文章转载自:seven97_top
评论