-
## 作者:谢照昆、王嘉伟 > 编者按:笔者在使用PriorityBlockingQueue实现按照优先级处理任务时遇到一类NPE问题,经过分析发现根本原因是在任务出队列时调用比较器异常,进而导致后续任务出队列抛出NullPointerException。本文通过完整的案例复现来演示在什么情况会触发该问题,同时给出了处理建议。希望读者在编程时加以借鉴,避免再次遇到此类问题。 ## 背景知识 PriorityBlockingQueue是一个无界的基于数组的优先级阻塞队列,使用一个全局ReentrantLock来控制某一时刻只有一个线程可以进行元素出队和入队操作,并且每次出队都返回优先级别最高的或者最低的元素。PriorityBlockingQueue通过以下两种方式实现元素优先级排序: 1. 入队元素实现Comparable接口来比较元素优先级; 2. PriorityBlockingQueue构造函数指定Comparator来比较元素优先级; 关于PriorityBlockingQueue中队列操作的部分,基本和PriorityQueue逻辑一致,只不过在操作时加锁了。在本文中我们主要关注PriorityBlockingQueue出队的take方法,该方法通过调用dequeue方法将元素出队列。当没有元素可以出队的时候,线程就会阻塞等待。 ```java public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); E result; try { // 尝试获取最小元素,即小顶堆第一个元素,然后重新排序,如果不存在表示队列暂无元素,进行阻塞等待。 while ( (result = dequeue()) == null) notEmpty.await(); } finally { lock.unlock(); } return result; } ``` ## 现象 在某个业务服务中使用PriorityBlockingQueue实现按照优先级处理任务,某一天环境中的服务突然间不处理任务了,查看后台日志,发现一直抛出NullPointerException。将进程堆dump出来,使用MAT发现某个PriorityBlockingQueue中的size值比实际元素个数多1个(入队时已经对任务进行非空校验)。 异常堆栈如下: ``` java.lang.NullPointerException at java.util.concurrent.PriorityBlockingQueue.siftDownComparable(PriorityBlockingQueue.java:404) at java.util.concurrent.PriorityBlockingQueue.dequeue(PriorityBlockingQueue.java:333) at java.util.concurrent.PriorityBlockingQueue.take(PriorityBlockingQueue.java:548) ... ``` MAT结果:  ## 原因分析 在此我们分析下PriorityBlockingQueue是如何出队列的,PriorityBlockingQueue最终通过调用dequeue方法出队列,dequeue方法处理逻辑如下: 1. 将根节点(array[0])赋值给result; 2. array[n] 赋值给 arrary[0]; 3. 将 array[n] 设置为 null; 4. 调用siftDownComparable或siftDownUsingComparator对队列元素重新排序; 5. size大小减1; 6. 返回result; 如果在第4步中出现异常,就会出现队列中的元素个数比实际的元素个数多1个的现象。此时size未发生改变,arry[n]已经被置为null,再进行siftDown操作时就会抛出NullPointerException。继续分析第4步中在什么情况下会出现异常,通过代码走读我们可以发现只有在调用Comparable#compareTo或者Comparator#compare方法进行元素比较的时候才可能出现异常。这块代码的处理逻辑和业务相关,如果业务代码处理不当抛出异常,就会导致上述现象。 ```java /** * Mechanics for poll(). Call only while holding lock. */ private E dequeue() { int n = size - 1; if (n < 0) return null; else { Object[] array = queue; E result = (E) array[0]; //step1 E x = (E) array[n]; //step2 array[n] = null; //step3 Comparator<? super E> cmp = comparator; if (cmp == null) //step4 如果指定了comparator,就按照指定的comparator来比较。否则就按照默认的 siftDownComparable(0, x, array, n); else siftDownUsingComparator(0, x, array, n, cmp); size = n; //step5 return result; //step6 } } private static <T> void siftDownComparable(int k, T x, Object[] array, int n) { if (n > 0) { Comparable<? super T> key = (Comparable<? super T>)x; int half = n >>> 1; while (k < half) { int child = (k << 1) + 1; Object c = array[child]; int right = child + 1; if (right < n && ((Comparable<? super T>) c).compareTo((T) array[right]) > 0) c = array[child = right]; if (key.compareTo((T) c) <= 0) break; array[k] = c; k = child; } array[k] = key; } } private static <T> void siftDownUsingComparator(int k, T x, Object[] array, int n, Comparator<? super T> cmp) { if (n > 0) { int half = n >>> 1; while (k < half) { int child = (k << 1) + 1; Object c = array[child]; int right = child + 1; if (right < n && cmp.compare((T) c, (T) array[right]) > 0) c = array[child = right]; if (cmp.compare(x, (T) c) <= 0) break; array[k] = c; k = child; } array[k] = x; } } ``` ## 复现代码 ```java import java.util.ArrayList; import java.util.List; import java.util.concurrent.PriorityBlockingQueue; public class PriorityBlockingQueueTest { static class Entity implements Comparable<Entity> { private int id; private String name; private boolean flag; public void setFlag(boolean flag) { this.flag = flag; } public Entity(int id, String name) { this.id = id; this.name = name; } @Override public int compareTo(Entity entity) { if(flag) { throw new RuntimeException("Test Exception"); } if (entity == null || this.id > entity.id) { return 1; } return this.id == entity.id ? 0 : -1; } } public static void main(String[] args) { int num = 5; PriorityBlockingQueue<Entity> priorityBlockingQueue = new PriorityBlockingQueue<>(); List<Entity> entities = new ArrayList<>(); for (int i = 0; i < num; i++) { Entity entity = new Entity(i, "entity" + i); entities.add(entity); priorityBlockingQueue.offer(entity); } entities.get(num - 1).setFlag(true); int size = entities.size(); for (int i = 0; i < size; i++) { try { priorityBlockingQueue.take(); } catch (Exception e) { e.printStackTrace(); } } } ``` 执行结果如下: ```java java.lang.RuntimeException: Test Exception at PriorityBlockingQueueTest$Entity.compareTo(PriorityBlockingQueueTest.java:31) at PriorityBlockingQueueTest$Entity.compareTo(PriorityBlockingQueueTest.java:8) at java.util.concurrent.PriorityBlockingQueue.siftDownComparable(PriorityBlockingQueue.java:404) at java.util.concurrent.PriorityBlockingQueue.dequeue(PriorityBlockingQueue.java:333) at java.util.concurrent.PriorityBlockingQueue.take(PriorityBlockingQueue.java:548) at PriorityBlockingQueueTest.main(PriorityBlockingQueueTest.java:71) java.lang.NullPointerException at java.util.concurrent.PriorityBlockingQueue.siftDownComparable(PriorityBlockingQueue.java:404) at java.util.concurrent.PriorityBlockingQueue.dequeue(PriorityBlockingQueue.java:333) at java.util.concurrent.PriorityBlockingQueue.take(PriorityBlockingQueue.java:548) at PriorityBlockingQueueTest.main(PriorityBlockingQueueTest.java:71) ``` ## 规避方案 可以通过以下两种方法规避: - 在take方法出现NPE时,清除队列元素,将未处理的元素重新进入队列; - 在 Comparable#compareTo 或 Comparator#compare 方法中做好异常处理,对异常情况进行默认操作; 建议使用后者。 ## 案例引申 使用PriorityBlockingQueue作为缓存队列来创建线程池时,使用submit提交任务会出现 java.lang.ClassCastException: java.util.concurrent.FutureTask cannot be cast to 异常,而使用execute没有问题。 观察submit源码可以发现在submit内部代码会将Runable封装成RunnableFuture对象,然后调用execute提交任务。 ```java public Future<?> submit(Runnable task) { if (task == null) throw new NullPointerException(); RunnableFuture<Void> ftask = newTaskFor(task, null); execute(ftask); return ftask; } ``` 以Comparable为例,任务入队列时,最终会调用siftUpComparable方法。该方法第一步将RunnableFuture强转为Comparable类型,而RunnableFuture类未实现Comparable接口,进而抛出ClassCastException异常。 ``` java public boolean offer(E e) { if (e == null) throw new NullPointerException(); final ReentrantLock lock = this.lock; lock.lock(); int n, cap; Object[] array; while ((n = size) >= (cap = (array = queue).length)) tryGrow(array, cap); try { Comparator<? super E> cmp = comparator; if (cmp == null) siftUpComparable(n, e, array); else siftUpUsingComparator(n, e, array, cmp); size = n + 1; notEmpty.signal(); } finally { lock.unlock(); } return true; } private static <T> void siftUpComparable(int k, T x, Object[] array) { Comparable<? super T> key = (Comparable<? super T>) x; while (k > 0) { int parent = (k - 1) >>> 1; Object e = array[parent]; if (key.compareTo((T) e) >= 0) break; array[k] = e; k = parent; } array[k] = key; } ``` 这也是常见的比较器调用异常案例,本文不再赘述,可自行参考其他文章。 ## 总结 在使用PriorityBlockingQueue时,注意在比较器中做好异常处理,避免出现类似问题。 ## 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击阅读原文进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。  ------------ 原文转载自毕昇编译-[PriorityBlockingQueue比较器异常导致的NPE问题分析](https://mp.weixin.qq.com/s?__biz=MzkyNTMwMjI2Mw==&mid=2247484624&idx=1&sn=1386b641924946224560d0fd36407c27&chksm=c1c9e848f6be615e23ffaaeac2830f66539b01511549fe073e73b65d22fa57622470c90ceaec&token=776677221〈=zh_CN#rd) 关注毕昇编译获取编译技术更多信息 
-
议题一:GCC、LLVM选型介绍和未来规划1.GCC/LLVM版本节奏openEuler GCC 每两年发一个版本,每次选择x.2.y以后的版本openEuler LLVM 每两年发布一个新版本,每次选择x.0.1以后的版本2.GCC/LLVM最新特性介绍openEuler GCC 引入Autoprefetch、AutoBolt、AutoFDO等优化特性openEuler yum源“yum install bisheng-compiler”引入毕昇编译器供开发者安装使用3.其他讨论 需求收集:openEuler发行版中多版本GCC支持、上游社区LTO+控制流优化支持。议题二:Bisheng JDK选型介绍和未来规划1.生命周期、发布节奏、roadmap毕昇JDK提供8、11、17三个版本二进制,其中8支持至2026.12,11支持至2024.12,17支持至2027.12毕昇JDK会在openjdk社区ga后两个月发布,一般在月底,即3.30、6.30、9.30、12.30发布毕昇JDK8预计在6.30合入dynamic cds特性(可能会推迟)2.毕昇JDK最近特性介绍 主要包括支持国密的BGMProvider,提供OpenJFX源码及构建方式,RISC-V后端合入openjdk19等。3.其他讨论需求收集:与会人员希望能够将BGMProvider合到上游社区,但由于底层算法采用了三方BC库,导致很难推进后续计划:ARM曾经在openssl社区实现过底层算法,后续可以考虑在openjdk社区实现,届时可以一起将BGMProvider推送至上游社区,相关信息在线下讨论,感兴趣的同学可以加入Compiler SIG微信群讨论。是否可以考虑将软件包构建重构?将底层算法依赖做成动态拉取和配置?议题三:Compiler SIG可持续发展讨论和开放讨论1.开源策略说明:明确Compiler SIG 在开源过程中upstream first的原则,只有上游社区不容易接受的特性、且对用户有价值的会整合到openEuler中。2.目前openEuler支持或者正在支持X86、ARM、龙芯、申威、RV架构,有专门的Intel、申威和Power SIG。Compiler后续需要和相关各个SIG合作,保证新特性和稳定性的平衡。扫码添加 SIG 小助手微信,邀请你进 Compiler SIG 微信交流群。原文转载自毕昇编译-Compiler SIG ODD 2022 版本规划会议回顾关注毕昇编译获取编译技术更多信息
-
【技术剖析】1. 使用 perf 解决 JDK8 小版本升级后性能下降的问题【技术剖析】2. JVM 锁 bug 导致 G1 GC 挂起问题分析和解决【技术剖析】3. Java Flight Recorder - 事件机制详解【技术剖析】4. 一个 JVM 解释器 bug 在 AArch64 平台导致应用崩溃的问题分析【技术剖析】5. JDK 从8升级到11,使用 G1 GC,HBase 性能下降近20%。JDK 到底干了什么?【技术剖析】6. JNI 中错误的信号处理导致 JVM 崩溃问题分析【技术剖析】7. 看看毕昇 JDK 团队是如何解决 JVM 中 CMS 的 Crash【技术剖析】8. 相同版本 JVM 和 Java 应用,在 x86 和AArch64 平台性能相差30%,何故?【技术剖析】9. 使用 NMT 和 pmap 解决 JVM 资源泄漏问题【技术剖析】10. JVM 中不正确的类加载顺序导致应用运行异常问题分析【技术剖析】11. 使用jemalloc解决JVM内存泄露问题【技术剖析】12. 毕昇 JDK 8 中 AppCDS 实现介绍【技术剖析】13. PriorityBlockingQueue比较器异常导致的NPE问题分析如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。
-
## 作者:伍家华 > 编者按:笔者通过在 Hive 的场景发现 AppCDS 技术存在的价值,然后分析了 AppCDS 的工作原理,并将 JDK 11 中的特性移植到毕昇 JDK 8,在移植过程中由于 JDK 8 和 JDK 11 在类加载实现有所不同,JDK 11 在加载过程中增强了安全性检查,为了达到相同的效果没有对 JDK 8 中的类加载进行修改,而是通过额外的安全检查保证共享类的安全性。最后笔者介绍了如何使用 AppCDS 以及使用的注意事项,希望对读者有所帮助。 基于某产品集群业务中有使用 Hive 场景,我们发现该集群在执行任务时会启动大量的 Java 进程,且进程很快就执行结束。对于这种情况一个有效的解决方法是让 Java 进程之间重用部分公共数据,从而减少 Java 进程产生公共数据的耗时。在 JDK 11 中支持 CDS 和 AppCDS,CDS 的全称是 Class-Data Sharing,CDS 的作用是可以让类被预处理放到一个归档文件中,后续 Java 程序启动的时候可以直接带上这个归档文件,这样 JVM 可以直接将这个归档文件映射到内存中,以节约应用启动的时间。AppCDS 是 CDS 的扩展,不止能够作用于 Boot Class Loader,App Class Loader 和自定义的 Class Loader 也都能够起作用,加大了 CDS 的适用范围 [1]。技术原理是让多个 Java 进程共享类的元数据,其基本原理如下所示:  左右两边分别都是一个 Java 进程,双方通过共享内存达到节约内存、减少类解析的目的。下面通过案例介绍一下我们发现的现象,然后再介绍类共享机制的内部原理。 # 1. 技术背景 ## 1.1 环境介绍 Hive on tez 集群,4 个节点,一个管理节点,其他三个节点作为工作节点。软件版本:Hadoop 3.1.0,hive 3.1.2,tez-0.9.1,ambari2.6,TPC-DS 标准测试套。硬件环境:Kunpeng 920,128 核,512G 内存。 ## 1.2 系统负载情况 运行 TPC-DS 自带的 sql8,使用 top 观察其中的一个节点的工作负载,发现启动了大量的 JVM 进程。  ## 1.3 热点分布 同时使用 perf 采集整机的热点数据,发现 JVM 的 C1, C2 编译线程占比很高,如下图所示。因此判断 hive sql 的 startup 和 warmup 阶段占比比较高,可以考虑从这两个方向优化。  考虑到目前 JVM 的 C2 代码比较难维护,也很难引进新的 feature。因此考虑看看在启动阶段有没有优化的可能。jdk1.8 带有 cds 功能,能够 share jdk 自带的 java class,能够减少 JVM 的类加载时间,因此首先尝试使用默认的 cds 功能,测试发现效果不明显。原因是 hive sql 执行过程中加载的类大部分是应用程序 classpath 下的类,jdk 自身的类占比很小,因此收益不明显。考虑到 jdk10 以后有了 AppCDS 功能,能够 share classpath 下的 class,因此考虑在 jdk1.8 上面实现 AppCDS 功能。 # 2. AppCDS 实现 毕昇 JDK 支持 AppCDS,即在原生 CDS 的基础上也支持用户自己程序 classpath 中的类共享。原生 CDS 只支持由 `Bootstrap classloader` 和 `Ext Classloader` 加载的 class,即 jdk 自带的 class 在多进程之间共享。但是在实际场景中,尤其是在使用了大量开源软件的情况下,用户指定 classpath 下的 jar 远远大于 jdk 自带的 class,这样 CDS 的实际效果比较有限。 AppCDS 能够扩大 class 共享的范围,从而在节省内存使用以及 JVM 的启动时间两个方面进一步提升效果。 ## 2.1 create class list 文件 java class 文件被 JVM 执行之前首先要被加载进 JVM 内存里面,作为 meta data 存储在 JVM 的 metaspace 内存区域。其中一个关键步骤就是解析 class 文件。JVM 内部有个 ClassFileParser 类,提供了 parseClassFile 方法,解析成功之后会生成一个对应 Klass。AppCDS 的第一步逻辑就是在类被解析完成之后,如果 class 不是匿名类,且 class 文件的 major version 大于 JAVA_1_5 Version,则会把 class 对应的 qualified name 写去到 list 文件中去。  ## 2.2 create jsa 归档文件  在这一步 JVM 会按行读取 appcds.lst 文件中内容,然后使用 App ClassLoader 去加载对应的 class。由于 Java classloader 自身的 delegate 机制,能够确保 jdk 自身的 class 也能够得到加载。Class 被加载之后存在 JVM 的 metaspace 区域,这个 metaspace 是 JVM Native Memory 的一部分,里面包含 klass, ConstantPool, Annotation, ConstMethod, Method, MethodData 等 meta 信息。 当 lst 文件中对应的 class 都被 JVM 加载完成之后,JVM 会把其对应的 metadata 写入到从虚拟地址 0x800000000 开始的内存区域,其格式是 JVM 内部私有的一种表示,然后再把这部分数据 dump 到归档文件中。 ## 2.3 与 JDK 11 实现的差别 与 JDK 11 中的 AppCDS 实现差别主要体现在加载用户指定的 class 逻辑上。当加载用户 class 时,是需要计算获取一个 ProtectionDomain 来做安全验证的。JDK 11 是直接在 hotspot 中使用 JavaCalls 模块构造这个 ProtectionDomain,而毕昇 JDK 8 是在 Java 的 ClassLoader 中提供了一个 `getProtectionDomainInternal` 函数来获取,并在加载 class 的时候使用 JavaCalls 调用这个 java 方法。 # 3. AppCDS 使用说明 ## 3.1 创建 lst 文件 启动 JVM 的时候添加 `- Xshare:off` 和 `- XX:+DumpLoadedClassList=/path/to/class.lst` 这两个选项。这里比较重要的是第二个选项,这个选项同时支持 %P,可以按进程生成 class.lst 文件,生成的文件名会带上 JVM 进程的 pid。class.lst 本质上是个纯文本文件,里面记录 JVM 运行过程中加载的 class 列表。在 java 程序被 JVM 执行的过程中,会不断地从 classpath 中加载 class,并在 JVM 内部创建对应的 klass 对象。Klass 对象创建成功之后 JVM 会把 class 在 JVM 内部的 qualified name 写入到 class.lst 文件中,比如 `java.lang.Objectd` 对应的是 java/lang/Object。 **使用例子:** ```Bash java -Xms16M -Xmx16M -XX:+UseG1GC -cp test.jar -Xshare:off -XX:+UseAppCDS -XX:DumpLoadedClassList=appcds.lst com.example.App ``` ## 3.2 创建 archive 文件 第二步:创建 cds 归档文件。使用方式: ```Bash java -Xms16M -Xmx16M -XX:+UseG1GC -cp test.jar -Xshare:dump -XX:+UseAppCDS -XX:SharedClassListFile=appcds.lst -XX:SharedArchiveFile=appcds.jsa com.example.App ``` ## 3.3 使用 archive 文件 使用示例: ```Bash java -Xms16M -Xmx16M -XX:+UseG1GC -cp test.jar -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=appcds.jsa com.example.App ``` 第三步:JVM 在启动的过程中会把 jsa mmap 到内存中,然后在触发类加载时候 JVM 会首先尝试从这块 mmap 进来的内存中去获取 klass 对象,如果获取到则会更新 JVM 内部的 SystemDictionary,以后再来获取对应的 klass 对象则不需要从 jsa 中获取。如果获取失败,JVM 则会走正常的类加载流程,尝试从 classpath 中加载对应的 class 文件,解析,链接,初始化,然后再放入 SystemDictionary 中。 # 4. AppCDS 效果 TPC-DS 测试套的 10 个 sql 在 5T 数据的场景下测试了一下 AppCDS 的效果,结果收益好,最低是 8.42% 的性能提升,最高是 26.9% 的性能提升。  # 5. 注意事项 使用 AppCDS 三部曲过程中要保持 JVM 版本、JAVA_HOME 路径一致,不然会校验失败,无法启动 JVM。 在第二步加载 class 并创建 jsa 归档文件的时候,JVM 会记录 class 的 fingerprint 信息 (相当于 hash 值),在第三步使用 jsa 中的 class 信息时会校验这个 fingerprint 信息,因此如果业务侧代码发生变更,要重新部署的话,jsa 归档文件要重新制作。 使用 AppCDS 或者 CDS 的时候不能 debug java 程序。因为 debug 的时候 jvmti 会修改 class 对应的内容,而根据第二点,必然会导致 fingerprint 不一致。 目前 AppCDS 并不支持 `Custom ClassLoader`,因此在 tomcat、jetty、SpringBoot 这些使用较多 `Custom ClassLoader` 场景的,整体收益跟原生 CDS 应该差不多,class 可以 share 的范围仅仅多了一些 Application 自身的 boot class。 # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击[阅读原文](https://mp.weixin.qq.com/s/QVljVWthj9mvAL0HAiWFcw)进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。  # 参考 [1] https://juejin.cn/post/6844903581246914574 原文转载自 openEuler-[毕昇 JDK 8 中 AppCDS 实现介绍](https://mp.weixin.qq.com/s/QVljVWthj9mvAL0HAiWFcw)
-
## 作者:王坤 > 编者按:JVM 发生内存泄漏,如何能快速定位到内存泄漏点并不容易。笔者通过使用 jemalloc(可以替换默认的 glibc 库)中的 profiling 机制(通过对程序的堆空间进行采样收集相关信息),演示了如何快速找到内存泄漏的过程。 Java 的内存对象一般可以分为堆内内存、堆外内存和 Native method 分配的内存,对于前面两种内存,可以通过 JVM 的 GC 进行管理,而 Native method 则不受 GC 管理,很容易引发内存泄露。Native Method 导致的内存泄漏,无法使用 JDK 自带的工具进行分析,需要通过 malloc_hook 追踪 malloc 的调用链帮助分析,一般可以采用内存分配跟踪工具(malloc tracing)协助分析内存泄漏。该工具的使用较复杂:需要修改源码,装载 hook 函数,然后运行修改后的程序,生成特殊的 log 文件,最后利用 mtrace 工具分析日志来确定是否存在内存泄露并分析可能发生内存泄露的代码位置。由于 hotspot 代码量较大,虽然可以通过一些选项逐步缩小可疑代码范围,但是修改代码总不是最优选择。另外,Valgrind 扫描也是一种常用的内存泄露分析工具,虽然 Valgrind 非常强大,但是 Valgrind 会报出很多干扰项,且使用较为麻烦。本文主要是介绍另一种分析 Native method 内存泄漏的方法,供大家参考。 jemalloc 是通过 malloc(3) 实现的一种分配器,代替 glibc 中的 malloc 实现,开发人员通过 jemalloc 的 Profiling 功能分析内存分配过程,可帮助解决一些 Native method 内存泄漏问题。 # 1 jemalloc 使用方法 jemalloc 使用方法的详细介绍,请参考本文附录章节。 # 2 使用 jemalloc 工具解决实际业务中遇到 Native method 内存泄漏问题 毕昇 JDK 某个版本内部迭代开发期间,在特性功能开发测试完毕后,进行 7*24 小时长稳测试时发现开启 `- XX:+UseG1GC` 选项会导致内存迅速增加,怀疑 JVM 层面存在内存泄露问题。 Java 例子参考(例子仅作为帮助问题理解使用): ```Java import java.util.LinkedHashMap; public class SystemGCTest { static int Xmx = 10; private static final int MB = 1024 * 1024; private static byte[] dummy; private static Integer[] funny; private static LinkedHashMap map = new LinkedHashMap(); public static void main(String[] args) { int loop = Integer.valueOf(args[0]); if (loop 0) { loop = loop * -1; while (true) { doGc(loop); map.clear(); System.gc(); } } else { doGc(loop); map.clear(); System.gc(); } } private static void doGc(int numberOfTimes) { final int objectSize = 128; final int maxObjectInYoung = (Xmx * MB) / objectSize; for (int i = 0; i numberOfTimes; i++) { for (int j = 0; j maxObjectInYoung + 1; j++) { dummy = new byte[objectSize]; funny = new Integer[objectSize / 16]; if (j % 10 == 0) { map.put(Integer.valueOf(j), funny); } } } } } ```  上图是开启 `- XX:+UseG1GC` 选项,Java 进程内存增长曲线图。横坐标是内存使用的统计次数,每 10 分钟统计一次;纵坐标是 Java 进程占用物理内存的大小。从上图可以看出:物理内存持续增涨的速度很快,存在内存泄露问题。 我们在设置了 jemalloc 的环境下,重新运行该测试用例: ```Bash java -Xms10M -Xmx10M -XX:+UseG1GC SystemGCTest 10000 ``` 注意:10000 与 jemalloc 无关,是 SystemGCTest 测试用例的参数,java 是疑似存在内存泄漏的 Java 二进制文件。 程序启动后,会在当前目录下逐渐生成一些 heap 文件,格式如:`jeprof.26205.0.i0.heap`。jeprof 工具的环境变量设置正确后(可参考本文附录),开发可以直接执行 jeprof 命令查看运行结果,了解 jeprof 的使用方式。jeprof 可基于 jemalloc 生成的内存 profile 堆文件,进行堆文件解析、分析并生成用户容易理解的文件,工具使用方便。 下面我们通过上述内存泄露问题,简单介绍 jeprof 工具的典型使用方法。 jeprof 工具可以生成内存申请代码的调用路径图。上述 Java 例子运行一段时间后会产生一些 heap 文件,jeprof 可帮助开发者获取有助于分析的可视化文件。 方法 1,通过使用 jeprof 工具将这些 heap 文件转成 svg 文件,命令如下: ```Bash jeprof --show_bytes --svg /home/xxxx/jdk1.8.0_292/bin/java jeprof*.heap > app-profiling.svg ``` 这里需要注意的是:`/home/xxxx/jdk1.8.0_292/bin/java` 必须是绝对路径。 注意:执行生成 svg 文件的命令时,部分环境会遇到类似如下错误: ```Bash Dropping nodes with = 2140452 B; edges with = 428090 abs(MB) dot: command not found ``` 该问题的解决方法,需要在环境中安装 graphviz 和 gv: ```Bash sudo apt install graphviz gv ``` 安装成功后,再次执行方法 1 中命令,可以得到可视化 svg 文件。 测试用例执行三十分钟后,我们对最后十分钟的内存增长进行分析,结果发现:95.9% 的内存增长来自 `G1DefaultParGCAllocator` 的构造函数调用,这里的最后 10 分钟是和用例设置相关,如下图所示:  上图比较清晰显示了内存申请相关函数的调用关系以及内存申请比例和数量,约95.9% 的堆内存是通过 `G1DefaultParGCAllocator` 完成申请,可以预测在 `G1DefaultParGCAllocator` 的构造函数中申请的内存没有被及时回收掉而导致内存泄漏的可能性非常大。这个时候可以通过代码协助分析了。 jeprof 工具不仅可以查看详细信息或者生成调用路径图(如上图所示),还可以用来比较两个 dump 文件(显示增量部分),既然作为工具使用介绍,我们继续介绍另一种补充性分析方法:将连续两次的 heap 文件做差异对比,输出的 PDF 可视化文件可以进一步确定是哪里内存持续申请没有被释放而导致内存增长。 方法如下: ```Bash jeprof --base=jeprof.34070.0.i0.heap --pdf /home/xxxx/jdk1.8.0_292/bin/java jeprof.34070.1.i1.heap > diff.pdf ``` 内存增加差异图:  通过上图可以非常清晰看到:`G1DefaultParGCAllocator` 的构造函数持续申请内存,导致内存增长迅速。后续的工作就是针对 `G1DefaultParGCAllocator` 构造函数中内存申请情况,排查释放逻辑,寻找问题原因并解决,这块的工作不属于 jemalloc 范畴,本内容不再赘述。 代码修复后 Java 进程物理内存使用情况如下(运行 30 小时 +):  内存使用符合预期,问题解决。 通过 jemalloc 工具和上面介绍的方法,帮助开发快速解决了此特性引起 Native method 内存泄漏问题,方法使用简单。在实际业务中有遇到类似问题的同学,不妨亲自尝试一下。 # 附录 A jemalloc 的编译 jemalloc 普通版并不包含 profiling 机制,所以需要下载源代码重新编译,在 configure 的时候添加了 --enable-prof 选项,这样才能打开 profiling 机制。 ## A.1 下载最新版本 jemalloc 源码 ```Bash git clone https://github.com/jemalloc/jemalloc.git ``` ## A.2 jemalloc 源码构建 1. 修改 autogen.sh 文件,使能 prof,如下: ```Bash diff --git a/autogen.sh b/autogen.sh index 75f32da6..6ab4053c 100755 --- a/autogen.sh +++ b/autogen.sh @@ -9,8 +9,8 @@ for i in autoconf; do fi done -echo "./configure --enable-autogen $@" -./configure --enable-autogen $@ +echo "./configure --enable-prof $@" +./configure --enable-prof $@ if [ $? -ne 0 ]; then echo "Error $? in ./configure" exit 1 ``` 执行: ```Bash $ ./autogen.sh $ make -j 6 ``` 以下命令可选: ```Bash $ make install ``` 2. 源码构建成功后(一般不会出错),会在当前目录的 bin 和 lib 目录下生成重要的文件: ```Bash $ ls -l total 376 -rw-rw-r-- 1 xxxx xxxx 1954 Jun 19 06:16 jemalloc-config -rw-rw-r-- 1 xxxx xxxx 1598 Jun 19 06:12 jemalloc-config.in -rw-rw-r-- 1 xxxx xxxx 145 Jun 19 06:16 jemalloc.sh -rw-rw-r-- 1 xxxx xxxx 151 Jun 19 06:12 jemalloc.sh.in -rw-rw-r-- 1 xxxx xxxx 182460 Jun 19 06:16 jeprof -rw-rw-r-- 1 xxxx xxxx 182665 Jun 19 06:12 jeprof.in $ cd ../lib/ $ ls -l total 89376 -rw-rw-r-- 1 xxxx xxxx 42058434 Jun 19 06:19 libjemalloc.a -rw-rw-r-- 1 xxxx xxxx 42062016 Jun 19 06:19 libjemalloc_pic.a lrwxrwxrwx 1 xxxx xxxx 16 Jun 19 06:19 libjemalloc.so -> libjemalloc.so.2 -rwxrwxr-x 1 xxxx xxxx 7390832 Jun 19 06:19 libjemalloc.so.2 $ pwd /home/xxxx/jemalloc/jemalloc/lib ``` 3. 设置环境变量和执行权限 bin 目录下的 jeprof 文件,没有执行权限,需要设置一下: ```Bash bin$ chmod +x ./* ``` 退到 bin 的上一层目录设置环境变量,可参考如下方法: ```Bash xxxx@hostname:jemalloc$ echo $JEMALLOC_DIR xxxx@hostname:jemalloc$ export JEMALLOC_DIR=`pwd` xxxx@hostname:jemalloc$ echo $JEMALLOC_DIR /home/xxxx/jemalloc/jemalloc xxxx@hostname:jemalloc$ export LD_PRELOAD=$JEMALLOC_DIR/lib/libjemalloc.so xxxx@hostname:jemalloc$ export MALLOC_CONF=prof:true,lg_prof_interval:30,lg_prof_sample:17 xxxx@hostname:jemalloc$ which jeprof xxxx@hostname:jemalloc$ export PATH=$PATH:$JEMALLOC_DIR/bin xxxx@hostname:jemalloc$ which jeprof /home/xxxx/jemalloc/jemalloc/bin/jeprof xxxx@hostname:jemalloc$ jeprof --version jeprof (part of jemalloc 5.2.1-737-g2381efab5754d13da5104b101b1e695afb442590) based on pprof (part of gperftools 2.0) Copyright 1998-2007 Google Inc. This is BSD licensed software; see the source for copying conditions and license information. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. ``` 到这一步,jeprof 可以在该环境中启动使用了。 # 后记 如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 社区查找相关资源(点击[阅读原文](https://www.openeuler.org/zh/other/projects/bishengjdk/)进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。  # 参考 1. https://chenhm.com/post/2018-12-05-debuging-java-memory-leak 2. https://github.com/jemalloc/jemalloc 3. https://blog.csdn.net/qq_36287943/article/details/105491301 4. https://www.cnblogs.com/minglee/p/10124174.html 5. https://www.yuanguohuo.com/2019/01/02/jemalloc-heap-profiling 原文转载自 openEuler-[使用jemalloc解决JVM内存泄露问题](https://mp.weixin.qq.com/s/SwW0s_BKc1hT_Zm1H-3Arw)
-
## 作者:宋尧飞 > 编者按:笔者使用 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" 的差别主要在输出信息的详细程度。  开启 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” | # 现象 某业务集群中,多个节点出现业务进程内存消耗缓慢增长现象,以其中一个节点为例:  如图所示,这个业务进程当前占用了 4.7G 的虚拟内存空间,以及 2.2G 的物理内存。已知正常状态下该业务进程的物理内存占用量不超过 1G。 # 分析 使用命令 `jcmdVM.native_memory detail` 可以看到所有受 JVM 监控的内存分布情况:  上图只是截取了 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 是进程号)详细内存地址的信息,直接给出最可疑的那块内存:  由图可知,这片 1.7G 左右的内存区域属于系统层面的堆区。 **备注:这片系统堆区之所以稍大于上面计算得到的差值,原因大概是 nmt 中显示的 committed 内存并不对应真正占用的物理内存(linux 使用 Lazy 策略管理进程内存),实际通常会稍小。** 系统堆区主要就是由 libc 库接口 malloc 申请的内存组合而成,所以接下来就是去跟踪业务进程中的每次 malloc 调用,可以借助 GDB:  实际上会有大量的干扰项,这些干扰项一方面来自 JVM 内部,比如:  这部分干扰项很容易被排除,凡是调用栈中存在 `os::malloc` 这个栈帧的干扰项就可以直接忽视,因为这些 malloc 行为都会被 nmt 监控到,而上面已经排除了受 JVM 监控内存泄漏的可能。 另一部分干扰项则来自 JDK,比如:  有如上图所示,不少 JDK 的本地方法中直接或间接调用了 malloc,这部分 malloc 行为通常是不受 JVM 监控的,所以需要根据具体情况逐个排查,还是以上图为例,排查过程如下:  注意图中临时中断的值(0x0000ffff5fc55d00)来自于第一个中断 b malloc 中断发生后的结果。 这里稍微解释一下上面 GDB 在做的排查过程,就是检查 malloc 返回的内存地址后续是否有通过 free 释放(通过 `tb free if X3` 这个命令,具体用法可以参考 GDB 调试),显然在这个例子中是有释放的。 通过这种排查方式,几经筛选,最终找到了一个可疑的 malloc 场景:  从调用栈信息可以知道,这是一个 JDK 中的本地方法 `sun.nio.fs.UnixNativeDispatcher.opendir0`,作用是打开一个目录,但后续始终没有进行关闭操作。进一步分析可知,该可疑 opendir 操作会周期性执行,而且都是操作同一个目录 `/xxx/nginx/etc/nginx/conf`,看来,是有个业务线程在定时访问 nginx 的配置目录,每次访问完却没有关闭打开的目录。 分析到这里,其实这个问题已经差不多水落石出。和业务方确认,存在一个定时器线程在周期性读取 nginx 的配置文件,代码大概是这样子的:  翻了一下相关 JDK 源码,Files.list 方法是有在末尾注册一个关闭钩子的:  也就是说,Files.list 方法返回的目录资源是需要手动释放的,否则就会发生资源泄漏。 由于这个目录资源底层是会关联一个 fd 的,所以泄漏问题还可以通过另一个地方进行佐证:  该业务进程目前已经消耗了 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 入群。  原文转载自openEuler-[使用 NMT 和 pmap 解决 JVM 资源泄漏问题](https://mp.weixin.qq.com/s/8Kca9sWOK4BoXx-7CyBpMQ)
-
## 作者: 王帅 > 编者按:笔者遇到一个非常典型 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)**。创建新对象时根据对象大小从空闲链表中选取合适的内存块存放新对象,但这种方式有两个问题,一个是空间局部性不太好,还有一个是容易产生内存碎片化的问题。  - 另一种对剩余空间的处理方式是 **Copy GC**,通过移动存活对象的方式,重新得到一个连续的空闲空间,创建新对象时总在这个连续的内存空间分配,直接使用碰撞指针方式分配(Bump-Pointer)。这里又分两种情况: - 将存活对象复制到另一块内存(to-space,也叫 survival space),原内存块全部回收,这种方式叫**撤离(Evacuation)**。 - 将存活对象推向内存块的一侧,另一侧全部回收,这种方式也被称为**标记-整理(Mark-Compact)**。  现代的垃圾回收算法基本都是分代回收的,因为大部分对象都是朝生夕死的,因此将新创建的对象放到一块内存区域,称为**年轻代**;将存活时间长的对象(由年轻代晋升)放入另一块内存区域,称为**老年代**。根据不同代,采用不同回收算法。 - 年轻代,一般采用 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 中对象布局如下图所示:  对象由对象头加字段组成,我们这里主要关注对象头。对象头包括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` 为例,表示屏障之后的读写操作不能乱序到屏障之前,但是屏障指令之前的读写可以乱序到屏障之后。  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  通过调用栈我们可以看出发生 core 的位置是在 `CompactibleFreeListSpace::block_size` 这个函数,至于这个函数具体是干什么的,我们待会再分析。从调用栈中我们还可以看到,这是一个 ParNew 的 GC 线程。上文提到 CMS 年轻代使用 ParNewGC 作为垃圾回收器。这里 Par 指的是 `Parallel`(并行)的意思,即多个线程进行回收。 - pc  pc 值是 `0x0000ffffb2f320e8`,相对这段 Instruction 开始位置 `0x0000ffffb2f320c8` 偏移为 0x20,将这段 Instructions 用反汇编工具得到如下指令:  根据相对偏移,我们可以计算出发生 core 的指令为 `02 08 40 B9 ldr w2, [x0, #8]`,然后从寄存器列表,可以看出 x0(上图中的 R0)寄存器的值为 `0x54b7af4c0`,这个值看起来不像是一个合法的地址。所以我们接下来看看堆的地址范围。 - heap  从堆的分布可以看出 `0x54b7af4c0` 肯定不在堆空间内,到这里可以怀疑大概率是访问了非法地址导致 crash,为了更进一步确认这个猜想,我们要结合源码和汇编,来确认这条指令的目的。 - 首先我们看看汇编 下图这段汇编是由 `objdump` 导出 `libjvm.so` 得到,对应 `block_size` 函数一小部分:  图中标黄的部分就是 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. } ```  通过 gdb 查看 Klass 的 fields offset,`_layout_helper` 的偏移刚好是 8 。 `klass->layout_helper();`这个函数就是取 Klass 的 `_layout_helper` 字段,这个字段在解析 class 文件时,会自动计算,如果为正,其值为对象的大小。如果为负,表示这个对象是数组,通过设置 bit 的方式来描述这个数组的信息。但无论怎样,这个进程都是在获取 `layouthelper` 时发生了 crash。 到这里,程序 core 在这个位置应该是显而易见的了,但是为什么 klass 会读到一个非法值呢?仅凭现有的信息,实在难以继续分析。幸运的是,我们通过 fuzz 测试,成功复现了这个问题,虽然复现概率极低,但是拿到了 `coredump` 文件。 ## debug 问题复现后,第一步要做的就是验证之前的分析结论:  上述标号对应指令含义如下: 1. narrow_klass 的值最初放在 x6 寄存器中,通过 load 指令加载到 x0 寄存器 2. 压缩指针解压缩 3. 判断解压缩后的 klass 指针是否为 NULL 4. 获取 Klass 的 layouthelper 查看上述指令相关的寄存器:  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 是年轻代的垃圾回收器,要识别出整个年轻代的所有活跃对象。有一种可能的情况是根引用一个老年代对象 ,同时这个老年代对象又引用了年轻代的对象,那么这个年轻代的对象也应该被识别为活对象。  所以我们需要考虑上述情况,但是没有必要扫描整个老年代的对象,这样太影响效率了,所以会有一个表记录老年代的哪些对象有引用到年轻代的对象。在 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 和内存不一致)的呢?请看下面的伪代码:  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 入群。  # 参考 [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)
-
打开一个表格的时候,JDK会直接拉满CPU,设计器也卡住不能操作,多次操作可能会报设计器内部异常,尝试重装设计器,配置环境变量都没得到解决,但在别人电脑上打开这个文件又不会出现这个问题。
-
看毕昇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中,如何配置?
-
毕昇 JDK 是华为基于 OpenJDK 优化后的开源版本,是一款高性能、可用于生产环境的 OpenJDK 发行版。毕昇 JDK 稳定运行在华为内部 500 多个产品上,毕昇 JDK 团队积累了丰富的开发经验,解决了许多实际业务中由原生 OpenJDK 缺陷引起的问题。毕昇 JDK 致力于为 Java 开发者提供一款稳定可靠、高性能、易调测的 JDK,也为用户在鲲鹏 AArch64 架构上提供一个更好的选择。本文基于毕昇 JDK8 和毕昇 JDK11 版本特性进行介绍。了解毕昇 JDK毕昇 JDK 是 OpenJDK 的下游,对华为内部一些应用场景上遇到的性能和稳定性问题进行了修复,并针对鲲鹏 AArch64 架构进行了稳定性增强和性能优化,尤其在大数据场景下性能更好。License:采用 GPLv2 with Classpath Exception 协议。支持 Java 版本:目前毕昇 JDK 支持 8 和 11 两个 LTS 版本。支持架构:支持鲲鹏 AArch64 架构,毕昇 JDK 开源代码支持 x86 版本自构建。支持操作系统:目前仅支持 Linux 版本,对操作系统的要求是鲲鹏 AArch64 平台上 glibc 版本不低于 2.17,基本覆盖所有主流操作系统,已验证的 OS 列表,可参考鲲鹏社区产品页的兼容性查询工具。毕昇 JDK 新增特性在 OpenJDK 基础上,毕昇 JDK 在鲲鹏 ARM 架构上进行了性能优化,新增了快速序列化、AppCDS、G1GC 堆内存伸缩、KAE Provider 等特性,充分提升毕昇 JDK 在鲲鹏 ARM 架构下的运行性能。下文将对这四项优势特性进行详细的介绍。快速序列化——提升原生序列化性能问题背景:在一些无法使用 Kyro(无法修改代码时),需要使用 OpenJDK 原生序列化特性的场景,OpenJDK 原生的序列化机制会耗时较长,导致性能较低。毕昇 JDK8&11 通过实行快速序列化特性提升其性能,快速序列化实现原理大致如下图:快速序列化的实现原理通过减少序列化数据字段和提供数据缓存的方式提升 OpenJDK 原生序列化的性能,详细介绍可参考毕昇 JDK 社区快速序列化特性介绍,该特性在毕昇 JDK8&11 都支持。场景建议:业务中序列化/反序列化部分占比较高的场景可以考虑使用。比如:readObject/ writeObject 热点方法占比较高,可以尝试使能该特性。重复对象越多,场景收益越大。实测在某序列化占比较高场景,快速序列化特性较原生序列化性能收益提升15%。使能方法:-XX:+UnlockExperimentalVMOptions-XX:+UseFastSerializer -DfastSerializerEscapeMode=trueAppCDS——提升 java 应用启动速度在 Java 程序运行初始阶段,类的加载是一个比较耗时的过程,且在每次程序运行中均需要执行一遍。而 CDS(Class Data Sharing)技术,就是把类加载后的数据保存到文件中,下次运行时,直接将加载后的类数据从文件中恢复到内存中,不需要再重新执行类的加载过程,从而提高性能。AppCDS 特性在原 CDS 特性基础上增加了对应用类的支持,实现原理如下图所示:AppCDS 的实现原理从实现原理图可以看到,AppCDS 特性通过从 JSA 文件读取共享数据,省略了共享类的加载过程。同时 Shared Memory 支持多进程间共享,也减少对内存的占用。具体使能方式可参考毕昇 JDK 社区关于 AppCDS 特性的使用介绍。G1GC 堆内存伸缩——及时释放空闲堆内存在 OpenJDK 社区的 8u 版本中,即使 G1GC 在空闲堆内存没有被使用时,也不会主动及时归还给 OS,会造成内存资源占用浪费情况。由于 G1 尽可能避免触发 Full GC,因此在许多情况下,除非强制从外部执行 Full GC,否则 G1 不会将空闲的 Java 堆内存释放给操作系统。毕昇 JDK8 通过在 G1 中引入堆内存伸缩特性,在应用程序 CPU 占比不高情况下,定期尝试释放 G1 的空闲堆内存空间给 OS,达到内存资源的最优使用。特性开启后内存释放示意图上图为某业务使能该特性后实测的 JProfile 示意图,可见空闲内存会被平滑释放。在产品线某业务场景下运行 49 个微服务,实测开启 G1 堆内存回收特性比默认 G1GC 的实际物理内存减少 40%。在默认情况下,毕昇 JDK8 不主动开启此功能,对于延迟和吞吐量敏感的业务,不会受到影响。使能方式:-XX:+UseG1GC –XX:+G1UncommitKAE Provider——支持鲲鹏硬加速/提升加解密速度KAE 加解密是鲲鹏加速引擎的加解密模块,鲲鹏硬加速模块实现了 RSA/ SM3/ SM4/ DH/ MD5/ AES等算法,提供了高性能对称加解密、非对称加解密算法能力,兼容 openssl1.1.1a 及其之后版本,支持同步和异步机制。毕昇 JDK 8 通过利用 Provider 机制,实现对鲲鹏服务器 KAE 加解密特性的支持,以帮助用户提升在鲲鹏 AArch64 服务器加解密业务的竞争力,特性实现原理可参考下图,详细介绍可参考毕昇 JDK 社区关于 KAE Provider 特性说明。KAE Provider 特性实现原理该特性在某 web 中间件业务场景中,性能收益较 OpenJDK 原生特性提升90%。支持算法列表:算法说明摘要算法包括 MD5、SHA256、SHA384、SM3对称加密算法 AES支持 ECB、CBC、CTR、GCM 模式对称加密算法 SM4包括 ECB、CBC、CTR、OFB 模式HMac包括 HmacMD5、HmacSHA1、HmacSHA224、HmacSHA256、HmacSHA384、HmacSHA51非对称加解密算法 RSA支持512、1024、2048、3072、4096位秘钥大小DH包括 DHKeyPairGenerator 和 DHKeyAgreement,支持512、1024、2048、3072、4096位秘钥ECDH包括 ECKeyPairGenerator 和 ECDHKeyAgreement,支持曲线secp224r1、prime256v1、secp384r1、secp521r1RSA 签名包括 RSASignature 和 RSAPSSSignature,私钥只支持 RSAPrivateCrtKey其他特性毕昇 JDK 其他特性如:G1GC 支持 Numa-Aware、毕昇 JDK 11 AArch64 版本支持的 ZGC、G1 Full GC 优化、Jmap 支持并行扫描等详情请点击毕昇JDK产品页获取。毕昇 JDK8 和 11 均已开源,并每隔 3 个月进行版本升级和新特性合入,欢迎来毕昇 JDK 开源社区获取最新信息和技术讨论。如果遇到相关技术问题(包括但不限于毕昇 JDK),也可以通过毕昇 JDK 社区进行讨论。原文转载自 华为计算-【鲲鹏 DevKit 黑科技揭秘】┃毕昇 JDK 4大关键特性解读
-
Java的发展从1995年开始经历了许多的过程,但是有三个最具有代表性的JDK版本;JDK1.0:标志着java的诞生;JDK1.2:加入了javax.swing组件,这是主要新特性;JDK1.5:标记为tiger,出现了许多一直沿用至今的特性;JDK1.8:Lambda表达式、接口的定义加强已经接触过了一些新特性,例如:自动装箱与拆箱、switch对String的判断支持。
-
作者:宋尧飞编者按: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 崩溃问题分析
-
作者:林军军、彭成寒编者按:笔者在 HBase 业务场景中尝试将 JDK 从 8 升级到 11,使用 G1 GC 作为垃圾回收器,但是性能下降 20%。到底是什么导致了性能衰退?又该如何定位解决?本文介绍如果通过使用 JFR、火焰图等工具确定问题,最后通过版本逐一验证找到了引起性能问题的代码。在毕昇 JDK 中率先修复问题最后将修复推送到上游社区中。希望通过本文的介绍让读者了解到如何解决大版本升级中遇到的性能问题;同时也提醒 Java 开发者要正确地使用参数(使用前要理解参数的含义)。HBase 从 2.3.x 开始正式默认的支持 JDK 11,HBase 对于 JDK 11 的支持指的是 HBase 本身可以通过 JDK 11 的编译、同时相关的测试用例全部通过。由于 HBase 依赖 Hadoop 和 Zookeeper,而目前 Hadoop 和 Zookeeper 尚未支持 JDK 11,所以 HBase 中仍然有一个 jira 来关注 JDK 11 支持的问题:https://issues.apache.org/jira/browse/HBASE-22972。G1 GC 从 JDK 9 以后就成为默认的 GC,而且 HBase 在新的版本中也采用 G1 GC,对于 HBase 是否可以在生产环境中使用 JDK 11?笔者尝试使用 JDK 11 来运行新的 HBase,验证 JDK 11 是否比 JDK 8 有优势。 1 环境介绍验证的方式非常简单,搭建一个 3 节点的 HBase 集群,安装 HBase,采用的版本为 2.3.2,关于 HBase 环境搭建可以参考官网。另外为了验证,使用一个额外的客户端机器,通过 HBase 自带的 PerformanceEvaluation 工具(简称 PE)来验证 HBase 读、写性能。PE 支持随机的读、写、扫描,顺序读、写、扫描等。例如一个简单的随机写命令如下:hbase org.apache.hadoop.hbase.PerformanceEvaluation --rows=10000 --valueSize=8000 randomWrite 5该命令的含义是:创建 5 个客户端,并且执行持续的写入测试。每个客户端每次写入 8000 字节,共写入 10000 行。PE 使用起来非常简单,是 HBase 压测中非常流行的工具,关于 PE 更多的用法可以参考相关手册。本次测试为了验证读写性能,采用如下配置:org.apache.hadoop.hbase.PerformanceEvaluation --writeToWAL=true --nomapred --size=256 --table=Test1 --inmemoryCompaction=BASIC --presplit=50 --compress=SNAPPY sequentialWrite 120JDK 采用 JDK 8u222 和 JDK 11.0.8 分别进行测试,当切换 JDK 时,客户端和 3 台 HBase 服务器统一切换。JDK 的运行参数为:-XX:+PrintGCDetails -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:-ResizePLAB注意:这里禁止 ResizePLAB 是业务根据 HBase 优化资料设置。 2 测试结果:JDK 11性能下降通过 PE 进行测试,运行结束有 TPS 数据,表示性能。在相同的硬件环境、相同的 HBase,仅仅使用不同的 JDK 来运行。同时为了保证结果的准确性,多次运行,取平均值。测试结果如下:从表中可以快速地计算得到吞吐量下降,运行时间增加。结论:使用 G1 GC,JDK 11 相对于 JDK 8 来说性能明显下降。3 原因分析从 JDK 8 到 JDK 11, G1 GC 做了非常多的优化用于提高性能。为什么 JDK 11 对于应用者来说更不友好?简单的总结一下从 JDK 8 到 JDK 11 做的一些比较大的设计变化,如下表所示:优化点描述IHOP 启发式设置IHOP 用于控制并发标记的启动时机,在 JDK 9 中引入该优化,根据应用运行的情况,计算 IHOP 的值,确保在内存耗尽之前启动并发标记。对于性能和运行时间理论上都是正优化,特殊情况下可能会导致性能下降Full GC 的并行话在 JDK10 中将 Full GC 从串行实现优化为并行实现,该优化不会产生负面影响动态线程调整根据 GC 工作线程的负载情况,引入动态的线程数来处理任务。该优化会带来正效果,注意不是 GC 工作线程数目越多 GC 的效果越好(GC 会涉及到多线程的任务窃取和同步机制,过多的线程会导致性能下降)引用集的重构引用集处理优化,设置处理大小、将并行修改为并发等由于从 JDK 8 到 JDK 11 特性变化太多,对于这样的性能下降问题,为了能快速有效的解决,我们做了如下的尝试。3.1 统一 JDK 8 和 JDK 11 的参数,验证效果由于 JDK 11 和 JDK 8 实现变化很多,部分功能完全不同,但是这些变化的功能一般都有参数控制,一种有效的尝试:梳理 JDK 8 和 JDK 11 关于 G1 的参数,将它们设置为相同的值,比如关闭 IHOP 的自适应,关闭线程调整等。这里简单的给出 JDK 8 和 JDK 11 不同参数的比较,如下图所示:将两者参数都设置为和 JDK 8 一样的值,重新验证测试,结果不变,JDK 11 性能仍然下降。3.2 GC日志分析,确定JDK 11性能下降点对于 JDK 8 和 JDK 11 同时配置日志收集功能,重新测试,获得 GC 日志。通过 GC 日志分析,我们发现差异主要在 G1 young gc 的 object copy 阶段(耗时基本在这),JDK 11 的 Young GC 耗时大概 200ms,JDK 8 的 Young GC 耗时大概 100ms,两者设置的目标停顿时间都是 100ms。JDK 11 中 GC 日志片段:JDK 8中 GC 日志片段:我们对整个日志做了统计,有以下发现:并发标记时机不同,混合回收的时机也不同;单次 GC 中对象复制的耗时不同,JDK 11 明显更长;总体 GC 次数 JDK 11 的更多,包括了并发标记的停顿次数;总体 GC 的耗时 JDK 11 更多。针对 Young GC 的性能劣化,我们重点关注测试了和 Young GC 相关的参数,例如:调整 UseDynamicNumberOfGCThreads、G1UseAdaptiveIHOP 、GCTimeRatio 均没有效果。下面我们尝试使用不同的工具来进一步定位到底哪里出了问题。3.3 JFR分析-确认日志分析结果毕昇 JDK 11和毕昇 JDK 8 都引入了 JFR,JFR 作为 JVM 中问题定位的新贵,我们也在该案例进行了尝试,关于JFR的原理和使用,参考本系列的技术文章:Java Flight Recorder - 事件机制详解。3.3.1 JDK 11总体信息JDK 8 中通过 JFR 收集信息。3.3.2 JDK 8总体信息JFR 的结论和我们前面分析的结论一致,JDK 11 中中断比例明显高于 JDK 8。3.3.3 JDK 11中垃圾回收发生的情况3.3.4 JDK 8中垃圾回收发生的情况从图中可以看到在 JDK 11 中应用消耗内存的速度更快(曲线速率更为陡峭),根据垃圾回收的原理,内存的消耗和分配相关。 3.3.5 JDK 11中VM操作3.3.6 JDK 8中VM操作通过 JFR 整体的分析,得到的结论和我们前面的一致,确定了 Young GC 可能存在问题,但是没有更多的信息。3.4 火焰图-发现热点为了进一步的追踪 Young GC 里面到底发生了什么导致对象赋值更为耗时,我们使用Async-perf 进行了热点采集。关于火焰图的使用参考本系列的技术文章:使用 perf 解决 JDK8 小版本升级后性能下降的问题。3.4.1 JDK 11的火焰图3.4.2 JDK 11 GC部分火焰图 3.4.3 JDK 8的火焰图3.4.4 JDK 8 GC部分火焰图通过分析火焰图,并比较 JDK 8 和 JDK 11 的差异,可以得到:在 JDK 11 中,耗时主要在:G1ParEvacuateFollowersClosure::do_void()G1RemSet::scan_rem_set 在 JDK 8 中,耗时主要在:G1ParEvacuateFollowersClosure::do_void()更一步,我们对 JDK 11 里面新出现的 scan_rem_set() 进行更进一步分析,发现该函数仅仅和引用集相关,通过修改 RSet 相关参数(修改 G1ConcRefinementGreenZone ),将 RSet 的处理尽可能地从Young GC的操作中移除。火焰图中参数不再成为热点,但是 JDK 11 仍然性能下降。比较 JDK 8 和 JDK 11 中 G1ParEvacuateFollowersClosure::do_void() 中的不同,除了数组处理外其他的基本没有变化,我们将 JDK 11 此处的代码修改和 JDK 8 完全一样,但是性能仍然下降。结论:虽然 G1ParEvacuateFollowersClosure::do_void() 是性能下降的触发点,但是此处并不是问题的根因,应该是其他的原因造成了该函数调用次数增加或者耗时增加。 3.5 逐个版本验证-最终确定问题我们分析了所有可能的情况,仍然无法快速找到问题的根源,只能使用最笨的办法,逐个版本来验证从哪个版本开始性能下降。在大量的验证中,对于 JDK 9、JDK 10,以及小版本等都重新做了构建(关于 JDK 的构建可以参考官网),我们发现 JDK 9-B74 和 JDK 9-B73 有一个明显的区别。为此我们分析了 JDK 9-B73 合入的代码。发现该代码和 PLAB 的设置相关,为此梳理了所有 PLAB 相关的变动:B66 版本为了解决 PLAB size 获取不对的问题(根据 GC 线程数量动态调整,但是开启 UseDynamicNumberOfGCThreads 后该值有问题,默认是关闭)修复了 bug。具体见 jira:Determining the desired PLAB size adjusts to the the number of threads at the wrong placeB74 发现有问题(desired_plab_sz 可能会有相除截断问题和没有对齐的问题),重新修改,具体见 8079555: REDO - Determining the desired PLAB size adjusts to the the number of threads at the wrong placeB115 中发现 B74 的修改,动态调整 PLAB 大小后,会导致很多情况 PLAB 过小(大概就是不走 PLAB,走了直接分配),频繁的话会导致性能大幅下降,又做了修复 Net PLAB size is clipped to max PLAB size as a whole, not on a per thread basis 重新修改了代码,打印 PLAB 的大小。对比后发现 desired_plab_sz 大小,在性能正常的版本中该值为 1024 或者 4096(分别是 YoungPLAB 和 OLDPLAB),在性能下降的版本中该值为 258。由此确认 desired_plab_sz 不正确的计算导致了性能下降。 3.6 PALB 为什么会引起性能下降?PLAB 是 GC 工作线程在并行复制内存时使用的缓存,用于减少多个并行线程在内存分配时的锁竞争。PLAB 的大小直接影响 GC 工作线程的效率。在 GC 引入动态线程调整的功能时,将原来 PLABSize 的大小作为多个线程的总体 PLAB 的大小,将 PLAB 重新计算,如下面代码片段:其中 desired_plab_sz 主要来自 YoungPLABSize 和 OldPLABSIze 的设置。所以这样的代码修改改变了 YoungPLABSize、OldPLABSize 参数的语义。 另外,在本例中,通过参数显式地禁止了 ResizePLAB 是触发该问题的必要条件,当打开 ResizePLAB 后,PLAB 会根据 GC 工作线程晋升对象的大小和速率来逐步调整 PLAB 的大小。注意,众多资料说明:禁止 ResziePLAB 是为了防止 GC 工作线程的同步,这个说法是不正确的,PLAB 的调整耗时非常的小。PLAB 是 JVM 根据 GC 工作线程使用内存的情况,根据数学模型来调整大小,由于模型的误差,可能导致 PLAB 的大小调整不一定有人工调参效果好。如果你没有对 YoungPLABSize、OldPLABSize 进行调优,并不建议禁止 ResizePLAB。在 HBase 测试中,当打开 ResizePLAB 后 JDK 8 和 JDK 11 性能基本相同,也从侧面说明了该参数的使用情况。 3.7 解决方法&修复方法由于该问题是 JDK 9 引入,在 JDK 9, JDK 10, JDK 11, JDK 12, JDK 13, JDK 14, JDK 15, JDK 16 都会存在性能下降的问题。我们对该问题进行了修正,并提交到社区,具体见Jira: https://bugs.openjdk.java.net/browse/JDK-8257145;代码见:https://github.com/openjdk/jdk/pull/1474;该问题在JDK 17中被修复。 同时该问题在毕昇 JDK 所有版本中第一时间得到解决。 当然对于短时间内无法切换 JDK 的同学,遇到这个问题,该如何解决?难道要等到 JDK 17?一个临时的方法是显式地设置 YoungPLABSize 和 OldPLABSize 的值。YoungPLABSize 设置为 YoungPLABSize* ParallelGCThreads,其中 ParallelGCThreads 为 GC 并行线程数。例如 YoungPLABSize 原来为 1024,ParallelGCThreads 为 8,在 JDK 9~16,将 YoungPLABSize 设置为 8192 即可。其中参数 ParallelGCThreads 的计算方法为:没有设置该参数时,当 CPU 个数小于等于 8, ParallelGCThreads 等于 CPU 个数,当 CPU 个数大于 8,ParallelGCThreads 等于 CPU 个数的 5/8)。 3.8 小结本文分享了针对 JDK 升级后性能下降的解决方法。Java 开发人员如果遇到此类问题,可以按照下面的步骤尝试自行解决:对齐不同 JDK 版本的参数,确保参数相同,看是否可以快速重现;分析 GC 日志,确定是否由 GC 引起。如果是,建议将所有的参数重新验证,包括移除原来的参数。本例中一个最大的失误是,在分析过程中没有将原来业务提供的参数 ResizePLAB 移除重新测试,浪费了很多时间。如果执行该步骤后,定位问题可能可以节约很多时间;使用一些工具,比如 JFR、NMT、火焰图等。本例中尝试使用这些工具,虽然无果,但基本上确认了问题点;最后的最后,如果还是没有解决,请联系毕昇 JDK 社区。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM 和 JDK 等相关编译技术,感兴趣的同学可以添加如下微信小助手入群。原文转载自 openEuler-JDK 从8升级到11,使用 G1 GC,HBase 性能下降近20%。JDK 到底干了什么?
-
作者:宋尧飞编者按:笔者遇到一个非常典型的问题,应用在 X86 正常运行,在 AArch64 上 JVM 就会崩溃。这个典型的 JVM 内部问题。笔者通过分析最终定位到是由于 JVM 中模板解释器代码存在 bug 导致在弱内存模型的平台上 Crash。在分析过程中,涉及到非常多的 JVM 内部知识,比如对象头、GC 复制算法操作、CAS 操作、字节码执行、内存序等,希望对读者有所帮助。本文介绍了一般分析 JVM crash 的方法,并且深入介绍了为什么在 aarch64 平台上引起这样的问题,最后还给出了修改方法并推送到上游社区中。**对于使用非毕昇 JDK 的其他 JDK 只有在 jdk8u292、jdk11.0.9、jdk15以后的版本才得到修复,读者使用时需要注意版本选择避免这类问题发生。背景知识java 程序在发生 crash 时,会生成 hs_err_pid.log 文件,以及 core 文件(需要操作系统开启相关设置),其中 hs_err 文件以文本格式记录了 crash 发生位置的小范围精确现场信息(调用栈、寄存器、线程栈、致命信号、指令上下文等)、jvm 各组件状态信息(java 堆、jit 事件、gc 事件)、系统层面信息(环境变量、入参、内存使用信息、系统版本)等,精简记录了关键信息。而 core 文件是程序崩溃时进程的二进制快照,完整记录了崩溃现场信息,可以使用 gdb 工具来打开 core 文件,恢复出一个崩溃现场,方便分析。约束文中描述的问题适用于 jdk8u292 之前的版本。现象某业务线隔十天半个月总会报过来 crash 问题,crash 位置比较统一,都是在某处执行 young gc 的上下文中,crash 的直接原因是 java 对象的头被写坏了,比如这样:而正常的对象头由 markoop 和 metadata 两部分组成,前者存放该对象的 hash 值、年龄、锁信息等,后者存放该对象所属的 Klass 指针。这里关注的是 markoop,64 位机器上它的具体布局如下:每种布局中每个字段的详细含义可以在 jdk 源码 jdk8u/hotspot/src/share/vm/oops/markOop.hpp 中找到,这里简单给出结论就是 gc 阶段一个正常对象头中的 markoop 不可能是全 0,而是比如这样:此外,crash 时间上也有个特点:基本每次都发生在程序刚启动时的几秒内。分析发生 crash 的 java 对象有个一致的特点,就是总位于 eden 区,我们仔细分析了 crash 位置的 gc 过程逻辑,特别是会在 gc 期间修改对象头的相关源码更是重点关注对象,因为那块代码为了追求性能,使用了无锁编程:补充介绍一下 CAS(Compare And Swap),CAS 的完整意思是比较并替换,并且确保整个操作原子性。CAS 需要 3 个操作数:内存地址 dst,比较值 cmp,要更新的目标值 value。当且仅当内存地址 dst 上的值跟比较值 cmp 相等时,将内存地址 dst 上的值改写为 value,否则就什么都不做,其在 aarch64 上的汇编实现类似如下:然而我们经过反复推敲,这块 gc 逻辑似乎无懈可击,而且位于 eden 区也意味着没有被 gc 搬移过的可能性,这个问题在很长时间里陷入了停滞……直到某一天又收到了一个类似的 crash,这个问题才迎来了转机。在这个 crash 里,也是 java 对象的头被写坏了,但特殊的地方在于,头上的错误值是 0x2000,凭着职业敏感,我们猜测这个特殊的错误值是否来自这个 java 对象本身呢?这个对象的 Java 名字叫 DynamicByteBuffer,来自某个基础组件。反编译得到了问题类 DynamicByteBuffer 的代码:再结合 core 信息中其他正常 DynamicByteBuffer 对象的布局,确定了这个特殊的 0x2000 值原本应该位于 segmentSize 字段上,而且从代码中注意到这个 segmentSize 字段是 final 属性,意味着其值只可能在实例构造函数中被设置,使用 jdk 自带的命令 javap 进行反汇编,得到对应的字节码如下:putfield 这条字节码的作用是给 java 对象的一个字段赋值,在红框中的语义就是给 DynamicByteBuffer 对象的 segmentSize 字段赋值。分析到这里,我们做一下小结,crash 的第一现场并非在 gc 上下文中,而是得往前追溯,发生在这个 java 对象被初始化期间,这期间在初始化它的 segmentSize 字段时,因为某种原因,0x2000 被写到了对象头上。接下来继续分析, JDK 在发生 crash 时会自动生成的 hs_err 日志,其中有记录最近发生的编译事件 “Compilation events (250 events)”,从中没有发现 DynamicByteBuffer 构造函数相关的编译事件,所以可以推断 crash 时 DynamicByteBuffer 这个类的构造函数尚未被编译过(由于 crash 发生在程序启动那几秒,JIT 往往需要预热后才会介入,所以可以假设记录的比较完整),这意味着,它的构造函数只会通过模板解释器去执行,更具体地说,是去执行模板解释器中的 putfield 指令来把 0x2000 写到 segmentSize 字段位置。具体怎么写其实很简单,就是先拿到 segmentSize 字段的偏移量,根据偏移量定位到写的位置,然后写入。然而 JVM 的模板解释器在实现这个 putfield 指令时,额外增加了一条快速实现路径,在 runtime 期间会自动(具体的时间点是 “完整” 执行完第一次 putfield 指令后)从慢速路径切到快速路径上,这个切换操作的实现全程没有加锁,同步完全依赖 barrier,由于整个过程比较复杂,这里首先给一个比较容易理解的并行流程图:注:图中 bcp 指的是 bytecode pointer,就是读字节码。上图表示接近同一时间点前后,两条并行流分别构建一个 DynamicByteBuffer 类型的对象过程中,各自完成 segmentSize 字段赋值的过程,用 Java 代码简单示意如下:其中第一条执行流走的慢速路径,第二条走的快速路径,可以留意到,红色标识的是几次公共内存的访存操作,barrier 就分布在这些位置前后(标在下图中)。接下来再给一个更加精确一点的指令流模型:简单介绍一下这个设计模型:线程从记录了指令的内存地址 bcp(bytecode pointer) 上取出指令,然后跳转到该指令地址上执行,当取出的指令是 bcp1(比如 putfeild 指令的慢速路径)时就是图中左边的指令流;左边的指令流就是计算出字段的 offset 并 str 到指定内存地址,然后插入 barrier,最后将 bcp2 指令(比如 putfeild 指令的快速路径)覆写到步骤 1 中的内存地址 addr 上;后续线程继续执行步骤 1 时,由于取出的指令变成了 bcp2,就改为跳转到图中右边的指令流;右边的指令流就是直接取出步骤 2 中已经存到指定内存地址中的 offset。回顾整个设计模型,左边的指令流通过一个等效于完整 dmb 的 barrier 来保证 str offset 和 str bcp2 这两条 str 指令的执行顺序并且全局可见;而右边的指令流中,ldr bcp 和 ldr offset 这两条 ldr 指令之间没有任何 barrier,设计者可能认为一个无条件跳转指令可以为两条 ldr 指令建立依赖,从而保证执行顺序,然而从实测结果来看是不成立的。这里先来简单补充介绍一下内存顺序模型的概念,现代 CPU 为了提高执行效率,在指令的执行顺序上拥有很大的自主权,对每个独立的 CPU 来说,只要确保语义不变,实际如何执行都有可能,这种方式对于单个 CPU 来说没有问题,当放到多个 CPU 共享数据的时候,这种乱序执行的行为就会引发每个 CPU 看到数据的顺序不一致问题,导致跨 CPU 的程序逻辑乱套了。这就需要对读、写内存指令进行约束,来规范每个 CPU 看到的内存生效行为,由此提出了内存顺序模型的概念:其中 ARM 采用的是一种弱内存模型,这种模型默认对读、写指令没有任何约束,需要由程序员自己通过插入 barrier 来手动保证。再回到这个问题上,测试方式是在 ldr offset 指令后额外加了检测指令:就是检查 offset 值是否为 0,如果为 0 则直接强制 crash(设计上保证了 java 对象的任何实例字段的 offset 不可能是 0)。经过长时间测试,程序果然在这个位置触发了 crash!这说明上面提到的两条 ldr 指令不存在依赖关系,或者说这种依赖关系类似 ARMv8 手册中描述的条件依赖,并不能保证执行顺序。ldr offset 指令先于 ldr bcp 执行,使得读到一个非法的 offset 值 0。更说明了,这才是这个案例的第一案发现场!找到了问题的根因后,解决方法也就顺利出炉了,那就是在两条 ldr 指令之间插入 barrier 来确保这两条 ldr 指令不发生乱序。实测证明,这种修复方案非常有效,这类 crash 现象消失。详细的修复 patch 见 https://hg.openjdk.java.net/jdk/jdk/rev/b9529fcbbd33。目前已经 backport 到 jdk8u292、jdk11.0.9、jdk15。总结Java 虚拟机 (JVM) 为了追求性能,大量使用了无锁编程进行设计,而且这么多年以来 JDK(特别是 JDK8)主要都是面向 X86 平台开发的,如今才慢慢的开始支持 aarch64 平台,所以 aarch64 弱内存序问题是我们面临的一个比较严峻的挑战。后记如果遇到相关技术问题(包括不限于毕昇 JDK),可以进入毕昇 JDK 官网查找相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇 JDK 社区每双周周二举行技术例会,同时有一个技术交流群讨论 GCC、LLVM、JDK 和 V8 等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复 Compiler 入群。原文转载自 openEuler-一个 JVM 解释器 bug 在 AArch64 平台导致应用崩溃的问题分析
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签