區區final和static,竟然隱藏著這麼多知識點!
這是我端午節去西湖玩的時候照的照片。那天的天氣很善變,早上出門的時候是陰雲密佈,中午突然就變成了藍天白雲,豔陽高照,到了下午又變成傾盆大雨。
有人說,人的心情、行為等都可能受到環境影響。我不否認這個理論,但我們可以降低環境對我們的影響。天氣也好,家庭出身也好,曾經的經歷也好,學習、工作環境也好。這些都算是一些客觀的環境因素,影響情緒的大概率不是環境本身,而是我們的態度。晴天也好,雨天也罷,你若嘗試喜歡,那便是好天氣。
正文分割線
是否有預設值?
前段時間群裡有個小夥伴丟擲來一個問題: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會在準備階段為類的靜態變數分配記憶體,並將其初始化為預設值。然後在初始化階段,對類的靜態變數,靜態程式碼塊執行初始化操作。
那麼問題來了,既被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,一個堅持技術原創的博主,我的微信公眾號是:編了個程
都看到這兒了,如果覺得我的文章寫得還行,不妨支援一下。
文章會首發到公眾號,閱讀體驗最佳,歡迎大家關注。
你的每一個轉發、關注、點贊、評論都是對我最大的支援!
還有學習資源、和一線網際網路公司內推哦