區區final和static,竟然隱藏著這麼多知識點!

語言: CN / TW / HK

這是我端午節去西湖玩的時候照的照片。那天的天氣很善變,早上出門的時候是陰雲密佈,中午突然就變成了藍天白雲,豔陽高照,到了下午又變成傾盆大雨。

有人說,人的心情、行為等都可能受到環境影響。我不否認這個理論,但我們可以降低環境對我們的影響。天氣也好,家庭出身也好,曾經的經歷也好,學習、工作環境也好。這些都算是一些客觀的環境因素,影響情緒的大概率不是環境本身,而是我們的態度。晴天也好,雨天也罷,你若嘗試喜歡,那便是好天氣。

正文分割線


是否有預設值?

前段時間群裡有個小夥伴丟擲來一個問題:Java中final宣告的變數,在初始化前,會有預設零值嗎?

我看到這個問題時,下意識地想:我不知道,我猜應該無,但寫個程式碼驗證不就可以了嗎?Show me the code!

我們先來看這樣一段程式碼,嘗試在初始化前列印一下,看能不能看到這個final變數的預設值。

class A {    private final static int a;    static {        // 這裡編譯會報錯 // System.out.println(a);        a = 2;        System.out.println(a);   } }

這裡第一次列印a變數,在編譯的時候就會報錯,因為沒有初始化a變數。換言之,編譯器儘量保證在使用一個final變數之前,這個變數已經進行了初始化

這符合Java對final的設計,final變數一旦賦值,就不再允許修改。所以如果我們這麼寫也是不行的:

class A {    private final static int a = 0;    static {        System.out.println(a);        // 這裡編譯會報錯        a = 2;        System.out.println(a);   } }

到這裡我們可能會有一個猜測:final變數沒有零值。

我們又回過頭來看看Java的類載入機制。Jvm會在準備階段為類的靜態變數分配記憶體,並將其初始化為預設值。然後在初始化階段,對類的靜態變數,靜態程式碼塊執行初始化操作。

Java類載入機制

那麼問題來了,既被final修飾又被static修飾的變數,也會在準備階段初始化為預設值,然後在初始化再賦值嗎?

先說結論:答案是不一定,有些情況會,有些情況不會

內聯優化

我們先看直接宣告就初始化這種情況:

class A {    private final static int a = 7;    public static int getA() {        return a;   } }

編譯再反編譯一下:

```

我的檔名是Demo.java

javac Demo.java javap -p -v -c A ```

可以看到這個getA()的反編譯結果,編譯器已經知道了a=7,並且由於它是一個final變數,不會變,所以直接寫死編譯進去了。相當於直接把return a替換成了return 7

public static int getA();   descriptor: ()I   flags: ACC_PUBLIC, ACC_STATIC   Code:      stack=1, locals=0, args_size=0         0: bipush        7         2: ireturn     LineNumberTable:       line 21: 0 ​

這其實是一個編譯器的優化,專業的稱呼叫“內聯優化”。其實不只是final變數會被內聯優化。一個方法也有可能被內聯優化,特別是熱點方法。JIT大部分的優化都是在內聯的基礎上進行的,方法內聯是即時編譯器中非常重要的一環。

一般來說,內聯的方法越多,生成程式碼的執行效率越高。但是對於即時編譯器來說,內聯的方法越多,編譯時間也就越長,程式達到峰值效能的時刻也就比較晚。有一些引數可以控制方法是否被內聯:

內聯相關引數

回到最開始的問題,這種能被編譯器內聯優化的final變數,是會在編譯成位元組碼的時候,就賦值了,所以在類載入的準備階段,不會給這個變數初始化為預設值。

騙過編譯器

那如果編譯器在編譯期的時候,不知道final變數的值是多少呢?比如給它一個隨機數:

class A {    private final static int a;    private static final Random random = new Random();    static {        // 這裡編譯會報錯 // System.out.println(a);        a = random.nextInt();        System.out.println(a);   } }

變數a會不會有一個“預設值”呢?如何去驗證這件事呢?驗證的思路就是在a賦值之前就打印出來,但編譯器不允許我們在賦值前就使用a。那怎麼辦呢?好辦,想辦法騙過編譯器就行了,畢竟它也不是那麼智慧嘛。怎麼騙?直接上程式碼。

class A {    final static int a;    static final Random random = new Random();    static {        B.printA();        a = random.nextInt();        B.printA();   } } ​ class B {    static void printA() {        System.out.println(A.a);   } } ​ public class Demo {    public static void main(String[] args) {        // 列印兩次,一次為0,一次為一個隨機數        A a  = new A();   } }

這段程式碼很簡單,從列印結果我們能看出來,這樣就能驗證final修飾的變數a,在被初始化前,是被賦值了預設值0的。

反射能修改嗎

研究到這,我又有一個問題了:反射能修改final變數的值嗎?根據上面的理論,我推測:

  • 如果是被內聯優化的變數,那反射改的已經不是原來那個變量了,而是一個“副本”,所有用到這個變數的地方都被直接編譯成了常量,所以看起來改不了,或者說改了也用不了。
  • 如果沒有被內聯優化,那理論上來說應該可以修改。

雖然理論上是這樣,但務實的我還是想用程式碼驗證一把,於是我去網上抄了一段程式碼:

這裡注意要用反射final修飾符去掉,可以說是很hack了

``` package com.tianma.sample;

import java.lang.reflect.Field; import java.lang.reflect.Modifier;

public class ChangeStaticFinalFieldSample {

static void changeStaticFinal(Field field, Object newValue) throws Exception {        field.setAccessible(true); // 如果field為private,則需要使用該方法使其可被訪問

Field modifersField = Field.class.getDeclaredField("modifiers");        modifersField.setAccessible(true);        // 把指定的field中的final修飾符去掉        modifersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

field.set(null, newValue); // 為指定field設定新值   }

public static void main(String[] args) throws Exception {        Sample.print();                Field canChangeField = Sample.class.getDeclaredField("CAN_CHANGE");        Field cannotChangeField = Sample.class.getDeclaredField("CANNOT_CHANGE");        changeStaticFinal(canChangeField, 2);        changeStaticFinal(cannotChangeField, 3);                Sample.print();   } }

class Sample {    private static final int CAN_CHANGE = new Integer(1); // 未內聯優化    private static final int CANNOT_CHANGE = 1; // 內聯優化

public static void print() {        System.out.println("CAN_CHANGE = " + CAN_CHANGE);        System.out.println("CANNOT_CHANGE = " + CANNOT_CHANGE);        System.out.println("------------------------");   } } ```

列印結果:

``` CAN_CHANGE = 1 CANNOT_CHANGE = 1


CAN_CHANGE = 2 CANNOT_CHANGE = 1


```

跟猜想是一致的,非常完美~

類載入與單例bug

瞭解到這,我就突然想起了很早很早之前遇到的一個關於單例模式的問題,也跟類載入有點關係,有點意思。

下面這段話出自於《碼出高效 - Java開發手冊》,是阿里的孤盡大佬寫的。

餓漢單例

這個是一個典型的餓漢單例模式。從我學設計模式的時候,學到的就是餓漢模式非常安全,除了不能懶載入,沒啥大的缺點。但書上卻說”某些特殊場景“下,返回的單例物件可能為空,這就勾起了我的好奇心了。

我當時絞盡腦汁,寫了一些程式碼去驗證這種為空的場景,還真讓我給找到了一種。既然餓漢是利用的類載入,那我們知道類載入會在準備階段先初始化為預設值,然後在初始化階段再賦值是吧。那關鍵就在於我們什麼情況下會在這個變數初始化前呼叫getInstance()方法?

迴圈依賴的時候會,咱們來看看下面這段程式碼

class A {    public A() {        try {            B b = B.getInstance();            System.out.println(b);            Thread.sleep(1000);            System.out.println("AAA");       } catch (InterruptedException e) {            e.printStackTrace();       }   } } ​ class B {    private static A a = new A();    private static B instance = new B(); ​    public B() {        try {            Thread.sleep(1000);            System.out.println("BBB");       } catch (InterruptedException e) {            e.printStackTrace();       }   } ​    public static B getInstance() {        return instance;   } } ​ public class Demo {    public static void main(String[] args) {        A a = new A();   } }

這段程式碼有點繞。是一個典型的迴圈依賴,A依賴B,B又依賴了A。列印結果和過程如下:

```

B在初始化的時候呼叫A的構造器

null AAA

B初始化

BBB

Demo呼叫A初始化

B@372f7a8d AAA ```

經過大佬同事的點撥,我們有一個更簡單的程式碼來表述這種場景:

class Config {    public static Config config = Config.getInstance();    private static Config instance = new Config(); ​    public static Config getInstance() {        return instance;   } } ​ public class Demo {    public static void main(String[] args) {        System.out.println(Config.config); // null        System.out.println(Config.getInstance()); // 有值   } }

這段程式碼很好解釋,我們在對Config類進行初始化的時候,先執行第一行程式碼,由於第二行程式碼還沒執行,所以這個時候Config.getInstance()返回的是null,寫進了這個靜態變數裡。所以無論你後面怎麼呼叫Config.config這個變數,它都會是null

所以底層原理其實都是一樣的:使用這個單例的時候,它還沒被初始化。我不知道孤盡大佬表達的“某些特殊場景”是不是這個意思,但確實上述場景下,它是可能為空的。

總結

寫了這麼多,這裡總結一下整篇文章涉及到的知識點。

  • final變數是有可能被編譯期內聯優化的;方法也可能會被JIT內聯優化
  • final變數如果沒被內聯優化,還是會有預設值,可以用“騙過編譯器”的方式拿到;
  • 反射可以修改final變數,但如果被內聯優化了,那就沒啥作用了;
  • 餓漢式單例模式,也可能利用類載入的機制拿到null物件

這些知識看起來是很“底層”的東西,有些同學看了後可能會覺得了解這些沒啥用。但其實瞭解一些底層知識可以給我們程式碼起一些指導作用,遇到問題也能有一個好的思路去分析,最關鍵的是,在群裡摸魚的時候,又多了一個談資,不是嗎?

求個支援

我是Yasin,一個堅持技術原創的博主,我的微信公眾號是:編了個程

都看到這兒了,如果覺得我的文章寫得還行,不妨支援一下。

文章會首發到公眾號,閱讀體驗最佳,歡迎大家關注。

你的每一個轉發、關注、點贊、評論都是對我最大的支援!

還有學習資源、和一線網際網路公司內推哦