concurrency_final

第三十二章 关键字final

1 概述

关键字final在Java程序设计中有多种用途。例如,final可用于声明一个变量为只读变量;声明一个成员方法不能被子类覆盖;声明一个类不能被继承。另外,如果一个成员变量被声明为final,那么,这个final声明对多线程程序有着另一层意义。本章着重介绍final成员变量在多线程程序中的意义。

2 对象初始化的问题

Final成员变量在初始化后,不能被修改。正是因为final成员变量的只读特性,它能够被多个线程安全的同时访问,而无需任何保护。这种设计常常被称为只读对象模式(Immutable Object Pattern)。

在Java语言中,只有当构造函数(Constructor)完全结束后,才能认为对象被创建成功,才能通过引用(Reference)来访问这个对象。但是因为Java语言给与了编译器足够的自由;在不改变原程序执行结果的条件下,编译器可以改变程序的执行顺序。但问题是,当编译器扫描程序时,编译器只能理解当前线程的执行环境,编译器无法理解两个线程同时访问一个共享成员变量的情况。因此,在多线程环境下,编译器有可能会犯错。

我们用一个Java标准文档中的例子来说明这个问题。如下例所示,FinalFieldExample类定义了两个成员变量x和y;f是静态成员变量。在构造函数中,x被初始化为3,y被初始化为4。静态方法writer()创建一个新的FinalFieldExample对象,赋值给f,而静态方法reader()则读取f对象中的x和y的值,如果f引用不为空的话。

当有两个线程同时运行,一个执行writer()方法,另一个执行reader()方法时,因为,在writer()方法中的引用赋值是原子操作,所以,在两个线程中分别运行writer()和readre()方法不会发生针对变量f是否为null的竞态条件。这样执行是安全的。并且,根据Java语言的要求,当f指向一个新对象时,FinalFieldExample的构造函数已经运行完毕。从代码上看,f.x的值应被设置为3;f.y的值应被设置为4。所以,此时,在reader()方法中获得的f.y的值应该是4。但是,编译器可能会“带来麻烦”。因为,在绝大多数情况下f.y的值是4,但是f.y的值并不能保证一定是4,它还有可能是0,因为Java编译器可能会调整语句的执行顺序,将构造函数中y=4;这条语句推后至构造函数调用完毕再运行。读者可能会觉得这有些不可思议,但是,这的确是事实。

public class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f = null;

    public FinalFieldExample() {
        x = 3;
        y = 4;
    }

    static void writer() {
        // 引用赋值是原子操作
        f = new FinalFieldExample();
    }

    static void reader() {
        if (f != null) {
            int i = f.x;  // 保证 f.x 的值是3
            int j = f.y;  // f.y 的值可能是4,也可能是0
        }
    }

    public static void main(String[] args) {
        try {
            // 因为Runnable是Functional Interface,
            // 所以,在这里,Java编译器会将Method Reference转换为Runnable接口对象
            Thread thread1 = new Thread(FinalFieldExample::writer);
            Thread thread2 = new Thread(FinalFieldExample::reader);
    
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
        } catch (InterruptedException ex) {}
    }
}

所以,Java语言在多线程内容中特别强调了final关键字的特殊用途。当成员变量被声明为final时,该成员变量必须在构造函数内初始化完毕。因此,当构造函数结束后,该对象的final成员变量一定是已经初始化完毕的。所以,在上例的reader()方法中,f.x的值一定是3。

从下面Java标准文档对final关键字的解释可以看出,使用final关键字可以确保两点。其一,final成员变量能在构造函数内安全的初始化;其二,在构造函数结束后,final成员变量的值对其他线程立即可见。

An object is considered to be completely initialized when its constructor finishes. A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object's final fields. -- Java 标准文档

3 为什么赋予关键字final特殊的意义

从上述的问题来看,当一个线程构造了一个新对象,并将其赋值给一个共享引用变量时,这个赋值操作实际上是将该新对象发布出去,让其他线程使用。然而,由于Java编译器会对代码进行优化,调整运行顺序。在构造函数结束后,对象可能仍然处于中间状态。因此,这个问题被称为对象发布安全问题(Safety of Publication)。

当将成员变量声明为final后,这相当于通知编译器,不要改变构造函数内代码的执行顺序。在程序运行时,如果对象包含了final成员变量,那么,JVM会在构造函数结束之前,执行一个synthetic freeze action操作。这个操作确保了当其他线程访问该对象的引用时,要么获得空引用(null),要么获得初始化完毕的对象(即:所有final成员变量已经初始化)。

使用关键字final来解决这个对象发布安全问题是否合适,仍然是一个公开讨论的问题。毕竟,关键字final有多种用途。当成员变量声明为final后,该成员变量只能是只读变量。这一限制不一定符合应用程序的逻辑。那么,如果成员变量不能被声明为final的话,开发人员应该如何处理呢?在这种情况下,就需要使用线程同步机制了,例如使用关键字synchronized锁Lock关键字volatile,或者用于线程同步的标准库中的类或者其他工具。或者,另一种做法是将多线程的逻辑转换为单线程运行。

4 总结

本小节介绍了关键字final对于多线程程序的特殊意义。关键字final有多种用法,本章介绍的只是其中之一。在实际应用中,也不常见,但是,这的确是Java语言特性中的一环。

 

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.