重新看待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中,從而引發潛在的沖突問題。
作者:sherlockyb
鏈接:https://www.jianshu.com/p/100439269148
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請註明出處。
重新看待Jar包沖突問題及解決方案