java进程内存大于Xmx问题

9 min read,created at 2024-06-19
javajvmrssheap内存

1 Rss > Xmx现象

生产环境下其实是很常见的,java进程的内存超过了设置的Xmx,这也很容易理解,因为Xmx指的是堆内存的上限,而Rss(Resident set size)是整个进程的内存,他不仅包括了堆内存,还包括了jvm这个c++进程运行所需要的内存。

简单讲Rss是总内存 = 堆内存 + 堆外内存,Xmx只是限制的堆内存大小,接下来用一个简单的Main.java为例

Main.java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        Scanner sc = new Scanner(System.in);
        while (true) {
            int cmd = sc.nextInt();
            switch (cmd) {
                case 1:
                    for (int i = 0; i< 10_000_000; i++) {
                        list.add("i=" + i);
                    }
                    System.out.println("add finish, current size " + list.size());
                    break;
                case 0:
                    list.clear();
                    System.out.println("clear finish");
                default:
                    break;
            }
        }
    }
}

首先使用java 8具体版本为8.0.412-zulu

$ javac Main.java
$ java -Xmx2g -Xms2g -XX:+PrintGC Main

## 此时查看内存占用,空壳程序大概占用32M内存

img

然后java进程的控制台输入1回车,代码逻辑是在一个list中加入1kw个String,连续输入两个1,日志如下。

image

image

此时rss - heap = 200M,这部分是堆外的diff,但是常见的堆外内存metaspace compressedClassSpace codeSpace加起来远远不到200M,是jvm进程自己占用了较多的堆外内存,这部分很难追溯。

此时java进程输入0清理内存,但是因为没有gc所以内存无变化,然后我们使用指令强制GC:jcmd $(jps | grep Main | awk '{print $1}') GC.run,发现rss内存一点都没有减小,而jmap查看堆内存已经只有不到100m了,那gc清理的内存为什么没有从RSS减去呢。

img

多次创建和清理后,Rss来到2.2G

img

触发GC后,内存仍是2.2G,堆内存仅79M

img

2 如何才能归还

这是因为jvm申请到内存后,是不会轻易将内存归还回去OS的。大内存占用有哪些坏处呢?不归还,当然对jvm进程本身是百益无害的,但是较大的rss可能会导致操作系统无法给其他进程分配内存,还有可能导致操作系统OOM,被迫把jvm进程给干掉。

因而如果操作系统内存远大于Xmx堆内存的设置的话,那其实无所谓,如果操作系统内存只比Xmx大一点点,那很有可能会在堆内存被撑大之后,无法归还OS,最后导致总Rss超过了系统内存,最终被kill。

归还的方式有以下几个方向:

  • UseG1GC并且设置Xms<Xmx,尤其是jdk11之后效果更佳
  • 调整MAX_ARENA_SIZE或使用Jemalloc改善底层libc的内存申请策略。

2.1 g1

修改启动指令-Xms200m -XX:+UseG1GC指定Xms只有200M,并且使用g1gc,重复上面的流程,最后进程RSS只有323M

$ java -Xmx2g -Xms200m -XX:+UseG1GC -XX:+PrintGC Main

img

从图中左侧看出,堆大小在gc的过程中不断调节,一开始young区200M,然后不够了,不断扩大最终2004M,在最后一次GC的时候大小又缩小到266M,缩小的过程中会把内存归还操作系统。

扩缩的过程是伴随在gc之后的,所以对整体的性能影响不算大,但是肯定也有一点点影响。但是生产环境较少看到Xms < Xmx的情况,一般都设置为相等,这个可能来自较早的流传下来的习惯,因为之前没有g1的时候,是没有这个效果,所以设置为不同对整体没有任何收益,尤其是服务端进程。这个大家可以酌情去设置,做好灰度验证,最终适合自己业务场景,那么就可以使用这种启动参数。

2.2 MAX_ARENA_SIZE

ARENA是linux的libc中的默认malloc实现(ptmalloc),分配内存时的概念,他是为了解决多线程分配内存的并发问题设置的,在多个(64位系统默认是核心数x8)空间上分配内存,这个空间就是ARENA,较大的ARENA数,会导致内存碎片较多,通过如下指令,改为 1 ,会加剧多线程内存分配的竞争问题,但是带来的好处是,可以在一个ARENA分配,如果之前有用不到的内存,一定程度上可以复用。

$ export MALLOC_ARENA_MAX=1 && java -Xmx2g -Xms2g -XX:+PrintGC Main

这个参数,并不会使得内存可以归还OS,但是多次分配->清理->分配->清理后,的Rss总大小会比原来小一些,例如之前重复操作会导致Rss大于2.1G这里我们同样多次操作,最后只有1.9G

image

2.3 Jemalloc

jemalloc是一种有着更好性能表现的malloc实现,可以替换libcptmalloc,在内存分配上可以更好的避免内存碎片,提高内存利用率,一定程度上能缓解jvm占用过多内存。

$ wget https://github.com/jemalloc/jemalloc/archive/5.3.0.tar.gz
$ tar zxvf 5.3.0.tar.gz
$ cd jemalloc-5.3.0/
$ ./autogen.sh
$ ./configure --prefix=/usr/local/jemalloc-5.3.0 --enable-prof
$ make
$ make install

指定环境变量LD_PRELOAD MALLOC_CONF的同时,启动java进程,这个shell指令下,环境变量只对java进程生效,如果想要整个OS都替换为jemalloc,也可以直接export LD_PRELOAD=/usr/local/jemalloc-5.3.0/lib/libjemalloc.so.2

$ LD_PRELOAD=/usr/local/jemalloc-5.3.0/lib/libjemalloc.so.2 MALLOC_CONF="prof:true,lg_prof_interval:20" java Main

pmap查看确实使用了jemalloc.so.2

img

运行1 1 0 1 1 0,折腾一圈之后发现内存时1834M,比之前的2.2G也要少一点。

img

可以看到malloc相关的两个策略,对于内存的缩小,表现非常有限,但是你会发现在网上搜索各种资料,最后都会指向这两者,因为他们确实还是有一点效果的,并且可能针对不同的程序环境,效果会有不同,只能说我这个简单场景下表现一般。

2.4 jemalloc的profile

上面构建的时候选了with-prof参数才能进行profiling,同时我们运行java进程的时候指定了MALLOC_CONF="prof:true,lg_prof_interval:20,含义是porf开启,然后lg_prof_interval是采样的频率,每2^20字节,也就是1M内存,所以这时候看当前目录下,其实有几百个文件了。但是没到几千个,说明这个值也是个估值。

修改MALLOC_CONF="prof:true,lg_prof_interval:30可以降低采样频率,30就是1G,也就是每申请1G会有一个文件,但是这个参数在上面2G的内存的时候,没有生成文件。实际的采样过程中,jemalloc 使用的是概率方法来决定是否记录采样信息,而不是严格按每 1 GiB 进行一次采样。

对于上述程序最后使用MALLOC_CONF="prof:true,lg_prof_interval:27可以得到较少的文件,接下来对生成的heap文件分析(在当前目录下生成的)

$ apt-get install graphviz
$ jeprof /root/.sdkman/candidates/java/8.0.412-zulu/bin/java jeprof.69365.0.i0.heap
$ top20