多執行緒與高併發(五)final關鍵字

茶底世界發表於2019-07-23

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)來確保記憶體可見性。

相關文章