對(duì) "類(lèi)加載的時(shí)機(jī)"的思考
1 - 引言
在閱讀書(shū)時(shí)看到里面的一道代碼題目,書(shū)中給出了題目的解答。自己對(duì)于這個(gè)題目拓展的想了幾個(gè)變式,結(jié)果有所差異,為了尋找產(chǎn)生差異的原因又深入了解了一番。
2 - 類(lèi)初始化時(shí)機(jī)
2.1 - 原題
在書(shū)中 "類(lèi)加載的時(shí)機(jī)",其代碼清單7-1有這么一段代碼:
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int VALUE = 1;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class Main {
public static void main(String[] args) {
System.out.println(SubClass.VALUE);
}
}
輸出的結(jié)果是:

書(shū)中給出了這個(gè)結(jié)果的解答:
上述代碼運(yùn)行之后,只會(huì)輸出“SuperClass init!”,而不會(huì)輸出“SubClass init!”。對(duì)于靜態(tài)字段, 只有直接定義這個(gè)字段的類(lèi)才會(huì)被初始化,因此通過(guò)其子類(lèi)來(lái)引用父類(lèi)中定義的靜態(tài)字段,只會(huì)觸發(fā)父類(lèi)的初始化而不會(huì)觸發(fā)子類(lèi)的初始化。
所以 main() 方法里調(diào)用 SubClass.VALUE 時(shí)實(shí)際上調(diào)用了 SuperClass.VALUE。而 SuperClass 之前還未被加載過(guò),就觸發(fā)了加載的過(guò)程, 在初始化的時(shí)候調(diào)用了 SuperClass 里的 static 靜態(tài)代碼塊。
2.2 - 變式一
這里把上面代碼稍作修改。
public class SuperClass {
static {
System.out.println("SuperClass init");
}
// public static int VALUE = 1;
public final static int VALUE = 1; // 添加一個(gè) final 修飾
}
在其他代碼不變的情況下,把 SuperClass.VALUE 增加一個(gè) final修飾符,這時(shí)候輸出結(jié)果是:

和原來(lái)的結(jié)果不同,"SuperClass init!"和"SubClass init!"都沒(méi)有輸出出來(lái)。
對(duì)于這個(gè)結(jié)果,我一開(kāi)始猜測(cè)的是由于 VALUE 字段被 final 修飾,且又是基本數(shù)據(jù)類(lèi)型,所以JVM做了一些優(yōu)化,不通過(guò) SuperClass.VALUE 而是直接引用這個(gè)字段的值。
后來(lái)看了一下IDEA反編譯 Main.class的源碼:
// Main.class
public class Main {
public Main() {
}
public static void main(String[] args) {
System.out.println(1);
}
}
Main類(lèi)在編譯的時(shí)候直接把 SubClass.VALUE 優(yōu)化成了值"1"。這和一開(kāi)始的猜測(cè)還是有些出入,Main類(lèi)不是被JVM在運(yùn)行時(shí)優(yōu)化的,而是在編譯器就直接被優(yōu)化了。
對(duì)于這種情況編譯器是依據(jù)什么原理優(yōu)化的,在后面在深入展開(kāi),先繼續(xù)看下一種變式。
2.3 - 變式二
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
// public static int VALUE = 1;
// public final static int VALUE = 1;
public final static Integer VALUE = 1; // 把VALUE改成Integer包裝類(lèi)
}
這次把之前 int 類(lèi)型的 VALUE 改成 包裝類(lèi)Integer,看一下運(yùn)行的結(jié)果。

這次的結(jié)果又輸出了"SuperClass init!"。確實(shí),包裝類(lèi)其實(shí)就是一種被 final 修飾的普通類(lèi),不能像基本數(shù)據(jù)類(lèi)型那樣被編譯器優(yōu)化,所以就要調(diào)用 SubClass.VALUE 而初始化 SuperClass。
2.4 - 變式三
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
// public static int VALUE = 1;
// public final static int VALUE = 1;
// public final static Integer VALUE = 1;
public final static String VALUE = "1"; // 把VALUE改成String
}
這次把 SubClass.VALUE 從 Integer 改成 String,看一下運(yùn)行的結(jié)果:

現(xiàn)在的結(jié)果和前面變式一的結(jié)果一樣了,這讓我有點(diǎn)疑惑的。String 和 Integer 不都是包裝類(lèi)嗎,為什么可以和基本數(shù)據(jù)類(lèi)型一樣不會(huì)觸發(fā) SuperClass 的初始化,難道 String 有什么特殊處理嗎?
我還是先去看了一下IDEA反編譯的 Main.class 的源碼:
// Main.class
public class Main {
public Main() {
}
public static void main(String[] args) {
System.out.println("1");
}
}
確實(shí)和變式一的情況一樣,編譯器直接在編譯階段就把 String 類(lèi)型的 VALUE 值直接優(yōu)化了。
3 - 編譯器優(yōu)化技術(shù)---條件常量傳播
對(duì)于上文中變式一和變式三的代碼運(yùn)行結(jié)果,只輸出了 VALUE 的值而沒(méi)有輸出"SuperClass init!",首要原因就是編譯器優(yōu)化技術(shù)。
編譯器的目標(biāo)雖然是做由程序代碼翻譯為本地機(jī)器碼的工作,但其實(shí)難點(diǎn)并不在于能不能成功翻譯出機(jī)器碼,輸出代碼優(yōu)化質(zhì)量的高低才是決定編譯器優(yōu)秀與否的關(guān)鍵。
OpenJDK的官方Wiki上,HotSpot虛擬機(jī)設(shè)計(jì)團(tuán)隊(duì)列出了一個(gè)相對(duì)比較全面的、即時(shí)編譯器中采用的優(yōu)化技術(shù)列表。地址:
https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex
官方列出了很多的編譯器優(yōu)化技術(shù),其中 條件常量傳播(conditional constant propagation) 就是造成上文變式一和變式三輸出結(jié)果的原因。
常量傳播是現(xiàn)代的編譯器中使用最廣泛的優(yōu)化方法之一,它通常應(yīng)用于高級(jí)中間表示(IR)。它解決了在運(yùn)行時(shí)靜態(tài)檢測(cè)表達(dá)式是否總是求值為唯一常數(shù)的問(wèn)題,如果在調(diào)用過(guò)程時(shí)知道哪些變量將具有常量值,以及這些值將是什么,則編譯器可以在編譯時(shí)期簡(jiǎn)化常數(shù)。
3.1 - 優(yōu)化常量
簡(jiǎn)單來(lái)說(shuō)就是編譯器會(huì)通過(guò)一定的算法發(fā)現(xiàn)代碼中存在的常量,然后直接替換指向它的變量值。例如:
public class Main {
public static final int a = 1; // 全局靜態(tài)常量
public static void main(String[] args) {
final int b = 2; // 局部常量
System.out.println(a);
System.out.println(b);
}
}
編譯器編譯之后:
// Main.class
public class Main {
public static final int a = 1;
public static void main(String[] args) {
int b = true;
System.out.println(1);
System.out.println(2);
}
}
3.2 - 優(yōu)化常量表達(dá)式
甚至一些常量的表達(dá)式,也可以預(yù)先直接把結(jié)果編譯出來(lái):
public class Main {
public static void main(String[] args) {
final int a = 3 * 4 + 5 - 6;
int b = 10;
if (a > b) {
System.out.println(a);
}
}
}
編譯之后:
// Main.class
public class Main {
public static void main(String[] args) {
int a = true;
int b = 10;
if (11 > b) {
System.out.println(11);
}
}
}
3.3 - 優(yōu)化字符串拼接
還可以編譯字符串的拼接,網(wǎng)上經(jīng)常有一些題目問(wèn)生成了多少個(gè) String 對(duì)象,在JVM虛擬機(jī)的層面一頓分析,其實(shí)都不正確,編譯器直接在編譯的時(shí)候就優(yōu)化掉了,根本到不了運(yùn)行時(shí)的內(nèi)存池。
public class Main {
public static void main(String[] args) {
final String str = "hel" + "lo";
System.out.println(str);
System.out.println("hello" == str);
}
}
編譯后的源碼,看到 str 直接被替換成了"hello"字符串,且 "hello" == str 為true,所以全程就一個(gè)String對(duì)象生成。
// Main.class
public class Main {
public static void main(String[] args) {
String str = "hello";
System.out.println("hello");
System.out.println(true);
}
}
小拓展: 很多地方都說(shuō)多個(gè)字符串拼接不能用"+"直接拼接,要用StringBuilder之類(lèi)的。實(shí)際上,即使用"+"也會(huì)被編譯器優(yōu)化成StringBuilder的,有興趣可以自己嘗試一下。
3.4 - 編譯器條件常量傳播帶來(lái)的風(fēng)險(xiǎn)
雖然編譯器優(yōu)化代碼可以提升運(yùn)行時(shí)的效率,但是也會(huì)帶來(lái)一定的風(fēng)險(xiǎn)
3.4.1 - 常量反射失效
雖然一些被 final 修飾的字段編譯器會(huì)認(rèn)定其為常量而進(jìn)行優(yōu)化,但是Java有反射機(jī)制,通過(guò)一些奇淫技巧可以更改這些值。但是由于被編譯器優(yōu)化了,可能導(dǎo)致被修改的值不能像預(yù)期那樣生效。如:
public class Main {
public static final String VALUE = "A";
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Class<Main> mainClass = Main.class;
Field value = mainClass.getField("VALUE");
value.setAccessible(true);
// 去除A的final修飾符
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(value, value.getModifiers() & ~Modifier.FINAL);
value.set(null, "B");
System.out.println(VALUE); // 實(shí)際還是輸出 "A"
}
}
這段代碼雖然一通操作把 VALUE 的值從"A"改成"B"了,但是編譯器在編譯的時(shí)候早就把 System.out.println(VALUE); 替換成 System.out.println("A");
,最后運(yùn)行結(jié)果會(huì)和預(yù)期不同。
3.4.2 - 部分編譯
如果常量和其引用的對(duì)象不在一個(gè)文件中,當(dāng)修改常量之后只重新編譯常量所在文件,那么未重新編譯的文件就會(huì)使用舊值。如:
// Constant.java
public class Constant {
public final static String VALUE = "A";
}
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Constant.VALUE);
}
}
假如把 Constant.VALUE 的值修改成"B"然后通過(guò) javac Constant.java 單獨(dú)編譯Constant.java文件,但是 Main 里面輸出的值依舊會(huì)是"A"。
4 - 常量、靜態(tài)常量池、動(dòng)態(tài)常量池
4.1 - 常量
常量是指在程序的整個(gè)運(yùn)行過(guò)程中值保持不變的量。在Java開(kāi)發(fā)的時(shí)候通常指的是被 final 修飾的變量。但從虛擬機(jī)的角度看"常量"的定義會(huì)有所不同。
在虛擬機(jī)中,常量會(huì)被存放于常量池中,而常量池中會(huì)存放兩大類(lèi)常量: 字面量(Literal)和符號(hào)引用(Symbolic References)。字面量比較接近于Java語(yǔ)言層面的常量概念,如文本字符串、被聲明為final的常量值等。
而符號(hào)引用則屬于編譯原理方面的概念,主要包含類(lèi)、字段、方法信息等,這里就不展開(kāi)描述了。
4.2 - 靜態(tài)常量池
(靜態(tài))常量池可以比喻為Class文件里的資源倉(cāng)庫(kù),它是Class文件結(jié)構(gòu)中與其他項(xiàng)目關(guān)聯(lián)最多的數(shù)據(jù),通常也是占用Class文件空間最大的數(shù)據(jù)項(xiàng)目之一,另外,它還是在Class文件中第一個(gè)出現(xiàn)的表類(lèi)型數(shù)據(jù)項(xiàng)目。
(靜態(tài))常量池里面存儲(chǔ)的數(shù)據(jù)項(xiàng)目類(lèi)型如下表:


靜態(tài)常量池編譯之后就寫(xiě)定在class文件里了,可以直接查看字節(jié)碼來(lái)觀察其組成結(jié)構(gòu),如以下代碼:
public class Main {
final static String A = "A";
final static int B = 1;
final static Integer C = 2;
public static void main(String[] args) {
System.out.println(A);
System.out.println(B);
System.out.println(C);
}
}
編譯之后通過(guò) javap -verbose Main.class 命令查看反編譯之后的字節(jié)碼:

可以發(fā)現(xiàn)代碼中的 String 和 int 型數(shù)據(jù)被存儲(chǔ)在靜態(tài)常量池中,Integer就沒(méi)有。因?yàn)榍罢邔?duì)應(yīng)常量池中的"CONSTANT_String_info"和"CONSTANT_Integer_info"類(lèi)型,而后者相當(dāng)于普通的對(duì)象,只被存儲(chǔ)了對(duì)象信息。
這就解釋了上文中變式一、變式三與變式二結(jié)果不同的原因。
4.3 - 動(dòng)態(tài)常量池
運(yùn)行時(shí)常量池(動(dòng)態(tài)常量池)相對(duì)于Class文件常量池(靜態(tài)常量池)的另外一個(gè)重要特征是具備動(dòng)態(tài)性,Java語(yǔ)言并不要求常量一定只有編譯期才能產(chǎn)生,也就是說(shuō),并非預(yù)置入Class文件中常量池的內(nèi)容才能進(jìn)入方法區(qū)運(yùn)行時(shí)常量池,運(yùn)行期間也可以將新的常量放入池中,這種特性被開(kāi)發(fā)人員利用得比較多的便是String類(lèi)的intern()方法。
<div id="refer-anchor"></div>

