关于container:把大象装入货柜里Java容器内存拆解

1次阅读

共计 11441 个字符,预计需要花费 29 分钟才能阅读完成。


[图片源:https://bell-sw.com/announcem…]

  • 介绍
  • 测试环境
  • 配置容量

    • POD 容量配置
    • JVM 容量配置

      • 神秘的 MaxDirectMemorySize 默认值
      • maxThreadCount 最大线程数起源
  • 使用量

    • Java 的视角看使用量

      • 如何采集 理论使用量
    • 原生利用的视角看使用量

      • *lib.so 动静库占用
      • *.jar mapping 占用
      • glibc malloc 耗费
      • GC 内存耗费
      • tmpfs 内存耗费
    • 操作系统 RSS

      • CGroup 限度
  • 潜在问题和举荐解决办法

    • Native Buffer 限度
    • glibc malloc arena 的节约
    • Jetty 线程池
    • Java code cache 慢涨
    • 容器的内存限度
  • 瞻望
  • 免责申明
  • 领会

介绍

置信很多人都晓得,云环境中,所有服务都必须作资源限度。内存作为一个重要资源当然不会例外。限度说得容易,但如何在限度的同时,保障服务的性能指标 (SLA) 就是个技术和艺术活。

为利用内存设置下限,从来不是个容易的事。因为设置下限的理据是:

  • 应用程序对内存的应用和回收逻辑,而这个逻辑个别异样地简单
  • 古代操作系统简单的虚拟内存治理、物理内存调配、回收机制

如果是 Java,还要加上:

  • JVM 中各类型组件的内存管理机制

以上 3 个方面还能够进一步细分。每一个细分都有它的内存机制。而只有咱们漏算了其中一个,就有可能让利用总内存应用超限。

而让人揪心的是,当利用总内存应用超限时,操作系统会无情地杀死利用过程(OOM, Out Of Memory)。而很多人对这一无所觉,只晓得容器重启了。而这可能是连锁反应的开始:

  • 如果容器 OOM 的起因只是个偶尔,那还好说。如果是个 BUG 引起的,那么这种 OOM 可能会在服务的所有容器中一一暴发,最初服务瘫痪
  • 原来服务容器群的资源就缓和,一个容器 OOM 敞开了,负载平衡把流量分到其它容器,于是其它容器也呈现同样的 OOM。最初服务瘫痪

JVM 是个 Nice 的经理,在发现内存缓和时,就不厌其烦地进行利用线程和执行 GC,而这种内存缓和的信号,在设计界称为“背压 (Backpressure)”。
但操作系统相同,是个雷厉风行的司令,一发现有过程超限,间接一枪 OOM Killed。

或者你深入研究过 cgroup memory,它其实也有一个 Backpressure 的告诉机制,不过当初的容器和 JVM 均疏忽之。

终上所述,容器过程 OOM Kllled 是件应该防止,但须要深入研究能力防止的事件。

网路上,咱们能够找到很多事实案例和教训:

Java 内存治理很简单。咱们对它理解越多,利用呈现 OOM Killed 的可能性就越低。上面我拿一个遇到的测试案例进行剖析。

剖析报告分为两个局部:

  1. 钻研利用实测出的指标、内存耗费,内存限度配置
  2. 潜在的问题和改良倡议

测试环境

主机:裸机(BareMetal)
CPU: 40 cores, 共 80 个超线程
Linux:
  Kernel: 5.3.18
  glibc: libc-2.26.so
Java: 1.8.0_261-b12
Web/Servlet 容器:Jetty

配置容量

POD 容量配置

    resources:
      limits:
        cpu: "8"
        memory: 4Gi
        # 4Gi = 4 * 1024Mb = 4*1024*1024k = 4194304k = 4294967296 bytes = 4096Mb
      requests:
        cpu: "2"
        memory: 4Gi

JVM 容量配置

开始说 JVM 容量配置前,我假如你曾经对 JVM 内存应用状况有个根本印象:


图片源:https://www.twblogs.net/a/5d8…

上面是我在测试环境收集到的配置:

配置 理论失效配置(Mbyte)
Young Heap + Old Heap -Xmx3G -XX:+AlwaysPreTouch 3072
MaxMetaspaceSize [默认] Unlimited
CompressedClassSpaceSize [默认] 1024
MaxDirectMemorySize [默认] 3072
ReservedCodeCacheSize [默认] 240
ThreadStackSize*maxThreadCount [默认] * 276(实测线程数) 276
汇总 7684 + (没限度 MaxMetaspaceSize)
神秘的 MaxDirectMemorySize 默认值

MaxDirectMemorySize 默认值,https://docs.oracle.com/javas… 如事说:

Sets the maximum total size (in bytes) of the New I/O (the java.nio package) direct-buffer allocations. Append the letter k or K to indicate kilobytes, m or M to indicate megabytes, g or G to indicate gigabytes. By 默认, the size is set to 0, meaning that the JVM chooses the size for NIO direct-buffer allocations automatically.

意思就是说了等于没说 🤨。

在我的测试环境中, 我应用 Arthas attached 到 JVM 而后查看外部的动态变量:

[arthas@112]$ dashboard
ognl -c 30367620 '@io.netty.util.internal.PlatformDependent@maxDirectMemory()'
@Long[3,221,225,472]

ognl '@java.nio.Bits@maxMemory'
@Long[3,221,225,472]

3221225472/1024/1024 = 3072.0 Mb

如果你想深刻,请参考资料:

  • http://www.mastertheboss.com/…
  • https://developer.aliyun.com/…

    MaxDirectMemorySize ~= `from -Xmx (Young Heap + Old Heap)` - `Survivor(Young) Capacity` ~= 3G
maxThreadCount 最大线程数起源

既然下面用了 Arthas,上面学是持续 Arthas 吧:

[arthas@112]$ dashboard
   Threads Total: 276

利用应用的是 Jetty,线程池配置 jetty-threadpool.xml

<Configure>
  <New id="threadPool" class="org.eclipse.jetty.util.thread.QueuedThreadPool">
    <Set name="maxThreads" type="int"><Property name="jetty.threadPool.maxThreads" deprecated="threads.max" default="200"/></Set>
...
  </New>
</Configure>

因为除了 Jetty,还有其它各种线程。

使用量

Java 的视角看使用量

容量配置 失效配置(Mbyte) 理论应用(Mbyte)
Young Heap + Old Heap -Xmx3G -XX:+AlwaysPreTouch 3072 3072
MaxMetaspaceSize [默认] Unlimited 128
CompressedClassSpaceSize [默认] 1024 15
MaxDirectMemorySize [默认] 3072 270
ReservedCodeCacheSize [默认] 240 82
ThreadStackSize*maxThreadCount [默认]*276 线程 276 276
Sum 7684 + (没限度 MaxMetaspaceSize) 3843

如何采集 理论使用量

  • ReservedCodeCache

在利用通过热身、压力测试之后,用 Arthas attached:

[arthas@112]$ dashboard
code_cache : 82Mb
  • DirectMemory
[arthas@112]$ 
ognl '@java.nio.Bits@reservedMemory.get()'
@Long[1,524,039]
ognl -c 30367620 '@io.netty.util.internal.PlatformDependent@usedDirectMemory()'
@Long[268,435,456]
  • Metaspace
  • CompressedClassSpaceSize
$ jcmd $PID GC.heap_info

 garbage-first heap   total 3145728K, used 1079227K [0x0000000700000000, 0x0000000700106000, 0x00000007c0000000)
  region size 1024K, 698 young (714752K), 16 survivors (16384K)
 Metaspace       used 127,323K, capacity 132,290K, committed 132,864K, reserved 1,167,360K
  class space    used 14,890K, capacity 15,785K, committed 15,872K, reserved 1,048,576K

原生利用的视角看使用量

原生利用的视角看使用量,包含上面这个方面:

  • *lib.so 动静库占用: 16Mb
  • *.jar 文件映射占用: 8Mb
  • GC 算法耗费: 未考察
  • glibc malloc 空间回收不及时耗费: 158Mb

总的原生利用耗费: 16+8+158 = 182Mb

小结一下:
Java 角度看使用量: 3843Mb
总利用使用量 = 3843 + 158 ~= 4001Mb

4001Mb,这里咱们没有算 *lib.so 动静库占用*.jar 文件映射占用。为什么?将在上面内容中作出解释。
4001Mb 这个数字有点可怕,离容器配置的下限 4096Mb 不远了。但这个数字有肯定水分。为什么?将在上面内容中作出解释。

以下我尝试剖析每个子项的数据起源

*lib.so 动静库占用

运行命令:

pmap -X $PID

局部输入:

         Address Perm   Offset Device      Inode     Size     Rss     Pss Referenced Anonymous  Mapping
...
    7f281b1b1000 r-xp 00000000  08:03 1243611251       48      48       3         48         0  /lib64/libcrypt-2.26.so
    7f281b1bd000 ---p 0000c000  08:03 1243611251     2044       0       0          0         0  /lib64/libcrypt-2.26.so
    7f281b3bc000 r--p 0000b000  08:03 1243611251        4       4       4          4         4  /lib64/libcrypt-2.26.so
    7f281b3bd000 rw-p 0000c000  08:03 1243611251        4       4       4          4         4  /lib64/libcrypt-2.26.so
...
    7f28775a5000 r-xp 00000000  08:03 1243611255       92      92       5         92         0  /lib64/libgcc_s.so.1
    7f28775bc000 ---p 00017000  08:03 1243611255     2048       0       0          0         0  /lib64/libgcc_s.so.1
    7f28777bc000 r--p 00017000  08:03 1243611255        4       4       4          4         4  /lib64/libgcc_s.so.1
    7f28777bd000 rw-p 00018000  08:03 1243611255        4       4       4          4         4  /lib64/libgcc_s.so.1
    7f28777be000 r-xp 00000000  08:03 1800445487      224      64       4         64         0  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28777f6000 ---p 00038000  08:03 1800445487     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28779f5000 r--p 00037000  08:03 1800445487       20      20      20         20        20  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28779fa000 rw-p 0003c000  08:03 1800445487        8       8       8          8         8  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
...
    7f28f43a7000 r-xp 00000000  08:03 1243611284       76      76       3         76         0  /lib64/libresolv-2.26.so
    7f28f43ba000 ---p 00013000  08:03 1243611284     2048       0       0          0         0  /lib64/libresolv-2.26.so
    7f28f45ba000 r--p 00013000  08:03 1243611284        4       4       4          4         4  /lib64/libresolv-2.26.so
    7f28f45bb000 rw-p 00014000  08:03 1243611284        4       4       4          4         4  /lib64/libresolv-2.26.so
    7f28f45bc000 rw-p 00000000  00:00          0        8       0       0          0         0  
    7f28f45be000 r-xp 00000000  08:03 1243611272       20      20       1         20         0  /lib64/libnss_dns-2.26.so
    7f28f45c3000 ---p 00005000  08:03 1243611272     2044       0       0          0         0  /lib64/libnss_dns-2.26.so
    7f28f47c2000 r--p 00004000  08:03 1243611272        4       4       4          4         4  /lib64/libnss_dns-2.26.so
    7f28f47c3000 rw-p 00005000  08:03 1243611272        4       4       4          4         4  /lib64/libnss_dns-2.26.so
    7f28f47c4000 r-xp 00000000  08:03 1243611274       48      48       2         48         0  /lib64/libnss_files-2.26.so
    7f28f47d0000 ---p 0000c000  08:03 1243611274     2044       0       0          0         0  /lib64/libnss_files-2.26.so
    7f28f49cf000 r--p 0000b000  08:03 1243611274        4       4       4          4         4  /lib64/libnss_files-2.26.so
    7f28f49d0000 rw-p 0000c000  08:03 1243611274        4       4       4          4         4  /lib64/libnss_files-2.26.so
    7f28f49d1000 rw-p 00000000  00:00          0     2072    2048    2048       2048      2048  
    7f28f4bd7000 r-xp 00000000  08:03 1800445476       88      88       6         88         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4bed000 ---p 00016000  08:03 1800445476     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4dec000 r--p 00015000  08:03 1800445476        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4ded000 rw-p 00016000  08:03 1800445476        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4dee000 r-xp 00000000  08:03 1800445477       68      64       4         64         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4dff000 ---p 00011000  08:03 1800445477     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4ffe000 r--p 00010000  08:03 1800445477        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4fff000 rw-p 00011000  08:03 1800445477        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so

💡 如果你不太理解 Linux 的 memory map 和 pmap 的输入,倡议浏览:https://www.labcorner.de/chea…。
如果你懈怠如我,我还是上个图吧:

大家晓得,古代操作系统都有过程间共享物理内存的机制,以节俭物理内存。如果你理解 COW(Copy on Write)就更好了。一台物理机上,运行着多个容器,而容器的镜像其实是分层的。对于同一个机构生成的不同服务的镜像,很多时候是会基于同一个根底层,而这个根底层包含是 Java 的相干库。而所谓的层不过是主机上的目录。即不同容器可能会共享读 (Mapping) 同一文件。

回到咱们的主题,内存限度。容器通过 cgroup 限度内存。而 cgroup 会记账容器内过程的每一次内存调配。而文件映射共享内存的计算方法显然要特地解决,因为跨了过程和容器。当初能查到的材料是说,只有第一个读 / 写这块 mapping 内存的 cgroup 才记账(https://www.kernel.org/doc/Do… 中 [2.3 Shared Page Accounting])。所以这个账比拟难预计的,个别咱们只做再坏状况的保留。

*.jar mapping 占用

pmap -X $PID

记账原理和下面的 .so 相似。不过 Java 9 后,就不再做 .jar mapping 了。就算是 Java 8,也只是 mapping 文件中的目录构造局部。

在我的测试中,只应用了 8Mb 内存.

glibc malloc 耗费

Java 在两种状况下应用 glibc malloc:

  1. NIO Direct Byte Buffer / Netty Direct Byte Buffer
  2. JVM 外部根底程序

业界对 glibc malloc 的节约颇有微词. 次要集中在不及时的内存偿还(给操作系统)。这种节约和主机的 CPU 数成比例,可参考:

  • https://medium.com/nerds-malt…
  • https://systemadminspro.com/j…
  • https://www.cnblogs.com/seaso…

可怜的是,我的测试环境是祼机,所有 CPU 都给容器看到了。而主机是 80 个 CPU 的。那么问题来了,如何测量节约了多少?
glibc 提供了一个 malloc_stats(3) 函数,它会输入堆信息(包含应用和保留)到规范输入流。那么问题又来了。如果调用这个函数?批改代码,写 JNI 吗?当然能够。不过,作为一个 Geek,当然要应用 gdb

cat <<"EOF" > ~/.gdbinit
handle SIGSEGV nostop noprint pass
handle SIGBUS nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGILL nostop noprint pass
EOF

export PID=`pgrep java`
gdb --batch --pid $PID --ex 'call malloc_stats()'

输入:

Arena 0:
system bytes     =     135168
in use bytes     =      89712
Arena 1:
system bytes     =     135168
in use bytes     =       2224
Arena 2:
system bytes     =     319488
in use bytes     =      24960
Arena 3:
system bytes     =     249856
in use bytes     =       2992
...
Arena 270:
system bytes     =    1462272
in use bytes     =     583280
Arena 271:
system bytes     =   67661824
in use bytes     =   61308192


Total (incl. mmap):
system bytes     =  638345216
in use bytes     =  472750720
max mmap regions =         45
max mmap bytes   =  343977984

所以后果是: 638345216 – 472750720 = 165594496 ~= 158Mb
即节约了 158Mb。因为我测试场景负载不大,在负载大,并发大的场景下,80 个 CPU 的节约远不止这样。

有一点须要指出的,操作系统物理内存调配是 Lazy 调配的,即只在理论读写内存时,才调配,所以,下面的 158Mb 从操作系统的 RSS 来看,可能会变小。

GC 内存耗费

未考察

tmpfs 内存耗费

未考察

操作系统 RSS

RSS(pmap -X $PID) = 3920MB。即操作系统认为应用了 3920MB 的物理内存。

CGroup 限度

cgroup limit 4Gi = 4*1024Mb = 4096Mb
pagecache 可用空间 : 4096 – 3920 = 176Mb

上面看看 cgroup 的 memory.stat 文件

$ cat cgroup `memory.stat` file
    rss 3920Mb
    cache 272Mb
    active_anon 3740Mb
    inactive_file 203Mb
    active_file 72Mb  # bytes of file-backed memory on active LRU list

仔细如你会发现:

3920 + 272 = 4192 > 4096Mb

不对啊,为何还不 OOM killed?

说来话长,pagecache 是块有弹性的内存空间,当利用须要 anonymous 内存时,内核能够主动回收 pagecache.

💡 感兴趣可参考:
https://engineering.linkedin….
https://github.com/kubernetes…
https://www.kernel.org/doc/ht…

潜在问题和举荐解决办法

Native Buffer 限度

默认 MaxDirectMemorySize ~= -Xmxsurvivor size ~= 3G .

这在高并发时,内存得不到及时回收时,会应用大量的 Direct Byte Buffer。所以倡议显式设置限度:

java ... -XX:MaxDirectMemorySize=350Mb

💡 感兴趣可参考:

  • Cassandra 客户端和 Redisson 均基于 Netty,固均应用了 Native Buffer. 留神的是 NettyUnsafe.class 根底上,还有外部的内存池。

glibc malloc arena 的节约

在我的测试环境中,主机有 80 个 CPU。glibc 为了缩小多线程分配内存时的锁竞争,在高并发时最多为每个 CPU 保留 8 个内存块 (Arena),而 Arena 的空间归还给操作系统的机会是不可预期的,和堆中内存碎片等状况无关。
在我的测试环境中察看的后果是:共创立了 271 个 Arena。应用了 608Mb 的 RSS。而理论程序用到的内存只有 450Mb。 节约了 157 Mb。节约的状况有随机性,和内存碎片等状况无关。对于容器,咱们不可能调配所有主机的 CPU。能够设置一个显式下限是正当的,且这个下限和容器的 memory limit、CPU limit 应该联动。

MALLOC_ARENA_MAX 这个环境变量就是用于配置这个下限的。

  • 和内存应用的分割:
    咱们实测中,共应用了 700Mb glibc 堆内存. 而每个 Arena 大小为 64Mb. 所以:

    700/64=10 Arena
  • 和容器 cpu limit 的分割:

  • cpu * (每个 cpu 8 arena) = 64 Arena.

咱们激进地应用大的保留空间:

export MALLOC_ARENA_MAX=64

💡 感兴趣可参考:
https://www.gnu.org/software/…

Jetty 线程池

经考察,每 API 的调用用时大概 100 ms。而现有配置指定了最大 200 个线程。所以:

200 thread / 0.1s = 2000 TPS

在咱们的测试中,单容器的 TPS 不出 1000。所以 100 个线程足以。缩小线程数的益处是,能够同时能够缩小适度的线程上下文切换、cgroup CPU 限流(cpu throttling)、线程堆栈内存、Native Buffer 内存。让申请堆在 Request Queue,而不是内核的 Runnale Queue。

<!-- jetty-threadpool.xml -->
<Configure>
  <New id="threadPool" class="org.eclipse.jetty.util.thread.QueuedThreadPool">
...
    <Set name="maxThreads" type="int"><Property name="jetty.threadPool.maxThreads" deprecated="threads.max" default="100"/></Set>
...
  </New>
</Configure>

Java code cache 慢涨

在咱们测试中,在通过零碎预热后,Java code cache 依然会慢涨。Java 8 的 code cache 最大值是 240Mb。如果 code cache 耗费了大量的内存,可能会触发 OOM killed。所以还是要作显式限度的。从测试环境的察看,100Mb 的空间曾经足够。

java ... -XX:ReservedCodeCacheSize=100M -XX:UseCodeCacheFlushing=true

💡 感兴趣可参考:
https://docs.oracle.com/javas…

容器的内存限度

从下面的考察可知,3G java heap + JVM overhead + DirectByteBuffer 曾经很靠近 4Gi 的容器内存下限了。在高并发状况下,OOM killed 危险还是很高的。而且这个问题在测试环境不肯定能呈现,有它的随机性。

cgroup 对容器靠近 OOM 的次数是有记录 (memory.failcnt) 的,在测试时发现这个数字在慢张。在内存缓和的时候,内核通过抛弃文件缓存 (pagecache) 来优先满足利用对内存的需要。而抛弃文件缓存象征什么?更慢的读,更频繁和慢的写硬盘。如果利用有读写 IO 压力,如果读 *.jar,写日志,那么 IO 慢问题会随之而来。

watch cat ./memory.failcnt 
19369

💡 感兴趣可参考:
https://engineering.linkedin….
https://www.kernel.org/doc/Do…
https://srvaroa.github.io/jvm…

对于我的利用,我倡议是放宽内存限度:

    resources:
      limits:
        memory: 4.5Gi
      requests:
        memory: 4.5Gi

瞻望

不全面地说,从服务运维者的角度看, 服务的资源分配基于这些系数:

  • 容器的 SLA

    • 指标容器的呑吐量

如我把下面系数作为一个工具程序的 输出 , 那么 输入 应该是:

  • 应该部署多少个容器
  • 每个容器的资源配置应该如何

    • CPU

      • 容器 CPU limit
      • 利用线程池 limit
    • Memory

      • 容器 memory limit
      • 利用线程池 d limit:

        • java: 堆内 / 堆外

💡 有一个开源工具可参考:
https://github.com/cloudfound…

免责申明

Every coin has two sides,利用调优更是,每种调优办法均有其所须要的环境前提,不然就不叫调优,间接上开源我的项目的默认配置 Pull Request 了。巨匠常说,不要简略 copy 调参就用。要思考本人的理论状况,而后作充沛测试方可应用。

领会

2016 年开始,各大公司开始追赶时尚,把应该的利用放入容器。而因为很多旧我的项目和组件在设计时,没思考在一个受限容器中运行,说白了,就是非 contaier aware。时隔数年,状况有所恶化,但还是有不少坑。而作为一个合格的架构师,除了 PPT 和远方外,咱们还得有个玻璃心。

以上是对一个 Java 容器内存的剖析,如果你对 Java 容器 CPU 和线程参数有趣味,请移步:Java 容器化的历史坑(史坑)– 资源限度篇。

用一个漫画了结本文:

正文完
 0