Java 服务的内存

#java #Spring

一个 Java 进程的内存使用

一个Java进程最大占用的物理内存为(JDK1.8):

Max Memory = Eden + Survivor + Old + String Constant Pool + Code cache + Compressed class space + Metaspace + Thread stack(thread trace size*thread num) + JVM + DirectByteBuffer + Mapped + Native Memory

这个计算公式中,大部分是 JVM 原生申请的,还有一些是封装的系统 API 申请的内存(如后面三个 Direct,Mapped,Native Memory等)。如果使用者用了一些系统调用的封装 API 去申请内存,那么对于自己在做什么以及相应的内存情况变化应该心里要有一个清晰的认知,不然很容易就造成内存泄漏等后果,且较难排查。

Native Memory Tracking

大体来说,一个Java 进程的内存使用,除了这些已经脱离的 JVM 管控的之外,正常业务系统开发的绝大部分情况下的大部分内存,都是JVM 给管理的。对于 JVM 管理的这部分内存,我们可以使用官方提供的一个 JVM 内存占用工具查看其内存大小,这个工具也是用于排查 Java 内存问题的利器—Native Memory Tracking,简称 NMT。 NMT默认不开启,且无法动态开启(但可以动态关闭),只能在启动 JVM 时用过 -XX:NativeMemoryTracking 开启。 一般我们会使用-XX:NativeMemoryTracking=summary开启 NMT。summary 是指分类统计 JVM 的内存使用情况,另外还有 detail 选项,会有更详细的内容采集(基本用不上,且消耗更大)。 使用方法:

  1. 开启java -XX:NativeMemoryTracking=summary -jar starter.jar
  2. 使用jcmd <pid> VM.native_memory baseline 打一个基线,记录当前的使用情况,以便后续做对比
  3. 过一段时间后,使用jcmd <pid> VM.native_memory summary查看当前情况,或者使用 summary.diff查看与 baseline 的差异。

注意,因为 NMT 对性能有一定影响,因此一般仅在有内存问题排查需求时开启,且使用完需要及时关闭。使用过程中,因为内存增长的时间不确定,一般会使用脚本或者代码的方式去持续采集。 一个 NMT summary 的内容如下(# 中文部分为个人加的注释):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
## java -Xms6144m -Xmx6144m -XX:NativeMemoryTracking=summary -jar starter.jar   启动命令
## jcmd <pid> VM.native_memory summary scale=MB  查看命令

Native Memory Tracking:

Total: reserved=7847MB, committed=6561MB
-                 Java Heap (reserved=6144MB, committed=6144MB)// 堆内存使用情况,reserve 和 commit 与 Xmx、Xms 一致
                            (mmap: reserved=6144MB, committed=6144MB)   // 通过 mmap 系统调用方式分配了这些内存
                
-                     Class (reserved=1080MB, committed=62MB)  // 用于存储 Class 元数据
                            (classes #12544)   //总共加载 12544 个类
                            (  instance classes #11751, array classes #793)  //11751个实体类,793 个数组类
                            (malloc=2MB #34553) //通过 malloc系统调用方式分配了 2M,一共调用了 34553 次 malloc
                            (mmap: reserved=1078MB, committed=60MB) //通过 mmap 方式 reserved 了 1078M,commited 了 60M
                            (  Metadata:   ) //元空间分两大类,class 与 non-class,此处为 non-class 
                            (    reserved=54MB, committed=52MB)
                            (    used=51MB) //used,free,waste这几个指标都是体现的元空间底层细节,一般不需要关注
                            (    free=1MB)
                            (    waste=0MB =0.00%)
                            (  Class space:)//class 的元空间--存放各种 Klass
                            (    reserved=1024MB, committed=8MB)
                            (    used=7MB)
                            (    free=1MB)
                            (    waste=0MB =0.00%)
                 
-                    Thread (reserved=55MB, committed=4MB)//线程部分使用的内存
                            (thread #55)//总共 55 个线程,默认的 Xss 是 1M,因此 reserved 了 55*1M=55MB
                            (stack: reserved=55MB, committed=4MB)
-                      Code (reserved=244MB, committed=26MB)// JIT 的代码缓存,整体使用 26M
                            (malloc=2MB #8072)
                            (mmap: reserved=242MB, committed=24MB)
                
-                        GC (reserved=294MB, committed=294MB)//GC 相关使用到的一些堆外内存 
                            (malloc=34MB #30435)
                            (mmap: reserved=261MB, committed=261MB)

-                  Internal (reserved=1MB, committed=1MB) //JVM 内部的一些占用
                            (malloc=1MB #1946)
                 
-                    Symbol (reserved=14MB, committed=14MB) //Jvm 层面加载类时的一些 C++ 字符串符号常量,不重要
                            (malloc=12MB #160252)
                            (arena=2MB #1)

-    Native Memory Tracking (reserved=4MB, committed=4MB)//NMT本身占用的一些内存
                            (tracking overhead=4MB)

-        Shared class space (reserved=11MB, committed=11MB)//共享类空间,应该是供多 JVM 环境中使用,不重要
                            (mmap: reserved=11MB, committed=11MB)

mmap 和 malloc 都是 Linux 的一个系统调用,用来进行内存申请的,此处不细说。下面是 man 里关于这两个函数的说明

mmap:map or unmap files or devices into memory

malloc: allocate and free dynamic memory

reserved和 committed 都是表示对内存的一种"使用状态"。Linux 的内存机制中,抽象了一层虚拟内存空间来管理真正的物理内存。reserved 的内存是 JVM 通过 mmap 去跟操作系统申请的虚拟地址空间,此时 JVM 还不能直接使用这块内存。JVM 的各个子系统通过 commit 之前 reserved 的内存,申请映射实际的物理内存,这部分内存可以被直接使用(但此时还没有真正分配物理内存)。通过向 committed 的内存写入数据的时候,此时操作系统才真正的给实际分配物理内存。整个 Java 进程实际分配的物理内存可以通过 top或者 pmap 等命令查看。

用个不是很恰当的例子来说明的话:reserve 的相当于跟银行申请了一笔贷款额度,commit 的部分相当于审批通过转到个人账户的部分,但此时都还是一些账户上的数字。等实际取出来或者花出去的时候,这笔钱才是真正的被你用掉了。

所以在上面的 NMT 数据例子里,total committed 是6561MB,但此时如果去用 top 或者 pmap 等查看,会发现实际被使用的内存并没有这么多。Java 的启动参数里有一个 -XX:+AlwaysPreTouch ,可以把 commit 的内存马上写入一些 0 来初始化从而强制让操作系统分配内存,如果加上后在系统启动完commit 的内存都会被实际分配。此参数默认不开启, 因此如果一台 16G 内存的机器,是可以启动超过 4 个 Xmx为 5G 的 Java 进程的。到这里我们不难发现,JVM 对于内存的这种管理,其实是为了进一步压榨机器的性能,因为一般情况下一个 Java 进程都不会把自己申请的内存跑满,所以是可以有空余内存可以让其余进程去利用的。这里可以视作操作系统进程层面的"超卖"。

Overcommit

Linux 下有个 overcommit 的内存管理策略可以配置,简单来说这个配置决定了进程们所 commit 的内存总和能否大于机器内存。该策略有 3 种模式:

1
cat /proc/sys/vm/overcommit_memory # 查看当前值 echo 1 | sudo tee /proc/sys/vm/overcommit_memory # 修改为 1 或sudo vi /etc/sysctl.conf # 在文件中添加 vm.overcommit_memory=1 以永久生效 sudo sysctl -p # 重新加载配置 

这个配置默认是0,某些应用因为其特殊的实现机制,会可能要求配置 overcommit_memory为 1 。例如 Redis 因为其特殊的 Save 机制,需要 fork 一个进程出来复用父进程的地址空间,因此要求能 overcommit以避免 fork 失败或被 oom-killer 杀死。TiDB也有类似的要求。

Out Of Memory

刚才说了如果一台有 16G 内存的机器,可以启动超过 4 个 Xmx为 5G 的 Java 进程(甚至更多)。假设此时 4 个进程都 reserved 了 5G(5G 只是堆的内存,实际会多一些),且 committed 也是 5G(假设 Xms 也是 5G),相当于操作系统这个银行其实只有 16G 的钱,却分别答应了 4 个人各自给他们 5G 的 Money,如果此时 4 个人都要去取出这笔钱,会发生什么情况呢? 此处相当于操作系统这个银行的储备金小于储户的存款总额且被挤兑取款,那操作系统能否“说到做到”呢?

答案是“可以”的,此处这个“可以”是带引号的。

在 Linux 系统里,会为进程在/proc/{pid}/里维护一个oom_score 文件,它显示了内核给每个进程的得分,用于在内存不足时选择一个进程被 OOM-killer 杀死(可以通过一些参数设置来阻止进程被oom-kliier杀死)。得分越高,被杀死的可能性越大。得分和进程占用的内存百分比成正比(具体算法不止根据内存占用),最大值为 1000。分值越高说明越容易在内存不足时被内核杀死。注意此处的 OOM 并不是 Java 里的 OOM 异常,而是 OS 层面的 OOM。

因此当操作系统答应过(reserved)的内存超过自己真实的内存,且真的被挤兑取用时,操作系统是没办法无中生有解决这个问题的,它会“杀富济贫”。根据 oom_score干掉一个内存使用的大户,然后大家一起吃大户。颇有种“解决不了问题就解决制造问题的人”的思路。被干掉的有可能是别的进程,也有可能是自己。

因此如果当我们的 Java 服务总是运行一段时间后突然崩溃,不妨看下/var/log/message里有没有"Out of memory:Kill process"等关键字。有则说明是在实际的物理内存使用过多后,某个进程申请内存时因为内存不足触发内核的 oom-killer 机制杀掉了我们的进程。这种情况一般需要排除我们的服务是否存在内存泄露,如果不存在泄露的话,则很可能是内存相关的参数(Xmx,Xss,MetaspaceSize)等设置得太高了,没留什么余地给操作系统跟其他进程。此时应该把内存参数设置小一些,以换取其他进程的正常活动空间。不然我们的Java 服务在内存接近极限的状态下很容易被机器上的一个 Zabbix或者filebeat 等小程序借操作系统的刀干翻。

K8S下的Java 进程

K8S 资源管理与 JVM 参数

在容器化盛行之后,K8S 已经成为了容器编排的事实标准了。在我们的服务上 K8S 时,往往会要求填写资源限制的相关参数,例如为一个 pod 里面的每个 Container 指定 resources.requests以及resources.limits。

1
resources:    limits:      cpu: '2'      memory: 2Gi    requests:      cpu: '1'      memory: 1Gi

此处 resources.requests限制的是 K8S 做调度时的选择。例如一个 Node 节点机器有 5G 内存可供调度,已经调度了 5 个resources.requests为 1G 的 Pod 之后,无论实际使用的内存是多少,实际空闲内存剩多少,都不会再调度其余 Pod 过去了。而resources.limits则是限制该 Pod 能使用的资源大小,这里对于内存的限制既不是上文的 Reserved 也不是 Committed ,而是实际使用的内存。limits 需要大于 requests。

因此当 Xmx=Xms>resources.limits.memory时,服务理论上是可以启动的。因为 Xmx 跟 Xms 在启动时影响的一般只是堆的 Reserved 和 Committed 内存,不代表实际使用内存。

此处注意,一个 Pod 里面往往不止跑了一个 我们的服务容器这一个Container,例如经常还会有一些类似 Istio-proxy 等的 SideCar。而一个 Container 里也不止我们的 Java 服务这一个进程。因此一般而言 Pod 所需的内存>我们的业务 Container 所需内存>Java进程所需内存。

我们多个Java 服务的 Pod,跑在同一台 Node 节点上时,很可能配置的 Xmx 数值总和会大于 resources.requests的总和,甚至大于 resources.limits的总和。这里可以视作K8S层面的"超卖"。超卖的好处不言而喻,可以在业务低谷期以尽量少的资源运行尽量多的服务。而超卖的代价也很明显,就是服务不稳定,一旦出现挤兑,将会出现不可预料的Pod宕机现象。而我们往往是多个项目组的不同服务跑在同一个 K8S 集群内的,这就可能造成A项目组业务高峰期导致 B 项目组服务宕机这种替别人背锅的现象。这是一个成本跟稳定性的权衡,因此在实践中,我们往往是非生产环境允许超卖,甚至超好几倍。而生产环境我们尽量去减少超卖甚至不超卖,当然这会相对应地增加不少成本。

结合以上内容,可以推导出,我们一般线上配置可以使用Xms = Xmx = resources.requests.memory < resources.limits.memory。而非线上时,为了降低成本,我们可以考虑把 requests.memory设置得小一些,甚至低于 Xmx/Xms,用服务可能不稳定的代价来换取较低的部署成本。

容器化下的特殊参数

如上一个小节所说,在容器化的时代,我们为了降低成本,往往会出现不同的环境所用的 K8S资源管理配置不一样,而我们的Java服务堆内存等一般又是需要同等配置的.又或者我们需要给不同的服务配置不同的Xmx/Xms,而使用同一个启动脚本,此时原来固定指定 Xmx/Xms 大小的方式再也无法满足我们的需求,我们需要用到 jdk8 后 Java 所提供的两个新参数MaxRAMPercentage和InitialRAMPercentage。

可以简单理解这两个参数的作用相当于原来的 Xmx 和 Xms,只不过一个是百分比的形式配置一个是具体数值配置。在物理机时,这个百分比作用的主题是物理机的物理内存大小,在容器化时是容器被设置的内存大小,在 K8S 时则作用于 resources.limits.memory。

例如一台机器(或容器)的内存是 8G,则配置 Xmx=4G 等价于配置 MaxRAMPercentage=50.0,同理 Xms=4G 也等价于 InitialRAMPercentage=50.0。

值得注意的是不要在启动参数中同时配置两种,若同时配置 MaxRAMPercentage/InitialRAMPercentage 和 Xmx/Xms,则只会生效后者。

例如一个 resource.limits.memory为 4G 的机器,配置 -XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=50.0,服务启动后用 jinfo pid 查看 VM Flags,能看到以下数值:

1
XX:CICompilerCount=2 -XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=2 -XX:G1EagerReclaimRemSetThreshold=8 -XX:G1HeapRegionSize=1048576 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=2147483648 -XX:InitialRAMPercentage=50.000000 -XX:MarkStackSize=4194304 -XX:MaxGCPauseMillis=100 -XX:MaxHeapSize=2147483648 -XX:MaxNewSize=1287651328 -XX:MaxRAM=4294967296 -XX:MaxRAMPercentage=50.000000 -XX:MinHeapDeltaBytes=1048576 -XX:MinHeapSize=8388608 -XX:NativeMemoryTracking=summary -XX:NonNMethodCodeHeapSize=5826188 -XX:NonProfiledCodeHeapSize=122916026 -XX:ProfiledCodeHeapSize=122916026 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:SoftMaxHeapSize=2147483648 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC

对于JVM 的堆内存管理来说,实际需要的数值是 InitialHeapSize 和 MaxHeapSize,而 MaxRAMPercentage/InitialRAMPercentage 或者 Xmx/Xms都是用于计算这两个值的参数而已。大致的算法如下图:

注意这里还有个参数是 MinRAMPercentage,从名字上看起来跟 MaxRAMPercentage 一副对应的样子,很容易让人误解为堆的最小内存大小设置,也就是 Xms,然而并不是。如上图算法所示,当物理机(或容器)的内存小于 200MB 时,它将取代 MaxRAMPercentage 参数来设置最大堆内存。现在一般的 Java 服务都不会跑在这么小内存的机器上,因此这个参数我们日常基本用不上。再强调一次,最小堆内存应该用InitialRAMPercentage而不是MinRAMPercentage。

ZGC 相关的一些内存情况

JDK11 里面,ZGC作为一种实验性功能存在。可以使用-XX:+UnlockExperimentalVMOptions -XX:+UseZGC来开启。因为 ZGC是自适应的,因此一般不需要过多的优化调节参数。相对之前的垃圾收集器们来说,ZGC 主打的是更低的停顿时间,对应的吞吐量会有所下降。这里主要讨论一下与本文主题相关的内存使用情况,关于 ZGC 的其他详情这里不再赘述。

使用 ZGC 时会造成RSS特别高,一般可达3倍Xmx,这是因为ZGC的实现机制导致的,它使用了内存多重映射和染色指针的技术,把同一块物理内存映射为三个虚拟地址视图,这样可以提高 GC 的并发能力,降低 STW 的时间。

但是这也导致使用 top等工具查看程序运行情况时,会发现内存占用超过了 100%。这是因为 top 命令会把三个虚拟地址视图的内存都算上,而实际上它们都指向同一块物理内存。尽管 ZGC 并不是真正占用了那么多的物理内存,但是Linux 已有的很多统计工具会对其统计错误,这往往会造成一些难以预测的情况,例如我们上文提过的 oom_score可能对会其错误计算得分,造成该进程得分虚高,特别容易被 kill。另外使用 NMT 时,也可以看到 Reserved 的内存变成一个巨大的值。

JDK11 下的同一个demo服务,其他配置一样的前提下,使用 G1和 ZGC启动后使用 free -h 查看系统内存:

垃圾收集器 total used free shared buff/cache available
G1 15G 1.4G 12G 44M 1.1G 13G
ZGC 15G 749M 7.6G 6.0G 7.1G 8.4G

这种影响到运维监控工具的统计数据,导致部分内存计算不准(偏大),还可能会影响到 K8S 的资源限制,导致“超卖”幅度降低。甚至同样的配置同样的服务也存在使用 G1可以正常运行使用 ZGC 无法启动等问题。

OpenJDK11 官方推荐使用 G1,相对于 JDK8,JDK11 的 G1做了一些优化,性能更好,一般情况下G1 的性能跟表现都能满足我们的业务需求,我们的性能卡点往往都不在 GC 而在业务代码跟数据库等。因此 OpenJDK11下推荐使用G1。另外,若真的想追求更极致的停顿时间使用 ZGC,可以考虑升级 JDK17(17 对于低版本兼容性比较差,要谨慎考虑),或考虑使用阿里的 Dragonwell 11 等。

Garbage-First Garbage Collector Tuning

依然顺滑!Dragonwell 11如何改造全新垃圾回收器ZGC? | 龙蜥技术-阿里云开发者社区

小结:

在深入一个应用乃至操作系统或者 K8S 等容器编排的内存使用情况过程中,我们处处可以看到程序员对于机器性能的压榨追求。这种对于在成本,性能,稳定性之间的平衡艺术充斥着我们的软件设计过程。我们现在使用的 K8S 集群有多种,不同集群有些差别,例如腾讯云有超级节点等特殊节点。我们在做服务的配置时,最好是能屏蔽了底层的细节,在配置层面实现抽象的统一以兼容不同环境不同集群。

我横竖睡不着,仔细看了半夜,才从字缝里看出字来,满本都写着两个字是“超卖”!