1. 程式人生 > >誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署

誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署

normal 選擇 加載器 行為 錯誤日誌 運維 屬性 lena 響應

誰還沒遇上過NoClassDefFoundError咋地——淺談字節碼生成與熱部署


前言

在Java程序員的世界裏,NoClassDefFoundError是一類相當令人厭惡的錯誤,因為這類錯誤通常非常隱蔽,難以調試。
通常,NoClassDefFoundError被認為是運行時類加載器無法在classpath下找不到需要的類,而該類在編譯時是存在的,這就通常預示著一些很麻煩的情況,例如:

  • 不同版本的包沖突。這是最最最常見的情況,尤其常見於用戶代碼需要運行於容器中,而本地容器和線上容器版本不同時;
  • 使用了多個classloader。要用的類被另一個類加載器加載了,導致當前類加載器作用域內找不到這個類,在破壞雙親委托時容易出這樣的問題;

除了上面提到的這幾種問題,還有一些可能導致這個錯誤的特殊案例,比如今天我遇到的這個:

問題背景

一個spring boot程序,maven打包本地運行毫無問題,發布到生產環境就會biang的報一個錯說NoClassDefFoundError。

該問題的隱蔽之處在於沒有辦法在本地復現,所以覺得有必要跟大家分享。

分析過程

  1. 第一反應,maven環境問題。我本地的maven連的是central倉庫,而線上環境連得是公司的私有倉庫。我司的maven倉庫被各種開發人員胡亂上傳的包弄的很像薛定諤的貓,鬼才知道它給你的哪個包是不是你想要的。
    如果它提供的包事實上是錯誤的,或者經過第三方(其他開發)的修改,那很容易造成這個錯誤。

排查這個其實也好辦,兩種方式一是打thin jar然後自己上傳依賴,二是找運維做一套獨立的maven環境,使用和本地相同的配置,總之一通折騰之後,重新部署,發現錯誤還在。

  1. 不是包版本錯誤的話,就比較隱蔽了。因為該程序在本地運行可以通過所有測試用例,也沒有在不同的線程裏狂秀classloader騷操作,所以也基本排除上面提到的2和3的可能性。

都不是的情況下,返回頭去重新看了一下錯誤日誌,發現雖然報的是NoClassDefFoundError,但後面跟的消息是類實例化失敗,這個消息給了我關鍵的提醒。

  1. 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咋地——淺談字節碼生成與熱部署