誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署
誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署
前言
在Java程序員的世界裏,NoClassDefFoundError是一類相當令人厭惡的錯誤,因為這類錯誤通常非常隱蔽,難以調試。
通常,NoClassDefFoundError被認為是運行時類加載器無法在classpath下找不到需要的類,而該類在編譯時是存在的,這就通常預示著一些很麻煩的情況,例如:
- 不同版本的包沖突。這是最最最常見的情況,尤其常見於用戶代碼需要運行於容器中,而本地容器和線上容器版本不同時;
- 使用了多個classloader。要用的類被另一個類加載器加載了,導致當前類加載器作用域內找不到這個類,在破壞雙親委托時容易出這樣的問題;
除了上面提到的這幾種問題,還有一些可能導致這個錯誤的特殊案例,比如今天我遇到的這個:
問題背景
一個spring boot程序,maven打包本地運行毫無問題,發布到生產環境就會biang的報一個錯說NoClassDefFoundError。
該問題的隱蔽之處在於沒有辦法在本地復現,所以覺得有必要跟大家分享。
分析過程
- 第一反應,maven環境問題。我本地的maven連的是central倉庫,而線上環境連得是公司的私有倉庫。我司的maven倉庫被各種開發人員胡亂上傳的包弄的很像薛定諤的貓,鬼才知道它給你的哪個包是不是你想要的。
如果它提供的包事實上是錯誤的,或者經過第三方(其他開發)的修改,那很容易造成這個錯誤。
排查這個其實也好辦,兩種方式一是打thin jar然後自己上傳依賴,二是找運維做一套獨立的maven環境,使用和本地相同的配置,總之一通折騰之後,重新部署,發現錯誤還在。
- 不是包版本錯誤的話,就比較隱蔽了。因為該程序在本地運行可以通過所有測試用例,也沒有在不同的線程裏狂秀classloader騷操作,所以也基本排除上面提到的2和3的可能性。
都不是的情況下,返回頭去重新看了一下錯誤日誌,發現雖然報的是NoClassDefFoundError,但後面跟的消息是類實例化失敗,這個消息給了我關鍵的提醒。
- NoClassDefFoundError是一個非常晦澀的錯誤,有一些意外的情況我認為其實不適合歸到這個錯誤裏,比如這次的類實例化錯誤,或者確切的說,類初始化錯誤
回到本文來,這個錯誤日誌裏寫了什麽呢?日誌告訴我,我的一個類cinit失敗,錯誤在第多少多少行。只有這一個錯誤堆棧,沒有輸出任何其他的錯誤信息,比如到底什麽原因導致這個類cinit失敗了。出錯的代碼在org.apache.logging.log4j.status.StatusLogger
這個類中,代碼如下所示:
private static final PropertiesUtil PROPS = new PropertiesUtil("log4j2.StatusLogger.properties");
這裏就是另外一種會導致NoClassDefFoundError發生的場合:在靜態字段和靜態代碼塊初始化時的異常導致類初始化失敗,會產生NoClassDefFoundError。
光看這句話是看不出什麽可能出錯的地方來的,我們跟進去看看裏面的代碼有哪個地方有問題:
//PropertyUtil.java
private static final String LOG4J_PROPERTIES_FILE_NAME = "log4j2.component.properties";
private static final PropertiesUtil LOG4J_PROPERTIES = new PropertiesUtil(LOG4J_PROPERTIES_FILE_NAME);
public PropertiesUtil(final String propertiesFileName) {
this.environment = new Environment(new PropertyFilePropertySource(propertiesFileName));
}
//Enviroment.java
private final Set<PropertySource> sources = new TreeSet<>(new PropertySource.Comparator());
private final Map<CharSequence, String> literal = new ConcurrentHashMap<>();
private final Map<CharSequence, String> normalized = new ConcurrentHashMap<>();
private final Map<List<CharSequence>, String> tokenized = new ConcurrentHashMap<>();
private Environment(final PropertySource propertySource) {
sources.add(propertySource);
for (final PropertySource source : ServiceLoader.load(PropertySource.class)) {
sources.add(source);
}
reload();
}
private synchronized void reload() {
literal.clear();
normalized.clear();
tokenized.clear();
for (final PropertySource source : sources) {
source.forEach(new BiConsumer<String, String>() {
@Override
public void accept(final String key, final String value) {
literal.put(key, value);
final List<CharSequence> tokens = PropertySource.Util.tokenize(key);
if (tokens.isEmpty()) {
normalized.put(source.getNormalForm(Collections.singleton(key)), value);
} else {
normalized.put(source.getNormalForm(tokens), value);
tokenized.put(tokens, value);
}
}
});
}
}
//PropertyFilePropertySource.java
public PropertyFilePropertySource(final String fileName) {
super(loadPropertiesFile(fileName));
}
private static Properties loadPropertiesFile(final String fileName) {
final Properties props = new Properties();
for (final URL url : LoaderUtil.findResources(fileName)) {
try (final InputStream in = url.openStream()) {
props.load(in);
} catch (IOException e) {
LowLevelLogUtil.logException("Unable to read " + url, e);
}
}
return props;
}
//PropertiesPropertySource.java PropertyFilePropertySource類的父類
public PropertiesPropertySource(final Properties properties) {
this.properties = properties;
}
@Override
public void forEach(final BiConsumer<String, String> action) {
for (final Map.Entry<Object, Object> entry : properties.entrySet()) {
action.accept(((String) entry.getKey()), ((String) entry.getValue()));
}
}
以上四個類就是全部涉及到的代碼,讀者能從中看出什麽來嗎?
本文開頭也提到過了,該bug在本地環境下不能復現,所以你盡管調試盡管單步,能調出來哪裏出了bug算我輸。
這段代碼看起來一點問題也沒有,完成的邏輯也很清晰,從log4j2的properties文件裏讀入屬性,保存下來。調試的結果也是一樣的,所有地方運行都正常。其實想想也對,這是spring boot的啟動邏輯的一部分,如果有bug早就被修復了。那問題就來了,一段按理說不可能出錯的代碼出錯了,可能原因是什麽?Spring aop?不會的,如果是aop導致的,那沒道理本地不出錯。唯一的可能是代碼在線上的時候被改變了。
考慮到該bug出現是挑環境的,那麽我就要檢查一下線上運行時的參數了。登到線上機上看了一眼,發現丫在命令行attach了一個別的jar (premain方式),目測是運維部門用來收集信息的,罪魁禍首應該就是它了。
剩下的確認bug操作就略過不提了,想重點聊聊動態字節碼相關的內容。
字節碼、Instrument與hotswap那些事兒
這次的問題,最後查出來原因是線上attach的那個jar文件修改了Log相關的類,在properties裏面放入了非String類型的對象,然後上面的PropertiesPropertySource.java
這個類的foreach方法默認從文本文件裏讀內容,所以就把key和value強轉為String類型,這時就發生了異常。這裏面的核心技術在於修改類的行為,是怎麽做到的呢?
字節碼生成技術:jdk cglib javassist與asm
jdk的動態代理是最為大家所熟知的一種修改類的行為的技術,通過生成和目標對象相同接口的類,並將該新類的對象返回給用戶使用。Spring框架的aop默認就選擇了這種實現方式,只有在類繼承時才選擇使用cglib生成子類的方式實現。jdk代理與cglib的特點是不對原類代碼進行修改,而是生成新的類,通過使用新的類來達到修改類行為的目的。
與之對比,javassist和asm可以直接生成字節碼類文件,或者對現有類文件進行修改。直接用asm需要對java的字節碼指令集很熟悉,所以我個人更傾向於用javassist提供的抽象api。當然,不管用什麽方式去生成字節碼,對於大量調用方法的場合使用反射的方式去調用代碼總是最愚蠢的。在本文的bug裏,運維就是用了javassist去修改了類文件。
那麽,既然我們知道了生成字節碼,或者說修改類,那麽接下來的任務是,如何讓jvm加載被修改過的類呢?
類替換:Instrument與hotswap
對於jdk和cglib的生成方式來說,不存在這類煩惱,在程序運行時就可以以java的方式拿到新的對象。
而對於直接修改字節碼的框架來說,生成新的字節碼並加載並不是很困難的事情,難的是修改現有字節碼,因為對於jvm來說,重新加載類並不像喝水那麽簡單。
最省事的方式,莫過於在jvm決定加載類之前,就把類修改掉——這正是premain所做的。它在正常程序的main方法之前運行,並且提供了ClassFileTransformer
接口讓我們可以在類加載之前註冊一些處理邏輯,在這些邏輯裏我們就可以對類進行修改。
有時候,在程序運行之前修改類還不夠,尤其是當我們必須把程序運行起來才知道會不會出錯的場合下。為了提供在運行時能夠對類進行修改的能力,java1.6中提供了agentmain。這樣,我們就可以啟動我們的程序,然後啟動VirtualMachine
,開始修改類,修改完後,再調用Instrumentation.redefineClasses
方法來更新類,這就是輕量級的hotswap。
截至目前,以上面這種方式來更新類有個弊端,就是只能對現有的方法進行修改,不能為類增加新字段或者新方法。網上很多講Instrument的博文提到了這個問題,但是很少有說出原因的。其實原因也很簡單:
考慮這樣一個場景,假如我們允許為類增加新字段,那麽我們是不是要為所有現存的對象都增加對應的字段,分配對應的內存?如何實現?如果該對象目前正在被使用呢?是不是還要找到所有的引用,給他們指定新位置?再比如如果我們允許增加新方法,那麽新方法該如何添加到方法表裏呢?已經被解析為直接引用的地址要不要調整?如果已經被調用了呢?如果你要調整的類的子類恰好有一個相同簽名的方法呢?
更進一步說,如果賦予了更大的方法修改能力,應該如何處理已經被jit優化尤其是內聯了的代碼?
不管你瘋不瘋,反正我是瘋了。
那麽,我們是不是就無計可施了?並不是。java仍然給了我們一種方式,來完全的控制和修改類:利用classloader。java並不允許我們扔掉已經加載的類,但是卻不限制我們利用一個新的classloader來加載一個同名新類。這樣的話,如果我們需要對一個類的功能做出修改,那麽我們只需要丟棄它的類加載器(和它的對象),然後重新創建一個類加載器,再加載修改過的類,從而繞過了jvm的限制,實現了hotswap的功能。事實上,Tomcat和OSGi就是這麽做的。以Tomcat為例,當我們修改了一個jsp頁面,reload一下,然後刷新頁面發現頁面已經做出了響應,這背後就是tomcat丟棄了加載了上一個jsp文件的加載器和jsp文件,重新創建了一個加載器,然後重新加載修改過的jsp文件,就是這麽簡單。
當然,在使用這種方式的hotswap時,你必須足夠小心,以避免因為類泄露造成OOM(說的更確切一點,不要讓對象在不經意間逃逸出當前classloader的context,特別要註意...線程池)。
全文完
誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署