問題
本地使用maven編譯和運(yùn)行時(shí)一切都正常,但是通過ci的方式,編譯、打包、發(fā)布到部署環(huán)境,運(yùn)行時(shí)拋出了一條顯而易見的JDK版本的錯(cuò)誤。
錯(cuò)誤是這個(gè)樣子:
java.lang.NoSuchMethodError: java.util.concurrent.ConcurrentHashMap.keySet()
Ljava/util/concurrent/ConcurrentHashMap$KeySetView;
報(bào)的是的NoSuchMethodError: java.util.concurrent.ConcurrentHashMap的錯(cuò)誤。所以不難排查出原因是ci使用了JDK 8來進(jìn)行編譯,導(dǎo)致生成的字節(jié)碼包含了JDK 8更改的新方法keySet(). 其返回值是ConcurrentHashMap$KeySetView這個(gè)JDK8新增內(nèi)部類。
為了進(jìn)一步驗(yàn)證部署服務(wù)器上的class文件都是JDK 8編譯的,我使用javap這個(gè)JDK自帶的工具做了如下的驗(yàn)證:
javap -v a.class |grep major
返回的結(jié)果是
major version: 51
問題初露端倪,51對(duì)應(yīng)的JDK版本號(hào)應(yīng)該是1.7(或者7),52才是JDK 8的major版本。這里出現(xiàn)了兩個(gè)疑惑:
- 為什么ci使用JDK 8編譯的class會(huì)是JDK 7的編譯結(jié)果?
- 既然是JDK 7編譯的class文件,那為何會(huì)出現(xiàn)JDK 8才有的內(nèi)部類?
先看第一個(gè)疑惑。之前說到ci也是通過maven compiler plugin進(jìn)行編譯的,pom.xml中可以配置language level如下:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
這實(shí)際對(duì)應(yīng)于javac的-source和-target參數(shù),那么這兩個(gè)參數(shù)具體代表什么呢?
$ javac -help
-source <release> Provide source compatibility with specified release
-target <release> Generate class files for specific VM version
source參數(shù)指的是源代碼級(jí)別的語法兼容,而target參數(shù)指的是生成release版本的兼容性的class文件,不過只確保目標(biāo)VM能夠加載class文件,卻無法保證運(yùn)行時(shí)的正確性。接下來,我們嘗試使用javac加上這些參數(shù)來編譯源碼。
首先我們寫一段程序,如下:
// App.java
package com.lambeta;
import java.util.concurrent.ConcurrentHashMap;
public class App {
public static void main(String[] args) {
ConcurrentHashMap map = new ConcurrentHashMap();
map.keySet();
}
}
我本機(jī)的java版本是1.8,直接使用javac來編譯App.java,結(jié)果如下
$ javac App.java
$ javap -v App.class |grep major
major version: 52
如果指定source和target參數(shù),再用javac編譯App.java
$ java -version
java version "1.8.0_45"
...
$ javac -source 7 -target 7 App.java
warning: [options] bootstrap class path not set in conjunction with -source 1.7
1 warning
$ ls
App.class App.java
這里有個(gè)警告,我們暫時(shí)不看。先使用javap反編譯App.class,觀察major version以及keySet()這個(gè)方法的返回值。
$ javap -v App.class
...
major version: 51
...
9: invokevirtual #4
// Method java/util/concurrent/ConcurrentHashMap.keySet:()
Ljava/util/concurrent/ConcurrentHashMap$KeySetView;
...
這樣,第二個(gè)疑惑也解開了??梢猿醪降贸鲆粋€(gè)結(jié)論。
小結(jié)
在javac指定了這些參數(shù),降低版本號(hào)來編譯,會(huì)導(dǎo)致生成class文件被標(biāo)識(shí)為較低版本以供指定的JVM加載。但是,基于JDK 8的bootstrap class編譯而成的keySet()方法,其返回值依舊是JDK 8中ConcurrentHashMap$KeySetView這個(gè)新增內(nèi)部類。運(yùn)行時(shí),1.7的JVM嘗試加載這個(gè)class文件,一定找不到KeySetView作為返回值的keySet()方法,出錯(cuò)。
解決方式
既然知道錯(cuò)在那里,就比較容易尋找到解決方案了。
- 編譯期間,替換掉bootstrap class
- 使用父類/接口替換子類,即ConcurrentMap替換ConcurrentHashMap聲明
編譯期間,替換掉bootstrap class
javac編譯時(shí),可以指定bootclasspath,來替換默認(rèn)的加載路徑,如下:
javac -bootclasspath /Library/Java/JavaVirtualMachines/jdk1.7.0_60.jdk/Contents/Home/jre/lib/rt.jar \
-source 7 -target 7 App.java
// or
javac -Xbootclasspath:/Library/Java/JavaVirtualMachines/jdk1.7.0_60.jdk/Contents/Home/jre/lib/rt.jar \
-source 7 -target 7 App.java
這時(shí)候,再去看看反編譯的結(jié)果,就會(huì)是這樣:
...
major version: 51
...
9: invokevirtual #4
// Method java/util/concurrent/ConcurrentHashMap.keySet:()Ljava/util/Set;
此時(shí)major是51(JDK 7),而keySet()的返回值也是JDK 7中的java.util.Set類型了。
使用父類/接口替換子類,即ConcurrentMap替換ConcurrentHashMap聲明
上一種方案雖然可行,但是卻不實(shí)用——因?yàn)椴荒芤骳i服務(wù)器上有兩個(gè)不同版本的JDK,也不能要求在maven構(gòu)建時(shí)傳遞與安裝路徑如此緊耦合的值作為bootclasspath的參數(shù)值。所以可以采取將具體實(shí)現(xiàn)類的聲明替換成為其接口的方式,如下:
package com.lambeta;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class App {
public static void main(String[] args) {
ConcurrentMap map = new ConcurrentHashMap();
map.keySet();
}
}
這樣編譯好的字節(jié)碼中就不會(huì)有ConcurrentHashMap$KeySetView這樣的返回值類型了。在JDK 7上運(yùn)行時(shí),JVM動(dòng)態(tài)調(diào)用的一定是ConcurrentHashMap的keySet():java.util.Set方法了。
結(jié)論
- 保證編譯、打包環(huán)境和最終部署環(huán)境JDK版本的一致性
- 如果無法保證,就盡量面向接口編程,尤其是JDK中提供的類。原因是接口不易改變,而實(shí)現(xiàn)類遵循“寬收嚴(yán)發(fā)”原則,方法的入?yún)⒑统鰠⒍际且鬃兊摹?/li>