-
作者:程经纬、谢照昆 > 编者按:两位笔者分享了不同的案例,一个是因为 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](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/101547onahjaeyofipegne.png) # 分析 从这个调用栈来看,问题可能出现在 JDK 自身,并没有涉及到业务代码。首先自然应该去看一下 `ClientHandshaker.java` 的源码,确认一下出错时的上下文。先看 JDK 8u191 的代码,相关如下: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/101618ousqpizdiroio8fj.png) 可以看到,虽然这里对 handshakeState 进行了 check,但是代码中完全没有出现 state 这个变量;那么 8u181 又如何呢?继续看代码: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1017207hybnlcq35mgadgb.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 java -XX:+TraceClassLoading ``` 会产生类似于下面的输出:(加载的类 + 类的来源) ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/101753egdbrghp9gz61pda.png) 从这个输出中搜索一下 ClientHandshaker,最终发现了这么一行: ```Bash [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](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/101844r6esqdjqnz5wipkp.png) **moduleA Main.java** ```Java package com.example; public class Main { public static void main(String[] args) { System.out.println("module A"); } } ] ``` **moduleB Main.java** ```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 目录,执行如下命令进行编译、打包。 ```Bash 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/ . ``` - 运行 ```Bash java -cp .:/home/username/demo/lib/* com.example.Main ``` # 测试结果 对于不同文件系统的环境,输出的结果可能不同,下面以 ext 和 xfs 文件系统为例,可以看到输出不同的结果。 - ext4 moduleA.jar 创建时间早于 moduleB.jar,输出 module B。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102158ej4whpqrutplv0je.png) moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102204gmz1taywyyllcbyf.png) **无论是先创建 moduleA.jar 还是 moduleB.jar,最终的输出结果都是 module B**。 - xfs moduleA.jar 创建时间早于 moduleB.jar,输出 module A。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102234yxexw8luhnnuhkzy.png) moduleA.jar 创建时间晚于 moduleB.jar,输出 module B。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1022385c7ma1ascbs8koyf.png) **如果先创建 moduleA.jar,然后创建 moduleB.jar,输出的结果是 module A;反之,输出的结果是 module B。** # 原因分析 ## 排查方法 使用 JDK 8 进程启动时,添加 VM 参数 `-XX:+TraceClassPaths -XX:+TraceClassLoading`。其中以 ext 文件系统为例,可以得到如下的日志: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102310vztfiafw8wdlny5m.png) 从日志可以发现 Classpath 解析后的路径是 moduleB.jar 在 moduleA.jar 之前,并且加载的是 moduleB.jar 的类。对于解析后的 Classpath,还可以通过添加 -XshowSettings 选项查看。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102318vzm6cskv1lhcvdpz.png) 对于常驻进程,查看通配符解析后的 Classpath,可以使用 jcmd 命令查看。当然使用 jconsole 或者 visualvm 等工具连接进程也可以查看解析后的 Classpath。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102326urtf1z7ikf8t6elo.png) ## Classpath 通配符如何解析 以 Linux 系统为例,在 Classpath 中使用冒号分割多个路径,并且按照定义的顺序进行通配符解析。如下所示,任何文件系统解析出来的路径始终是 lib 目录的 jar 包在 lib2 目录的 jar 包之前。 ```Bash .:/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 属性值。编译、执行命令如下所示: **编译** ```Shell gcc test_readdir.c -o test_readdir.out ``` **执行** ```Shell ./test_readdir.out /home/user/testdir ``` test_readdir.c 文件 ```C #includetypes.h> #include #include #include 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 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102542o2ajtbw1owvfrqdq.png) xfs ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102550ea9dgmc0odlieo6r.png) ## 解决办法&修复方法 可以将需要加载的主类所在的 jar 包存放在新创建的文件目录 libmain 下,并且将 libmain 目录放在 lib 目录之前,则指定 Classpath 路径的顺序如下所示: ```Bash .:/home/user/demo/libmain/*:/home/user/demo/lib/* ``` # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击[阅读原文](https://www.openeuler.org/zh/other/projects/bishengjdk/)进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。 ![](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/102630rvfhk390y0cvvrvi.jpg) 原文转载自 openEuler-[JVM 中不正确的类加载顺序导致应用运行异常问题分析](https://mp.weixin.qq.com/s/nSl-xWbB3GKfm2AwxkHK6Q)
-
## 作者:宋尧飞 > 编者按:笔者使用 JDK 自带的内存跟踪工具 NMT 和 Linux 自带的 pmap 解决了一个非常典型的资源泄漏问题。这个资源泄漏是由于 Java 程序员不正确地使用 Java API 导致的,使用 Files.list 打开的文件描述符必须关闭。本案例一方面介绍了怎么使用 NMT 解决 JVM 资源泄漏问题,如果读者遇到类似问题,可以尝试用 NMT 来解决;另一方面也提醒 Java 开发人员使用 Java API 时需要必须弄清楚 API 使用规范,希望大家通过这个案例有所收获。 # 背景知识 ## NMT NMT 是 Native Memory Tracking 的缩写,一个 JDK 自带的小工具,用来跟踪 JVM 本地内存分配情况(本地内存指的是 non-heap,例如 JVM 在运行时需要分配一些辅助数据结构用于自身的运行)。 NMT 功能默认关闭,可以在 Java 程序启动参数中加入以下参数来开启: `-XX:NativeMemoryTracking=[summary | detail]` 其中,"summary" 和 "detail" 的差别主要在输出信息的详细程度。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100207eqp7izyfvxa4l5fp.png) 开启 NMT 功能后,就可以使用 JDK 提供的 jcmd 命令来读取 NMT 采集的数据了,具体命令如下: ```Java jcmd VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] ``` NMT 参数的含义可以通过 `jcmd help VM.native_memory` 命令查询。通过 NMT 工具,我们可以快速区分内存泄露是否源自 JVM 分配。 ## pmap 对于非 JVM 分配的内存,经常需要用到 pmap 这个工具了,这是一个 linux 系统自带工具,能够从系统层面输出目标进程内存使用的详细情况,用法非常简单: ```Java pmap [参数] ``` 常用的选项是 "-x" 或 "-X",都是用来控制输出信息的详细程度。 上图是 pmap 部分输出信息,每列含义为 | Address | 每段内存空间起始地址 | | ---- | ---- | | Kbytes | 每段内存空间大小(单位 KB) | | RSS | 每段内存空间实际使用内存大小(单位 KB) | | Dirty | 每段内存空间脏页大小(单位 KB) | | Mode | 每段内存空间权限属性 | | Mapping | 可以映射到文件,也可以是“anon”表示匿名内存段,还有一些特殊名字如“stack” | # 现象 某业务集群中,多个节点出现业务进程内存消耗缓慢增长现象,以其中一个节点为例: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1005325ilrliz8ahmowoyt.png) 如图所示,这个业务进程当前占用了 4.7G 的虚拟内存空间,以及 2.2G 的物理内存。已知正常状态下该业务进程的物理内存占用量不超过 1G。 # 分析 使用命令 `jcmdVM.native_memory detail` 可以看到所有受 JVM 监控的内存分布情况: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100608owdtwmrm9leyukea.png) 上图只是截取了 nmt(Native Memory Tracking) 命令展示的概览信息,这个业务进程占用的 2.2G 物理内存中,受 JVM 监控的大概只占了 0.7G(上图中的 committed),意味着有 1.5G 物理内存不受 JVM 管控。JVM 可以监控到 Java 堆、元空间、CodeCache、直接内存等区域,但无法监控到那些由 JVM 之外的 Native Code 申请的内存,例如典型的场景:第三方 so 库中调用 malloc 函数申请一块内存的行为无法被 JVM 感知到。 nmt 除了会展示概览之外,还会详细罗列每一片受 JVM 监控的内存,包括其地址,将这些 JVM 监控到的内存布局和用 pmap 得到的完整的进程内存布局做一个对比筛查,这里忽略 nmt 和 pmap(下图 pmap 命令中 25600 是进程号)详细内存地址的信息,直接给出最可疑的那块内存: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100618tjpfyqh0yrp8ss5f.png) 由图可知,这片 1.7G 左右的内存区域属于系统层面的堆区。 **备注:这片系统堆区之所以稍大于上面计算得到的差值,原因大概是 nmt 中显示的 committed 内存并不对应真正占用的物理内存(linux 使用 Lazy 策略管理进程内存),实际通常会稍小。** 系统堆区主要就是由 libc 库接口 malloc 申请的内存组合而成,所以接下来就是去跟踪业务进程中的每次 malloc 调用,可以借助 GDB: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100633kpb31dj8wkf7vlex.png) 实际上会有大量的干扰项,这些干扰项一方面来自 JVM 内部,比如: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1006459vtkfen2ogs8lxku.png) 这部分干扰项很容易被排除,凡是调用栈中存在 `os::malloc` 这个栈帧的干扰项就可以直接忽视,因为这些 malloc 行为都会被 nmt 监控到,而上面已经排除了受 JVM 监控内存泄漏的可能。 另一部分干扰项则来自 JDK,比如: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100657ozdudwqjgtcteq5m.png) 有如上图所示,不少 JDK 的本地方法中直接或间接调用了 malloc,这部分 malloc 行为通常是不受 JVM 监控的,所以需要根据具体情况逐个排查,还是以上图为例,排查过程如下: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100707bizeverlsiqjxgpv.png) 注意图中临时中断的值(0x0000ffff5fc55d00)来自于第一个中断 b malloc 中断发生后的结果。 这里稍微解释一下上面 GDB 在做的排查过程,就是检查 malloc 返回的内存地址后续是否有通过 free 释放(通过 `tb free if X3` 这个命令,具体用法可以参考 GDB 调试),显然在这个例子中是有释放的。 通过这种排查方式,几经筛选,最终找到了一个可疑的 malloc 场景: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100719wqgwwuv9y9yv3gnm.png) 从调用栈信息可以知道,这是一个 JDK 中的本地方法 `sun.nio.fs.UnixNativeDispatcher.opendir0`,作用是打开一个目录,但后续始终没有进行关闭操作。进一步分析可知,该可疑 opendir 操作会周期性执行,而且都是操作同一个目录 `/xxx/nginx/etc/nginx/conf`,看来,是有个业务线程在定时访问 nginx 的配置目录,每次访问完却没有关闭打开的目录。 分析到这里,其实这个问题已经差不多水落石出。和业务方确认,存在一个定时器线程在周期性读取 nginx 的配置文件,代码大概是这样子的: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100727x11xrekm7nfpjvx4.png) 翻了一下相关 JDK 源码,Files.list 方法是有在末尾注册一个关闭钩子的: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1007353v3dw56ppbaufuxg.png) 也就是说,Files.list 方法返回的目录资源是需要手动释放的,否则就会发生资源泄漏。 由于这个目录资源底层是会关联一个 fd 的,所以泄漏问题还可以通过另一个地方进行佐证: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100741eilhspkdikaf08d6.png) 该业务进程目前已经消耗了 51116 个 fd! 假设这些 fd 都是 opendir 关联的,每个 opendir 消耗 32K,则总共消耗 1.6G,显然可以跟上面泄漏的内存值基本对上。 # 总结 稍微了解了一下,发现几乎没人知道 JDK 方法 Files.list 是需要关闭的,这个案例算是给大家都提了个醒。 # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击[阅读原文](https://www.openeuler.org/zh/other/projects/bishengjdk/)进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。 ![](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/100821urankftzcpzashgm.jpg) 原文转载自openEuler-[使用 NMT 和 pmap 解决 JVM 资源泄漏问题](https://mp.weixin.qq.com/s/8Kca9sWOK4BoXx-7CyBpMQ)
-
package day01;public class demo{ public static void main(String [] args){ Integer integer=1; Integer integer1=new Integer(1); System.out.println(integer.hashcode()); System.out.println(integer.hashcode);System.out.println(integer==integer1);System.out.println(integer.equals(intege1)); }}这是一道很经典的题,看似简单,实则涉及到了jvm的底层知识,如果对jvm恐怕也是很难将这个问题讲解清楚吧!本题的答案为1 1 false ture对于integer,它是一个变量,在jvm中放在方法区中,而integer1是一个包装类,它是存储在堆里面的 所以他们的地址会相等么,是肯定不会相等啊,!通熟的讲,equals比较的是值,而==比较的是地址
-
## 作者: 吴言 > 编者按:目前许多公司同时使用 x86 和 AArch64 2 种主流的服务器。这两种环境的算力相当,内存相同的情况下:相同版本的 JVM 和 Java 应用,相同的 JVM 参数,应用性能在不同的平台中表现相差 30%,x86 远好于 AArch64 平台。本文分析了一个应用在 AArch64 平台上性能下降的例子,发现 JVM 的 CodeCache 大小是引起这个性能问题的根源,进而研究什么导致了不同平台上 CodeCache 大小的不同。最后笔者给出了不同平台中该如何设置参数规避该问题。希望本文能给读者一些启示:当使用不同的硬件平台时需要关注底层硬件对于上层应用的影响。 业务在 x86 和 AArch64 上同时部署时(相同的 JDK 和 Java 应用版本),发现 AArch64 平台性能下降严重问题。进一步查看日志,发现在 AArch64 平台中偶有如下情况: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/165449eheaobshkkdyjvwj.png) 这代表 JVM 中的 `CodeCache` 满了,导致编译停止,未编译的方法只能解释执行,进而严重影响应用性能。那什么是 `CodeCache`? # CodeCache 是什么 简单来说,`CodeCache` 用于存放编译后的方法,主要分为三部分: 1. `Non-nmethods`:包括运行时 Stub,Adapter 等; 2. `Profiled nmethod`:包括会采集信息的方法,即分层编译中第 2、3 层的方法; 3. `Non-Profiled nmethods`:包括不采集信息的方法,即分层编译中第 1、4 层的方法,也包括 JNI 的方法。 注:分层编译指的是 JVM 同时存在 C1 和 C2 两种编译器,C1 做一些简单的编译优化,耗时较短,C2 做更多复杂的编译优化,性能较好,编译耗时较多。分层编译的触发在 JVM 内会根据相应的条件进行触发,关于更多分层编译相关知识可以参考相关资料 [1]。 在 JDK 9 之后 [2],这些会分配到不同的区域(使用不同区域的优点:查找、回收等),JDK 8 中会分配到同一块区域。 JVM 平时会清理一些不可达的方法,例如由于退优化等产生的死方法,另外 `UseCodeCacheFlushing` 选项(默认开启),还会清理较老以及执行较少的方法。一旦 `CodeCache` 满了之后,会停止编译,直到 `CodeCache` 有空间,若关闭了 `UseCodeCacheFlushing` 选项,则会直接永久停止编译。 不同的 JVM 版本以及不同的参数,默认的 `CodeCache` 大小不同。JDK 11 中默认参数下 `CodeCache` 大小为 240M,若想获取(确认)默认情况下的 `CodeCache` 大小,建议使用 `- XX:+PrintFlagsFinal` 选项获取 `ReservedCodeCache` 的大小。 `CodeCache` 大小主要通过以下选项调节: | Option | Description | | ---- | ---- | | InitialCodeCacheSize | 初始的 CodeCache 大小(单位字节) | | ReservedCodeCacheSize | 预留的 CodeCache 大小,即最大CodeCache 大小(单位字节) | | CodeCacheExpansionSize | CodeCache 每次扩展大小(单位字节) | 使用 `–XX:+PrintCodeCache` 选项可以打印应用使用的 `CodeCache` 情况,如下: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/165821hkyahcfaigtmuzzo.png) 其中 `max_used` 表示应用中使用到的 `CodeCache` 大小,据此可以设置合适的 `ReservedCodeCacheSize` 值。 # AArch64 vs x86_64 我们都知道 AArch64 和 x86 分别为 RISC 和 CISC 架构,因此代码密度方面存在一定差异,在这篇文章 [3] 中比较了不同指令集下手写汇编的大小,可以看到 AArch64 的代码密度是 RISC 架构中较优的,但相比 x86_64 仍稍差些(其中 RISC 最差,m68k 最好)。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/165852wriojhzz828dq5pd.png) 另外笔者选用业界通用的 java 测试套 dacapo[4] 比较 AArch64 和 x86_64 下 `CodeCache` 占用的大小。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/165915oga4aq9iswtlcjgi.png) 可以看到,在 AArch64 架构下,`CodeCache` 均比 x86_64 要大,但根据不同场景,大小差距不同,在 5%-20% 之间。因此在我们发现相同应用在 x86 和 AArch64 上时,`CodeCache` 大小需要进行相应的调节。 除此之外,还需要注意 `InlineSmallCode` 选项,JVM 只会 `inline` 代码体积比该值小的方法。JVM 通过 `inline` 可以触发更多的优化,因此 `inline` 对于性能提升也很重要。在 JDK 11 中,`InlineSmallCode` 在 x86 下的默认值为 2000 字节,在 AArch64 下的默认值为 2500 字节。而 JDK 8 中,`InlineSmallCode` 在 x86 和 AArch64 下默认值均为 2000 字节。因此建议迁移时也相应修改 `InlineSmallCode` 的值。业务通过对 `CodeCache ` 相关参数的调整,达到助力 JIT 的最佳编译效果。 # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入[毕昇 JDK 社区](https://www.openeuler.org/zh/other/projects/bishengjdk/)查找相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。 ![](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/1700185zsa6iqkhnpdz07g.jpg) # 参考 [1]http://cr.openjdk.java.net/~thartmann/talks/2017-hotspot_under_the_hood.pdf [2]https://bugs.openjdk.java.net/browse/jdk-8015774 [3]http://web.eece.maine.edu/~vweaver/papers/iccd09/ll_document.pdf [4]http://dacapobench.org/
-
## 作者: 王帅 > 编者按:笔者遇到一个非常典型 JVM 架构相关问题,在 x86 正常运行的应用,在 aarch64 环境上低概率偶现 JVM 崩溃。这是一个典型的 JVM 内部 bug 引发的问题。通过分析最终定位到 CMS 代码存在 bug,导致 JVM 在弱内存模型的平台上 Crash。在分析过程中,涉及到 CMS 垃圾回收原理、内存屏障、对象头、以及 ParNew 并行回收算法中多个线程竞争处理的相关技术。笔者发现并修复了该问题,并推送到上游社区中。毕昇 JDK 发布的所有版本均解决了该问题,其他 JDK 在 jdk8u292、jdk11.0.9、jdk13 以后的版本修复该问题。 # bug 描述 目标进程在 aarch64 平台上运行,使用的 GC 算法为 `CMS(-XX:+UseConcMarkSweepGC)`,会概率性地发生 JVM crash,且问题发生的概率极低。我们在 aarch64 平台上使用 fuzz 测试,运行目标进程 50w 次只出现过一次 crash(连续运行了 3 天)。 JBS issue:https://bugs.openjdk.java.net/browse/JDK-8248851 # 约束 - 我们对比了 x86 和 aarch64 架构,发现问题仅在 aarch64 环境下会出现。 - 文中引用的代码段取自 openjdk-8u262:http://hg.openjdk.java.net/jdk8u/jdk8u-dev/。 - 读者需要对 JVM 有基本的认知,如垃圾回收,对象布局,GC 线程等,且有一定的 C++ 基础。 # 背景知识 ## GC GC(Garbage Collection)是 JVM 中必不可少的部分,用于回收不再会被使用到的对象,同时释放对象占用的内存空间。 垃圾回收对于释放的剩余空间有两种处理方式: - 一种是存活对象不移动,垃圾对象释放的空间用空闲链表(free_list)来管理,通常叫做**标记-清除(Mark-Sweep)**。创建新对象时根据对象大小从空闲链表中选取合适的内存块存放新对象,但这种方式有两个问题,一个是空间局部性不太好,还有一个是容易产生内存碎片化的问题。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/154328iq9tj6d4ljgnbfqc.png) - 另一种对剩余空间的处理方式是 **Copy GC**,通过移动存活对象的方式,重新得到一个连续的空闲空间,创建新对象时总在这个连续的内存空间分配,直接使用碰撞指针方式分配(Bump-Pointer)。这里又分两种情况: - 将存活对象复制到另一块内存(to-space,也叫 survival space),原内存块全部回收,这种方式叫**撤离(Evacuation)**。 - 将存活对象推向内存块的一侧,另一侧全部回收,这种方式也被称为**标记-整理(Mark-Compact)**。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/154932g2q2hjgwwew5dubb.png) 现代的垃圾回收算法基本都是分代回收的,因为大部分对象都是朝生夕死的,因此将新创建的对象放到一块内存区域,称为**年轻代**;将存活时间长的对象(由年轻代晋升)放入另一块内存区域,称为**老年代**。根据不同代,采用不同回收算法。 - 年轻代,一般采用 Evacuation 方式的回收算法,没有内存碎片问题,但会造成部分空间浪费。 - 老年代,采用 Mark-Sweep 或者 Mark-Compact 算法,节省空间,但效率低。 GC 算法是一个较大的课题,上述介绍只是给读者留下一个初步的印象,实际应用中会稍微复杂一些,本文不再展开。 ## CMS CMS(Concurrent Mark Sweep)是一个以低时延为目标设计的 GC 算法,特点是 GC 的部分步骤可以和 mutator 线程(可理解为 Java 线程)同时进行,减少 STW(Stop-The-World)时间。年轻代使用 ParNewGC,是一种 Evacuation。老年代则采用 ConcMarkSweepGC,如同它的名字一样,采用 Mark-Sweep(默认行为)和 Mark-Compact(定期整理碎片)方式回收,它的具体行为可以通过参数控制,这里就不展开了,不是本文的重点研究对象。 CMS 是 openjdk 中实现较为复杂的 GC 算法,条件分支很多,阅读起来也比较困难。在高版本 JDK 中已经被更优秀和高效的 G1 和 ZGC 替代(CMS 在 JDK 13 之后版本中被移除)。 本文讨论的重点主要是年轻代的回收,也就是 ParNewGC 。 ## 对象布局 在 Java 的世界中,万物皆对象。对象存储在内存中的方式,称为对象布局。在 JVM 中对象布局如下图所示: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155037uvgeamjdswbf3xzn.png) 对象由对象头加字段组成,我们这里主要关注对象头。对象头包括markOop和_matadata。前者存放对象的标志信息,后者存放 Klass 指针。所谓 Klass,可以简单理解为这个对象属于哪个 Java 类,例如:String str = new String(); 对象 str 的 Klass 指针对应的 Java 类就是 Ljava/lang/String。 - markOop 的信息很关键,它的定义如下[1]: ```Java 1. // 32 bits: 2. // -------- 3. // hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object) 4. // JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object) 5. // size:32 ------------------------------------------>| (CMS free block) 6. // PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object) 7. // 8. // 64 bits: 9. // -------- 10. // unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object) 11. // JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object) 12. // PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object) 13. // size:64 ----------------------------------------------------->| (CMS free block) 14. // 15. // unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object) 16. // JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object) 17. // narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object) 18. // unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block) ``` 对于一般的 Java 对象来说,markOop 的定义如下(以 64 位举例): 1. 低两位表示对象的锁标志:00-轻量锁,10-重量锁,11-可回收对象, 01-表示无锁。 2. 第三位表示偏向锁标志:0-表示无锁,1-表示偏向锁,注意当偏向锁标志生效时,低两位是 01-无锁。即 ---->|101 表示这个对象存在偏向锁,高 54 位存放偏向的 Java 线程。 3. 第 4-7 位表示对象年龄:一共 4 位,所以对象的年龄最大是 15。 CMS 算法还会用到 `markOop`,用来判断一个内存块是否为 `freeChunk`,详细的用法见下文分析。 `_metadata` 的定义如下: ```Java 1. class oopDesc { 2. friend class VMStructs; 3. private: 4. volatile markOop _mark; 5. union _metadata { 6. Klass* _klass; 7. narrowKlass _compressed_klass; 8. } _metadata; 9. // ... 10. } ``` `_metadata` 是一个 union,不启用压缩指针时直接存放 `Klass` 指针,启用压缩指针后,将 `Klass` 指针压缩后存入低 32 位。高 32 位留作它用。至于为什么要启用压缩指针,理由也很简单,因为每个引用类型的对象都要有 `Klass` 指针,启用压缩指针的话,每个对象都可以节省 4 个 byte,虽然看起来很小,但实际上却可以减少 GC 发生的频率。而压缩的算法也很简单,`base + _narrow_klass << offset` 。`base` 和 `offset` 在 JVM 启动时会根据运行环境初始化好。offset 常见的取值为 0 或者 3(8 字节对齐)。 ## memory barrier 内存屏障(Memory barrier)是多核计算机为了提高性能,同时又要保证程序正确性,必不可少的一个设计。简单来说是为了防止因为系统优化,或者指令调度等因素导致的指令乱序。 所以多核处理器大都提供了内存屏障指令,C++ 也提供了关于内存屏障的标准接口,参考 memory order 。 总的来说分为 `full-barrier` 和 `one-way-barrier`。 - `full barrier` 保证在内存屏障之前的读写操作的真正完成之后,才能执行屏障之后的读写指令。 - `one-way-barrier` 分为 `read-barrier` 和 `write-barrier`。以 `read-barrier` 为例,表示屏障之后的读写操作不能乱序到屏障之前,但是屏障指令之前的读写可以乱序到屏障之后。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155338dxshvz9c9l56feeg.png) openjdk 中的 `barrier`定义[3] ```Java 1. class OrderAccess : AllStatic { 2. public: 3. static void loadload(); 4. static void storestore(); 5. static void loadstore(); 6. static void storeload(); 7. 8. static void acquire(); 9. static void release(); 10. static void fence(); 11. // ... 12. static jbyte load_acquire(volatile jbyte* p); 13. // ... 14. static void release_store(volatile jint* p, jint v); 15. // ... 16. private: 17. // This is a helper that invokes the StubRoutines::fence_entry() 18. // routine if it exists, It should only be used by platforms that 19. // don't another way to do the inline eassembly. 20. static void StubRoutines_fence(); 21. }; ``` 其中 `acquire()`和 `release()` 是 `one-way-barrier`, `fence()` 是 `full-barrier`。不同架构依照这个接口,实现对应架构的 `barrier` 指令。 # 问题分析 在问题没有复现之前,我们能拿到的信息只有一个名为 `hs_err_$pid.log` 的文件,JVM 在发生 crash 时,会自动生成这个文件,里面包含 crash 时刻 JVM 的详细信息。但即便如此,分析这个问题还是有相当大的困难。因为没有 core 文件,无法查看内存中的信息。好在我们在一台测试环境上成功复现了问题,为最终解决这个问题奠定了基础。 ## 第一现场 首先我们来看下 crash 的第一现场。 - backtrace ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155519tqmvb8ngunplzby6.png) 通过调用栈我们可以看出发生 core 的位置是在 `CompactibleFreeListSpace::block_size` 这个函数,至于这个函数具体是干什么的,我们待会再分析。从调用栈中我们还可以看到,这是一个 ParNew 的 GC 线程。上文提到 CMS 年轻代使用 ParNewGC 作为垃圾回收器。这里 Par 指的是 `Parallel`(并行)的意思,即多个线程进行回收。 - pc ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155554fai7uikkzl6es5kh.png) pc 值是 `0x0000ffffb2f320e8`,相对这段 Instruction 开始位置 `0x0000ffffb2f320c8` 偏移为 0x20,将这段 Instructions 用反汇编工具得到如下指令: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155615x0brjk4qrhiejvrd.png) 根据相对偏移,我们可以计算出发生 core 的指令为 `02 08 40 B9 ldr w2, [x0, #8]`,然后从寄存器列表,可以看出 x0(上图中的 R0)寄存器的值为 `0x54b7af4c0`,这个值看起来不像是一个合法的地址。所以我们接下来看看堆的地址范围。 - heap ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155715urhs51qnyhcmme7y.png) 从堆的分布可以看出 `0x54b7af4c0` 肯定不在堆空间内,到这里可以怀疑大概率是访问了非法地址导致 crash,为了更进一步确认这个猜想,我们要结合源码和汇编,来确认这条指令的目的。 - 首先我们看看汇编 下图这段汇编是由 `objdump` 导出 `libjvm.so` 得到,对应 `block_size` 函数一小部分: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/155745bvji8fivltyfzhsp.png) 图中标黄的部分就是 crash 发生的地址,这个地址在 hs_err_pid.log 文件中也有体现,程序运行时应该是由 `0x4650ac` 这个位置经过 cbnz 指令跳转过来的。而图中标红的这条指令是一条逻辑左移指令,结合 x5 寄存器的值是 3,我首先联想到 x0 寄存器的值应当是一个 Klass 指针。因为在 64 位机器上,默认会开启压缩指针,而 hs_err_$pid.log 文件中的 narrowklass 偏移刚好是 3(heap 中的 Narrow klass shift: 3)。到这里,如果不熟悉 Klass 指针是什么,可以回顾下背景知识中的对象布局。 如果 x0 寄存器存放的是 Klass 指针,那么 ldr w2, [x0, #8] 目的就是获取对象的大小,至于为什么,我们结合源码来分析。 - 源码分析 CompactibleFreeListSpace::block_size 源码[4]: ```Java 1. size_t CompactibleFreeListSpace::block_size(const HeapWord* p) const { 2. NOT_PRODUCT(verify_objects_initialized()); 3. // This must be volatile, or else there is a danger that the compiler 4. // will compile the code below into a sometimes-infinite loop, by keeping 5. // the value read the first time in a register. 6. while (true) { 7. // We must do this until we get a consistent view of the object. 8. if (FreeChunk::indicatesFreeChunk(p)) { 9. volatile FreeChunk* fc = (volatile FreeChunk*)p; 10. size_t res = fc->size(); 11. 12. // Bugfix for systems with weak memory model (PPC64/IA64). The 13. // block's free bit was set and we have read the size of the 14. // block. Acquire and check the free bit again. If the block is 15. // still free, the read size is correct. 16. OrderAccess::acquire(); 17. 18. // If the object is still a free chunk, return the size, else it 19. // has been allocated so try again. 20. if (FreeChunk::indicatesFreeChunk(p)) { 21. assert(res != 0, "Block size should not be 0"); 22. return res; 23. } 24. } else { 25. // must read from what 'p' points to in each loop. 26. Klass* k = ((volatile oopDesc*)p)->klass_or_null(); 27. if (k != NULL) { 28. assert(k->is_klass(), "Should really be klass oop."); 29. oop o = (oop)p; 30. assert(o->is_oop(true /* ignore mark word */), "Should be an oop."); 31. 32. // Bugfix for systems with weak memory model (PPC64/IA64). 33. // The object o may be an array. Acquire to make sure that the array 34. // size (third word) is consistent. 35. OrderAccess::acquire(); 36. 37. size_t res = o->size_given_klass(k); 38. res = adjustObjectSize(res); 39. assert(res != 0, "Block size should not be 0"); 40. return res; 41. } 42. } 43. } 44. } ``` 这个函数的功能我们先放到一边,首先发现 `else` 分支中有关于 `Klass` 的判空操作,且仅有这一处,这和反汇编之后的 cbnz 指令对应。如果 k 不等于 NULL,则会马上调用 size_given_klass(k) 这个函数[5],而这个函数第一步就是取 klass 偏移 8 个字节的内容。和 ldr w2, [x0, #8]对应。 ```Java 1. inline int oopDesc::size_given_klass(Klass* klass) { 2. int lh = klass->layout_helper(); 3. int s; 4. // ... 5. } ``` ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/160004vhfo74ojmghuharc.png) 通过 gdb 查看 Klass 的 fields offset,`_layout_helper` 的偏移刚好是 8 。 `klass->layout_helper();`这个函数就是取 Klass 的 `_layout_helper` 字段,这个字段在解析 class 文件时,会自动计算,如果为正,其值为对象的大小。如果为负,表示这个对象是数组,通过设置 bit 的方式来描述这个数组的信息。但无论怎样,这个进程都是在获取 `layouthelper` 时发生了 crash。 到这里,程序 core 在这个位置应该是显而易见的了,但是为什么 klass 会读到一个非法值呢?仅凭现有的信息,实在难以继续分析。幸运的是,我们通过 fuzz 测试,成功复现了这个问题,虽然复现概率极低,但是拿到了 `coredump` 文件。 ## debug 问题复现后,第一步要做的就是验证之前的分析结论: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/160102pgh9ryo1x0qernhl.png) 上述标号对应指令含义如下: 1. narrow_klass 的值最初放在 x6 寄存器中,通过 load 指令加载到 x0 寄存器 2. 压缩指针解压缩 3. 判断解压缩后的 klass 指针是否为 NULL 4. 获取 Klass 的 layouthelper 查看上述指令相关的寄存器: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/160127dltjaslsukhy5ptt.png) 1. 寄存器 x0 的值为 0x5b79f1c80 2. 寄存器 x0 的值是一个非法地址 3. 查看 narrow_klass 的 offset 4. 查看 narrow_klass 的 base 5. narrow_klass 解压缩,得到的结果是 0x100000200 和 x0 的值对应不上??? 6. 查看这个对象是什么类型,发现是一个 char 类型的数组。 通过以上调试基本信息,可以确认我们的猜想正确 ,但是问题是我们解压缩后得到的 Klass 指针是正确的,也能解析出 C,这是一个有效的 Klass。 但是 x0 中的值确实一个非法值。也就是说,内存中存放的 Klass 指针是正确的,但是 CPU 看见的 x0,也就是存放 Klass 指针的寄存器值是错误的。为什么会造成这种不一致呢,可能的原因是,这个地址刚被其他线程改写,而当前线程获取到的是写入之前的值,这在多线程环境下是非常有可能发生的,但是如果程序写的正确,且加入了正确的 `memory barrier`,也是不会有问题的,但现在出了问题,只能说明是程序没有插入适当的 `memory barrier`,或者插入得不正确。到这里,我们可以知道这个问题和内存序有关,但具体是什么原因导致这个地方读取错误,还要结合 GC 算法的逻辑进行分析。 ## ParNewTask 结合上文的调用栈,这个线程是在做根扫描,根扫描的意思是查找活跃对象的根,然后根据这个根集合,查找出根引用的对象的集合,进而找到所有活跃对象。因为 ParNew 是年轻代的垃圾回收器,要识别出整个年轻代的所有活跃对象。有一种可能的情况是根引用一个老年代对象 ,同时这个老年代对象又引用了年轻代的对象,那么这个年轻代的对象也应该被识别为活对象。 ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/160216ynzr7dox3nznt4fd.png) 所以我们需要考虑上述情况,但是没有必要扫描整个老年代的对象,这样太影响效率了,所以会有一个表记录老年代的哪些对象有引用到年轻代的对象。在 JVM 中有一个叫 `Card Table`的数据结构,专门干这个事情。 ## Card table 关于 `Card table` 的实现细节,本文不做展开,只是简单介绍下实现思路。有兴趣的读者可以参考网上其他关于 `Card table` 的文章。也可以根据本文的调用栈,去跟一下源码中的实现细节。 简单来说就是使用 `1 byte` 的空间记录一段连续的 `512 byte` 内存空间中老年代的对象引用关系是否发生变化。如果有,则将这个 `card` 标记置为 `dirty`,这样做根扫描的时候,只关注这些 `dirty card` 即可。当找到一个 `dirty card` 之后,需要对整个 `card` 做扫描,这个时候,就需要计算 `dirty card` 中的一块内存的大小。回忆下 CMS 老年代分配算法,是采用的 `freelist`。也就是说,一块连续的 `dirty card`,并不都是一个对象一个对象排布好的。中间有可能会产生缝隙,这些缝隙也需要计算大小。调用栈中的 `process_stride` 函数就是用来扫描一个 `dirtyCard` 的,而最顶层的 `block_size` 就是计算这个 `dirtyCard` 中某个内存块大小的。 `FreeChunk::indicatesFreeChunk(p)` 是用来判断块 p 是不是一个 `freeChunk`,就是这块内存是空的,加在 `free_list` 里的。如果不是一个 `freeChunk`,那么继续判断是不是一个对象,如果是一个对象,计算对象的大小,直到整个 card 遍历完。 ## 晋升 从上文中 gdb 的调试信息不难看出这个对象的地址为 `0xc93e2a18`(klass 地址 0xc93e2a20 -8),结合 heap 信息,这个对象位于老年代。如果是一个正常的老年代对象,在上一次 GC 完成之后,对象是不会移动的,那么作为对象头的 markOop 和 Klass 是大概率不会出现寄存器和内存值不一致的情况,因为这离现场太远了。那么更加可能的情况是什么呢?答案就是晋升。 熟悉 GC 的朋友们肯定知道这个概念,这里我再简单介绍下。所谓晋升就是发生 Evacuation 时,如果对象的年龄超过了阈值,那么认为这个对象是一个长期存活的对象,将它 copy 到老年代,而不是 survival space。还有一种情况是 survival space 空间已经不足了,这时如果还有活的对象没有 copy,那么也需要晋升到老年代。不管是那种情况,发生晋升和做根扫描这两个线程是可以同时发生的,因为都是 ParNewTask。 到这里,问题的重点怀疑对象,放在了对象晋升和根扫描两个线程之间没有做好同步,从而导致根扫描时读到错误的 Klass 指针。 所以简单看下晋升实现[6]。 ```Java 1. ConcurrentMarkSweepGeneration::par_promote { 2. 3. HeapWord* obj_ptr = ps->lab.alloc(alloc_sz); 4. |---> CFLS_LAB::alloc 5. |--->FreeChunk::markNotFree 6. 7. oop obj = oop(obj_ptr); 8. OrderAccess::storestore(); 9. 10. obj->set_mark(m); 11. OrderAccess::storestore(); 12. 13. // Finally, install the klass pointer (this should be volatile). 14. OrderAccess::storestore(); 15. obj->set_klass(old->klass()); 16. 17. ...... 18. 19. void markNotFree() { 20. // Set _prev (klass) to null before (if) clearing the mark word below 21. _prev = NULL; 22. #ifdef _LP64 23. if (UseCompressedOops) { 24. OrderAccess::storestore(); 25. set_mark(markOopDesc::prototype()); 26. } 27. #endif 28. assert(!is_free(), "Error"); 29. } ``` 看到这个地方,隔三岔五的一个 `OrderAccess::storestore();` 我感觉到我离真相不远了,这里已经插了这么多 `memory barrier` 了,难道之前经常出过问题吗?但是已经插了这么多了,难道还有问题吗?哈哈哈… 看下代码逻辑,首先从 `freelist` 中分配一块内存,并将其初始化为一个新的对象 oop,这里需要注意的一个地方是 `markNotFree` 这个函数,将 `prev`(转换成 oop 是对象的 Klass)设置为 NULL,然后将需要 copy 的对象的 `markOop` 赋值给这个新对象,再然后 copy 对象体,最后再将需要 copy 对象的 Klass 赋值给新对象。这中间的几次赋值都插入了 `OrderAccess::storestore()`。回忆下背景知识中的 `memory barrier` ,`OrderAccess::storestore()` 的含义是,`storestore` 之前的写操作,一定比 `storestore` 之后的写操作先完成。换句话说,其他线程当看到 `storestore` 之后写操作时,那么它观察到的 `storestore` 之前的写操作必定能完成。 ## 根因 通过上面的介绍,相信大家理解了 `block_size` 的功能,以及 `par_promote` 的写入顺序。那么这两个函数,或者说执行这两个函数的线程是如何造成 `block_size` 函数看见的 `klass` 不一致(CPU 和内存不一致)的呢?请看下面的伪代码: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/160948nbataq1lzladq1jg.png) 1. `scan card` 线程先读 klass,此时读到取到的 klass 是一个非法地址; 2. `par_promote` 线程设置 klass 为 NULL; 3. `par_promote` 设置 `markoop`,判断一块内存是不是一个 `freeChunk`,就是 `markoop` 的第 8 位判断的(回忆背景知识); 4. `scan card` 线程根据 `markoop` 判断该内存块是一个对象,进入 `else` 分支; 5. `par_promote` 线程此时将正确的 `klass` 值写入内存; 6. `scan card` 线程发现 `klass` 不是 NULL,访问 `klass` 的` _layout_helper`,出现非法地址访问,发生 coredump。 到这里,所有的现象都可以解释通了,但是线程真正执行的时候,会发生上述情况吗?答案是会发生的。 - 我们先看 scan card 线程 ① 中 `isfreeChunk` 会读 p(对应 `par_promote` 的 oop)的 `markoop`,④ 会读 p 的 `klass`,这两者的读写顺序,按照程序员的正常思维,一定是先读 `markoop`,再读 `klass`,但是 CPU 运行时,为了提高效率,会一次性取多条指令,还可能进行指令重排,使流水线吞吐量更高。所以 `klass` 是完全有可能在 `markoop` 之前就被读取。那么我们实际的期望是先读 `markoop`,再读 `klass`。那么怎样确保呢? - 接下来看下 par_promote 线程 根据之前堆 `storestore` 的解释,③ 写入 `markoop` 之后,`scan_card` 线程必定能观察到 `klass` 赋值为 NULL,但也有可能直接观察到 ⑤ `klass` 设置了正确的值。 - 我们再看下 scan card 线程 试想以下,如果 `markoop` 比 `klass` 先读,那么在 ① 读到的 `klass`,要么是 NULL,要么是正确的 `Klass`,如果读到是 NULL,则会在 `while(true)` 内循环,再次读取,直到读到正确的 `klass`。那么如果反过来 `klass` 比 `markoop` 先读,就有可能产生上述标号顺序的逻辑,造成错误。 综上,我们只要确保 `scan_card` 线程中 `markoop` 比 `klass` 先读,就能确保这段代码逻辑无懈可击。所以修复方案也自然而然想到,在 ① 和 ④ 之间插入 load 的 `memory barrier`,即加入一条 `OrderAccess::loadload()`。 详细的修复 patch 见 https://hg.openjdk.java.net/jdk-updates/jdk11u/rev/ae52898b6f0d 。目前已经 backport 到 jdk8u292,以及 JDK 13。 # x86 ? 至于这个问题为什么在 x86 上不会出现,这是因为 x86 的内存模型是 `TSO(Total Store Ordering)`的,他不允许读读乱序,从架构层面避免了这个问题。而 aarch64 内存模型是松散模型(Relaxed),读和写可以任意乱序,这个问题也随之暴露。关于这两种内存模型,Relaxed 的模型理论上肯定是更有性能优势的,但是对程序员的要求也更大。TSO 模型虽然只允许写后读提前,但是在大多数情况下,能够确保程序顺序和执行顺序保持一致。 # 总结 这是一个极小概率发生的 bug,因此隐藏的很深。解这个 bug 也耗费了很长时间,虽然最后修复方案就是一行代码,但涉及的知识面还是比较广的。其中 `memory barrier` 是一个有点绕的概念,GC 算法的细节也需要理解到位。如果读者第一次接触 JVM,希望有耐心看下去,反复推敲,相信你一定会有所收获。 # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入[毕昇 JDK 社区](https://www.openeuler.org/zh/other/projects/bishengjdk/)查找相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。 ![](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/10/161859lazmb9h9qwwajjoz.jpg) # 参考 [1]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markoop.hpp: L37~L54 [2]https://developer.arm.com/documentation/100941/0100/barriers [3]https://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/orderaccess.hpp:L243~L316 [4]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/bca17e38de00/src/share/vm/gc_implementation/concurrentmarksweep/compactiblefreelistspace.cpp:L986~L1017 [5]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.inline.hpp:L403~L481 [6]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/gc_implementation/concurrentmarksweep/concurrentmarksweepgeneration.cpp:L1354https://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/orderAccess.hpp:L243~L316 [4]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/bca17e38de00/src/share/vm/gc_implementation/concurrentmarksweep/compactiblefreelistspace.cpp:L986~L1017 [5]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.inline.hpp:L403~L481 [6]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/gc_implementation/concurrentmarksweep/concurrentmarksweepgeneration.cpp:L1354 原文转载自openEuler-[看看毕昇 JDK 团队是如何解决 JVM 中 CMS 的 Crash](https://mp.weixin.qq.com/s/RwAapBnaY5-FiZzPgGp4aQ)
-
1.两者都是可重入锁可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。2.synchronized 依赖于 JVM 而 ReentrantLock 依赖于 APIsynchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)3.ReentrantLock 比 synchronized 增加了一些高级功能相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)等待可中断.通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”4.使用选择除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放
-
1. 锁膨胀上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。偏向锁一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查**Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word**的ThreadID即可,这样就省去了大量有关锁申请的操作。轻量级锁轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。重量级锁重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。2.锁消除消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。3. 锁粗化锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。4. 自旋锁与自适应自旋锁轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
-
jps:JVM Process Status Tool,显示指定系统内所有的 HotSpot 虚拟机进程jcmd:发送诊断命令请求到正在运行的 JVMjstat:JVM Statistics Monitoring Tool,用于收集 HotSpot 虚拟机各方面的运行数据jstack:Stack Trace for Java,显示虚拟机的线程快照jinfo:Configuration Info for Java,显示虚拟机配置信息jmap:Memory Map for Java,生成虚拟机的内存转储快照(heapdump 文件)jhat:JVM Heap Dump Browser,用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上査看分析结果
-
在这里以使用数组交换两个数来了解数组在虚拟机是如何运行的(1)代码:public class Demo{ public static void swap(int[] a){ int tmp = a[0]; a[0] = a[1]; a[1] = tmp; } public static void main(String[] args) { int[] arr = {1,2}; System.out.println("-----交换前------"); System.out.println(Arrays.toString(arr)); swap(arr); System.out.println("-----交换后------"); System.out.println(Arrays.toString(arr)); }}
-
看毕昇jdk的介绍,在specjvm上性能有4.6%提升。安装配置毕昇jdk 11# java -versionopenjdk version "11.0.10" 2021-02-27OpenJDK Runtime Environment Bisheng (build 11.0.10+13)OpenJDK 64-Bit Server VM Bisheng (build 11.0.10+13, mixed mode)运行specjvm测试,报javac检查不过,请问怎么解决?The Javac version test in check failed.The Javac version must be the one included in SPECjvm2008.另外请问xms等堆栈参数能否配置在specjvm.properties中,如何配置?
-
更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个jvm内部的队列中读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个jvm内部的队列中一个队列对应一个工作线程每个工作线程串行拿到对应的操作,然后一条一条的执行这样的话,一个数据变更的操作,先执行,删除缓存,然后再去更新数据库,但是还没完成更新此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成
-
JDK中提供了4个元注解,在自定义注解时候经常需要用到1、@Target:描述当前注解能够作用的位置ElementType.TYPE:作用在类上ElementType.METHOD:作用在方法上ElementType.FIELD:作用在成员变量上2、@Retention:描述注解被保留到的阶段SOURCE:表示当前注解只有在代码阶段有效,编译期间丢弃CLASS:该注解会被保留到字节码阶段,指.class文件,提供给java编译器而不是JVM,包含在class文件RUNTIME:该注解会被保留到运行阶段 JVM,指的是运行时,可供 java 编译器和 JVM 使用SOURCE < CLASS < RUNTIME3、@Documented:描述注解是否被抽取到JavaDoc api4、@Inherited:描述注解是否可以被子类继承
-
作者:宋尧飞编者按:JNI 是 Java 和 C 语言交互的主要手段,要想做好 JNI 的编程并不容易,需要了解 JVM 内部机理才能避免一些错误。本文分析 Cassandra 使用 JNI 本地库导致 JVM 崩溃的一个案例,最后定位问题根源是信号的错误处理(一些 C 编程人员经常会截获信号,做一些额外的处理),该案例提示 JNI 编程时不要随意截获信号处理。现象在使用 Cassandra 时遇到运行时多个位置都有发生 crash 现象,并且没有 hs_err 文件生成,这里列举了其中一个 crash 位置:分析首先直接基于上面这个 crash 的 core 文件展开分析,下面分别是对应源码上下文和指令上下文:使用 GDB 调试对应的 core 文件,如下图所示:在 GDB 中进行单步调试(GDB 调试可以参考官方文档),配合源代码发现 crash 的原因是传入的 name 为 null,导致调用 name.split("\_") 时触发了 SIGSEGV 信号,直接 crash。暂时抛开这个方法传入 name 为 null 是否有问题不论,从 JVM 运行的机制来说,这里有个疑问,遇到一个 Null Pointer 为什么不是抛出 Null Pointer Exception(简称 NPE)而是直接 crash 了呢?这里有一个知识需要普及一下:Java 层面的 NPE 主要分为两类,一类是代码中主动抛出 NPE 异常,并被 JVM 捕获 (这里的代码既可以是 Java 代码,也可以是 JVM 内部代码);另一类隐式 NPE(其原理是 JVM 内部遇到空指针访问,会产生 SIGSEGV 信号, 在 JVM 内部还会检查运行时是否存在 SIGSEGV 信号)。带着上面的疑问,又看了几处其他位置的 crash,发现都是因为对象为 null 导致的 SIGSEGV,却都没有抛出 NPE,而是直接 crash 了,再结合都没有 hs_err 文件生成的现象, hs_err 文件生成功能位于 JVM 的 SIGSEGV 信号处理函数中,代码如下:由于 hs_err 文件没有产生,一个很自然的推断:Cassandra 运行中可能篡改了或者捕获了 SIGSEGV 信号,并且可能做了处理,以至于 JVM 无法正常处理 SIGSEGV 信号。然后排查业务方是否在 Cassandra 中用到了自定义的第三方 native 库,果然笔者所猜测的,有两个 native 库里都对 SIGSEGV 信号做了捕获,注释掉这些代码后重新跑对方的业务,crash 现象不再发生,问题(由于 Cassandra 中对 NPE 有异常处理导致 JVM 崩溃)解决。总结C/C++ 的组件在配合 Java 一起使用时,需要注意的一点就是不要随意去捕获系统信号,特别是 SIGSEGV、SIGILL、SIGBUS 等,因为会覆盖掉 JVM 中的信号捕获逻辑。附录 这里贴一个 demo 可以用来复现 SIGSEGV 信号覆盖造成的后果,有兴趣的可以跑一下:// JNITest.java import java.util.UUID; public class JNITest { public static void main(String[] args) throws Exception { System.loadLibrary("JNITest"); UUID.fromString(null); } }// JNITest.c #include <signal.h> #include <jni.h> JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) { signal(SIGSEGV, SIG_DFL);//如果注释这条语句,在运行时会出现NullPointerExcetpion异常 return JNI_VERSION_1_8; }通过 GCC 编译并执行就可以触发相同的问题,编译执行命令如下:$ gcc -Wall -shared -fPIC JNITest.c -o libJNITest.so -I$JAVA_HOME/include -I$JAVA_HOME/include/linux $ javac JNITest.java $ java -Xcomp -Djava.library.path=./ JNITest后记如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。原文转载自 openEuler-JNI 中错误的信号处理导致 JVM 崩溃问题分析
-
JVM运行时数据区JVM会在执行Java程序的过程中把它管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用处,各有各的创建与销毁时间,有的区域随着JVM进程的启动而存在,有的区域则依赖用户线程的启动和结束而创建与销毁。一般来说,JVM所管理的内存将会包含以下几个运行时数据区域线程私有区域:程序计数器、Java虚拟机栈、本地方法栈线程共享区域:Java堆、方法区、运行时常量池线程独占:每个线程都会有它独立的空间,随线程生命周期而创建和销毁线程共享:所有线程能访问这块内存数据,随虚拟机或者GC而创建和销毁
-
因为Java有一个很重要的特性——跨平台也就是“一次编写,到处运行”,也就是编写一个程序,可以在Windows,Linux,Mac等系统上直接运行,我们都知道不同的计算机有不同的硬件结构(CPU的体系架构不同),也有不同的操作系统,操作系统提供的API也不一样,而JVM就是屏蔽不同计算机上软硬件的差异,也就是代码只需要对虚拟机负责就OK,也就达到了我们的跨平台的目的。JVM也不仅仅是一个程序,不同的系统都会对应一个JVM的版本(如:Windows的JVM是一个版本,Linux的JVM也是一个版本),但是不管是那个版本的JVM,都懂Java语言。这也就说明为什么我们在官网上面下载的同一个 Tomcat 压缩包,既能在Windows上运行,也能够在Linux上运行,都是靠JVM把其中的差异给屏蔽掉了。注:JVM发展到现在,不仅仅是为了跨平台,而是已经打造成了一个生态圈,大量的程序/库/编程语言都是基于JVM的,但是对于一个编程语言来说,实现编译器(就是把源代码翻译成可执行指令),直接翻译成机器指令native指令,比较复杂,考虑到系统的差异,CPU的差异,那么工作量也就大了,干脆就将编程语言直接翻译成JVM能够之别的字节码,由JVM去考虑软硬件之间的差异,大大降低工作量。JVM有没有什么缺点呢?答:当然有,因为没有什么东西是完美无瑕的,在一个程序中,要先将编程语言翻译成JVM能够识别的字节码,然后再不同的系统中,JVM在屏蔽软硬件的差异再去翻译成不同系统能够识别的程序,那么效率肯定会受到影响。(注:虽然虚拟机能够影响到一定的效率,但是在真正的商业项目中,性能瓶颈往往不是编程语言自身带来的,更主要的瓶颈是网络IO/数据库/磁盘IO/复杂的CPU密集计算……)。
上滑加载中
推荐直播
-
TinyEngine低代码引擎系列第2讲——向下扎根,向上生长,TinyEngine灵活构建个性化低代码平台
2024/11/14 周四 16:00-18:00
王老师 华为云前端开发工程师,TinyEngine开源负责人
王老师将从TinyEngine 的灵活定制能力出发,带大家了解隐藏在低代码背后的潜在挑战及突破思路,通过实践及运用,帮助大家贴近面向未来低代码产品。
回顾中 -
华为云AI入门课:AI发展趋势与华为愿景
2024/11/18 周一 18:20-20:20
Alex 华为云学堂技术讲师
本期直播旨在帮助开发者熟悉理解AI技术概念,AI发展趋势,AI实用化前景,了解熟悉未来主要技术栈,当前发展瓶颈等行业化知识。帮助开发者在AI领域快速构建知识体系,构建职业竞争力。
去报名 -
华为云软件开发生产线(CodeArts)10月新特性解读
2024/11/19 周二 19:00-20:00
苏柏亚培 华为云高级产品经理
不知道产品的最新特性?没法和产品团队建立直接的沟通?本期直播产品经理将为您解读华为云软件开发生产线10月发布的新特性,并在直播过程中为您答疑解惑。
去报名
热门标签