• [技术干货] 相同版本 JVM 和 Java 应用,在 x86 和AArch64 平台性能相差30%,何故?
    吴言编者按:目前许多公司同时使用 x86 和 AArch64 2 种主流的服务器。这两种环境的算力相当,内存相同的情况下:相同版本的 JVM 和 Java 应用,相同的 JVM 参数,应用性能在不同的平台中表现相差 30%,x86 远好于 AArch64 平台。本文分析了一个应用在 AArch64 平台上性能下降的例子,发现 JVM 的 CodeCache 大小是引起这个性能问题的根源,进而研究什么导致了不同平台上 Codecache 大小的不同。最后笔者给出了不同平台中该如何设置参数规避该问题。希望本文能给读者一些启示:当使用不同的硬件平台时需要关注底层硬件对于上层应用的影响。业务在 x86 和 AArch64 上同时部署时(相同的 JDK 和 Java 应用版本),发现 AArch64 平台性能下降严重问题。进一步查看日志,发现在 AArch64 平台中偶有如下情况:这代表 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 大小主要通过以下选项调节:OptionDescriptionInitialCodeCacheSize初始的 CodeCache 大小(单位字节)ReservedCodeCacheSize预留的 CodeCache 大小,即最大CodeCache 大小(单位字节)CodeCacheExpansionSizeCodeCache 每次扩展大小(单位字节)使用–XX:+PrintCodeCache 选项可以打印应用使用的 CodeCache 情况,如下:其中 max_used 表示应用中使用到的 CodeCache 大小,据此可以设置合适的 ReservedCodeCacheSize 值。AArch64 vs x86_64我们都知道 AArch64 和 x86 分别为 RISC 和 CISC 架构,因此代码密度方面存在一定差异,在这篇文章 [3] 中比较了不同指令集下手写汇编的大小,可以看到 AArch64 的代码密度是 RISC 架构中较优的,但相比 x86_64 仍稍差些(其中 RISC 最差,m68k 最好)。另外笔者选用业界通用的 java 测试套 dacapo[4] 比较 AArch64 和 x86_64 下 CodeCache 占用的大小。可以看到,在 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社区查找相关资源(点击原文进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇JDK社区每双周周二举行技术例会,同时有一个技术交流群讨论GCC、LLVM、JDK和V8等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复Compiler入群。
  • [技术干货] 看看毕昇JDK团队是如何解决JVM中CMS的Crash
    编者按:笔者遇到一个非常典型 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 。• 读者需要对 JVM 有基本的认知,如垃圾回收,对象布局,GC 线程等,且有一定的 C++ 基础。背景知识GCGC(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]: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 的定义如下: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. }11.   _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 为例,表示屏障之后的读写操作不能乱序到屏障之前,但是屏障指令之前的读写可以乱序到屏障之后。One-way barriers [2]• openjdk 中的 barrier 定义[3]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 that19.   // 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(并行)的意思,即多个线程进行回收。• pcpc值是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]: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 compiler4.   // will compile the code below into a sometimes-infinite loop, by keeping5.   // 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). The13.       // block's free bit was set and we have read the size of the14.       // block. Acquire and check the free bit again. If the block is15.       // still free, the read size is correct.16.       OrderAccess::acquire();17. 18.       // If the object is still a free chunk, return the size, else it19.       // 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 array34.         // 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]对应。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 指针是否为NULL4. 获取 Klass 的 layouthelper查看上述指令相关的寄存器:1. 寄存器 x0 的值为0x5b79f1c802. 寄存器 x0 的值是一个非法地址3. 查看 narrow_klass 的 offset4. 查看 narrow_klass 的 base5. 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,并不都是一个对象一个对象排布好的。中间有可能会产生缝隙,这些缝隙也需要计算大小。调用栈中的 processstride 函数就是用来扫描一个 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]。1.   ConcurrentMarkSweepGeneration::par_promote {2. 3.   HeapWord* obj_ptr = ps->lab.alloc(alloc_sz);4.           |---> CFLS_LAB::alloc5.                           |--->FreeChunk::markNotFree6.   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 below21.      _prev = NULL;22. #ifdef _LP6423.      if (UseCompressedOops) {24.        OrderAccess::storestore();25.        set_mark(markOopDesc::prototype());26.      }27. #endif28.      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 之前的写操作必定能完成。根因通过上面的介绍,相信大家理解了 blocksize 的功能,以及 parpromote 的写入顺序。那么这两个函数,或者说执行这两个函数的线程是如何造成 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社区查找相关资源(点击原文进入官网),包括二进制下载、代码仓库、使用教学、安装、学习资料等。毕昇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 :L1354
  • [技术干货] JNI不正确的信号处理导致JVM崩溃分析
    JNI 中错误的信号处理导致 JVM 崩溃问题分析宋尧飞编者按: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.javaimport 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>JNIEXPORTjint 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入群。
  • [技术干货] Java Flight Recorder - 事件机制详解
    编者按:Java Flight Recorder(简称为JFR)曾经是Oracle JDK商业版的附属组件,在JDK 11中被正式开始开源,后又被移植到JDK8中。JFR本身对运行期系统的侵入性很小,同时又能提供相对准确和丰富的运行期信息;合理使用该工具可以极大地提高工作效率。本文介绍JFR剖析的事件机制,希望能帮助大家从原理上理解JFR并正确使用JFR。1. 本篇文章中的源码大部分来自 openjdk8u262 2. 本文出发点是梳理 JFR 的事件机制, 侧重点在于理解而非应用3. 不要太过拘泥细节,需要时刻问自己想要的是什么。1 Prologue1.1 对于JFR我们有着怎样的预期它是一个辅助分析工具,我们希望借助它,尽可能低开销地收集运行时数据,从而辅助对JVM可能存在的故障、性能瓶颈进行分析。我们结合 JFR 的 Goals 来看:• Provide APIs for producing and consuming data as events ;提供基于生成和消费数据作为以事件为载体的生产、消费数据的的API• 提供缓存机制和二进制数据格式• 允许配置和过滤事件• 为OS、JVM、JDK库提供相应的事件从中,我们能粗略地获取这些信息 :%4. 事件以自描述的二进制形式(.jfr)被保存着%4. 事件中包含了数据,事件 ≈ 数据%4. .jfr 文件 => read by some Provided API => 重现运行时数据 [ => 可视化]我们想尝试了解 JFR 的事件驱动机制, 具体点, 就是回答几个问题:一个事件何时产生/启动监控? 经历了怎样的路径? 如何被保存? 保存到哪里?1.2 JFR是事件驱动的 (JEP 328)本节主要是一些前置信息 (假如你有所了解,可以粗略快速浏览或者跳过扫一眼本节内容):JVM 行为基本都是 Event,如类加载对应着 Class Load Event, 垃圾回收对应 GC Event;Event 主要由 timestamp, event name, additional info, data 这几部分组成。Event 收集四类事件的信息:• Instant Event , 发生就收集(e.g. Thread Start ...)• Duration Event,持续收集一段时间(e.g. GC Event ...)• Timed Event , 收集超过指定时间的事件• Sample Event,按频率采样以JFR的 Class Load Event 为例, 看看 一个事件的结构. (共计 24 bytes)<memory address> : 98 80 80 00 87 02 95 ae e4 b2 92 03 a2 f7 ae 9a 94 02 02 01 8d 11 00 00• Event Size : 98 80 80 00• Event ID : 87 02• TimeStamp : 95 ae e4 b2 92 03• Duration : a2 f7 ae 9a 94 02• Thread ID : 02• Stack trace ID : 01• PayLoad(记录的数据,fields 取决于各个 Event 类型):o 加载的类 : 8d 11o 定义类的 ClassLoader : 00o 初始化类的 ClassLoader : 00多个线程都会产生Event,线程通过无锁(Lock-free)设计记录事件。线程将事件首先写入到 ThreadLocalBuffer(简称TLB), TLB被填满后,将被转存到 Global buffer(circular),对于较旧的数据,可以通过配置,选择丢弃或者写入磁盘,以便连续保存历史记录。示意图如下所示:线程将无锁事件(lock-free)首先写入到 ThreadLocalBuffer, TLB被填满后,将被转存到 Global buffer(circular),对于较旧的数据,可以通过配置,选择丢弃或者写入磁盘,以便连续保存历史记录。注意:(TLB、Global Buffer 和 磁盘文件中的事件记录不会 Overlap, 未及时转存的数据可能发生丢失,本文不会就这点展开阐述。)前置内容已经交代清楚,接着, 回到正轨。5 一个事件的生命周期以下是枯燥乏味的一堆代码,但是不得不看。首先来看jfr的结构,如下图所示:肉眼可见的一堆钩子,这些hook用于记录对应的触发事件。。我们简单地挑一个 Thread Start 的事件,关注一下它的整个被触发到被记录的过程。在线程创建并执行时会调用记录jfr事件,代码如下:可见当一个新的Java线程被创建时,只要开启了JFR, 那么就会执行上述代码;接着看一下 on_thread_start 干了什么在此,我们看到了一个熟悉事件EventThreadStart的结构,并且在事件中设置信息后被提交。在: [见 JEP 328 中有一个更为简单直接例子,如下:无需太过关心其内容。我们只需关注这个事件生成的结构:这里的 EventType 定义于 jfrEventClass.hpp, 该文件是编译时生成的,简单贴一下生成逻辑,可以参考Makefile文件,如下 (同样无需在意太多细节):回到主旋律,继续来看事件的结构和成员函数,如下:其中最为重点要的成员函数是在于这个 JfrEvent::commit 方法,用于提交事件,代码如下.最后一段代码, 也是核心所在 :在函数中,最后一段代码, 也是核心所在,用于真正记录事件:这下,就可以很容易地和第1节的内容对应上了,特别是其中的事件模型的图片 :)6 Epilogue用户是否可以自定义一个JFR事件?注意点有哪些?这是里通过 JEP 328 里的例子(稍微有点改动),来掩饰 如何一个自定义JFR事件。通过编译后直接执行如下命令:$> java -XX:StartFlightRecording,filename=event.jfr Test可以得到如下日志信息:Started recording 1. No limit specified, using maxsize=250MB as default.Use jcmd 57980 JFR.dump name=1 to copy recording data to file.记录得到日志可以通过标准的API进行解析,下面通过一个简单代码解析上面生成的事件,代码如下:编译运行$> java Viewer | less可以得到如下结果。相信此时你已经对 jfr 的事件机制有了个不错的感觉。实际上JFR的使用一般配合JMC使用,在JMC中通过页面可以得到统计信息,更有助于判断系统的运行情况。
  • [技术干货] JVM锁组件中的弱内存模型问题分析
    现象:这个典型的弱内存模型案例也是来自下游用户,大致的现象就是aarch64平台上,业务挂死,而进程占用cpu持续维持在300%。配合top和gdb,可以看到是3个gc线程在offer_termination处陷入了死循环:并行gc线程被设计为需要在offer_termination中自旋等待所有同伴的到来,所以问题应该是出在那些一直没有完成任务的gc线程上,还是使用gdb观察了一下其他gc线程,却发现那些gc线程全都阻塞在一把JVM的锁上:而这把Monitor中的情况如下:• cxq上积累了大量gc线程• OnDeck记录的GC线程却已经跑了• _owner记录的锁持有者为NULL分析:在进一步分析前,首先普及一下JVM锁组件Monitor的基本原理,Monitor类主要包含4个核心字段:1. “Thread * volatile _owner;” 字段指向这把锁的持有线程2. “SplitWord _LockWord;” 字段被设计为1个机器字长的目的是为了确保操作时天然的原子性,它的最低位被设计为上锁标记位,而高位区域用来存放256字节对齐的竞争队列(cxq)地址3. “ParkEvent * volatile _EntryList;” 字段指向一个等待队列,跟cxq差别不大,个人理解只是为了缓解cxq的竞争压力而设计4. “ParkEvent * volatile _OnDeck;” 字段指向这把锁的法定继承人,同时最低位还充当了内部锁的角色接下来通过一组流程图来介绍加解锁的具体流程:上图是加锁的一个整体流程,大致分为3步:1. 首先走快速上锁流程,主要对应锁本身无人持有的最理想情况2. 接着是自旋上锁流程,这是预期将在短时间内获取到锁的情况3. 最后是慢速上锁流程,申请者将会加入等待队列(cxq),然后进入睡眠,直到醒来发现自己变成了法定继承者,于是进入自旋,直到完成上锁。而且,基于性能考虑,整个上锁流程中的每一步几乎都做了“插队”的尝试:如上图代码中所示,“插队”的意思就是不经过排队(cxq),直接尝试置上锁标志位。上图就是整个解锁流程了,显然真正的解锁操作在第二步中就已经完成了(意味着接下来时刻有“插队”现象发生),剩下的主要就是选出继承者的过程,大致分为以下几步:1. 解锁线程首先需要将内部锁(_OnDeck)标记上锁2. 从竞争队列(cxq)抽取所有等待者放入等待队列(_EntryList)3. _ EntryList取出头一个元素,写入_OnDeck的同时解除内部锁标记,这代表选出了继承者4. 唤醒继承者当然伴随着整个解锁流程每一步的,还有对“插队”行为的处理。至此,JVM锁组件Monitor的原理就介绍到这里,再回归到问题本身,一个疑问就是_OnDeck上记录的继承者为何已经跑了?作为继承者,既然已经消失在竞争队列和等待队列里,显然意味着它大概率已经持有锁、然后解锁走人了,所以问题很可能跟继承者选取过程有关。基于这种猜测,我们对相关代码着重进行了梳理,就发现了上面两处红框标记位置存在可疑,那就是在选继承者过程第3步中: 写_ EntryList和写_OnDeck之间没有barrier来保证执行顺序,这可能出现_OnDeck先于_ EntryList写入的情况,一旦继承人提前持有锁,后果就可能非常糟糕…这里贴了一张可能的问题场景:1. 线程A处于解锁流程中,由于乱序,先写入了继承者同时解除内部锁2. 线程B处于上锁流程,发现自己就是法定继承者后,立刻完成上锁3. 线程B又迅速进入解锁流程,并从_EntryList中取出头元素(也就是线程B!)作为继承者写入_OnDeck,完成解锁走人4. 线程A此时才更新_EntryList,然后唤醒继承者(也就是线程B!),完成解锁走人5. _OnDeck上的继承者线程B,实际已经完成加解锁离开,后续等待线程再也无法被唤醒然后正巧在社区的高版本上找到了一个相关的修复记录(JDK- 8166197),这里贴出2个关键的代码片段:上面这段代码位于慢速上锁流程,被唤醒后检查继承者是否是自己,修复后的代码在读_OnDeck时加了Load-Acquire的barrier。上面这段代码位于解锁时选继承者流程,从_ EntryList取出头一个元素,写入_OnDeck的同时解除内部锁标记,修复后的代码在写_OnDeck时加了Store-Release的barrier。显然,围绕_OnDeck添加的这对One-way barrier可以确保:当继承者线程被唤醒时,该线程可以“看”到_EntryList已经被及时更新。总结:在aarch64这种弱内存模型的平台上,一旦涉及多线程对公共内存的每一次访问,必须进行反复确认是否需要通过barrier来严格保序,而且除非存在有效的依赖关系,否则barrier需要在读写端成对使用。
  • [技术干货] SPECjvm2008测试过程出现startup.compiler.sunflow堵塞一直卡住问题解决办法
    【问题现象】SPECjvm2008测试过程中startup.compiler.sunflow堵塞一直卡住问题,执行java -jar SPECjvm2008.jar -base -ikv后卡在如下界面【解决方法】执行ps -ef | grep sunflow查看对应进程ID执行cd /proc/33494/task进入对应进程ID的task路径注:33494为sunflow的PID,需根据实际系统情况调整;依次执行如下命令,显示当前进程正在执行的系统调用cd 33494cat syscallcat fd/2可查看到startup.compiler.sunflow项已经执行成功:
  • [技术干货] JVM模板解释器中的弱内存模型问题分析
    背景知识:· java程序在发生crash时,会生成hs_err_pid<XXX>.log文件,以及core文件(需要操作系统开启相关设置),其中hs_err文件以文本格式记录了crash发生位置的小范围精确现场信息(调用栈、寄存器、线程栈、致命信号、指令上下文等)、jvm各组件状态信息(java堆、jit事件、gc事件)、系统层面信息(环境变量、入参、内存使用信息、系统版本)等,精简记录了关键信息。而core文件是程序崩溃时进程的二进制快照,完整记录了崩溃现场信息,可以使用gdb工具来打开core文件,恢复出一个崩溃现场,方便分析。约束:· 文中描述的问题适用于jdk8u292之前的版本现象:某业务线隔十天半个月总会报过来crash问题,crash位置比较统一,都是在某处执行young gc的上下文中,crash的直接原因是java对象的头被写坏了,比如这样:而正常的对象头由markoop和metadata两部分组成,前者存放该对象的hash值、年龄、锁信息等,后者存放该对象所属的Klas指针。这里关注的是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,由于整个过程比较复杂,这里首先给一个比较容易理解的并行流程图:上图表示接近同一时间点前后,两条并行流分别构建一个DynamicByteBuffer类型的对象过程中,各自完成segmentSize字段赋值的过程,用Java代码简单示意如下:其中第一条执行流走的慢速路径,第二条走的快速路径,可以留意到,红色标识的是几次公共内存的访存操作,barrier就分布在这些位置前后(标在下图中)。接下来再给一个更加精确一点的指令流模型:简单介绍一下这个设计模型:1.  线程从记录了指令的内存地址bcp(bytecode pointer)上取出指令,然后跳转到该指令地址上执行,当取出的指令是bcp1(比如putfeild指令的慢速路径)时就是图中左边的指令流2.  左边的指令流就是计算出字段的offset并str到指定内存地址,然后插入barrier,最后将bcp2指令(比如putfeild指令的快速路径)覆写到步骤1中的内存地址addr上3.  后续线程继续执行步骤1时,由于取出的指令变成了bcp2,就改为跳转到图中右边的指令流4.  右边的指令流就是直接取出步骤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-从JDK8升级到JDK11,使用G1 GC,HBase性能下降近20%。 JDK到底干了什么?
    HBase从2.3.x开始正式默认的支持JDK11, HBase对于JDK 11的支持指的是HBase本身可以通过JDK11的编译、同时相关的测试用例全部通过。由于HBase依赖Hadoop和Zookeeper,而目前Hadoop和Zookeeper尚未支持JDK11,所以HBase中任然有一个jira来关注JDK11支持的问题。https://issues.apache.org/jira/browse/HBASE-22972.G1 GC从JDK9以后就称为默认的GC,而且HBase在新的版本中也采用G1 GC.对于HBase是否可以在生成环境中使用JDK11?笔者尝试使用JDK11来运行新的HBase,严重JDK11是否比JDK8有优势。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采用JDKu222和JDK11.0.8分别进行测试,当切换JDK时,客户端和3台HBase服务器同意切换。JDK的运行参数为:-XX:+PrintGCDetails -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:-ResizePLAB注意:这里对禁止ResizePLAB是业务根据HBase优化资料设置。2      测试结果:JDK11性能下降通过PE进行测试,运行结束有TPS数据,表示性能。在相同的硬件环境、相同的HBase,仅仅使用不同的JDK来运行。同时为了保证结果的准确性,多次运行,取平均值。测试结果如下:从表中可以快速的计算得到吞吐量下降,运行时间增加。结论:使用G1 GC,JDK11相对于JDK8来说性能明显下降。3      原因分析从JDK8到JDK11, G1 GC做了非常多的优化用于提高性能。为什么JDK11对于应用的来说更不友好?简单的总结一下从JDK8到JDK11做的一些比较大的设计变化,如下表所示: 优化点描述IHOP启发式设置IHOP用于控制并发标记的启动时机,在JDK9中引入该优化,根据应用运行的情况,计算IHOP的值,确保在内存耗尽以前启动并发标记。对于性能和运行时间理论上都是正优化,在一些的情况,例如可能会导致性能下降Full GC的并行话在JDK10中将Full GC从串行实现优化为并行实现,该优化不会产生负面影响动态线程调整根据GC工作线程的负载情况,引入动态的线程数来处理任务。该优化会带来正效果,注意不是GC工作线程数目越多GC的效果越好(GC会涉及到多线程的任务窃取和同步机制,过多的线程会导致性能下降)引用集的重构引用集处理优化,设置处理大小、将并行修改为并发等 由于从JDK8到JDK11特性变化太多,对于这样的性能下降问题,如何能快速有效的解决。我们做了如下的尝试3.1      统一JDK8和JDK11的参数,验证效果由于JDK11和JDK8实现变化很多,部分功能完全不同,但是这些变化的功能一般都有参数控制,一种有效的尝试:梳理JDK8和JDK11关于G1的参数,将它们设置为相同的值,比如关闭IHOP的自适应,关闭线程调整等。这里简单的给出JDk8和JDK11不同参数的比较,如下图所示:将两者参数都设置和JDK8一样的值,重新验证测试,结果不变,JDK11性能仍然下降。3.2      GC日志分析,确定JDK11性能下降点对于JDK8和JDK11同时配置日志收集功能,重新测试,获得GC日志。通过GC日志分析,我们发现差异主要在G1 young gc的object copy阶段(耗时基本在这),JDK11的Young GC耗时大概200ms,JDK8Young GC耗时大概100ms,两者设置的目标停顿时间都是100ms。JDK11中GC日志片段JDK8中GC日志片段我们对整个日志做了统计,有以下发现:并发标记时机不同,混合回收的时机也不同;单次GC中对象复制的耗时不同,JDK11明显更长;总体GC次数JDK11的更多,包括了并发标记的停顿次数;总体GC的耗时JDK11更多。 针对Young GC的性能劣化,我们重点关注测试了和Young GC相关的参数,例如:调整UseDynamicNumberOfGCThreads、G1UseAdaptiveIHOP 、GCTimeRatio均没有效果。尝试使用不同的工具来进一步定位到底哪里出了问题。3.3      JFR分析-确认日志分析结果毕昇JDK11和JDK8都引入了JFR,JFR作为JVM中问题定位的新贵,我们也在该案例进行了尝试,关于JFR的原理和使用,参考本系列的其他文章(JFR原理介绍和使用)。3.3.1        JDK11总体信息JDK8中通过JFR收集信息。3.3.2        JDK8总体信息 JFR的结论和我们前面分析的结论一致,JDK11中中断比例明显高于JDK8。 3.3.3        JDK 11中垃圾回收发生的情况3.3.4        JDK 8中垃圾回收发生的情况从图中可以看到在JDK11中应用消耗内存的速度更快(曲线速率更为陡峭),根据垃圾回收的原理,内存的消耗和分配相关。 3.3.5        JDK11中VM操作 3.3.6        JDK8中VM操作通过JFR整体的分析,得到的结论和我们前面的一致,确定了Young GC可能存在问题,但是没有更多的信息。3.4      火焰图-发现热点为了进一步的追踪Young GC里面到底发生了什么导致对象赋值更为耗时,我们使用Async-perf进行了热点采集。关于该工具的使用参考本系列的其他文章(如何使用火焰图解决性能问题)3.4.1        JDK11的火焰图3.4.2        JDK 11 GC部分火焰图 3.4.3        JDK8的火焰图3.4.4        JDK 8 GC部分火焰图 通过分析火焰图,并比较JDK 8和JDK 11的差异,可以得到:在JDK 11中,耗时主要在:G1ParEvacuateFollowersClosure::do_void()G1RemSet::scan_rem_set 在JDK 8中,耗时主要在:G1ParEvacuateFollowersClosure::do_void()更一步,我们对JDK11里面新出现的scan_rem_set()进行更进一步分析,发现该函数仅仅和引用集相关,通过修改RSet相关参数(修改G1ConcRefinementGreenZone),将RSet处理尽可能的移除Young GC的处理。火焰图中参数不再成为热点,但是JDK11仍然性能下降。比较JDK8和JDK11中G1ParEvacuateFollowersClosure::do_void()中的不同的,出来数组处理基本没有变化,我们将JDK 11此处的代码修改和JDK8完全一样,但是性能仍然下降。结论:虽然G1ParEvacuateFollowersClosure::do_void()是性能下降的触发点,但是此处并不是问题的根因,应该是其他的原因造成了该函数调用次数增加或者耗时增加。3.5      逐个版本验证-最终确定问题我们分析了所有可能的情况,仍然无法快速找到问题的根源,只能使用最笨的办法,逐个版本来验证从哪个版本开始性能下降。在大量的验证中,对于JDK9,JDK10,以及小版本等都重新做了构建(关于JDK的构建可以参考官网),我们发现JDK9-B74和JDK9-B73有一个明显的区别。为此我们分析了JDK9-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后JDK8和JDK11性能基本相同,也从侧面说明了该参数的使用情况。 3.7      解决方法&修复方法由于该问题是JDK9引入,在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,在JDK9~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-用好性能调优利器:火焰图,事半功倍
    兵欲善其事,必先利其器。程序员在定位性能瓶颈的时候,要是有一个趁手的性能调优工具,能一针见血的指出程序的性能问题,可谓事半功倍。我们常用的性能调优工具Perf(Linux系统原生提供的性能分析工具),能按出现的百分比降序打印CPU正在执行的函数名以及调用栈,如命令:perf recordperf report -n可打印出:这种结果的输出还是不直观的,Linux性能优化大师Brendan Gregg发明了火焰图(因整个图形看起来像燃烧的火焰而得名),以全局的方式来看各个函数的调用时间分布,以图形化的方式列出调用栈。1      初识火焰图火焰图是基于perf的结果生成的图形,我们先了解一下怎么去看火焰图。以下图为例:X轴表示被抽样到的次数。理解X轴的含义,需先了解采样数据的原理。Perf是在指定时间段内,每隔一段时间采集一次数据,被采集到的次数越多,说明该函数的执行总时间长,可能的情况有调用次数多,或者单次执行时间长。因此,X轴的宽度不能简单的认为是运行时长。Y轴表示调用栈。如何从火焰图看出性能的瓶颈在哪里?最有理由怀疑的地方,顶层的“平顶”。下面是我们利用火焰图来定位问题的一次实战。2      火焰图定位问题的实战2.1      问题场景问题发生的场景是客户端向服务器发起http请求,服务器返回数据给客户端。客户发现使用OracleJDK 8u_74的性能要远优于OracleJDK 8u_202的性能,图中体现为业务线统计的得到服务器响应的响应时长。典型的性能问题,202使用CPU的情况是74的两倍,考虑使用火焰图来定位性能消耗的问题点。2.2      火焰图定位对比两张火焰图,使用74时ClientHandshaker.processMessage占比为1.15%,而在202中这个函数占比为23.98%,很明显在ClientHandshaker.processMessage带来了性能差异。2.3      根因定位两者在这个ClientHandshaker.processMessage上的cpu消耗差异很大,继续分析这个函数找到根因。void processMessage(byte handshakeType, int length) throws IOException {    if(this.state >= handshakeType && handshakeType != 0) {        //... 异常    } else {        label105:        switch(handshakeType) {        case 0://hello_request            this.serverHelloRequest(new HelloRequest(this.input));            break;        //...        case 2://sever_hello             this.serverHello(new ServerHello(this.input, length));            break;        case 11:///certificate             this.serverCertificate(new CertificateMsg(this.input));            this.serverKey = this.session.getPeerCertificates()[0].getPublicKey();            break;        case 12://server_key_exchange 该消息并不是必须的,取决于协商出的key交换算法            //...        case 13: //certificate_request 客户端双向验证时需要            //...        case 14://server_hello_done            this.serverHelloDone(new ServerHelloDone(this.input));            break;        case 20://finished            this.serverFinished(new Finished(this.protocolVersion, this.input, this.cipherSuite));        }        if(this.state < handshakeType) {//握手状态            this.state = handshakeType;        }    }}processMessage()主要是通过不同的信息类型进行不同的握手消息的处理。而在火焰图中可以看到,74图中,主要消耗在serverFinished()和serverHello()上,而202主要消耗在serverHelloDone()和serverKeyExchange()。在介绍火焰图的时候,我们有提到,X轴的长度是映射了被采样到的次数。因此需要进一步确定是一直卡在该函数上,还是因为调用频繁。可通过字节码插桩查看serverHelloDone()的调用次数及执行时间。JDK8u202 数据Execute count : 253Execute count : 258Execute count : 649Execute count : 661serverHelloDone execute time [1881195 ns]Execute count : 1223Execute count : 1234Execute count : 1843Execute count : 1852serverHelloDone execute time [1665012 ns]Execute count : 2446Execute count : 2456serverHelloDone execute time [1686206 ns]JDK8u74 数据Execute count : 56Execute count : 56Execute count : 56Execute count : 56Execute count : 56Execute count : 56Execute time是取了每1000次调用的平均值,Execute count每5000ms输出一次总执行次数。很明显使用JDK8u202时在不断调用serverHelloDone,而74在调用56次后没有再调用过这个函数。初始化握手时,serverHelloDone方法中,客户端会根据服务端返回加密套件决定加密方式,构造不同的Client Key Exchange消息;服务器如果允许重用该会话,则通过在Server Hello消息中设置相同的会话ID来应答。这样,客户端和服务器就可以利用原有会话的密钥和加密套件,不必重新协商,也就不再走serverHelloDone方法。从现象来看,OracleJDK8u202没有复用会话,而是建立的新的会话。2.4      水落石出查看161的release notes,添加了TLS会话散列和扩展主密钥扩展支持,找到引入的一个还未修复的issue,对于带有身份验证的TLS的客户端,支持UseExtendedMasterSecret会破坏TLS-Session的恢复,导致不使用现有的TLS-Session,而执行新的Handshake。OracleJDK8u161之后的版本,包括161版本,若复用会话时不能成功恢复Session,而是创建新的会话,会造成较大性能消耗,且积压的大量的不可复用的session造成GC压力变大;如果业务场景存在不变更证书密钥,需要复用会话,且对性能有要求,可通过添加参数-Djdk.tls.useExtendedMasterSecret=false来解决这个问题。
  • [新手课堂] JVM原理学习总结
    这篇总结主要是基于我之前JVM系列文章而形成的的。主要是把重要的知识点用自己的话说了一遍,可能会有一些错误,还望见谅和指点。谢谢#更多详细内容可以查看我的专栏文章:深入理解JVM虚拟机https://blog.csdn.net/column/details/21960.htmlJVM介绍和源码首先JVM是一个虚拟机,当你安装了jre,它就包含了jvm环境。JVM有自己的内存结构,字节码执行引擎,因此class字节码才能在jvm上运行,除了Java以外,Scala,groovy等语言也可以编译成字节码而后在jvm中运行。JVM是用c开发的。JVM内存模型内存模型老生常谈了,主要就是线程共享的堆区,方法区,本地方法栈。还有线程私有的虚拟机栈和程序计数器。堆区存放所有对象,每个对象有一个地址,Java类jvm初始化时加载到方法区,而后会在堆区中生成一个Class对象,来负责这个类所有实例的实例化。栈区存放的是栈帧结构,栈帧是一段内存空间,包括参数列表,返回地址,局部变量表等,局部变量表由一堆slot组成,slot的大小固定,根据变量的数据类型决定需要用到几个slot。方法区存放类的元数据,将原来的字面量转换成引用,当然,方法区也提供常量池,常量池存放-128到127的数字类型的包装类。字符串常量池则会存放使用intern的字符串变量。JVM OOM和内存泄漏这里指的是oom和内存泄漏这类错误。oom一般分为三种,堆区内存溢出,栈区内存溢出以及方法区内存溢出。堆内存溢出主要原因是创建了太多对象,比如一个集合类死循环添加一个数,此时设置jvm参数使堆内存最大值为10m,一会就会报oom异常。栈内存溢出主要与栈空间和线程有关,因为栈是线程私有的,如果创建太多线程,内存值超过栈空间上限,也会报oom。方法区内存溢出主要是由于动态加载类的数量太多,或者是不断创建一个动态代理,用不了多久方法区内存也会溢出,会报oom,这里在1.7之前会报permgem oom,1.8则会报meta space oom,这是因为1.8中删除了堆中的永久代,转而使用元数据区。内存泄漏一般是因为对象被引用无法回收,比如一个集合中存着很多对象,可能你在外部代码把对象的引用置空了,但是由于对象还被集合给引用着,所以无法被回收,导致内存泄漏。测试也很简单,就在集合里添加对象,添加完以后把引用置空,循环操作,一会就会出现oom异常,原因是内存泄漏太多了,导致没有空间分配新的对象。常见调试工具命令行工具有jstack jstat jmap 等,jstack可以跟踪线程的调用堆栈,以便追踪错误原因。jstat可以检查jvm的内存使用情况,gc情况以及线程状态等。jmap用于把堆栈快照转储到文件系统,然后可以用其他工具去排查。visualvm是一款很不错的gui调试工具,可以远程登录主机以便访问其jvm的状态并进行监控。class文件结构class文件结构比较复杂,首先jvm定义了一个class文件的规则,并且让jvm按照这个规则去验证与读取。开头是一串魔数,然后接下来会有各种不同长度的数据,通过class的规则去读取这些数据,jvm就可以识别其内容,最后将其加载到方法区。JVM的类加载机制jvm的类加载顺序是bootstrap类加载器,extclassloader加载器,最后是appclassloader用户加载器,分别加载的是jdk/bin ,jdk/ext以及用户定义的类目录下的类(一般通过ide指定),一般核心类都由bootstrap和ext加载器来加载,appclassloader用于加载自己写的类。双亲委派模型,加载一个类时,首先获取当前类加载器,先找到最高层的类加载器bootstrap让他尝试加载,他如果加载不了再让ext加载器去加载,如果他也加载不了再让appclassloader去加载。这样的话,确保一个类型只会被加载一次,并且以高层类加载器为准,防止某些类与核心类重复,产生错误。defineclass findclass和loadclass类加载classloader中有两个方法loadclass和findclass,loadclass遵从双亲委派模型,先调用父类加载的loadclass,如果父类和自己都无法加载该类,则会去调用findclass方法,而findclass默认实现为空,如果要自定义类加载方式,则可以重写findclass方法。常见使用defineclass的情况是从网络或者文件读取字节码,然后通过defineclass将其定义成一个类,并且返回一个Class对象,说明此时类已经加载到方法区了。当然1.8以前实现方法区的是永久代,1.8以后则是元空间了。JVM虚拟机字节码执行引擎jvm通过字节码执行引擎来执行class代码,他是一个栈式执行引擎。这部分内容比较高深,在这里就不献丑了。编译期优化和运行期优化编译期优化主要有几种1 泛型的擦除,使得泛型在编译时变成了实际类型,也叫伪泛型。2 自动拆箱装箱,foreach循环自动变成迭代器实现的for循环。3 条件编译,比如if(true)直接可得。运行期优化主要有几种1 JIT即时编译Java既是编译语言也是解释语言,因为需要编译代码生成字节码,而后通过解释器解释执行。但是,有些代码由于经常被使用而成为热点代码,每次都编译太过费时费力,干脆直接把他编译成本地代码,这种方式叫做JIT即时编译处理,所以这部分代码可以直接在本地运行而不需要通过jvm的执行引擎。2 公共表达式擦除,就是一个式子在后面如果没有被修改,在后面调用时就会被直接替换成数值。3 数组边界擦除,方法内联,比较偏,意义不大。4 逃逸分析,用于分析一个对象的作用范围,如果只局限在方法中被访问,则说明不会逃逸出方法,这样的话他就是线程安全的,不需要进行并发加锁。1JVM的垃圾回收1 GC算法:停止复制,存活对象少时适用,缺点是需要两倍空间。标记清除,存活对象多时适用,但是容易产生随便。标记整理,存活对象少时适用,需要移动对象较多。2 GC分区,一般GC发生在堆区,堆区可分为年轻代,老年代,以前有永久代,现在没有了。年轻代分为eden和survior,新对象分配在eden,当年轻代满时触发minor gc,存活对象移至survivor区,然后两个区互换,等待下一场gc,当对象存活的阈值达到设定值时进入老年代,大对象也会直接进入老年代。老年代空间较大,当老年代空间不足以存放年轻代过来的对象时,开始进行full gc。同时整理年轻代和老年代。一般年轻代使用停止复制,老年代使用标记清除。3 垃圾收集器serial串行parallel并行它们都有年轻代与老年代的不同实现。然后是scanvage收集器,注重吞吐量,可以自己设置,不过不注重延迟。cms垃圾收集器,注重延迟的缩短和控制,并且收集线程和系统线程可以并发。cms收集步骤主要是,初次标记gc root,然后停顿进行并发标记,而后处理改变后的标记,最后停顿进行并发清除。g1收集器和cms的收集方式类似,但是g1将堆内存划分成了大小相同的小块区域,并且将垃圾集中到一个区域,存活对象集中到另一个区域,然后进行收集,防止产生碎片,同时使分配方式更灵活,它还支持根据对象变化预测停顿时间,从而更好地帮用户解决延迟等问题。
  • [新手课堂] JVM的内存分几个区域?
    程序计数器当前线程所执行的字节码的行号指示器。虚拟机栈Java方法执行的内存模型,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。本地方法栈本地方法执行的内存模型,和虚拟机栈非常相似,其区别是本地方法栈为JVM使用到的Native方法服务。堆用于存储对象实例,是垃圾收集器管理的主要区域。方法区用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • [新手课堂] JVM工作原理
    >>数据类型  Java虚拟机中,数据类型可以分为两类:基本类型和引用类型。  基本类型的变量保存原始值,即:他代表的值就是数值本身;而引用类型的变量保存引用值。  “引用值”代表了某个对象的引用,而不是对象本身,对象本身存放在这个引用值所表示的地址的位置。  基本类型包括:byte,boolean(1 byte),short,char(2 bytes),int,float(4 bytes),long,double(8 bytes),returnAddress(不确定是否是基本类型)  引用类型包括:类类型,接口类型和数组。 >>堆与栈   栈是运行时的单位,而堆是存储的单位。   栈解决程序的运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储的问题,即数据怎么放、放在哪儿。   在Java中一个线程就会相应有一个线程栈与之对应,这点很容易理解,因为不同的线程执行逻辑有所不同因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。   堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。   堆和栈中,栈是程序运行最根本的东西。程序运行可以没有堆,但是不能没有栈。而堆是为栈进行数据存储服务,说白了堆就是一块共享的内存。不过,正是因为堆和栈的分离的思想,才使得Java的垃圾回收成为可能。  Java中,栈的大小通过-Xss来设置,当栈中存储数据比较多时,需要适当调大这个值,否则会出现java.lang.StackOverflowError异常。常见的出现这个异常的是无法返回的递归,因为此时栈中保存的信息都是方法返回的记录点。 >>Java中的参数传递时传值呢?还是传引用?  要说明这个问题,先要明确两点:   1. 不要试图与C进行类比,Java中没有指针的概念。   2. 程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。  明确以上两点后。Java在方法调用传递参数时,因为没有指针,所以它都是进行传值调用(这点可以参考C的传值调用)。因此,很多书里面都说Java是进行传值调用,这点没有问题,而且也简化的C中复杂性。  传值传引用都不够准确,可以理解成传引用变量的副本值。引用变量分为字面值引用变量(即基本数据类型引用变量)和对象引用变量 。 详情需要了解数据类型使用机制和堆栈的概念:http://www.cnblogs.com/alexlo/archive/2013/02/21/2920209.html  对象引用变量:即普通java对象的引用变量 ,如 String a = "abc" , a就是对象引用变量。java 是不能直接操作对象的,只能通过对“对象引用的操作”来操作对象。而对象的引用的表示就是对象变量。可以多个对象引用变量指向同一个对象。  字面值引用变量:即普通数据类型的引用变量 ,如 int b = 1 , b就是字面值引用变量。可以有多个字面值引用变量指向同一字面值,但其中一个引用修改字面值,不会影响另一个引用字面值,这点要与对象引用区别开。 >>Java对象的大小  基本数据的类型的大小是固定的,Java基本数据类型与位运算,这里就不多说了。对于非基本类型的Java对象,其大小就值得商榷。在Java中,一个空Object对象的大小是8byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句:Object ob = new Object(); 这样在程序中完成了一个Java对象的生命,但是它所占的空间为:4byte(栈)+8byte(堆)。4byte是上面部分所说的Java栈中保存引用的所需要的空间。而那8byte则是Java堆中对象的信息。因为所有的Java非基本类型的对象都需要默认继承Object对象,因此不论什么样的Java对象,其大小都必须是大于8byte。Class NewObject {int count;boolean flag;Object ob;} 其大小为:空对象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。  但是因为Java在对对象内存分配时都是以8的整数倍来分,因此大于17byte的最接近8的整数倍的是24,因此此对象的大小为24byte。  这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把他们作为对象来看待。包装类型的大小至少是12byte(声明一个空Object至少需要的空间),而且12byte没有包含任何有效信息,同时,因为Java对象大小是8的整数倍,因此一个基本类型包装类的大小至少是16byte。这个内存占用是很恐怖的,它是使用基本类型的N倍(N>2),有些类型的内存占用更是夸张(随便想下就知道了)。  因此,可能的话应尽量少使用包装类。在JDK5.0以后,因为加入了自动类型装换,因此,Java虚拟机会在存储方面进行相应的优化。 >>引用类型  对象引用类型分为强引用、软引用、弱引用和虚引用。  强引用:就是我们一般声明对象时虚拟机生成的引用,强引用环境下,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收。    软引用:软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。换句话说,虚拟机在发生OutOfMemory时,肯定是没有软引用存在的。  弱引用:弱引用与软引用类似,都是作为缓存来使用。但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内。  强引用不用说,我们系统一般在使用时都是用的强引用。而“软引用”和“弱引用”比较少见。他们一般被作为缓存使用,而且一般是在内存大小比较受限的情况下做为缓存。因为如果内存足够大的话,可以直接使用强引用作为缓存即可,同时可控性更高。因而,他们常见的是被使用在桌面应用系统的缓存。 JVM的生命周期一、首先分析两个概念   JVM实例和JVM执行引擎实例  (1)JVM实例对应了一个独立运行的java程序,它是进程级别。  (2)JVM执行引擎实例则对应了属于用户运行程序的线程,它是线程级别的。二、JVM的生命周期  (1)JVM实例的诞生:当启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。   (2)JVM实例的运行 main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以标明自己创建的线程是守护线程。   (3)JVM实例的消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。
  • [毕昇JDK] 【技术剖析】系列文章汇总帖
    【技术剖析】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 入群。
  • [毕昇JDK] 【技术剖析】11. 使用jemalloc解决JVM内存泄露问题
    ## 作者:王坤 > 编者按: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); } } } } } ``` ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/103043nbueckzoob7wlrqw.png) 上图是开启 `- 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 分钟是和用例设置相关,如下图所示: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/103218zerzcbwj8vajtb3v.png) 上图比较清晰显示了内存申请相关函数的调用关系以及内存申请比例和数量,约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 ``` 内存增加差异图: ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1033256kcctizr1j2rct1e.png) 通过上图可以非常清晰看到:`G1DefaultParGCAllocator` 的构造函数持续申请内存,导致内存增长迅速。后续的工作就是针对 `G1DefaultParGCAllocator` 构造函数中内存申请情况,排查释放逻辑,寻找问题原因并解决,这块的工作不属于 jemalloc 范畴,本内容不再赘述。 代码修复后 Java 进程物理内存使用情况如下(运行 30 小时 +): ![image.png](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/103348vmyly0dqsqphh5tf.png) 内存使用符合预期,问题解决。 通过 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 入群。 ![](https://bbs-img.huaweicloud.com/data/forums/attachment/forum/202111/11/1036288oizzgopij7py9k7.jpg) # 参考 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] 【技术剖析】10. JVM 中不正确的类加载顺序导致应用运行异常问题分析
    作者:程经纬、谢照昆 > 编者按:两位笔者分享了不同的案例,一个是因为 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)
总条数:85 到第
上滑加载中