如果 classpath 含有 /tmp/jars/*
这种存在通配符的目录,而目录下的不同的 jar 正好含有全限定名称相同的类,这个时候是先加载的哪一个 jar 下面的类呢?这个问题之前一直没搞清楚,之前印象中只是说顺序未定义,但是为什么是未定义的呢,直到一次线上发布,在其中一台机器报类加载相关的错误后,才把这个问题具体的定位了一遍,先从 Java 的系统类加载器说起,我们可以写个简单的程序来验证,比如下面的代码:
1 | package me.tianshuang; |
这段代码非常简单,仅仅打印当前运行时的 classpath,我们直接运行,可以看到以下输出:
1 | /Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/charsets.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/deploy.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/cldrdata.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/dnsns.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/jaccess.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/jfxrt.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/localedata.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/nashorn.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/sunec.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/sunjce_provider.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/sunpkcs11.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/ext/zipfs.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/javaws.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/jce.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/jfr.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/jfxswt.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/jsse.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/management-agent.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/plugin.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/resources.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/jre/lib/rt.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/ant-javafx.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/dt.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/javafx-mx.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/jconsole.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/packager.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/sa-jdi.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home/lib/tools.jar:/Users/tianshuang/IdeaProjects/test/target/test-classes:/Users/tianshuang/IdeaProjects/test/target/classes:/Users/tianshuang/.m2/repository/junit/junit/4.12/junit-4.12.jar:/Users/tianshuang/.m2/repository/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar |
以上程序输出了当前运行时的 classpath,用到 classpath 主要是系统类加载器,我们先看看系统类加载器的获取代码:
1 | /** |
可以看出获取系统类加载器方法 getSystemClassLoader
会调用 initSystemClassLoader
去尝试初始化系统类加载器,在初始化方法中,可以看出 sun.misc.Launcher.getLauncher()
的实例 l
上进行 getClassLoader
方法调用获取到了系统类加载器,这两个方法的源码如下:
1 | public static Launcher getLauncher() { |
再看看 sun.misc.Launcher
类的构造函数:
1 | public Launcher() { |
可以看到扩展类加载器和系统类加载器均在此构造函数中创建,其中系统类加载器由 AppClassLoader.getAppClassLoader(extcl)
进行获取,该方法的实现如下:
1 | public static ClassLoader getAppClassLoader(final ClassLoader extcl) |
从上面这段代码,可以看出是通过运行时系统属性 java.class.path
的值作为 classpath 创建的系统类加载器,那么,如果我们在运行最开始的测试程序时,如果指定的 classpath 含有通配符,后面又是怎样处理的呢?于是我在运行最开始的测试程序时加上了以下参数 -classpath /Users/tianshuang/IdeaProjects/test/target/test-classes:/tmp/jars/*
,即我们把该程序的所在目录和 /tmp/jars/*
设置为了 classpath 并运行程序,输出如下:
1 | /Users/tianshuang/IdeaProjects/test/target/test-classes:/tmp/jars/activation-1.1.jar:/tmp/jars/xmlenc-0.52.jar:/tmp/jars/sentinel-transport-common-1.8.0.jar:/tmp/jars/spymemcached-2.8.4.jar:/tmp/jars/netty-codec-4.1.25.Final.jar:/tmp/jars/antlr-2.7.7.jar:/tmp/jars/jdbc-redis-1.0-SNAPSHOT.jar:/tmp/jars/commons-pool-1.5.4.jar:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar |
这时我们发现,通配符已经被处理了,直接替换为了对应目录下的相关 jar,而不是在 Java 层处理的通配符匹配逻辑,于是我查询了 openjdk8 的相关源码,源码位于 src/share/bin/wildcard.c,在此截取最重要的两段注释:
1 | /* |
文档里面清楚的提到,加载顺序因平台而异,即使在同一台机器上也可能变化。一个结构良好的应用不应该依赖于任何特定的顺序。到这里,我们终于搞清楚了加载顺序不确定的原因。
那么回到文章开始提到的线上发布时其中一台机器报的类加载的相关的异常问题,经过排查,发现由 /WEB-INF/lib/
目录中 jar 的加载顺序引起,那么,在 tomcat 中,同一目录下不同 jar 的加载顺序又是怎样的呢?于是我查询了相关源码,在 org.apache.catalina.loader.WebappClassLoaderBase
的 start 方法中有以下代码:
1 | /** |
跟踪以上 resources.listResources("/WEB-INF/lib")
方法调用,最后发现是使用的 org.apache.catalina.webresources.DirResourceSet#list
方法,源码如下:
1 |
|
其中调用的是 File
实例上的 list()
方法:
1 | /** |
即整个调用栈如下:
1 | list:1159, File (java.io) |
java.io.File#list()
方法的注释特意提到该方法不保证名称的字符串以特定的方式返回,尤其是不保证以字母顺序返回,这也解释了我之前遇到的同一版本的应用在新的一台实例上发布报类加载的相关错误,正好 /WEB-INF/lib
下存在两个 jar 含有同名的类,在新的实例上,file.list()
将不该加载的类作为数组低索引元素进行了返回,导致首先加载到了不该加载的类触发了后续的问题,后面进行了依赖排除解决了该问题。对于 tomcat /WEB-INF/lib
目录中 jar 的加载顺序,在 tomcat 7 及以前其实是按照字母顺序加载的,从 tomcat 8 及之后,调整为了依赖底层 File 类的 list
实现,对于这次改动,可以参考这个 bug: Bug 57129 - Regression. Load WEB-INF/lib jarfiles in alphabetical order。而 java.io.File#list()
的底层实现,通过 openjdk8 源码可知,在 Linux 上,最终调用的方法位于 UnixFileSystem_md.c:
1 |
|
即最终使用的 Linux 系统调用 readdir_r
,关于该系统调用,readdir_r(3) - Linux manual page 中无任何关于文件名顺序的说明,但是通过文档我们知道自 2.24 开始,glibc 废弃了 readdir_r
,且推荐使用 readdir
系统调用,而在文档 readdir(3) - Linux manual page 中,存在如下关于文件名顺序的说明:
The order in which filenames are read by successive calls to
readdir()
depends on the filesystem implementation; it is unlikely that the names will be sorted in any fashion.
即返回的文件名的顺序依赖于文件系统底层实现,不太可能是根据文件名排序的顺序。更多细节可以参考:Chris’s Wiki :: blog/unix/ReaddirOrder,该文章介绍了一些文件系统的实现,如基于数组的实现,基于平衡树的实现等,且当底层基于平衡树实现时,可能使用文件名的哈希值进行插入,所以往往给人的感觉是无序的。这也与 JDK 源码中的注释相符合,总之,一个结构良好的应用不应该依赖于特定的文件顺序。
Reference
Class Path Wild Cards
Order of loading jar files from lib directory - Stack Overflow
Does readdir() guarantee an order? - Stack Overflow