重新看待Jar包衝突問題及解決方案
Jar包衝突是老生常談的問題,幾乎每一個Java程式猿都不可避免地遇到過,並且也都能想到通常的原因一般是同一個Jar包由於maven傳遞依賴等原因被引進了多個不同的版本而導致,可採用依賴排除、依賴管理等常規方式來嘗試解決該問題,但這些方式真正能徹底解決該衝突問題嗎?答案是否定的。筆者之所以將文章題目起為“重新看待”,是因為之前對於Jar包衝突問題的理解僅僅停留在前面所說的那些,直到在工作中遇到的一系列Jar包衝突問題後,才發現並不是那麼簡單,對該問題有了重新的認識,接下來本文將圍繞Jar包衝突的問題本質和相關的解決方案這兩個點進行闡述。
Jar包衝突問題
一、衝突的本質
Jar包衝突的本質是什麼?Google了半天也沒找到一個讓人滿意的完整定義。其實,我們可以從Jar包衝突產生的結果來總結,在這裡給出如下定義(此處如有不妥,歡迎拍磚~-~):
Java應用程式因某種因素,載入不到正確的類而導致其行為跟預期不一致。
具體來說可分為兩種情況:1)應用程式依賴的同一個Jar包出現了多個不同版本,並選擇了錯誤的版本而導致JVM載入不到需要的類或載入了錯誤版本的類,為了敘述的方便,筆者稱之為第一類Jar包衝突問題;2)同樣的類(類的全限定名完全一樣)出現在多個不同的依賴Jar包中,即該類有多個版本,並由於Jar包載入的先後順序導致JVM載入了錯誤版本的類,稱之為第二類Jar包問題。這兩種情況所導致的結果其實是一樣的,都會使應用程式載入不到正確的類,那其行為自然會跟預期不一致了,以下對這兩種型別進行詳細分析。
1.1 同一個Jar包出現了多個不同版本
隨著Jar包迭代升級,我們所依賴的開源的或公司內部的Jar包工具都會存在若干不同的版本,而版本升級自然就避免不了類的方法簽名變更,甚至於類名的更替,而我們當前的應用程式往往依賴特定版本的某個類 M ,由於maven的傳遞依賴而導致同一個Jar包出現了多個版本,當maven的仲裁機制選擇了錯誤的版本時,而恰好類 M在該版本中被去掉了,或者方法簽名改了,導致應用程式因找不到所需的類 M或找不到類 M中的特定方法,就會出現第一類Jar衝突問題。可總結出該類衝突問題發生的以下三個必要條件:
- 由於maven的傳遞依賴導致依賴樹中出現了同一個Jar包的多個版本
- 該Jar包的多個版本之間存在介面差異,如類名更替,方法簽名更替等,且應用程式依賴了其中有變更的類或方法
- maven的仲裁機制選擇了錯誤的版本
1.2 同一個類出現在多個不同Jar包中
同樣的類出現在了應用程式所依賴的兩個及以上的不同Jar包中,這會導致什麼問題呢?我們知道,同一個類載入器對於同一個類只會載入一次(多個不同類載入器就另說了,這也是解決Jar包衝突的一個思路,後面會談到),那麼當一個類出現在了多個Jar包中,假設有 A 、 B 、 C 等,由於Jar包依賴的路徑長短、宣告的先後順序或檔案系統的檔案載入順序等原因,類載入器首先從Jar包 A 中載入了該類後,就不會載入其餘Jar包中的這個類了,那麼問題來了:如果應用程式此時需要的是Jar包 B 中的類版本,並且該類在Jar包 A 和 B 中有差異(方法不同、成員不同等等),而JVM卻載入了Jar包 A 的中的類版本,與期望不一致,自然就會出現各種詭異的問題。
從上面的描述中,可以發現出現不同Jar包的衝突問題有以下三個必要條件:
- 同一個類 M 出現在了多個依賴的Jar包中,為了敘述方便,假設還是兩個: A 和 B
- Jar包 A 和 B 中的該類 M 有差異,無論是方法簽名不同也好,成員變數不同也好,只要可以造成實際載入的類的行為和期望不一致都行。如果說Jar包 A 和 B 中的該類完全一樣,那麼類載入器無論先載入哪個Jar包,得到的都是同樣版本的類 M ,不會有任何影響,也就不會出現Jar包衝突帶來的詭異問題。
- 載入的類 M 不是所期望的版本,即載入了錯誤的Jar包
二、衝突的產生原因
2.1 maven仲裁機制
當前maven大行其道,說到第一類Jar包衝突問題的產生原因,就不得不提maven的依賴機制了。傳遞性依賴是Maven2.0引入的新特性,讓我們只需關注直接依賴的Jar包,對於間接依賴的Jar包,Maven會通過解析從遠端倉庫獲取的依賴包的pom檔案來隱式地將其引入,這為我們開發帶來了極大的便利,但與此同時,也帶來了常見的問題——版本衝突,即同一個Jar包出現了多個不同的版本,針對該問題Maven也有一套仲裁機制來決定最終選用哪個版本,但Maven的選擇往往不一定是我們所期望的,這也是產生Jar包衝突最常見的原因之一。先來看下Maven的仲裁機制:
- 優先按照依賴管理<dependencyManagement>元素中指定的版本宣告進行仲裁,此時下面的兩個原則都無效了
- 若無版本宣告,則按照“短路徑優先”的原則(Maven2.0)進行仲裁,即選擇依賴樹中路徑最短的版本
- 若路徑長度一致,則按照“第一宣告優先”的原則進行仲裁,即選擇POM中最先宣告的版本
從maven的仲裁機制中可以發現,除了第一條仲裁規則(這也是解決Jar包衝突的常用手段之一)外,後面的兩條原則,對於同一個Jar包不同版本的選擇,maven的選擇有點“一廂情願”了,也許這是maven研發團隊在總結了大量的專案依賴管理經驗後得出的兩條結論,又或者是發現根本找不到一種統一的方式來滿足所有場景之後的無奈之舉,可能這對於多數場景是適用的,但是它不一定適合我——當前的應用,因為每個應用都有其特殊性,該依賴哪個版本,maven沒辦法幫你完全搞定,如果你沒有規規矩矩地使用<dependencyManagement>來進行依賴管理,就註定了逃脫不了第一類Jar包衝突問題。
2.1 Jar包的載入順序
對於第二類Jar包衝突問題,即多個不同的Jar包有類衝突,這相對於第一類問題就顯得更為棘手。為什麼這麼說呢?在這種情況下,兩個不同的Jar包,假設為 A、 B,它們的名稱互不相同,甚至可能完全不沾邊,如果不是出現衝突問題,你可能都不會發現它們有共有的類!對於A、B這兩個Jar包,maven就顯得無能為力了,因為maven只會為你針對同一個Jar包的不同版本進行仲裁,而這倆是屬於不同的Jar包,超出了maven的依賴管理範疇。此時,當A、B都出現在應用程式的類路徑下時,就會存在潛在的衝突風險,即A、B的載入先後順序就決定著JVM最終選擇的類版本,如果選錯了,就會出現詭異的第二類衝突問題。
那麼Jar包的載入順序都由哪些因素決定的呢?具體如下:
- Jar包所處的載入路徑,或者換個說法就是載入該Jar包的類載入器在JVM類載入器樹結構中所處層級。由於JVM類載入的雙親委派機制,層級越高的類載入器越先載入其載入路徑下的類,顧名思義,引導類載入器(bootstrap ClassLoader,也叫啟動類載入器)是最先載入其路徑下Jar包的,其次是擴充套件類載入器(extension ClassLoader),再次是系統類載入器(system ClassLoader,也就是應用載入器appClassLoader),Jar包所處載入路徑的不同,就決定了它的載入順序的不同。比如我們在eclipse中配置web應用的resin環境時,對於依賴的Jar包是新增到
Bootstrap Entries
中還是User Entries
中呢,則需要仔細斟酌下咯。 - 檔案系統的檔案載入順序。這個因素很容易被忽略,而往往又是因環境不一致而導致各種詭異衝突問題的罪魁禍首。因tomcat、resin等容器的ClassLoader獲取載入路徑下的檔案列表時是不排序的,這就依賴於底層檔案系統返回的順序,那麼當不同環境之間的檔案系統不一致時,就會出現有的環境沒問題,有的環境出現衝突。例如,對於Linux作業系統,返回順序則是由iNode的順序來決定的,如果說測試環境的Linux系統與線上環境不一致時,就極有可能出現典型案例:測試環境怎麼測都沒問題,但一上線就出現衝突問題,規避這種問題的最佳辦法就是儘量保證測試環境與線上一致。
三、衝突的表象
Jar包衝突可能會導致哪些問題?通常發生在編譯或執行時,主要分為兩類問題:一類是比較直觀的也是最為常見的錯誤是丟擲各種執行時異常,還有一類就是比較隱晦的問題,它不會報錯,其表現形式是應用程式的行為跟預期不一致,分條羅列如下:
- java.lang.ClassNotFoundException,即java類找不到。這類典型異常通常是由於,沒有在依賴管理中宣告版本,maven的仲裁的時候選取了錯誤的版本,而這個版本缺少我們需要的某個class而導致該錯誤。例如httpclient-4.4.jar升級到httpclient-4.36.jar時,類org.apache.http.conn.ssl.NoopHostnameVerifier被去掉了,如果此時我們本來需要的是4.4版本,且用到了NoopHostnameVerifier這個類,而maven仲裁時選擇了4.6,則會導致ClassNotFoundException異常。
- java.lang.NoSuchMethodError,即找不到特定方法,第一類衝突和第二類衝突都可能導致該問題——載入的類不正確。若是第一類衝突,則是由於錯誤版本的Jar包與所需要版本的Jar包中的類介面不一致導致,例如antlr-2.7.2.jar升級到antlr-2.7.6.Jar時,介面antlr.collections.AST.getLine()發生變動,當maven仲裁選擇了錯誤版本而載入了錯誤版本的類AST,則會導致該異常;若是第二類衝突,則是由於不同Jar包含有的同名類介面不一致導致,典型的案例:Apache的commons-lang包,2.x升級到3.x時,包名直接從commons-lang改為commons-lang3,部分介面也有所改動,由於包名不同和傳遞性依賴,經常會出現兩種Jar包同時在classpath下,org.apache.commons.lang.StringUtils.isBlank就是其中有差異的介面之一,由於Jar包的載入順序,導致載入了錯誤版本的StringUtils類,就可能出現NoSuchMethodError異常。
- java.lang.NoClassDefFoundError,java.lang.LinkageError等,原因和上述雷同,就不作具體案例分析了。
- 沒有報錯異常,但應用的行為跟預期不一致。這類問題同樣也是由於執行時載入了錯誤版本的類導致,但跟前面不同的是,衝突的類介面都是一致的,但具體實現邏輯有差異,當我們載入的類版本不是我們需要的實現邏輯,就會出現行為跟預期不一致問題。這類問題通常發生在我們自己內部實現的多個Jar包中,由於包路徑和類名命名不規範等問題,導致兩個不同的Jar包出現了介面一致但實現邏輯又各不相同的同名類,從而引發此問題。
解決方案
一、問題排查和解決
- 如果有異常堆疊資訊,根據錯誤資訊即可定位導致衝突的類名,然後在eclipse中
CTRL+SHIFT+T
或者在idea中CTRL+N
就可發現該類存在於多個依賴Jar包中 - 若步驟1無法定位衝突的類來自哪個Jar包,可在應用程式啟動時加上JVM引數
-verbose:class
或者-XX:+TraceClassLoading
,日誌裡會打印出每個類的載入資訊,如來自哪個Jar包 - 定位了衝突類的Jar包之後,通過
mvn dependency:tree -Dverbose -Dincludes=<groupId>:<artifactId>
檢視是哪些地方引入的Jar包的這個版本 - 確定Jar包來源之後,如果是第一類Jar包衝突,則可用<excludes>排除不需要的Jar包版本或者在依賴管理<dependencyManagement>中申明版本;若是第二類Jar包衝突,如果可排除,則用<excludes>排掉不需要的那個Jar包,若不能排,則需考慮Jar包的升級或換個別的Jar包。當然,除了這些方法,還可以從類載入器的角度來解決該問題,可參考博文——如果jar包衝突不可避免,如何實現jar包隔離,其思路值得借鑑。
二、有效避免
從上一節的解決方案可以發現,當出現第二類Jar包衝突,且衝突的Jar包又無法排除時,問題變得相當棘手,這時候要處理該衝突問題就需要較大成本了,所以,最好的方式是在衝突發生之前能有效地規避之!就好比資料庫死鎖問題,死鎖避免和死鎖預防就顯得相當重要,若是等到真正發生死鎖了,常規的做法也只能是回滾並重啟部分事務,這就捉襟見肘了。那麼怎樣才能有效地規避Jar包衝突呢?
2.1 良好的習慣:依賴管理
對於第一類Jar包衝突問題,通常的做法是用<excludes>排除不需要的版本,但這種做法帶來的問題是每次引入帶有傳遞性依賴的Jar包時,都需要一一進行排除,非常麻煩。maven為此提供了集中管理依賴資訊的機制,即依賴管理元素<dependencyManagement>,對依賴Jar包進行統一版本管理,一勞永逸。通常的做法是,在parent模組的pom檔案中儘可能地宣告所有相關依賴Jar包的版本,並在子pom中簡單引用該構件即可。
來看個示例,當開發時確定使用的httpclient版本為4.5.1時,可在父pom中配置如下:
...
<properties>
<httpclient.version>4.5.1</httpclient.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
...
然後各個需要依賴該Jar包的子pom中配置如下依賴:
...
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
...
2.2 衝突檢測外掛
對於第二類Jar包衝突問題,前面也提到過,其核心在於同名類出現在了多個不同的Jar包中,如果人工來排查該問題,則需要逐個點開每個Jar包,然後相互對比看有沒同名的類,那得多麼浪費精力啊?!好在這種費時費力的體力活能交給程式去幹。maven-enforcer-plugin,這個強大的maven外掛,配合extra-enforcer-rules工具,能自動掃描Jar包將衝突檢測並打印出來,汗顏的是,筆者工作之前居然都沒聽過有這樣一個外掛的存在,也許是沒遇到像工作中這樣的衝突問題,算是漲姿勢了。其原理其實也比較簡單,通過掃描Jar包中的class,記錄每個class對應的Jar包列表,如果有多個即是衝突了,故不必深究,我們只需要關注如何用它即可。
在最終需要打包執行的應用模組pom中,引入maven-enforcer-plugin的依賴,在build階段即可發現問題,並解決它。比如對於具有parent pom的多模組專案,需要將外掛依賴宣告在應用模組的pom中。這裡有童鞋可能會疑問,為什麼不把外掛依賴宣告在parent pom中呢?那樣依賴它的應用子模組豈不是都能複用了?這裡之所以強調“打包執行的應用模組pom”,是因為衝突檢測針對的是最終整合的應用,關注的是應用執行時是否會出現衝突問題,而每個不同的應用模組,各自依賴的Jar包集合是不同的,由此而產生的<ignoreClasses>列表也是有差異的,因此只能針對應用模組pom分別引入該外掛。
先看示例用法如下:
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>1.4.1</version>
<executions>
<execution>
<id>enforce</id>
<configuration>
<rules>
<dependencyConvergence/>
</rules>
</configuration>
<goals>
<goal>enforce</goal>
</goals>
</execution>
<execution>
<id>enforce-ban-duplicate-classes</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<banDuplicateClasses>
<ignoreClasses>
<ignoreClass>javax.*</ignoreClass>
<ignoreClass>org.junit.*</ignoreClass>
<ignoreClass>net.sf.cglib.*</ignoreClass>
<ignoreClass>org.apache.commons.logging.*</ignoreClass>
<ignoreClass>org.springframework.remoting.rmi.RmiInvocationHandler</ignoreClass>
</ignoreClasses>
<findAllDuplicates>true</findAllDuplicates>
</banDuplicateClasses>
</rules>
<fail>true</fail>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.codehaus.mojo</groupId>
<artifactId>extra-enforcer-rules</artifactId>
<version>1.0-beta-6</version>
</dependency>
</dependencies>
</plugin>
maven-enforcer-plugin是通過很多預定義的標準規則(standard rules)和使用者自定義規則,來約束maven的環境因素,如maven版本、JDK版本等等,它有很多好用的特性,具體可參見官網。而Extra Enforcer Rules則是MojoHaus專案下的針對maven-enforcer-plugin而開發的提供額外規則的外掛,這其中就包含前面所提的重複類檢測功能,具體用法可參見官網,這裡就不詳細敘述了。
典型案例
第一類Jar包衝突
這類Jar包衝突是最常見的也是相對比較好解決的,已經在三、衝突的表象這節中列舉了部分案例,這裡就不重複列舉了。
第二類Jar包衝突
Spring2.5.6與Spring3.x
Spring2.5.6與Spring3.x,從單模組拆分為多模組,Jar包名稱(artifactId)也從spring變為spring-submoduleName,如
spring-context、spring-aop等等,並且也有少部分介面改動(Jar包升級的過程中,這也是在所難免的)。由於是不同的Jar包,經maven的傳遞依賴機制,就會經常性的存在這倆版本的Spring都在classpath中,從而引發潛在的衝突問題。