天天看點

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼final引用不能從構造函數中“溢出”

1. final的簡介

final可以修飾變量,方法和類,用于表示所修飾的内容一旦指派之後就不會再被改變,比如String類就是一個final類型的類。即使能夠知道final具體的使用方法,我想對final在多線程中存在的重排序問題也很容易忽略,希望能夠一起做下探讨。

2. final的具體使用場景

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

2.1 變量

在java中變量,可以分為成員變量以及方法局部變量。是以也是按照這種方式依次來說,以避免漏掉任何一個死角。

2.1.1 final成員變量

通常每個類中的成員變量可以分為類變量(static修飾的變量)以及執行個體變量。針對這兩種類型的變量賦初值的時機是不同的,類變量可以在聲明變量的時候直接賦初值或者在靜态代碼塊中給類變量賦初值。而執行個體變量可以在聲明變量的時候給執行個體變量賦初值,在非靜态初始化塊中以及構造器中賦初值。類變量有兩個時機賦初值,而執行個體變量則可以有三個時機賦初值。當final變量未初始化時系統不會進行隐式初始化,會出現報錯。這樣說起來還是比較抽象,下面用具體的代碼來示範。(代碼涵蓋了final修飾變量所有的可能情況,耐心看下去會有收獲的:) )

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼final引用不能從構造函數中“溢出”

看上面的圖檔已經将每種情況整理出來了,這裡用截圖的方式也是覺得在IDE出現紅色出錯的标記更能清晰的說明情況。現在我們來将這幾種情況歸納整理一下:

  1. 類變量:必須要在靜态初始化塊中指定初始值或者聲明該類變量時指定初始值,而且隻能在這兩個地方之一進行指定;
  2. 執行個體變量:必要要在非靜态初始化塊,聲明該執行個體變量或者在構造器中指定初始值,而且隻能在這三個地方進行指定。

2.2.2 final局部變量

final局部變量由程式員進行顯式初始化,如果final局部變量已經進行了初始化則後面就不能再次進行更改,如果final變量未進行初始化,可以進行指派,當且僅有一次指派,一旦指派之後再次指派就會出錯。下面用具體的代碼示範final局部變量的情況:

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼final引用不能從構造函數中“溢出”

現在我們來換一個角度進行考慮,final修飾的是基本資料類型和引用類型有差別嗎?

final基本資料類型 VS final引用資料類型

通過上面的例子我們已經看出來,如果final修飾的是一個基本資料類型的資料,一旦指派後就不能再次更改,那麼,如果final是引用資料類型了?這個引用的對象能夠改變嗎?我們同樣來看一段代碼。

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](https://my.oschina.net/u/1162528)
        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()方法時出現報錯,如下圖:

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼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修飾,當子類繼承該父類的時候,就會報錯,如下圖:

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼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。

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

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼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有一種的可能執行時序為下圖:

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼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方法。下圖就以這種執行時序出現的一種情況來讨論(耐心看完才有收獲)。

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼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
        }
    }
}
           

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

6.你以為你真的了解final嗎?1. final的簡介2. final的具體使用場景3. final的例子4. 多線程中你真的了解final嗎?5.final的實作原理6. 為什麼final引用不能從構造函數中“溢出”

假設一個線程A執行writer方法另一個線程執行reader方法。因為構造函數中操作1和2之間沒有資料依賴性,1和2可以重排序,先執行了2,這個時候引用對象referenceDemo是個沒有完全初始化的對象,而當線程B去讀取該對象時就會出錯。盡管依然滿足了final域寫重排序規則:在引用對象對所有線程可見時,其final域已經完全初始化成功。但是,引用對象“this”逸出,該代碼依然存線上程安全的問題。

參看文獻

《java并發程式設計的藝術》

《瘋狂java講義》

轉載于:https://my.oschina.net/u/3467730/blog/1862496