JVM 中不正确的类加载顺序导致应用运行异常问题分析

嘟嘟鱼啊鱼
发布于 2022-8-3 18:11
浏览
1收藏

编者按:两位笔者分享了不同的案例,一个是因为 JDK 小版本升级后导致运行出错,最终分析定位原因为应用启动脚本中指定了 Classpath 导致 JVM 加载了同一个类的不同版本,而 JVM 在选择加载的类则是先遇到的先加载,进而导致应用出错,该问题的根因是设置了错误的 Classpath。第二个案例是在相同的 OS、JDK 和应用,不同的文件系统导致应用运行的结果不一致,最终分析定位的原因是 JVM 在加载类时遇到了多个版本的问题,但是该问题的根因是没有指定 Classpath,JVM 加载类会依赖于 OS 读取文件的顺序,而不同的文件系统导致提供文件的顺序不同,导致了问题的发生。经过这两个案例的分析,可以得到 2 个结论:需要指定 Classpath 避免不同文件系统(或者 OS)提供不同的文件顺序;需要正确地指定 Classpath,避免加载错误的类。希望读者可以了解 JVM 加载类文件的基本原理,避免出现类似错误。


现象01
某产品线进行 JDK 升级,将 JDK 版本从 8u181 升级到 8u191 后,日志中报出大量 java.lang.NoSuchFieldError,导致基本服务功能不可用。具体报错如下:

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区分析
从这个调用栈来看,问题可能出现在 JDK 自身,并没有涉及到业务代码。首先自然应该去看一下 ClientHandshaker.java 的源码,确认一下出错时的上下文。先看 JDK 8u191 的代码,相关如下:

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区可以看到,虽然这里对 handshakeState 进行了 check,但是代码中完全没有出现 state 这个变量;那么 8u181 又如何呢?继续看代码:

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区

这里的实现方式和 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

会产生类似于下面的输出:(加载的类 + 类的来源)

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区

从这个输出中搜索一下 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,而在升级的时候却没有适配启动脚本。

 

现象02
笔者将相同的 java 应用和 JDK 部署在 Linux 环境中,一台机器上运行正常,另外一台和预期不一样。对于这个现象我们非常奇怪,从问题表象应该是外部环境导致了运行结果不同,为此我们对软件、硬件、环境进行排查,发现 2 台机器除了使用的文件系统不同外,其他并无不同。

为什么不同的文件系统会影响 JVM 的运行结果?根源在哪里?

通过排查发现有两个 jar 中存在全限定名相同的两个主类,那么 JDK 会去选择哪个主类加载呢,对于 Classpath 通配符 JDK 又是如何解析的,下面笔者对上面问题进行分析。

 

环境介绍
准备两台 Linux 机器,其中一台文件系统为 ext4,另一台为 xfs。可以使用 df -T 命令查看使用的文件系统。

复现问题的方式非常简单,可以创建两个全限定名一样的类,然后编译打成 jar 包放在同一个目录中。运行 java 进程时,指定的 Classpath 路径使用通配符。可以通过下面的 demo 复现这个问题。

 

Demo 的目录结构

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区

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 文件系统为例,可以看到输出不同的结果。

 

ext4

moduleA.jar 创建时间早于 moduleB.jar,输出 module B。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区

无论是先创建 moduleA.jar 还是 moduleB.jar**,最终的输出结果都是 module B**。

 

xfs

moduleA.jar 创建时间早于 moduleB.jar,输出 module A。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区如果先创建 moduleA.jar,然后创建 moduleB.jar,输出的结果是 module A;反之,输出的结果是 module B。

 

原因分析

排查方法

使用 JDK 8 进程启动时,添加 VM 参数 -XX:+TraceClassPaths -XX:+TraceClassLoading。其中以 ext 文件系统为例,可以得到如下的日志:

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区从日志可以发现 Classpath 解析后的路径是 moduleB.jar 在 moduleA.jar 之前,并且加载的是 moduleB.jar 的类。对于解析后的 Classpath,还可以通过添加 -XshowSettings 选项查看。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区对于常驻进程,查看通配符解析后的 Classpath,可以使用 jcmd 命令查看。当然使用 jconsole 或者 visualvm 等工具连接进程也可以查看解析后的 Classpath。

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区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

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区xfs

JVM 中不正确的类加载顺序导致应用运行异常问题分析-鸿蒙开发者社区

解决办法&修复方法

可以将需要加载的主类所在的 jar 包存放在新创建的文件目录 libmain 下,并且将 libmain 目录放在 lib 目录之前,则指定 Classpath 路径的顺序如下所示:

.:/home/user/demo/libmain/*:/home/user/demo/lib/*

 

 

 

文章转载自公众号:openEuler

已于2022-8-3 18:11:08修改
1
收藏 1
回复
举报
回复
    相关推荐