區區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,一個堅持技術原創的博主,我的微信公眾號是:編了個程
都看到這兒了,如果覺得我的文章寫得還行,不妨支持一下。
文章會首發到公眾號,閲讀體驗最佳,歡迎大家關注。
你的每一個轉發、關注、點贊、評論都是對我最大的支持!
還有學習資源、和一線互聯網公司內推哦