概述
最近我们公司在帮一个客户查一个 JVM 的问题 (JDK1.8.0_191-b12), 发现一个系统老是被 OS Kill 掉, 是内存泄露导致的. 在查的过程中, 阴差阳错地发现了 JVM 另外的一个 Bug. 这个 Bug 可能会导致大量物理内存被使用, 我们已经反馈给了社区, 并得到快速反馈, 预计在 OpenJDK8 最新版中发布 (JDK11 中也存在这个问题).
PS: 用户的那个问题最终也解决了, 定位下来算是 C2 的一个设计缺陷导致大量内存被使用, 安全性上没有得到保障.
找出消耗大内存的线程
接下来主要分享下这个 BUG 的发现过程, 先要客户实时跟踪进程的情况, 当内存使用明显上升的时候, 通过 / proc/<pid> /smaps, 看到了不少 64MB 的内存分配, Rss 也基本消耗完了.
7fd690000000-7fd693f23000 rw-p 00000000 00:00 0 Size: 64652 kBRss: 64652 kBPss: 64652 kBShared_Clean: 0 kBShared_Dirty: 0 kBPrivate_Clean: 0 kBPrivate_Dirty: 64652 kBReferenced: 64652 kBAnonymous: 64652 kBAnonHugePages: 0 kBSwap: 0 kBKernelPageSize: 4 kBMMUPageSize: 4 kBLocked: 0 kBVmFlags: rd wr mr mw me nr sd 7fd693f23000-7fd694000000 ---p 00000000 00:00 0 Size: 884 kBRss: 0 kBPss: 0 kBShared_Clean: 0 kBShared_Dirty: 0 kBPrivate_Clean: 0 kBPrivate_Dirty: 0 kBReferenced: 0 kBAnonymous: 0 kBAnonHugePages: 0 kBSwap: 0 kBKernelPageSize: 4 kBMMUPageSize: 4 kBLocked: 0 kBVmFlags: mr mw me nr sd
再通过 strace 命令跟踪了下系统调用, 再回到上面的虚拟地址, 我们找到了相关的 mmap 系统调用
[pid 71] 13:34:41.982589 mmap(0x7fd690000000, 67108864, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7fd690000000 <0.000107>
执行 mmap 的线程是 71 号线程, 接着通过 jstack 把线程 dump 出来, 找到了对应的线程其实是 C2 CompilerThread0
"C2 CompilerThread0" #39 daemon prio=9 os_prio=0 tid=0x00007fd8acebb000 nid=0x47 runnable [0x0000000000000000] java.lang.Thread.State: RUNNABLE
最后再 grep 了一下 strace 的输出, 果然看到这个线程在大量的进行内存分配, 总共有 2G 多.
经典的 64M 问题
对于 64M 的问题, 是一个非常经典的问题, 在 JVM 中并没有这种大量分配 64M 大小的逻辑, 因此可以排除 JVM 特定意义的分配. 这其实是 glibc 里针对 malloc 函数分配内存的一种机制, glibc 从 2.10 开始提供的一种机制, 为了分配内存更加高效, glibc 提供了 arena 的机制, 默认情况下在 64 位下每一个 arena 的大小是 64M, 下面是 64M 的计算逻辑, 其中 sizeof(long) 为 8
- define DEFAULT_MMAP_THRESHOLD_MAX (4 * 1024 * 1024 * sizeof(long))define HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX)
- p2 = (char *) MMAP (aligned_heap_area, HEAP_MAX_SIZE, PROT_NONE, MAP_NORESERVE);
一个进程最多能分配的 arena 个数在 64 位下是 8 * core,32 位下是 2 * core 个
- #define NARENAS_FROM_NCORES(n) ((n) * (sizeof (long) == 4 ? 2 : 8))
- { int n = __get_nprocs ();
- if (n>= 1) narenas_limit = NARENAS_FROM_NCORES (n); else /* We have no information about the system. Assume two cores. */ narenas_limit = NARENAS_FROM_NCORES (2); }
这种分配机制的好处, 主要是应对多线程的环境, 为每个核留有几个 64M 的缓存块, 这样线程在分配内存的时候因为没有锁而变得更高效, 如果达到上限了就会去慢速的 main_arena 里分配了.
可以通过设置环境变量 MALLOC_ARENA_MAX 来设置 64M 块的个数, 当我们设置为 1 的时候就会发现这些 64M 的内存块都没有了, 然后都集中分配到一个大区域了, 也就是 main_arena, 说明这个参数生效了.
无意的发现
再回过来思考为什么 C2 线程会出现大于 2G 的内存消耗的时候, 无意中跟踪 C2 这块代码发现了如下代码可能会导致大量内存消耗, 这个代码的位置是 nmethod.cpp 的 nmethod::metadata_do 方法, 不过这块如果真的发生的话, 肯定不是看到 C2 的线程大量分配, 而是看到 VMThread 这个线程, 因为下面这块代码主要是它执行的.
- void nmethod::metadata_do(void f(Metadata*)) {
- address low_boundary = verified_entry_point(); if (is_not_entrant()) {
- low_boundary += NativeJump::instruction_size; // %%% Note: On SPARC we patch only a 4-byte trap, not a full NativeJump. // (See comment above.)
- } {
- // Visit all immediate references that are embedded in the instruction stream. RelocIterator iter(this, low_boundary); while (iter.next()) {
- if (iter.type() == relocInfo::metadata_type ) {
- metadata_Relocation* r = iter.metadata_reloc(); // In this metadata, we must only follow those metadatas directly embedded in // the code. Other metadatas (oop_index>0) are seen as part of // the metadata section below. assert(1 == (r->metadata_is_immediate()) + (r->metadata_addr()>= metadata_begin() && r->metadata_addr() <metadata_end()), "metadata must be found in exactly one place"); if (r->metadata_is_immediate() && r->metadata_value() != NULL) {
- Metadata* md = r->metadata_value(); if (md != _method) f(md);
- }
- } else if (iter.type() == relocInfo::virtual_call_type) {
- // Check compiledIC holders associated with this nmethod CompiledIC *ic = CompiledIC_at(&iter); if (ic->is_icholder_call()) {
- CompiledICHolder* cichk = ic->cached_icholder(); f(cichk->holder_metadata()); f(cichk->holder_klass());
- } else {
- Metadata* ic_oop = ic->cached_metadata(); if (ic_oop != NULL) {
- f(ic_oop);
- }
- }
- }
- }
- }
- inline CompiledIC* CompiledIC_at(RelocIterator* reloc_iter) {
- assert(reloc_iter->type() == relocInfo::virtual_call_type || reloc_iter->type() == relocInfo::opt_virtual_call_type, "wrong reloc. info"); CompiledIC* c_ic = new CompiledIC(reloc_iter); c_ic->verify(); return c_ic;
- }
注意上面的 CompiledIC *ic = CompiledIC_at(&iter); 这段代码, 因为 CompiledIC 是一个 ResourceObj, 这种资源会在 c heap 里分配 (malloc), 不过他们是和线程进行关联的, 假如我们在某处代码声明了 ResourceMark, 那当执行到这里的时候会标记当前的位置, 再接下来线程要分配内存的时候如果线程关联的内存不够用, 就会 malloc 一块插进去并被管理起来, 否则会实现内存的复用. 当 ResourceMark 析构函数执行的时候, 会将之前的位置还原, 后面这个线程如果要分配内存又会从这个位置开始复用内存块. 注意这里说的内存块和上面的 64M 内存块不是一个概念.
因为这段代码在 while 循环里, 因此存在非常多次数的重复调用, 这样明明在执行完一次之后可以复用内存的地方并不能复用, 而可能会导致大量的内存被不断分配. 表现起来可能就是物理内存消耗很大, 远大于 Xmx.
这个修复办法也很简单, 就是在 CompiledIC *ic = CompiledIC_at(&iter); 前加上 ResourceMark rm; 即可.
这个问题主要发生的场景是针对频繁大量做 Class Retransform 或者 Class Redefine 的场景. 所以如果系统里有这种 agent 的时候还是要稍微注意下这个问题.
这个问题发现后我们给社区提了 patch, 不过后面发现再 JDK12 中其实已经修复了, 但是在之前的版本里的都没有修复, 这个问题提交给社区后, 有人很快响应了, 并可能在 OpenJDK1.8.0-212 中被 fix.
最后在这里也简单提下客户那边的那个问题, 之所以 C2 线程消耗太大, 最主要的原因是存在非常大的方法需要编译, 而这个编译的过程是需要大量的内存消耗的, 正因为如此, 才会导致内存突然暴增, 所以给大家一个建议, 方法不要写太大啦, 如果这个方法调用还很频繁, 那真的会很悲剧的.
题外话
在解决这个 C2 的问题过程中, 和雨迪讨论了挺多, 也学到不少, 给了不少建议, 雨迪是 Oracle 虚拟机团队里主要从事 Graal 编译器开发的, 对 C2 也是比较了解. 在此非常感谢雨迪, 另外他在极客时间上开了一门 JVM 的课程, 有几万人买了, 这两天正好在搞活动, 原价 99 的现在只要 58, 有兴趣的可以看看.
如果大家在公司碰到比较难解的 JVM 相关的性能问题, 可以随时联系我们 (微信号: PerfMa), 另外上海分公司也已经注册下来了, 有意向在上海发展的同学也可以联系我.
来源: http://www.tuicool.com/articles/fimARj