并发编程之并发关键字篇--final

目录

final的简介

多线程中的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关键字并不影响对象的可变性。即使一个对象的引用被声明为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

上面我们聊的final使用,应该属于Java基础层面的,下面将介绍final在多线程并发的情况。

final域重排序规则

final域为基本类型

final域的重排序规则是确保在构造函数结束之前,该final域的写操作不会被重排序到构造函数之外。这个规则包含以下两个方面:

  • 编译器禁止对final域的写操作重排序到构造函数之外。
  • 编译器会在final域的写操作之后,构造函数的return语句之前插入一个storestore屏障,以防止处理器将final域的写操作重排序到构造函数之外。

读final域的重排序规则是:

  • 在一个线程中,初次读取对象引用和初次读取该对象的final域时,JMM会禁止这两个操作的重排序。
  • 具体实现上,处理器会在读取final域操作之前插入一个LoadLoad屏障,以确保读取final域之前已经读取到了对应的对象引用。

通过上述的重排序规则,可以确保在读取final域之前先读取了对象引用,从而避免了出现读取未初始化的final域的情况。这样可以保证在任意线程中,初次读取final域时一定是已经被正确初始化过的。而普通域则没有这个保证,可能会出现读取未初始化值的情况。

final域为引用类型

对于final域为引用类型的情况,其重排序规则与基本类型的final域略有不同。我们来具体分析下引用类型的final域的重排序规则。

写final引用域的重排序规则:

  • 编译器禁止在构造函数内将final引用对象的成员域写操作重排序到构造函数之外。
  • 编译器会在成员域写操作之后、构造函数结束之前,插入一个storestore屏障,以确保final引用对象的成员域写操作不会被重排序到构造函数之外。

与基本类型不同的是,写final引用域时并不需要考虑引用域内部的对象的构造过程。因为引用域只是保存了一个指向对象的地址,并不包含对象的具体内容。这样可以保证final引用对象的成员域写入在构造函数执行期间是可见的,并且不会被后续的代码所看到。

读final引用域的重排序规则:

对于final修饰的引用类型的成员域读操作,JMM可以确保在一个线程中初次读取final引用对象的成员域和初次读取该成员域所引用对象的普通域时,不会被重排序。换句话说,线程C可以至少看到线程A对final引用对象的成员域写入操作的结果,即能看到arrays[0] = 1。但是线程B对数组元素的写入可能被线程C看到,也可能看不到。这种情况下存在数据竞争,结果是不确定的。

如果需要保证对final引用对象的成员域写入操作对其他线程可见,则可以采用锁或者使用volatile关键字来保证可见性。

关于final重排序的总结

按照final修饰的数据类型分类:

1、对于final修饰的基本数据类型的域:

在构造函数内对final修饰的基本数据类型的写操作,与随后在构造函数之外将该对象的引用赋给一个引用变量的操作,这两个操作是不能被重排序的。编译器和处理器会禁止这种重排序。

2、对于final修饰的引用类型的域:

  • 写操作:在构造函数内对final修饰的引用类型的成员域的写操作,与随后在构造函数之外将该对象的引用赋给一个引用变量的操作,这两个操作是不能被重排序的。编译器会禁止将构造函数内的写操作重排序到构造函数之外。
  • 读操作:JMM可以确保在一个线程中初次读取final引用对象的成员域和初次读取该成员域所引用的对象时,不会被重排序。但在多线程环境下,final引用对象的成员域写操作可能不一定对其他线程可见,因此在多线程环境下需要使用同步机制(如锁或volatile关键字)来确保可见性和正确性。

总的来说,对于final修饰的域,在构造函数内的写操作不会被重排序到构造函数之外,并确保在构造函数完成之前对其他线程可见。但是在多线程环境下,必须使用同步机制来确保final引用对象的成员域写操作的可见性和有序性。

final的实现原理

Java中对final域的内存屏障插入是由编译器和虚拟机共同协作来完成的,并不是固定的规则适用于所有平台。不同的编译器、虚拟机和硬件架构可能有不同的策略来处理final域的内存屏障。

在一般情况下,写final域会要求编译器在final域写操作之后插入一个StoreStore屏障,这可以确保写操作对其他线程的可见性。而读final域的重排序规则会要求编译器在读操作之前插入一个LoadLoad屏障,以确保读操作不会被乱序执行。

但是,在某些情况下,特别是在X86处理器上,由于它的内存模型较强(内存访问按照程序顺序执行),可能会省略一些内存屏障,因为X86不会对写-写重排序,也不会对有间接依赖性的操作重排序。

因此,对于final域的内存屏障插入在不同平台和硬件上的行为可能会有所不同。具体的插入与否还取决于编译器和虚拟机的策略。这也强调了编写并发代码时,不仅要依赖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。在构造函数中,我们初始化了该final域,使其指向一个新的HashMap对象。

为了确保线程安全,我们使用synchronized关键字对setData()和getData()方法的访问进行同步。在这两个方法中,我们采用了“锁住final对象,访问可变状态”的模式,即通过synchronized (map)语句块,锁住map对象,确保并发访问时只有一个线程可以访问该map对象,从而保证了线程安全性。

在main()方法中,我们创建了一个final对象example,并启动两个线程,分别向该对象中写入数据和读取数据。由于example是一个final对象,因此可以在多线程环境中安全地访问和修改其中的可变状态。

总之,通过使用final关键字,我们可以确保final域的初始化和线程安全性,在多线程编程中更加方便和安全。

你可能感兴趣的:(Java进阶篇,java,jvm,开发语言)