[转帖]【技术剖析】10. JVM 中不正确的类加载顺序导致应用运行异常问题分析

技术,剖析,jvm,正确,加载,顺序,导致,应用,运行,异常,问题,分析 · 浏览次数 : 0

小编点评

**问题:** * 在 Java 中如何解析 Classpath 通配符? * 在不同的文件系统上如何创建文件? **解决方案:** **解析 Classpath 通配符:** * 使用 `split()` 方法将 Classpath 中的路径分割成数组。 * 将数组中每个路径的第一个字符转换为小写。 * 将所有路径的第一个字符连接起来。 **创建文件:** * 根据 Classpath 中的路径创建文件。 * 在不同的文件系统中,文件创建顺序可能不同。 * 使用 `mkdirs()` 方法创建目录。 **例子:** **Classpath:** .:/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...

 

发表于 2021-11-15 10:36:113973查看

作者:程经纬、谢照昆

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

现象01

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

image.png

分析

从这个调用栈来看,问题可能出现在 JDK 自身,并没有涉及到业务代码。首先自然应该去看一下 ClientHandshaker.java 的源码,确认一下出错时的上下文。先看 JDK 8u191 的代码,相关如下:

image.png

可以看到,虽然这里对 handshakeState 进行了 check,但是代码中完全没有出现 state 这个变量;那么 8u181 又如何呢?继续看代码:

image.png

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

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

image.png

从这个输出中搜索一下 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 的目录结构

image.png

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。

image.png

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

image.png

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

  • xfs

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

image.png

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

image.png

如果先创建 moduleA.jar,然后创建 moduleB.jar,输出的结果是 module A;反之,输出的结果是 module B。

原因分析

排查方法

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

image.png

从日志可以发现 Classpath 解析后的路径是 moduleB.jar 在 moduleA.jar 之前,并且加载的是 moduleB.jar 的类。对于解析后的 Classpath,还可以通过添加 -XshowSettings 选项查看。

image.png

对于常驻进程,查看通配符解析后的 Classpath,可以使用 jcmd 命令查看。当然使用 jconsole 或者 visualvm 等工具连接进程也可以查看解析后的 Classpath。

image.png

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-&gt;d_off,ptr-&gt;d_name);
  }
  closedir(dir);
  return 0;
}

运行结果

分别在 ext4、xfs 文件系统上按顺序创建 m1~m10 文件,然后查看运行结果。对比发现 readdir 函数在不同文件系统中都是按照 d_off 属性值从小到大顺序进行遍历,不同的是 xfs 文件 d_off 的值按照创建时间依次增大,而 ext4 和文件的创建顺序无关。

ext4

image.png

xfs

image.png

解决办法&修复方法

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

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

后记

如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击阅读原文进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。

与[转帖]【技术剖析】10. JVM 中不正确的类加载顺序导致应用运行异常问题分析相似的内容:

[转帖]【技术剖析】10. JVM 中不正确的类加载顺序导致应用运行异常问题分析

https://bbs.huaweicloud.com/forum/thread-169439-1-1.html 神Bug... 发表于 2021-11-15 10:36:113973查看 作者:程经纬、谢照昆 > 编者按:两位笔者分享了不同的案例,一个是因为 JDK 小版本升级后导致运行出错,最终

[转帖]【技术剖析】5. JDK 从8升级到11,使用 G1 GC,HBase 性能下降近20%。JDK 到底干了什么?

https://bbs.huaweicloud.com/forum/thread-145649-1-1.html 发表于 2021-08-04 10:22:135894查看 作者:林军军、彭成寒 编者按:笔者在 HBase 业务场景中尝试将 JDK 从 8 升级到 11,使用 G1 GC 作为垃圾回

[转帖]【技术剖析】15. Native Memory Tracking 详解(1):基础介绍

https://bbs.huaweicloud.com/forum/thread-0246998875346680043-1-1.html 0.引言 我们经常会好奇,我启动了一个 JVM,他到底会占据多大的内存?他的内存都消耗在哪里?为什么 JVM 使用的内存比我设置的 -Xmx 大这么多?我的内存

[转帖]【技术剖析】16. Native Memory Tracking 详解(2):追踪区域分析(一)

https://bbs.huaweicloud.com/forum/thread-0295101552606827089-1-1.html 上篇文章 Native Memory Tracking 详解(1):基础介绍 中,分享了如何使用NMT,以及NMT内存 & OS内存概念的差异性,本篇将介绍NM

[转帖]【技术剖析】17. Native Memory Tracking 详解(3):追踪区域分析(二)

https://bbs.huaweicloud.com/forum/thread-0227103792775240073-1-1.html 应用性能调优 发表于 2022-11-14 15:19:36143查看 上篇文章 Native Memory Tracking 详解(2):追踪区域分析(一) 

[转帖]【技术剖析】18. Native Memory Tracking 详解(4):使用 NMT 协助排查内存问题案例

https://bbs.huaweicloud.com/forum/thread-0211103793043202049-1-1.html 其他 发表于 2022-11-14 15:38:571174查看 从前面几篇文章,我们了解了 NMT 的基础知识以及 NMT 追踪区域分析的相关内容,本篇文章将

[转帖]【技术剖析】8. 相同版本 JVM 和 Java 应用,在 x86 和AArch64 平台性能相差30%,何故?

https://bbs.huaweicloud.com/forum/thread-168532-1-1.html 作者: 吴言 > 编者按:目前许多公司同时使用 x86 和 AArch64 2 种主流的服务器。这两种环境的算力相当,内存相同的情况下:相同版本的 JVM 和 Java 应用,相同的 J

[转帖]【技术剖析】12. 毕昇 JDK 8 中 AppCDS 实现介绍

https://bbs.huaweicloud.com/forum/thread-169622-1-1.html 作者:伍家华 > 编者按:笔者通过在 Hive 的场景发现 AppCDS 技术存在的价值,然后分析了 AppCDS 的工作原理,并将 JDK 11 中的特性移植到毕昇 JDK 8,在移植

[转帖]【技术剖析】11. 使用jemalloc解决JVM内存泄露问题

https://bbs.huaweicloud.com/forum/thread-169523-1-1.html 作者:王坤 > 编者按:JVM 发生内存泄漏,如何能快速定位到内存泄漏点并不容易。笔者通过使用 jemalloc(可以替换默认的 glibc 库)中的 profiling 机制(通过对程

[转帖]【技术剖析】9. 使用 NMT 和 pmap 解决 JVM 资源泄漏问题

https://bbs.huaweicloud.com/forum/thread-168749-1-1.html 作者:宋尧飞 > 编者按:笔者使用 JDK 自带的内存跟踪工具 NMT 和 Linux 自带的 pmap 解决了一个非常典型的资源泄漏问题。这个资源泄漏是由于 Java 程序员不正确地使