區區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,一個堅持技術原創的博主,我的微信公眾號是:編了個程

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

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

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

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