多線程與高並發(五)final關鍵字


final可以修飾變量,方法和類,也就是final使用范圍基本涵蓋了java每個地方,我們先依次學習final的基礎用法,然后再研究final關鍵字在多線程中的語義。

一、變量

變量,可以分為成員變量以及方法局部變量,我們再依次進行學習。

1.1 成員變量

成員變量可以分為類變量(static修飾的變量)以及實例變量,這兩種類型的變量賦初值的時機是不同的,類變量可以在聲明變量的時候直接賦初值或者在靜態代碼塊中給類變量賦初值,實例變量可以在聲明變量的時候給實例變量賦初值,在非靜態初始化塊中以及構造器中賦初值。

這里面要注意,在final變量未初始化時系統不會進行隱式初始化,會出現報錯。

歸納總結:

  1. 類變量:必須要在靜態初始化塊中指定初始值或者聲明該類變量時指定初始值,而且只能在這兩個地方之一進行指定;

  2. 實例變量:必要要在非靜態初始化塊聲明該實例變量或者在構造器中指定初始值,而且只能在這三個地方進行指定。

1.2 局部變量

對於局部變量使用final,理解就更簡單,局部變量的僅有一次賦值,一旦賦值之后再次賦值就會出錯:

1.3 基本數據類型 VS 引用數據類型

上面討論的基本都是基本數據類型,基本數據類型一旦賦值之后,就不允許修改,那引用類型呢?

public class FinalDemo1 {
    //在聲明final實例成員變量時進行賦值
    private final static Person person = new Person(24, 170);

    public static void main(String[] args) {
        //對final引用數據類型person進行更改
        person.age = 22;
        Person p = new Person(50, 160);
        //對引用類型變量直接修改會報錯
        //person = p;
        System.out.println(person.toString());
    }

    static class Person {
        private int age;
        private int height;

        public Person(int age, int height) {
            this.age = age;
            this.height = height;
        }

        @Override
        public String toString() {
            return "Person{" +
                    "age=" + age +
                    ", height=" + height +
                    '}';
        }
    }
}

上面的例子可以看出,我們可以對引用數據類型的屬性進行更改,但是不能直接對引用類型的變量進行修改,

final只保證這個引用類型變量所引用的地址不會發生改變

二、方法

當一個方法被final關鍵字修飾時,說明此方法不能被子類重寫

public class FinalDemoParent {
    //final修飾的方法不能被子類重載
    public final void test() {

    }
}

子類不能重寫該方法

在Object中,getClass()方法就是final的,我們就不能重寫該方法,但是hashCode()方法就不是被final所修飾的,我們就可以重寫hashCode()方法。

三、類

當一個類被final修飾時,表示該類是不能被子類繼承的,當我們想避免由於子類繼承重寫父類的方法和改變父類屬性,帶來一定的安全隱患時,就可以使用final修飾。

擴展思考,為什么String類為什么是final的?先看下源碼

final修飾的String,代表了String的不可繼承性,final修飾的char[]代表了被存儲的數據不可更改性。但是:我們知道引用類型的不可變僅僅是引用地址不可變,不代表了數組本身不會變,這個時候,起作用的還有private,正是因為兩者保證了String的不可變性。

那么為什么保證String不可變呢,因為只有當字符串是不可變的,字符串池才有可能實現。字符串池的實現可以在運行時節約很多heap空間,因為不同的字符串變量都指向池中的同一個字符串。但如果字符串是可變的,那么字符串池將不能實現,因為這樣的話,如果變量改變了它的值,那么其它指向這個值的變量的值也會一起改變。

因為字符串是不可變的,所以是多線程安全的,同一個字符串實例可以被多個線程共享。這樣便不用因為線程安全問題而使用同步。字符串自己便是線程安全的。

因為字符串是不可變的,所以在它創建的時候HashCode就被緩存了,不需要重新計算。這就使得字符串很適合作為Map中的鍵,字符串的處理速度要快過其它的鍵對象。這就是HashMap中的鍵往往都使用字符串。

四、final的重排序規則

對於final域,編譯器和處理器要遵守兩個重排序規則。

  1. 在構造函數內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
  2. 初次讀一個包含final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。

我們通過下面的例子來看:

public class FinalDemo3 {
private int i;// 普通變量
private final int j;// final變量
private static FinalDemo3 obj;

public FinalDemo3() { // 構造函數
i = 1; // 寫普通域
j = 2;// 寫final域
}

public static void writer() {// 寫線程A執行
obj = new FinalDemo3();
}

public static void reader() {// 讀線程B執行
FinalDemo3 object = obj; // 讀對象引用
int a = object.i; // 讀普通域
int b = object.j; // 讀final域
}
}

這里假設一個線程A執行writer()方法,隨后另一個線程B執行reader()方法。下面我們通過這兩個線程的交互來說明這兩個規則。

4.1 寫final域的重排序規則

寫final域的重排序規則禁止對final域的寫重排序到構造函數之外,這個規則的實現主要包含了兩個方面:

  1. JMM禁止編譯器把final域的寫重排序到構造函數之外;

  2. 編譯器會在final域寫之后,構造函數return之前,插入一個storestore屏障。這個屏障可以禁止處理器把final域的寫重排序到構造函數之外。

我們分析writer()方法,writer方法雖然只有一行代碼,但其實是做了兩件事情的:

  1. 構造了一個FinalDemo3對象;

  2. 把這個對象賦值給成員變量obj。

我們先假設線程B讀對象引用與讀對象的成員域之間沒有重排序,那以下是一種可能的執行時序:

這里可以看出, 寫普通域的操作被編譯器重排序到了構造函數之外,讀線程B錯誤地讀取了普通變量i初始化之前的值。而寫final域的操作,被寫final域的重排序規則“限定”在了構造函數之內,讀線程B正確地讀取了final變量初始化之后的值。

寫final域的重排序規則可以確保:在對象引用為任意線程可見之前,對象的final域已經被正確初始化過了,而普通域不具有這個保障

要得到這個效果,還需要一個保證:在構造函數內部,不能讓這個被構造對象的引用為其他線程所見,也就是對象引用不能在構造函數中“逸出”。

4.2 讀final域的重排序規則

讀final域的重排序規則是,在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。

初次讀對象引用與初次讀該對象包含的final域,這兩個操作之間存在間接依賴關系。由於編譯器遵守間接依賴關系,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,也不會重排序這兩個操作。但有少數處理器允許對存在間接依賴關系的操作做重排序(比如alpha處理器),這個規則就是專門用來針對這種處理器的。

reader()方法包含3個操作。

  1. 初次讀引用變量obj。

  2. 初次讀引用變量obj指向對象的普通域j。

  3. 初次讀引用變量obj指向對象的final域i。

假設寫線程A沒有發生任何重排序,同時程序在不遵守間接依賴的處理器上執行,那以下一種可能的執行時序:

讀對象的普通域的操作被處理器重排序到讀對象引用之前。讀普通域時,該域還沒有被寫線程A寫入,這是一個錯誤的讀取操作。而讀final域的重排序規則會把讀對象final域的操作“限定”在讀對象引用之后,此時該final域已經被A線程初始化過了,這是一個正確的讀取操作。

讀final域的重排序規則可以確保:在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用。在這個示例程序中,如果該引用不為null,那么引用對象的final域一定已經被A線程初始化過了。

4.3 final域為引用類型

上面看到的final域是基礎數據類型,如果final域是引用類型,將會有什么效果?請看下列示例代碼:

public class FinalDemo4 {
    final int[] intArray; // final是引用類型
    static FinalDemo4 obj;

    public FinalDemo4() { // 構造函數
        intArray = new int[1]; // 1
        intArray[0] = 1; // 2
    }

    public static void writerOne() { // 寫線程A執行
        obj = new FinalDemo4(); // 3
    }

    public static void writerTwo() { // 寫線程B執行
        obj.intArray[0] = 2; // 4
    }

    public static void reader() { // 讀線程C執行
        if (obj != null) { // 5
            int temp1 = obj.intArray[0]; // 6
        }
    }
}

final域為一個引用類型,它引用一個int型的數組對象。對於引用類型,寫final域的重排序規則對編譯器和處理器增加了如下約束:在構造函數內對一個final引用的對象的成員域的寫入,與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序

對上面的示例程序,假設首先線程A執行writerOne()方法,執行完后線程B執行writerTwo()方法,執行完后線程C執行reader()方法。那下面就可能是一種時序:

 

 1是對final域的寫入,2是對這個final域引用的對象的成員域的寫入,3是把被構造的對象的引用賦值給某個引用變量。這里除了前面提到的1不能和3重排序外,2和3也不能重排序。 JMM可以確保讀線程C至少能看到寫線程A在構造函數中對final引用對象的成員域的寫入。即C至少能看到數組下標0的值為1。而寫線程B對數組元素的寫入,讀線程C可能看得到,也可能看不到。JMM不保證線程B的寫入對讀線程C可見,因為寫線程B和讀線程C之間存在數據競爭,此時的執行結果不可預知。 如果想要確保讀線程C看到寫線程B對數組元素的寫入,寫線程B和讀線程C之間需要使用同步原語(lock或volatile)來確保內存可見性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM