目录
final的简介
多线程中的final
final域重排序规则
final域为基本类型
final域为引用类型
关于final重排序的总结
final的实现原理
为什么final引用不能从构造函数中“溢出”
代码例子
final是Java语言中的关键字,可以用于修饰类、方法和变量。
1、对于类:使用final修饰的类是最终类,即不能被继承。例如,final class MyClass表示MyClass是一个不可继承的最终类。
2、对于方法:使用final修饰的方法是最终方法,即不能被子类重写。例如,public final void myMethod()表示myMethod方法是一个最终方法,子类无法重写该方法。
3、对于变量:
final关键字的主要作用是提供不可改变性、安全性和效率等方面的保证。它可以确保类、方法或变量的定义在程序运行过程中不会被修改,从而减少了错误的可能性,使代码更加稳定和可靠。
对于类而言,使用final关键字可以避免派生类对原有类的修改或覆盖,保护了类的完整性和一致性。
对于方法而言,使用final关键字可以防止子类修改或覆盖方法的行为,确保了方法在继承体系中的稳定性和可靠性。
对于变量而言,使用final关键字可以在并发编程中提供可见性和安全性的保证。一旦变量被赋予初始值,其值将不可更改,避免了多线程环境下的数据竞争问题。
需要注意的是,final关键字并不影响对象的可变性。即使一个对象的引用被声明为final,其内部状态仍然可以被修改。要实现不可变对象,需要采取其他手段,例如通过将字段声明为私有并提供只读方法等方式。
另外,final在多线程中存在的重排序问题是指某些情况下,由于编译器、处理器或缓存等因素的影响,可能会导致程序执行顺序与我们所期望的不一致。这种问题可以通过使用volatile或synchronized等机制来解决。
以下是一个使用final关键字的Java代码示例,包含了对类、方法和变量的使用:
public final class Main { // 使用final修饰的类,不能被继承
private final int num1; // 使用final修饰的实例变量,在初始化后值不能再被修改
private static final int num2 = 100; // 使用final修饰的类变量,也称常量,只能被赋值一次
public Main(int num1) {
this.num1 = num1; // 使用final修饰的实例变量必须在构造函数中进行初始化
}
public final void myMethod(final int param) { // 使用final修饰的方法,不能被子类重写,参数也不能被修改
final String str = "Hello"; // 使用final修饰的局部变量,值也不能被修改
System.out.println(str + " World!");
for (int i = 0; i < num1; i++) { // 使用final修饰的实例变量可以在方法中使用
System.out.print(i + " ");
}
System.out.println();
System.out.println(param); // 使用final修饰的参数可以在方法中使用,但不能被修改
}
public static void main(String[] args) {
final Main obj = new Main(5); // 使用final修饰的引用,指向的对象不可变
obj.myMethod(10);
System.out.println(Main.num2); // 使用final修饰的类变量可以直接通过类名访问,值不可变
}
}
//输出结果:
//Hello World!
//0 1 2 3 4
//10
//100
上面我们聊的final使用,应该属于Java基础层面的,下面将介绍final在多线程并发的情况。
final域的重排序规则是确保在构造函数结束之前,该final域的写操作不会被重排序到构造函数之外。这个规则包含以下两个方面:
读final域的重排序规则是:
通过上述的重排序规则,可以确保在读取final域之前先读取了对象引用,从而避免了出现读取未初始化的final域的情况。这样可以保证在任意线程中,初次读取final域时一定是已经被正确初始化过的。而普通域则没有这个保证,可能会出现读取未初始化值的情况。
对于final域为引用类型的情况,其重排序规则与基本类型的final域略有不同。我们来具体分析下引用类型的final域的重排序规则。
写final引用域的重排序规则:
与基本类型不同的是,写final引用域时并不需要考虑引用域内部的对象的构造过程。因为引用域只是保存了一个指向对象的地址,并不包含对象的具体内容。这样可以保证final引用对象的成员域写入在构造函数执行期间是可见的,并且不会被后续的代码所看到。
读final引用域的重排序规则:
对于final修饰的引用类型的成员域读操作,JMM可以确保在一个线程中初次读取final引用对象的成员域和初次读取该成员域所引用对象的普通域时,不会被重排序。换句话说,线程C可以至少看到线程A对final引用对象的成员域写入操作的结果,即能看到arrays[0] = 1。但是线程B对数组元素的写入可能被线程C看到,也可能看不到。这种情况下存在数据竞争,结果是不确定的。
如果需要保证对final引用对象的成员域写入操作对其他线程可见,则可以采用锁或者使用volatile关键字来保证可见性。
按照final修饰的数据类型分类:
1、对于final修饰的基本数据类型的域:
在构造函数内对final修饰的基本数据类型的写操作,与随后在构造函数之外将该对象的引用赋给一个引用变量的操作,这两个操作是不能被重排序的。编译器和处理器会禁止这种重排序。
2、对于final修饰的引用类型的域:
总的来说,对于final修饰的域,在构造函数内的写操作不会被重排序到构造函数之外,并确保在构造函数完成之前对其他线程可见。但是在多线程环境下,必须使用同步机制来确保final引用对象的成员域写操作的可见性和有序性。
Java中对final域的内存屏障插入是由编译器和虚拟机共同协作来完成的,并不是固定的规则适用于所有平台。不同的编译器、虚拟机和硬件架构可能有不同的策略来处理final域的内存屏障。
在一般情况下,写final域会要求编译器在final域写操作之后插入一个StoreStore屏障,这可以确保写操作对其他线程的可见性。而读final域的重排序规则会要求编译器在读操作之前插入一个LoadLoad屏障,以确保读操作不会被乱序执行。
但是,在某些情况下,特别是在X86处理器上,由于它的内存模型较强(内存访问按照程序顺序执行),可能会省略一些内存屏障,因为X86不会对写-写重排序,也不会对有间接依赖性的操作重排序。
因此,对于final域的内存屏障插入在不同平台和硬件上的行为可能会有所不同。具体的插入与否还取决于编译器和虚拟机的策略。这也强调了编写并发代码时,不仅要依赖final域的特性,还需要考虑其他同步手段来确保线程安全性。
这个问题的根本原因在于不安全的发布(Unsafe Publication)。一个对象在被构造完成之前,如果它的引用被其他线程“溢出”,那么这个对象可能处于不稳定或者未完全创建完成的状态,其他线程就有可能会看到不一致或者不完整的对象。
对于final域的写重排序规则,确保了一个对象的final域初始化操作在构造函数中完成。但是,如果这个对象在构造函数中被发布给其他线程(即“溢出”),那么其他线程可能仍然会看到该对象处于未完全创建完成的状态,这可能会导致不一致或者不完整的对象。
为了避免不安全的发布,我们应该避免在构造函数中让对象引用“溢出”。一种比较常见的方式是,在构造函数中限制final引用的作用域,确保对象的正确初始化。另外,我们也可以使用私有构造函数和工厂方法来控制对象的创建和发布,从而确保对象的正确性和线程安全性。
总之,为了避免不安全的发布和确保对象的正确初始化,final引用不能从构造函数中“溢出”。
以下面的例子来说:
public class UnsafePublicationExample {
private final int value;
public UnsafePublicationExample() {
value = 42; // 初始化final域
// 将当前对象的引用传递给其他线程,造成引用的"溢出"
SomeOtherClass.publish(this);
}
public int getValue() {
return value;
}
}
class SomeOtherClass {
public static void publish(UnsafePublicationExample example) {
// 在另一个线程中访问被发布的对象
System.out.println(example.getValue());
}
}
在上述示例中,UnsafePublicationExample类有一个final域value,在构造函数中进行了初始化。然而,在构造函数中,通过调用SomeOtherClass.publish()方法,将当前对象的引用this传递给了其他线程。
如果在构造函数执行过程中,其他线程使用该对象的引用来访问getValue()方法,那么就有可能看到一个处于不一致状态的对象。因为在构造函数返回之前,其他线程可能会访问到还未完全创建完成的对象,从而导致不安全的发布。
为了避免这种不安全的发布,我们可以将对SomeOtherClass.publish()方法的调用放在对象完全构造完成之后再进行,或者使用同步机制确保正确的发布。
下面是一个Java多线程代码示例,演示如何使用final关键字确保线程安全:
import java.util.HashMap;
import java.util.Map;
public class Main {
private final Map map; // final域
public Main() {
map = new HashMap<>(); // 初始化final域
}
public void setData(String key, String value) {
synchronized (map) {
// 在同步块中更新final对象的可变状态
map.put(key, value);
}
}
public String getData(String key) {
synchronized (map) {
// 在同步块中访问final对象的可变状态
return map.get(key);
}
}
public static void main(String[] args) throws InterruptedException {
// 创建一个final对象
final Main example = new Main();
// 启动两个线程,分别写入和读取数据
Thread writer = new Thread(new Runnable() {
@Override
public void run() {
example.setData("key", "value");
}
});
Thread reader = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(example.getData("key"));
}
});
writer.start();
reader.start();
writer.join();
reader.join();
}
}
//输出结果:value
在上述示例中,FinalExample类有一个final域map,类型为Map
为了确保线程安全,我们使用synchronized关键字对setData()和getData()方法的访问进行同步。在这两个方法中,我们采用了“锁住final对象,访问可变状态”的模式,即通过synchronized (map)语句块,锁住map对象,确保并发访问时只有一个线程可以访问该map对象,从而保证了线程安全性。
在main()方法中,我们创建了一个final对象example,并启动两个线程,分别向该对象中写入数据和读取数据。由于example是一个final对象,因此可以在多线程环境中安全地访问和修改其中的可变状态。
总之,通过使用final关键字,我们可以确保final域的初始化和线程安全性,在多线程编程中更加方便和安全。