你覺得你真的瞭解final嗎?

1. final的簡介

final能夠修飾變量,方法和類,用於表示所修飾的內容一旦賦值以後就不會再被改變,好比String類就是一個final類型的類。即便可以知道final具體的使用方法,我想對final在多線程中存在的重排序問題也很容易忽略,但願可以一塊兒作下探討。java

2. final的具體使用場景

final可以修飾變量,方法和類,也就是final使用範圍基本涵蓋了java每一個地方,下面就分別以鎖修飾的位置:變量,方法和類分別來講一說。程序員

2.1 變量

在java中變量,能夠分爲成員變量以及方法局部變量。所以也是按照這種方式依次來講,以免漏掉任何一個死角。編程

2.1.1 final成員變量

一般每一個類中的成員變量能夠分爲類變量(static修飾的變量)以及實例變量。針對這兩種類型的變量賦初值的時機是不一樣的,類變量能夠在聲明變量的時候直接賦初值或者在靜態代碼塊中給類變量賦初值。而實例變量能夠在聲明變量的時候給實例變量賦初值,在非靜態初始化塊中以及構造器中賦初值。類變量有兩個時機賦初值,而實例變量則能夠有三個時機賦初值。當final變量未初始化時系統不會進行隱式初始化,會出現報錯。這樣提及來仍是比較抽象,下面用具體的代碼來演示。(代碼涵蓋了final修飾變量全部的可能狀況,耐心看下去會有收穫的:) )數組

final修飾成員變量

看上面的圖片已經將每種狀況整理出來了,這裏用截圖的方式也是以爲在IDE出現紅色出錯的標記更能清晰的說明狀況。如今咱們來將這幾種狀況概括整理一下:安全

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

2.2.2 final局部變量

final局部變量由程序員進行顯式初始化,若是final局部變量已經進行了初始化則後面就不能再次進行更改,若是final變量未進行初始化,能夠進行賦值,當且僅有一次賦值,一旦賦值以後再次賦值就會出錯。下面用具體的代碼演示final局部變量的狀況:性能優化

final修飾局部變量

如今咱們來換一個角度進行考慮,final修飾的是基本數據類型和引用類型有區別嗎?多線程

final基本數據類型 VS final引用數據類型併發

經過上面的例子咱們已經看出來,若是final修飾的是一個基本數據類型的數據,一旦賦值後就不能再次更改,那麼,若是final是引用數據類型了?這個引用的對象可以改變嗎?咱們一樣來看一段代碼。ide

public class FinalExample {
    //在聲明final實例成員變量時進行賦值
    private final static Person person = new Person(24, 170);
    public static void main(String[] args) {
        //對final引用數據類型person進行更改
        person.age = 22;
        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修飾的引用數據類型變量person的屬性改爲22,是能夠成功操做的。經過這個實驗咱們就能夠看出來當final修飾基本數據類型變量時,不能對基本數據類型變量從新賦值,所以基本數據類型變量不能被改變。而對於引用類型變量而言,它僅僅保存的是一個引用,final只保證這個引用類型變量所引用的地址不會發生改變,即一直引用這個對象,但這個對象屬性是能夠改變的函數

宏變量

利用final變量的不可更改性,在知足一下三個條件時,該變量就會成爲一個「宏變量」,便是一個常量。

  1. 使用final修飾符修飾;
  2. 在定義該final變量時就指定了初始值;
  3. 該初始值在編譯時就可以惟一指定。

注意:當程序中其餘地方使用該宏變量的地方,編譯器會直接替換成該變量的值

2.2 方法

重寫?

當父類的方法被final修飾的時候,子類不能重寫父類的該方法,好比在Object中,getClass()方法就是final的,咱們就不能重寫該方法,可是hashCode()方法就不是被final所修飾的,咱們就能夠重寫hashCode()方法。咱們仍是來寫一個例子來加深一下理解: 先定義一個父類,裏面有final修飾的方法test();

public class FinalExampleParent {
    public final void test() {
    }
}
複製代碼

而後FinalExample繼承該父類,當重寫test()方法時出現報錯,以下圖:

final方法不能重寫

經過這個現象咱們就能夠看出來被final修飾的方法不可以被子類所重寫

重載?

public class FinalExampleParent {
    public final void test() {
    }

    public final void test(String str) {
    }
}
複製代碼

能夠看出被final修飾的方法是能夠重載的。通過咱們的分析能夠得出以下結論:

1. 父類的final方法是不可以被子類重寫的

2. final方法是能夠被重載的

2.3 類

當一個類被final修飾時,表名該類是不能被子類繼承的。子類繼承每每能夠重寫父類的方法和改變父類屬性,會帶來必定的安全隱患,所以,當一個類不但願被繼承時就可使用final修飾。仍是來寫一個小例子:

public final class FinalExampleParent {
    public final void test() {
    }
}
複製代碼

父類會被final修飾,當子類繼承該父類的時候,就會報錯,以下圖:

final類不能繼承

3. final的例子

final常常會被用做不變類上,利用final的不可更改性。咱們先來看看什麼是不變類。

不變類

不變類的意思是建立該類的實例後,該實例的實例變量是不可改變的。知足如下條件則能夠成爲不可變類:

  1. 使用private和final修飾符來修飾該類的成員變量
  2. 提供帶參的構造器用於初始化類的成員變量;
  3. 僅爲該類的成員變量提供getter方法,不提供setter方法,由於普通方法沒法修改fina修飾的成員變量;
  4. 若是有必要就重寫Object類 的hashCode()和equals()方法,應該保證用equals()判斷相同的兩個對象其Hashcode值也是相等的。

JDK中提供的八個包裝類和String類都是不可變類,咱們來看看String的實現。

/** The value is used for character storage. */
 private final char value[];
複製代碼

能夠看出String的value就是final修飾的,上述其餘幾條性質也是吻合的。

4. 多線程中你真的瞭解final嗎?

上面咱們聊的final使用,應該屬於Java基礎層面的,當理解這些後咱們就真的算是掌握了final嗎?有考慮過final在多線程併發的狀況嗎?在java內存模型中咱們知道java內存模型爲了能讓處理器和編譯器底層發揮他們的最大優點,對底層的約束就不多,也就是說針對底層來講java內存模型就是一弱內存數據模型。同時,處理器和編譯爲了性能優化會對指令序列有編譯器和處理器重排序。那麼,在多線程狀況下,final會進行怎樣的重排序?會致使線程安全的問題嗎?下面,就來看看final的重排序。

4.1 final域重排序規則

4.1.1 final域爲基本類型

先看一段示例性的代碼:

public class FinalDemo {
    private int a;  //普通域
    private final int b; //final域
    private static FinalDemo finalDemo;

    public FinalDemo() {
        a = 1; // 1. 寫普通域
        b = 2; // 2. 寫final域
    }

    public static void writer() {
        finalDemo = new FinalDemo();
    }

    public static void reader() {
        FinalDemo demo = finalDemo; // 3.讀對象引用
        int a = demo.a;    //4.讀普通域
        int b = demo.b;    //5.讀final域
    }
}
複製代碼

假設線程A在執行writer()方法,線程B執行reader()方法。

寫final域重排序規則

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

  1. JMM禁止編譯器把final域的寫重排序到構造函數以外;
  2. 編譯器會在final域寫以後,構造函數return以前,插入一個storestore屏障(關於內存屏障能夠看這篇文章)。這個屏障能夠禁止處理器把final域的寫重排序到構造函數以外。

咱們再來分析writer方法,雖然只有一行代碼,但實際上作了兩件事情:

  1. 構造了一個FinalDemo對象;
  2. 把這個對象賦值給成員變量finalDemo。

咱們來畫下存在的一種可能執行時序圖,以下:

final域寫可能的存在的執行時序

因爲a,b之間沒有數據依賴性,普通域(普通變量)a可能會被重排序到構造函數以外,線程B就有可能讀到的是普通變量a初始化以前的值(零值),這樣就可能出現錯誤。而final域變量b,根據重排序規則,會禁止final修飾的變量b重排序到構造函數以外,從而b可以正確賦值,線程B就可以讀到final變量初始化後的值。

所以,寫final域的重排序規則能夠確保:在對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,而普通域就不具備這個保障。好比在上例,線程B有可能就是一個未正確初始化的對象finalDemo。

讀final域重排序規則

讀final域重排序規則爲:在一個線程中,初次讀對象引用和初次讀該對象包含的final域,JMM會禁止這兩個操做的重排序。(注意,這個規則僅僅是針對處理器),處理器會在讀final域操做的前面插入一個LoadLoad屏障。實際上,讀對象的引用和讀該對象的final域存在間接依賴性,通常處理器不會重排序這兩個操做。可是有一些處理器會重排序,所以,這條禁止重排序規則就是針對這些處理器而設定的。

read()方法主要包含了三個操做:

  1. 初次讀引用變量finalDemo;
  2. 初次讀引用變量finalDemo的普通域a;
  3. 初次讀引用變量finalDemo的final與b;

假設線程A寫過程沒有重排序,那麼線程A和線程B有一種的可能執行時序爲下圖:

final域讀可能存在的執行時序

讀對象的普通域被重排序到了讀對象引用的前面就會出現線程B還未讀到對象引用就在讀取該對象的普通域變量,這顯然是錯誤的操做。而final域的讀操做就「限定」了在讀final域變量前已經讀到了該對象的引用,從而就能夠避免這種狀況。

讀final域的重排序規則能夠確保:在讀一個對象的final域以前,必定會先讀這個包含這個final域的對象的引用。

4.1.2 final域爲引用類型

咱們已經知道了final域是基本數據類型的時候重排序規則是怎麼的了?若是是引用數據類型了?咱們接着繼續來探討。

對final修飾的對象的成員域寫操做

針對引用數據類型,final域寫針對編譯器和處理器重排序增長了這樣的約束:在構造函數內對一個final修飾的對象的成員域的寫入,與隨後在構造函數以外把這個被構造的對象的引用賦給一個引用變量,這兩個操做是不能被重排序的。注意這裏的是「增長」也就說前面對final基本數據類型的重排序規則在這裏仍是使用。這句話是比較拗口的,下面結合實例來看。

public class FinalReferenceDemo {
    final int[] arrays;
    private FinalReferenceDemo finalReferenceDemo;

    public FinalReferenceDemo() {
        arrays = new int[1];  //1
        arrays[0] = 1;        //2
    }

    public void writerOne() {
        finalReferenceDemo = new FinalReferenceDemo(); //3
    }

    public void writerTwo() {
        arrays[0] = 2;  //4
    }

    public void reader() {
        if (finalReferenceDemo != null) {  //5
            int temp = finalReferenceDemo.arrays[0];  //6
        }
    }
}
複製代碼

針對上面的實例程序,線程線程A執行wirterOne方法,執行完後線程B執行writerTwo方法,而後線程C執行reader方法。下圖就以這種執行時序出現的一種狀況來討論(耐心看完纔有收穫)。

寫final修飾引用類型數據可能的執行時序

因爲對final域的寫禁止重排序到構造方法外,所以1和3不能被重排序。因爲一個final域的引用對象的成員域寫入不能與隨後將這個被構造出來的對象賦給引用變量重排序,所以2和3不能重排序。

對final修飾的對象的成員域讀操做

JMM能夠確保線程C至少能看到寫線程A對final引用的對象的成員域的寫入,即能看下arrays[0] = 1,而寫線程B對數組元素的寫入可能看到可能看不到。JMM不保證線程B的寫入對線程C可見,線程B和線程C之間存在數據競爭,此時的結果是不可預知的。若是可見的,可以使用鎖或者volatile。

關於final重排序的總結

按照final修飾的數據類型分類:

基本數據類型:

  1. final域寫:禁止final域寫構造方法重排序,即禁止final域寫重排序到構造方法以外,從而保證該對象對全部線程可見時,該對象的final域所有已經初始化過。
  2. final域讀:禁止初次讀對象的引用讀該對象包含的final域的重排序。

引用數據類型:

額外增長約束:禁止在構造函數對一個final修飾的對象的成員域的寫入與隨後將這個被構造的對象的引用賦值給引用變量 重排序

5.final的實現原理

上面咱們提到過,寫final域會要求編譯器在final域寫以後,構造函數返回前插入一個StoreStore屏障。讀final域的重排序規則會要求編譯器在讀final域的操做前插入一個LoadLoad屏障。

頗有意思的是,若是以X86處理爲例,X86不會對寫-寫重排序,因此StoreStore屏障能夠省略。因爲不會對有間接依賴性的操做重排序,因此在X86處理器中,讀final域須要的LoadLoad屏障也會被省略掉。也就是說,以X86爲例的話,對final域的讀/寫的內存屏障都會被省略!具體是否插入仍是得看是什麼處理器

6. 爲何final引用不能從構造函數中「溢出」

這裏還有一個比較有意思的問題:上面對final域寫重排序規則能夠確保咱們在使用一個對象引用的時候該對象的final域已經在構造函數被初始化過了。可是這裏實際上是有一個前提條件的,也就是:在構造函數,不能讓這個被構造的對象被其餘線程可見,也就是說該對象引用不能在構造函數中「逸出」。如下面的例子來講:

public class FinalReferenceEscapeDemo {
    private final int a;
    private FinalReferenceEscapeDemo referenceDemo;

    public FinalReferenceEscapeDemo() {
        a = 1;  //1
        referenceDemo = this; //2
    }

    public void writer() {
        new FinalReferenceEscapeDemo();
    }

    public void reader() {
        if (referenceDemo != null) {  //3
            int temp = referenceDemo.a; //4
        }
    }
}
複製代碼

可能的執行時序如圖所示:

final域引用可能的執行時序

假設一個線程A執行writer方法另外一個線程執行reader方法。由於構造函數中操做1和2之間沒有數據依賴性,1和2能夠重排序,先執行了2,這個時候引用對象referenceDemo是個沒有徹底初始化的對象,而當線程B去讀取該對象時就會出錯。儘管依然知足了final域寫重排序規則:在引用對象對全部線程可見時,其final域已經徹底初始化成功。可是,引用對象「this」逸出,該代碼依然存在線程安全的問題。

參看文獻

《java併發編程的藝術》

《瘋狂java講義》

相關文章
相關標籤/搜索