JVM之 DJL使用不当和自身bug引起的内存泄露排查

1. 背景

排序服务新上线一个DPP(Determinantal Point Process)重排算法逻辑,基于DJL(Deep Java Library)实现的, DJL目前提供了MXNet,、PyTorch和TensorFlow的实现,通过调用JNI或者JNA来调用相应的底层操作。
新重排算法上线之服务调用方每隔一段时间就会出现超时报错,每个一段时间还会出现OOM,这是之前从来没有的情况,因此开始了JVM排查之路。
排序服务是部署在K8S集群上,配置是8C16G,使用的JDK11的ZGC。

调用方超时报错
排序服务OOM事件

2 排查之路

2.1 NDManager

  1. 监控显示18点30左右,排序服务调用方有超时报错。查看接口的平均响应时间,确实有尖峰(因为排序服务被多个服务调用,所以截取的是服务调用方展示的接口平均耗时曲线)。
调用排序服务的平均响应时间
  1. 查看CPU利用率发现 18:30 确实有提升,但是CPU throttled time还好。说明响应时间升高跟CPU throttled没有关系,再结合OOM怀疑跟内存泄露有关(废话,都OOM了,肯定是跟内存泄露有关系了,但是响应时间升高是有很多因素引起的,还要排除CPU throttled的影响)。
CPU利用率
CPU throttled time
  1. 怀疑内存泄露第一时间查看堆的使用情况和GC情况(频率与耗时),但是18:30 左右都是正常的。
堆内存使用情况
zgc次数
zgc耗时
  1. 问题暂时陷入了僵局, 忽然发现18:30左右的超时报错大部分都跟某一台机器有关,查看stdout日志,发现 18:31:27 Tomcat被重启了,同样09:20:34、12:22:34 Tomcat都被kill然后重启。这样CPU利用率在这几个时间点彪高也能解释了。
微服务接口统计
stdout日志
CPU利用率曲线
  1. 排查的过程中意外发现Direct BufferPool使用率随着时间逐渐增加。每次重启之后骤降,过一段时间又增加。堆内的使用情况又正常, 怀疑跟堆外内存有关。
BufferPool
堆内内存使用情况
  1. 堆外内存主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,java 8里还包括metaspace元数据空间。而且之前也提到了新上线基于DJL的DPP重排逻辑,会有大量使用JNI。因为对JNI不熟悉,只是简单了解过其机制,且堆外的内存特别难以排查,所以试着查看代码看看有没有什么发现。DPP的主要代码如下所示(省略部分代码和参数):
private void dppRank ( ) {
   DPPModel dppModel = new DPPModel(itemCount, topK);
   List<Integer> resDPP = dppModel.dppSW(/* 参数省略 */);
}
public class DPPModel {
    private NDArray rankScore;
    private NDArray kernelMatrix;
    private NDManager manager = NDManager.newBaseManager();
    // ... ...
}

这里使用的DJL的pytorch-engine:0.18.0,通过NDManger.create()创建NDArray,会调manager.attachInternal() 将创建的NDArray放到NDManager.resources中,以便统一管理。

    public PtNDArray(PtNDManager manager, long handle) {
        super(handle);
        this.manager = manager;
        this.ptNDArrayEx = new PtNDArrayEx(this);
        manager.attachInternal(getUid(), this);
    }
    public synchronized void attachInternal(String resourceId, AutoCloseable resource) {
        if (closed.get()) {
            throw new IllegalStateException("NDManager has been closed already.");
        }
        tempResources.compute(
                resourceId,
                (key, tempResource) -> {
                    if (tempResource != null) {
                        // This state occurs when this manager (manA) tempAttaches a resource that
                        // is later
                        // tempAttached to another manager (manB)
                        // When manB is closed, it will use attach to return the resource to this
                        // (manA)
                        // In that case, it should stay as a tempResource in this (manA)
                        tempResource.detached = false;
                    } else {
                        resources.put(resourceId, resource);
                    }
                    return tempResource;
                });
    }

PtNDManager继承自BaseNDManager,实现AutoCloseable的close接口,会遍历resources调所有NDArray的close方法

    public void close() {
        if (!closed.getAndSet(true)) {
            for (AutoCloseable closeable : resources.values()) {
                try {
                    closeable.close();
                } catch (Exception e) {
                    logger.error("Resource close failed.", e);
                }
            }
            for (TempResource resource : tempResources.values()) {
                resource.returnResource();
            }
            parent.detachInternal(uid);
            resources.clear();
            tempResources.clear();
        }
    }

查看PtNDManger的create 方法,会删除NDArray的内存。

    @Override
    public void close() {
        Long pointer = handle.getAndSet(null);
        if (pointer != null) {
            JniUtils.deleteNDArray(pointer);
        }
        manager.detachInternal(getUid());
        dataRef = null;
    

查到这里可以肯定是因为DJL的使用不当引起的, 可以通过try-with-resource方式使用NDManager,使用后会自动释放资源。

 try (NDManager manager = NDManager.newBaseManager(Device.cpu())) {
            NDArray rankScore = manager.create(rankScoreArray);
 }

修改后,重新发布后,可以发现 BufferPool direct used 使用正常。

BufferPool direct使用情况

2.2 NDManager自身bug

但是故事到这里还没有结束,本以为调整使用方式就万事大吉了,但是上线一段时间后系统仍然报OOM、Tomcat也被重启,只是频率比之前低了很多,又开始漫长的排查之路。

  1. 查看gc.log,发现确实有内存泄露,如下图所示,只是被之前误用的问题掩盖起来了。这里介绍一个网站https://gceasy.io/ 可以分析GC日志,发现GC后的堆使用情况一直是增加的。 同时Mem RSS使用量也是不断增加的。
GC后堆内存使用情况
Mem Rss使用情况
  1. 查看堆内存dump
    使用Memory Analyzer (MAT)查看堆内存使用情况,直方图如下所示,其中:
  • Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存;
  • Shallow Heap(浅堆)代表对象本身占用的内存。
内存dump直方图

其中有大量的PtNDManager,远超正常使用。通过list objects- with incoming referencs 查看, 发现被PtNDManager$SystemManager的resources引用。

每个一段时间执行一下jmap -histo:live 也可以发现PtNDManager一直增加。

  1. 继续查看DJL源码
    NDManager.newBaseManager() 创建PtNDManager源码如下所示, 会调用PtNDManager$SystemManager的attachUncappedInternal将创建的PtNDManager挂载到resoures里。
   PtNDManager manager = new PtNDManager(this, device);
   attachUncappedInternal(manager.uid, manager);
   return manager;
   resources.put(resourceId, resource);

当PtNDManager执行close方法时,会调用parent的detachInternal方法,parent是PtNDManager$SystemManager, PtNDManager$SystemManager的detachInternal什么都没干

   public void close() {
        if (!closed.getAndSet(true)) {
             // ignore some code
            parent.detachInternal(uid);
            resources.clear();
            tempResources.clear();
        }
    }
@Override
 public void detachInternal(String resourceId) {}

查看pytorch-engine的0.17.0的源码就正常多了,使用之后也没有问题,于是去DJL的github怒提issue: https://github.com/deepjavalibrary/djl/issues/1886

总结

此次内存泄露有两个原因:

  1. DJL使用不当,NDManager创建之后没有关闭,导致相应的NDArray和JNI内存都没有被回收;
  2. DJL自身bug, NDManager即使执行了close,但是还是被引用,导致自身无法被回收。

虽然排查之后再进行复盘,根据各种现象,顺理成章就排查了问题的根源。 但是一开始排查时,毫无头绪,各种细节充斥其中,有些是原因,有些是结果, 比如最开始怀疑是CPU彪高导致响应时间变长,但其实是因为OOM导致tomcat 重启导致的CPU彪高。只能不停地假设,然后根据现象验证假设,最终定位问题。

总结内存泄露的排查流程如下:

  1. 首先要熟悉JVM、包括垃圾收集器的原理,这样才能有的放矢;
  2. 借助一些工具,事半功倍:本文涉及到有graphana、cat、mat、jmap 等,每个工具都有自身的功能,帮忙定位一些问题;
  3. 结合理论与工具,抓住线索,不停地去怀疑、假设,然后验证自己的假设;
  4. 剩下的就是交给时间,本次排查也耗时好久,开始走了很多的弯路,但是硬刚到底最终会发现问题根源。
    经过此次排查,对很多东西的理解和工具的使用更上一层楼。

最后想说一下DJL,虽然本文的内存泄露与DJL有关,但是不得不说DJL是一个难得的JAVA 机器学习工具包,可以在Java中开发及应用原生的机器学习和深度学习模型,同时简化了深度学习开发的难度。通过DJL提供的直观的、高级的API,Java开发人员可以训练自己的模型,或者利用数据科学家用Python预先训练好的模型来进行推理。如果恰好是对学习深度学习感兴趣的Java开发者,那么DJL无疑将是开始深度学习应用的一个最好的起点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容