Java class被提前加載之深度歷險記!
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競爭對手一起精進(jìn)!
編輯:業(yè)余草
juejin.cn/post/7047014740693876744
推薦:https://www.xttblog.com/?p=5306
1. 先說問題
我司搭建了一個類似于Skywalking的字節(jié)碼插件平臺?;驹韰⒖?span style="color: rgb(89, 89, 89);">談?wù)凧ava Intrumentation和相關(guān)應(yīng)用[1] 。所以我們就編寫了各種神奇的插件。其中就有一個使用Sentinel限流MQ的插件。其核心邏輯就是,當(dāng)用戶空間有Sentinel相關(guān)類的時候,就使用Sentinel來做限流。
下面這個SentinelUtil類是用來判斷是否有相關(guān)Sentinel
import com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport;
public class SentinelUtil {
private static boolean sentinelDisabled = true;
static {
try {
//檢測相關(guān)類和對應(yīng)的方法是否存在
final Class<?> circuitBreakerSupportClass = Class.forName("com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport", false, SentinelUtil.class.getClassLoader());
sentinelDisabled = false;
} catch (Throwable throwable) {
}
}
private SentinelUtil() {
}
public static boolean sentinelDisabled() {
return sentinelDisabled;
}
}
下面是MQ消費(fèi)的邏輯:不存在Sentinel相關(guān)依賴就直接消費(fèi),存在的時候使用Sentinel限流消費(fèi)
public abstract class BaseConcurrentMessageListener implements MessageListenerConcurrently {
// ....
@Override
public ConsumeConcurrentlyStatus consumeMessage(final List<MessageExt> msgs, final ConsumeConcurrentlyContext context) {
final MessageExt messageExt = msgs.get(0);
// 不存在Sentinel相關(guān)依賴的時候就直接消費(fèi)
if (SentinelUtil.sentinelDisabled()) {
return consumeInner(messageExt);
}
// 存在Sentinel相關(guān)類的時候就直接使用Sentinel來限流消費(fèi)
return CircuitBreakerSupport.syncExecute(resourceName, resourceType, origin,
new CircuitBreakerCallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
// normal consumer logic
return consumeInner(messageExt);
}
},
new CircuitBreakerFallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus fallBack() {
// fallBack logic
}
});
}
}
這里補(bǔ)充一下CircuitBreakerSupport用到的兩個接口的定義
public interface CircuitBreakerCallback<T> {
T doWithCircuitBreaker();
}
public interface CircuitBreakerFallback<T> {
T fallBack();
}
此時,我們對Sentinel的依賴是provided級別。
<!-- 此依賴是我司對Sentinel的簡單封裝的jar包,用來簡化Sentinel的使用 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-common</artifactId>
<scope>provided</scope>
</dependency>
所以上面的代碼可以正常編譯,但是運(yùn)行期正常情況下會根據(jù)用戶空間有沒有scope=compile級別的該依賴來走不同的邏輯。
我們做完這個兼容判斷后給自己的評價就是:完美。然后我們本地做了自測,測試了有Sentinel compile的依賴以及沒有該依賴的場景,都沒什么問題,完全在我們的意料之中。

但是,當(dāng)我們把這個插件放開后真實(shí)地在開發(fā)環(huán)境跑的時候直接啟動失敗,拋出了一個java.lang.NoClassDefFoundError異常。


2. 初步分析
看到上面那個錯誤,我們初步分析如下:
用戶應(yīng)該是沒有Sentinel的依賴的,不然不會找不到類 這個錯誤的原因肯定不是運(yùn)行了 BaseConcurrentMessageListener的consumeMessage方法導(dǎo)致的。因為如果是運(yùn)行時發(fā)生的話,應(yīng)該因為有了判斷Sentinel是否存在的邏輯,所以不會走到CircuitBreakerSupport的syncExecute方法。而且,我們根本就沒有發(fā)送消息,也就不會出發(fā)消費(fèi)邏輯。
然后我們繼續(xù)看異常棧,發(fā)現(xiàn)是這一行導(dǎo)致的異常:

我們找到那一行代碼,如下:
public class DefaultRMQConsumer extends AbstractClientConfig {
private DefaultMQPushConsumer createConsumer(...) throws MQClientException {
//...
// 就是這一行導(dǎo)致的錯誤
baseConcurrentMessageListener = new NormalConcurrentMessageListener(nameServerAlias, subscribeTable);
//...
}
}
我們發(fā)現(xiàn),這一行代碼與我們代碼發(fā)送唯一關(guān)聯(lián)的就是NormalConcurrentMessageListener是BaseConcurrentMessageListener的子類。根據(jù)周志明大大總結(jié)的類加載的知識

「new」一個NormalConcurrentMessageListener確實(shí)會導(dǎo)致加載其父類BaseConcurrentMessageListener。但問題是:CircuitBreakerFallback只是BaseConcurrentMessageListener 類的一個方法中使用的類。按照周志明大大總結(jié)的類加載的知識,不應(yīng)該是主動使用CircuitBreakerFallback的時候才會加載該類的嗎?在沒有主動使用的時候是不應(yīng)該被加載的。
所以總結(jié)起來,按照我掌握的常規(guī)知識與現(xiàn)象來解釋的話是自相矛盾的:
這個異常應(yīng)該是主動使用該類的時候才會拋出,也就是實(shí)際運(yùn)行 BaseConcurrentMessageListener的consumeMessage方法才會拋出。如果我們承認(rèn)上面一個結(jié)論是正確的話,那么又會導(dǎo)致實(shí)際不會執(zhí)行到 CircuitBreakerFallback的方法,也就不會觸發(fā)上面的異常。
好吧,我要崩潰了。。。

再用我簡單的小腦袋瓜總結(jié)一下,現(xiàn)在我們有兩個問題難以理解:
??
為什么本地沒有出現(xiàn)這個異常,到了開發(fā)環(huán)境就有了這個異常? 為什么方法中用到的類被提前加載了?
3. 我的瞎想
根據(jù)上面 的兩個問題,我自然第一步就聯(lián)想到了可能的原因:是不是JVM的鍋?
?難道是JVM在Linux平臺上的實(shí)現(xiàn)有bug,在windows(我本機(jī)是windows)和mac(其他同事用的mac也是一樣的問題)上的實(shí)現(xiàn)沒有bug?這個bug就是:某些情況下會導(dǎo)致類的提前加載。
?
然后我就去JDK官方issue管理渠道(JBS - JDK Bug System:https://bugs.openjdk.java.net/projects/JDK/issues/JDK-8279300?filter=allopenissues)搜索了ClassLoader相關(guān)的issue。

然后我就一個個翻閱了相關(guān)的issue。果然jdk還是靠譜的。
4. 我的猜想
在經(jīng)過上面一輪「瞎想」之后,我開始反思這個過程可能的原因。然后我又去翻閱了周志明大大關(guān)于類加載方面的所有知識。果然,被我翻到了一點(diǎn)蛛絲馬跡:

從這段話中,我們可以讀出兩點(diǎn):
類加載的時機(jī)是不確定的,但是類初始化的時機(jī)是由JVM規(guī)范固定的那5種情況 類加載和類的初始化大部分情況下是同時發(fā)生的,但是少數(shù)情況還是有可能只發(fā)送類的加載,不發(fā)生類的初始化的
結(jié)合到我們這個場景下,實(shí)際上就是提前加載了類,但是估計沒有初始化。
?那到底什么情況下會提前(這里的提前是指沒有主動使用類)加載類,但是不發(fā)生類的初始化呢?
?
5. 歪打正著
那既然遇到這個問題了,而且我們還不知道是啥原因的情況下,我們又該怎么解決呢?

我們再來分析下,其實(shí)像我們這種處理方式,在很多其他的框架中應(yīng)該都有類似的方式。
?就判斷有沒有這個類,有的話就使用這個類提供的方法等。沒有的話走兜底邏輯。這種兼容邏輯在開源框架中應(yīng)該都有類似的解決方案。那為什么開源框架沒有出現(xiàn)這種問題呢?
?
肯定有某些條件限制住了該異常的發(fā)生。那到底是什么條件呢?
然后,我們就開始了嘗試。既然找不到類,那我把找不到的那個類隱藏到另外一個類中是不是就可以了呢?
大體方案就是把限流邏輯隱藏到SentinelUtil中,然后調(diào)用SentinelUtil 的限流方法來做
public class SentinelUtil {
private static boolean sentinelDisabled = true;
static {
try {
//檢測相關(guān)類和對應(yīng)的方法是否存在
final Class<?> circuitBreakerSupportClass = Class.forName("com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport", false, SentinelUtil.class.getClassLoader());
sentinelDisabled = false;
} catch (Throwable throwable) {
}
}
private SentinelUtil() {
}
public static boolean sentinelDisabled() {
return sentinelDisabled;
}
/**
* 把限流邏輯移到該方法中
*/
public static <T> T supplySyncExecute(String resourceName, int resourceType, Supplier<T> circuitBreakerCallback, Supplier<T> fallback) {
// 存在Sentinel相關(guān)類的時候就直接使用Sentinel來限流消費(fèi)
return CircuitBreakerSupport.syncExecute(resourceName, resourceType, origin,
new CircuitBreakerCallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
// normal consumer logic
return circuitBreakerCallback.get();
}
},
new CircuitBreakerFallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus fallBack() {
return fallback.get();
}
});
}
private ConsumeConcurrentlyStatus consumeInne(...){
//...消費(fèi)邏輯
}
}
消費(fèi)監(jiān)聽器更改如下:
public abstract class BaseConcurrentMessageListener implements MessageListenerConcurrently {
// ....
@Override
public ConsumeConcurrentlyStatus consumeMessage(final List<MessageExt> msgs, final ConsumeConcurrentlyContext context) {
final MessageExt messageExt = msgs.get(0);
// 不存在Sentinel相關(guān)依賴的時候就直接消費(fèi)
if (SentinelUtil.sentinelDisabled()) {
return consumeInner(messageExt);
}
// 限流邏輯調(diào)用SentinelUtil的方法
// 把限流邏輯以及
return SentinelUtil.supplySyncExecute(...,
new Supplier<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus get() {
// 消費(fèi)邏輯
}
},
new Supplier<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus get() {
// fall back邏輯
}
}
);
}
}
然后,下面就是見證奇跡的時刻了。我們在開發(fā)環(huán)境測試竟然沒有那個神奇的異常了...

?所以,隱藏是有用的。我只要退后一步,JVM就不需要看到我了?。?!
?
6. 意外之喜
雖然,我們也不知道為啥就解決了上面的那個問題。但是心總是懸著的。因為在本地?zé)o法復(fù)現(xiàn),只能在開發(fā)環(huán)境驗證。那就是說,隨時都有可能在本地?zé)o法復(fù)現(xiàn),在其他環(huán)境有可能復(fù)現(xiàn)。那這種風(fēng)險實(shí)際是挺大的。尤其如果沒有經(jīng)過開發(fā)和測試環(huán)境的驗證就直接上生產(chǎn)環(huán)境的話,就可能直接嗝屁了。

所以,一直在搜索,卻一直沒有任何大佬給出相關(guān)的解釋。
?然而,驗證了一句話,叫做:再牛逼的難題,也抵不住傻逼似的堅持。
?
終于在某乎上搜索到了我想要的答案:
關(guān)于Java class被提前加載的問題記錄:https://zhuanlan.zhihu.com/p/417557986。
大家有興趣可以看一下大佬的解答。這個博客不僅有實(shí)驗代碼,還有JVM規(guī)范內(nèi)容??梢哉f是牛逼大發(fā)了,正是我想要的。
這篇文章總結(jié)起來,就以下幾點(diǎn):
??
在一個類中存在這種涉及類型cast,即使是隱式的子類cast成父類的行為,就可能導(dǎo)致父類和子類被提前加載。
這種提前加載的行為是發(fā)生在校驗字節(jié)碼階段
7. 驗證結(jié)論
我們按照上面博客的內(nèi)容,自己做了對應(yīng)的實(shí)驗,確實(shí)如博客中所說的一樣,在有類型轉(zhuǎn)換的時候,會導(dǎo)致這種提前加載類的行為。
?那既然這種行為發(fā)生在字節(jié)碼校驗階段,那是不是說我只要不校驗字節(jié)碼,這種提前加載的行為就不會發(fā)生呢?
?
正好,JVM提供了相關(guān)的參數(shù)可以用來控制是否驗證字節(jié)碼
-Xverify:none
// 或者
-noverify
然后,我們就在開發(fā)環(huán)境中先使用我們第一版的代碼(出現(xiàn)java.lang.NoClassDefFoundError異常的代碼)跑了一下確實(shí)還是會拋出java.lang.NoClassDefFoundError異常。
然后,我們給JVM加上-noverify參數(shù)(或者-Xverify:none )。神奇的事情發(fā)生了,沒有異常了。意不意外,驚不驚喜。

8. 峰回路轉(zhuǎn)
再回首一下我們之前的兩個問題:
??
為什么本地沒有出現(xiàn)這個異常,到了開發(fā)環(huán)境就有了這個異常? 為什么方法中用到的類被提前加載了?
現(xiàn)在第二個問題,其實(shí)我們已經(jīng)有答案了:因為在調(diào)用CircuitBreakerSupport的syncExecute方法的時候需要接受一個CircuitBreakerCallback以及CircuitBreakerFallback接口類型的參數(shù)。又因為實(shí)際傳入的是一這兩個接口類型的匿名內(nèi)部類,所以在加載BaseConcurrentMessageListener類的時候需要校驗這種存在類型轉(zhuǎn)換的情況,需要需要提前加載接口CircuitBreakerCallback以及CircuitBreakerFallback。所以發(fā)生了java.lang.NoClassDefFoundError異常。
并且,這種情況實(shí)際上在JVM規(guī)范中是有提到的:

鏈接如下:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4.1。
那既然如此,到底是為什么我們在本地沒有出現(xiàn)這個異常呢?
允許你們停頓一下,思考研究個幾分鐘吧!

?1分鐘 2分鐘 n分鐘 。。。
?

好吧,不賣關(guān)子了,直接說出我當(dāng)時的想法吧。
?既然這個問題只要我們加上-noverify參數(shù)(或者-Xverify:none )就不會出現(xiàn)該問題,那我們本地開發(fā)的時候是不是ide開發(fā)工具自動幫我們加上了這個參數(shù)了呢?
?
然后,一啟動,一看,世界都亮了。。。。


9. 一探到底
我自己又沒有加上這個參數(shù),那究竟是為啥ide要為我加上這樣一個神奇的參數(shù)呢?然后我就百度了下,真被我找到原因了,竟然是因為這個:

好了,一切真相大白了。
?對于SpringBoot項目,【Enbale launch optimization】選項默認(rèn)是勾選上的。這個選項會給JVM加上兩個參數(shù)(其中一個就是-noverify參數(shù))。然后我們的異常只會出現(xiàn)在字節(jié)碼的驗證階段。由于-noverify參數(shù)關(guān)掉了字節(jié)碼校驗,所以本地是不會出現(xiàn)該異常的。
?
10. 如何解決
上面,我們討論了提前加載的原因(可能是一部分原因)。那我們編碼的時候如果規(guī)避掉提前加載的問題呢?
退后一步:將需要校驗的類放到另外一個類中(我們之前的解決方案就是這種方案) 盡量使用lamda表達(dá)式
對于第一種解決方案其實(shí)比較好理解,那第二種解決方案究竟是什么意思呢?
我們來看一下具體的代碼(詳細(xì)代碼加我微信:xttblog2),下面就是最核心的測試代碼:
package com.demo.load.lambda;
import com.alibaba.fastjson.serializer.JSONSerializer;
import com.alibaba.fastjson.serializer.ObjectSerializer;
import java.io.IOException;
import java.lang.reflect.Type;
public class InterfacesTest {
public static void sayHello() {
System.out.println("hello");
}
public static void testInterfaces(){
InterfacesHolder holder = new InterfacesHolder();
// 不會拋出異常
// 原因:lambda表達(dá)式在編譯期只會生成方法名類似于lambda$0的靜態(tài)私有方法,不會生成對應(yīng)接口實(shí)現(xiàn)類的class,對應(yīng)class是在運(yùn)行期生成
// 所以在校驗本類的字節(jié)碼的時候是不需要校驗類型的
// 關(guān)于lambda表達(dá)式的實(shí)現(xiàn)原理參考:https://www.cnblogs.com/WJ5888/p/4667086.html
//holder.invokeInterfaces((serializer, object, fieldName, fieldType, features) -> {
// do nothing
//});
// 會拋出異常
// 原因:匿名內(nèi)部類在編譯期就生成了對應(yīng)接口的實(shí)現(xiàn)類,所以在校驗本類字節(jié)碼的時候會校驗類型
holder.invokeInterfaces(new ObjectSerializer() {
@Override
public void write(JSONSerializer serializer, Object object, Object fieldName, Type fieldType, int features) throws IOException {
// do nothing
}
});
}
public static void main(String[] args) {
InterfacesTest.sayHello();
}
}
?你會發(fā)現(xiàn),使用lamda時不會拋出異常的,但是使用匿名內(nèi)部類是會拋出異常的。
?

是不是已經(jīng)智商不夠用了呢?
下面我們簡單分析下(太深入分析可能需要了解比較多的lamda表達(dá)式的實(shí)現(xiàn)原理):
對于匿名內(nèi)部類類,在編譯期會生成一個對應(yīng)的子類:

簡單反編譯

那實(shí)際上這個場景跟我們一開始遇到的場景是一樣的。所以還是會拋異常。
那為什么使用lamda表達(dá)式就不會拋出異常呢?
首先,使用lamda表達(dá)式是不會在編譯期生成對應(yīng)接口的實(shí)現(xiàn)類或者父類的子類的:

其次,實(shí)際上lamda表達(dá)式也會生成實(shí)現(xiàn)類,但是是在運(yùn)行期動態(tài)生成的。
所以,這樣就比較好理解了,因為lamda表達(dá)式是在運(yùn)行期生產(chǎn)的子類,所以在校驗字節(jié)碼的時候根本無法校驗。但是匿名內(nèi)部類在編譯期就生產(chǎn)了子類,所以在字節(jié)碼校驗的時候就可以校驗對應(yīng)的子類了。
例子中,還有其他的幾種情況會導(dǎo)致類的提前加載,這里簡單總結(jié)一下:
存在類型轉(zhuǎn)換的情況 catch塊中使用異常的情況(這種情況我沒有在JVM規(guī)范中找到對應(yīng)的說明)
11. 總結(jié)一下
類的加載和類的初始化,大部分情況下是同時觸發(fā)的,少數(shù)情況下只有類的加載,沒有類的初始化 如果存在類型轉(zhuǎn)換,可能會導(dǎo)致會導(dǎo)致提前加載接口或者父類。如果catch塊中顯示使用異常的情況,那么就會導(dǎo)致提前加載異常類。 在使用SpringBoot測試的時候,對于開發(fā)字節(jié)碼植入邏輯的同學(xué)來說,一定要關(guān)掉【Enbale launch optimization】選項來測試。
