相关文章推荐

java进程内存大于Xmx问题

9 min read,created at 2024-06-19
java jvm rss heap 内存

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内存

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

此时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减去呢。

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

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

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
    

    从图中左侧看出,堆大小在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

    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

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

    可以看到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 进行一次采样。

     
    推荐文章