.:/home/user/demo/libmain/*:/home/user/demo/lib/*
**创建文件:**
* 在 `libmain` 目录中创建 `m1` 文件。
* 在 `libmain` 目录中创建 `m2` 文件。
* 在 `libmain` 目录中创建 `m3` 文件。
**运行结果:**
* `m1` 文件的创建顺序为 `libmain/m1`。
* `m2` 文件的创建顺序为 `libmain/m2`。
* `m3` 文件的创建顺序为 `libmain/m3`。
**总结:**
解析 Classpath 通配符需要使用 `split()` 方法将 Classpath 中的路径分割成数组。
创建文件需要根据 Classpath 中的路径创建文件。
在不同的文件系统上,文件创建顺序可能不同。
https://bbs.huaweicloud.com/forum/thread-169439-1-1.html 神Bug...
作者:程经纬、谢照昆
> 编者按:两位笔者分享了不同的案例,一个是因为 JDK 小版本升级后导致运行出错,最终分析定位原因为应用启动脚本中指定了 Classpath 导致 JVM 加载了同一个类的不同版本,而 JVM 在选择加载的类则是先遇到的先加载,进而导致应用出错,该问题的根因是设置了错误的 Classpath。第二个案例是在相同的 OS、JDK 和应用,不同的文件系统导致应用运行的结果不一致,最终分析定位的原因是 JVM 在加载类时遇到了多个版本的问题,但是该问题的根因是没有指定 Classpath,JVM 加载类会依赖于 OS 读取文件的顺序,而不同的文件系统导致提供文件的顺序不同,导致了问题的发生。经过这两个案例的分析,可以得到 2 个结论:需要指定 Classpath 避免不同文件系统(或者 OS)提供不同的文件顺序;需要正确地指定 Classpath,避免加载错误的类。希望读者可以了解 JVM 加载类文件的基本原理,避免出现类似错误。
某产品线进行 JDK 升级,将 JDK 版本从 8u181 升级到 8u191 后,日志中报出大量 java.lang.NoSuchFieldError
,导致基本服务功能不可用。具体报错如下:
从这个调用栈来看,问题可能出现在 JDK 自身,并没有涉及到业务代码。首先自然应该去看一下 ClientHandshaker.java
的源码,确认一下出错时的上下文。先看 JDK 8u191 的代码,相关如下:
可以看到,虽然这里对 handshakeState 进行了 check,但是代码中完全没有出现 state 这个变量;那么 8u181 又如何呢?继续看代码:
这里的实现方式和 8u191 中有明显的不同,其中最重要的一点是,在 194 行中确确实实访问了 state 这个变量。追踪一下代码可以得知,ClientHandshaker 类继承自 Handshaker 类,state 也是从父类之中继承过来的一个 field。于是,可以得到一个初步的结论:JDK 8u191 中,ClientHandshaker 的实现方式与 8u181 不同,去除了 state 这个 field。
既然报错报了这个 field,因此可以确定,JVM 中加载的 ClientHandshaker 肯定不是 8u191 中的这一个。那么,可能是产品线在替换 JDK 时,没有替换完全,导致残留了一部分 8u181 的东西,让 JVM 加载了?这个猜测很快就被否定了,因为行号对不上:错误栈中的行号是 198,而 8u181 代码中对 state 的访问是在 194 行。
因此,为了直接推进问题,最好的办法就是确定 JVM 到底是从哪里加载了 ClientHandshaker。在 java 启动命令中加入如下参数,就可以追踪每一个 class 的加载:
java -XX:+TraceClassLoading
会产生类似于下面的输出:(加载的类 + 类的来源)
从这个输出中搜索一下 ClientHandshaker,最终发现了这么一行:
[Loaded sun.security.ssl.ClientHandshaker from /mypath2/lib/alpn-boot.jar]
果然,出问题的 ClientHandshaker 并不是加载自 JDK 8u191 中,而是加载自 alpn-boot.jar 这个包。那么这个包又是从哪里找到的?检查了一下产品线的 java 启动命令,发现里面用 -cp 参数指定了许多 Classpath 路径,最后从里面找到了 "/mypath2/lib/alpn-boot.jar"。
到这个目录下,找到产品线所使用的 jar 包,然后将其中包含的 ClientHandshaker.class 反编译后,发现代码基本与 8u181 代码相同——也访问了 state,并且连行号(198)也能对应上。到此,根因基本确定。
alpn-boot.jar 是 Jetty 中用来实现 TLS 的扩展。产品线当时所使用的 alpn 版本是 8.1.12.v20180117,根据官方文档,这个版本只能兼容到 JDK 8u181,而 8u191 之后,alpn 的版本也应有相应的变化,以兼容新的 JDK 代码。为什么当时 alpn 没有自动适应 JDK 版本?因为产品的启动脚本里写死了那个老版本的 alpn-boot.jar,而在升级的时候却没有适配启动脚本。
笔者将相同的 java 应用和 JDK 部署在 Linux 环境中,一台机器上运行正常,另外一台和预期不一样。对于这个现象我们非常奇怪,从问题表象应该是外部环境导致了运行结果不同,为此我们对软件、硬件、环境进行排查,发现 2 台机器除了使用的文件系统不同外,其他并无不同。
为什么不同的文件系统会影响 JVM 的运行结果?根源在哪里?
通过排查发现有两个 jar 中存在全限定名相同的两个主类,那么 JDK 会去选择哪个主类加载呢,对于 Classpath 通配符 JDK 又是如何解析的,下面笔者对上面问题进行分析。
准备两台 Linux 机器,其中一台文件系统为 ext4,另一台为 xfs。可以使用 df -T 命令查看使用的文件系统。
复现问题的方式非常简单,可以创建两个全限定名一样的类,然后编译打成 jar 包放在同一个目录中。运行 java 进程时,指定的 Classpath 路径使用通配符。可以通过下面的 demo 复现这个问题。
moduleA Main.java
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println("module A");
}
}
]
moduleB Main.java
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println("module B");
}
}
先使用 java 命令将这两个类编译,然后分别使用 jar 命令打包到 moduleA.jar 和 moduleB.jar 中,并保存在 lib 目录中。切换到 demo 目录,执行如下命令进行编译、打包。
mkdir -p moduleA/out
javac moduleA/src/com/example/Main.java -d moduleA/out/
mkdir -p moduleB/out
javac moduleB/src/com/example/Main.java -d moduleB/out/
mkdir lib
jar -cvf lib/moduleA.jar -C moduleA/out/ .
jar -cvf lib/moduleB.jar -C moduleB/out/ .
java -cp .:/home/username/demo/lib/* com.example.Main
对于不同文件系统的环境,输出的结果可能不同,下面以 ext 和 xfs 文件系统为例,可以看到输出不同的结果。
moduleA.jar 创建时间早于 moduleB.jar,输出 module B。
moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。
无论是先创建 moduleA.jar 还是 moduleB.jar,最终的输出结果都是 module B。
moduleA.jar 创建时间早于 moduleB.jar,输出 module A。
moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。
如果先创建 moduleA.jar,然后创建 moduleB.jar,输出的结果是 module A;反之,输出的结果是 module B。
使用 JDK 8 进程启动时,添加 VM 参数 -XX:+TraceClassPaths -XX:+TraceClassLoading
。其中以 ext 文件系统为例,可以得到如下的日志:
从日志可以发现 Classpath 解析后的路径是 moduleB.jar 在 moduleA.jar 之前,并且加载的是 moduleB.jar 的类。对于解析后的 Classpath,还可以通过添加 -XshowSettings 选项查看。
对于常驻进程,查看通配符解析后的 Classpath,可以使用 jcmd 命令查看。当然使用 jconsole 或者 visualvm 等工具连接进程也可以查看解析后的 Classpath。
以 Linux 系统为例,在 Classpath 中使用冒号分割多个路径,并且按照定义的顺序进行通配符解析。如下所示,任何文件系统解析出来的路径始终是 lib 目录的 jar 包在 lib2 目录的 jar 包之前。
.:/home/username/demo/lib/*:/home/username/demo/lib2/*
JVM 在解析通配符 * 时,最终会调用系统函数 opendir、readdir 读取遍历目录。ext4 创建文件的顺序与实际 readdir 读取的顺序不一致的原因主要在于 ext 系列文件系统有个 feature,即 dir_index,用于加快查找目录项(可直接计算其 hash 值定位到它的目录项),目录项也便成了以 hash 值大小进行排序。通常 dir_index 默认开启,可以通过 / etc/mke2fs.conf 查看默认配置。
创建一个 test_readdir.c 文件,用 C 语言实现一个 demo。通过调用系统 readdir 遍历目录,并且打印文件的 d_off、d_name 属性值。编译、执行命令如下所示:
编译
gcc test_readdir.c -o test_readdir.out
执行
./test_readdir.out /home/user/testdir
test_readdir.c 文件
#include<sys types.h="">
#include<stdio.h>
#include<dirent.h>
#include<unistd.h>
int main(int argc, char *argv[])
{
DIR *dir;
struct dirent *ptr;
int i;
if(argc==1)
{
dir = opendir(".");
}
else
{
dir = opendir(argv[1]);
printf("%s\n",argv[1]);
}
while((ptr = readdir(dir))!=NULL)
{
printf(" d_off:%ld d_name: %s\n",ptr->d_off,ptr->d_name);
}
closedir(dir);
return 0;
}
运行结果
分别在 ext4、xfs 文件系统上按顺序创建 m1~m10 文件,然后查看运行结果。对比发现 readdir 函数在不同文件系统中都是按照 d_off 属性值从小到大顺序进行遍历,不同的是 xfs 文件 d_off 的值按照创建时间依次增大,而 ext4 和文件的创建顺序无关。
ext4
xfs
可以将需要加载的主类所在的 jar 包存放在新创建的文件目录 libmain 下,并且将 libmain 目录放在 lib 目录之前,则指定 Classpath 路径的顺序如下所示:
.:/home/user/demo/libmain/*:/home/user/demo/lib/*
如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击阅读原文进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。