Jar包沖突的本質是什么?
Java應用程序因某種因素,加載不到正確的類而導致其行為跟預期不一致。
具體來說可分為兩種情況:
- 應用程序依賴的同一個Jar包出現了多個不同版本,并選擇了錯誤的版本而導致JVM加載不到需要的類或加載了錯誤版本的類,為了敘述的方便,暫且稱之為第一類Jar包沖突問題;
- 同樣的類(類的全限定名完全一樣)出現在多個不同的依賴Jar包中,即該類有多個版本,并由于Jar包加載的先后順序導致JVM加載了錯誤版本的類,稱之為第二類Jar包問題。這兩種情況所導致的結果其實是一樣的,都會使應用程序加載不到正確的類,那其行為自然會跟預期不一致了,以下對這兩種類型進行詳細分析。
同一個Jar包出現了多個不同版本
隨著Jar包迭代升級,我們所依賴的開源的或公司內部的Jar包工具都會存在若干不同的版本,而版本升級自然就避免不了類的方法簽名變更,甚至于類名的更替,而我們當前的應用程序往往依賴特定版本的某個類 M ,由于maven的傳遞依賴而導致同一個Jar包出現了多個版本,當maven的仲裁機制選擇了錯誤的版本時,而恰好類 M在該版本中被去掉了,或者方法簽名改了,導致應用程序因找不到所需的類 M或找不到類 M中的特定方法,就會出現第一類Jar沖突問題。
可總結出該類沖突問題發生的以下三個必要條件:
- 由于maven的傳遞依賴導致依賴樹中出現了同一個Jar包的多個版本
- 該Jar包的多個版本之間存在接口差異,如類名更替,方法簽名更替等,且應用程序依賴了其中有變更的類或方法
- maven的仲裁機制選擇了錯誤的版本
同一個類出現在多個不同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包
沖突的產生原因
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包沖突問題。
例子
如果項目的依賴A和依賴B同時引入了依賴C。
如果依賴C在A和B中的版本不一致就可能依賴沖突。
比如 項目 <- A, B, A <- C(1.0),B <- C(1.1)。
那么maven如果選擇高版本C(1.1)來導入(這個選擇maven會根據不等路徑短路徑原則和同等路徑第一聲明原則選取),C(1.0)中的類c在C(1.1)中被修改而不存在了。
在編譯期可能并不會報錯,因為編譯的目的只是把業務源代碼編譯成class文件,所以如果項目源代碼中沒有引入共有依賴C因升級而缺失的類c,就不會出現編譯失敗。除非源代碼就引入了共有依賴C因升級而缺失的類c則會直接編譯失敗。
在運行期,很有可能出現依賴A在執行過程中調用C(1.0)以前有但是升級到C(1.1)就缺失的類c,導致運行期失敗,出現很典型的依賴沖突時的NoClassDefFoundError錯誤。
如果是升級后出現原有的方法被修改而不存在的情況時,就會拋出NoSuchMethodError錯誤
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包所處加載路徑的不同,就決定了它的加載順序的不同。
- 文件系統的文件加載順序。這個因素很容易被忽略,而往往又是因環境不一致而導致各種詭異沖突問題的罪魁禍首。因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包出現了接口一致但實現邏輯又各不相同的同名類,從而引發此問題。
解決方案
- 如果有異常堆棧信息,根據錯誤信息即可定位導致沖突的類名
- 若步驟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包,然后相互對比看有沒同名的類,那得多么浪費精力啊?!好在這種費時費力的體力活能交給程序去干。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>