真实还原定位java内存OOM步骤

abstract

自己做了很久的java开发了, 很久没有写关于内存泄漏/溢出相关的问题定位了. 本文会描述一个十分曲折的定位过程. 从本文里面可以学到:

  • jdk11的内存dump
  • 如何分析大对象
  • 如何结合OQL还原真实的问题现场

问题现象

产品某一台服务器发现内存(4G), 已经使用超过90%, 所以我就收到了相关alert如下图:

在这里插入图片描述
可以看出来内存一直在以非常缓慢的速率增加, 而且增长速率很慢. 拿到这个问题的第一想法:

  • 为什么其他服务器没有问题? 我们的这个服务本身是无状态的,还有大概12台机器, 只有另外一台也有相关问题.
  • 为啥内存增长如此缓慢? 内存泄漏应该会很快出现.
  • 都~4个月没有改过代码了

头大的分析步骤

既然问题已经发生了, 那么第一步应该是获取内存dump (因为服务还在运行, 这是个好信号).

如何获取内存dump?

尝试1

一般的应用都会打开HeapDumpOnOOM选项, 但是此时进程还是okay的, 所以不行.

尝试2

用jmap试试. 因为我们已经开始使用JDK11了 (小版本11.0.3), 结果发现不行.

/usr/java/jdk11.0.3.7.1/bin/jmap -dump:format=b,file=test.heap 32065
Exception in thread "main" com.sun.tools.attach.AttachNotSupportedException: Unable to open socket file /proc/32065/root/tmp/.java_pid32065: target process 32065 doesn't respond within 10500ms or HotSpot VM not loaded
	at jdk.attach/sun.tools.attach.VirtualMachineImpl.<init>(VirtualMachineImpl.java:100)
	at jdk.attach/sun.tools.attach.AttachProviderImpl.attachVirtualMachine(AttachProviderImpl.java:58)
	at jdk.attach/com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:207)
	at jdk.jcmd/sun.tools.jmap.JMap.executeCommandForPid(JMap.java:128)
	at jdk.jcmd/sun.tools.jmap.JMap.dump(JMap.java:196)
	at jdk.jcmd/sun.tools.jmap.JMap.main(JMap.java:114)

同样的用jcmd也是上面的错误.

尝试3

突然发现, 既然现在程序还在运行, 试试JMX中的heapdump bean是否可行呢? 结果是可以的. 而且我们的程序内置了一个groovy 执行框架(支持在jvm上运行groovy脚本), 所以我们就可以用如下的代码来执行heapdump:

import com.sun.management.HotSpotDiagnosticMXBean

import javax.management.MBeanServer
import java.lang.management.ManagementFactory

class HeapDumper {
    private static final String HOTSPOT_BEAN_NAME = "com.sun.management:type=HotSpotDiagnostic";
    private static volatile HotSpotDiagnosticMXBean hotspotMBean;


    static void dumpHeap(String fileName, boolean live) {
        // initialize hotspot diagnostic MBean
        initHotspotMBean();
        try {
            hotspotMBean.dumpHeap(fileName, live);
        }
        catch (RuntimeException re) {
            throw re;
        }
        catch (Exception exp) {
            throw new RuntimeException(exp);
        }
    }

    // initialize the hotspot diagnostic MBean field
    private static void initHotspotMBean() {
        if (hotspotMBean == null) {
            synchronized (HeapDumper.class) {
                if (hotspotMBean == null) {
                    hotspotMBean = getHotspotMBean();
                }
            }
        }
    }

    public static HotSpotDiagnosticMXBean getHotspotMBean() {
        try {
            MBeanServer server = ManagementFactory.getPlatformMBeanServer();
            HotSpotDiagnosticMXBean bean = ManagementFactory.newPlatformMXBeanProxy(server, HOTSPOT_BEAN_NAME,
                    HotSpotDiagnosticMXBean.class);
            return bean;
        }
        catch (RuntimeException re) {
            throw re;
        }
        catch (Exception exp) {
            throw new RuntimeException(exp);
        }
    }
}

HeapDumper.dumpHeap("heap.hprof", false)
println "Done"

成功获取到dump, 发现有4.5G.

如何分析内存dump?

自己平常用Eclipse MemoryAnalyzer比较多, 所以后面都是用的这个工具, 因为它具有很强大的OQL(object query language) 功能.
TIPS:

因为我们的heap比较大, 所以可能需要设置下mat的加载参数xmx然后来避免OOM, 如下是mac下的地址:
/Users/edward.gao/work/mat.app/Contents/Eclipse/eclipse.ini 里面可以设置xmx, 比如我的设置为了7G左右.

-startup
../Eclipse/plugins/org.eclipse.equinox.launcher_1.5.0.v20180512-1130.jar
--launcher.library
../Eclipse/plugins/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.1.700.v20180518-1200
-vmargs
-Xmx7096m
-Dorg.eclipse.swt.internal.carbon.smallFonts
-XstartOnFirstThread

然后我们就可以打开该heap文件:
在这里插入图片描述

初步分析

从上面可看到有个对象占用了2.3GB. 然后我们可以查看左下角的Dominator Tree 来查看这个最大的部分.
可以看到如下对象: org.apache.http.impl.nio.reactor.BaseIOReactor中的newChannels占用了绝大多数空间(超过2G):
在这里插入图片描述

BaseIOReactor 是干啥的?

看到这的第一想法, 会不会是apache的nio有啥bug? 我们使用的是httpcore-nio 4.4.10 , 然后自己维护了修改了部分相关代码. 所以(1) 查看了apache最新的这个类, 没啥变化. (2) 只能看看这个集合是干啥了.
httpcore-nio的代码在这里,
往这个集合里面加对象:
在这里插入图片描述
从集合移除对象
这里面有个processNewChannels 会去poll对象:
在这里插入图片描述
所以可能会有如下的可能:
(1) selector没有超时, 导致卡住了.
(2) 这里的status已经不是active了, 导致进不到方法.
(3) 当前线程挂掉了? 异常终止了?
验证(1), 可以查看selector的超时, 发现是1000ms(可以从mat的attributes面板看到某个对象的所有属性):
在这里插入图片描述
验证(2): 上图的status属性, 选择右键-> Go Into, 发现也是active的:
在这里插入图片描述
验证(3): 查看代码, 可以知道这个BaseIOReactor的execute方法的调用逻辑:
类org.apache.http.impl.nio.reactor.AbstractMultiworkerIOReactor 会创建多个Worker线程, 然后worker线程就会负责分发相关的IO事件. 然后每个Worker线程有一个BaseIOReactor负责管理一组channel.

// 初始化dispatcher线程组
            for (int i = 0; i < this.workerCount; i++) {
                final BaseIOReactor dispatcher = this.dispatchers[i];
                this.workers[i] = new Worker(dispatcher, eventDispatch);
                this.threads[i] = this.threadFactory.newThread(this.workers[i]);
            }
// worker run方法:
        public void run() {
            try {
                this.dispatcher.execute(this.eventDispatch);
            } catch (final Error ex) {
                this.exception = ex;
                throw ex;
            } catch (final Exception ex) {
                this.exception = ex;
            }
        }

新的请求逻辑: (我们是客户端):

  • 新的连接请求放到DefaultConnectingIOReactor.Queue requestQueue 并wakeup selector

  • selector被wakeup后 调用org.apache.http.impl.nio.reactor.DefaultConnectingIOReactor#processEvents

  • org.apache.http.impl.nio.reactor.DefaultConnectingIOReactor#processSessionRequests 处理connect key

  • 处理完成后 绑定到不同的dispatcher中newChannels里面 (可以看到这里是Round-robin的方式)

    /**
     * Assigns the given channel entry to one of the worker I/O reactors.
     *
     * @param entry the channel entry.
     */
    protected void addChannel(final ChannelEntry entry) {
        // Distribute new channels among the workers
        final int i = Math.abs(this.currentWorker++ % this.workerCount);
        this.dispatchers[i].addChannel(entry);
    }

分析线程堆栈

根据怀疑 可能是线程dead或者线程卡住了? 还好之前做了线程dump (也不能用jstack, 还是用的groovy执行jmx类似的print)
dispatcher线程的大多数的线程堆栈都是这样: (在执行select)


"sitemon-webservice-task-thread-async-http-client-io-dispatch20-241" prio=5 Id=1012188 RUNNABLE
	at [email protected]/sun.nio.ch.EPoll.wait(Native Method)
	at [email protected]/sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:120)
	at [email protected]/sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:124)
	-  locked sun.nio.ch.Util$2@2ae6b913
	-  locked sun.nio.ch.EPollSelectorImpl@12d5897f
	at [email protected]/sun.nio.ch.SelectorImpl.select(SelectorImpl.java:136)
	at org.apache.http.impl.nio.reactor.AbstractIOReactor.execute(AbstractIOReactor.java:255)
	at org.apache.http.impl.nio.reactor.BaseIOReactor.execute(BaseIOReactor.java:104)
	at org.apache.http.impl.nio.reactor.AbstractMultiworkerIOReactor$Worker.run(AbstractMultiworkerIOReactor.java:588)
	at [email protected]/java.lang.Thread.run(Thread.java:834)

但是有一个线程不是这样的: 发现在执行socket read:

"sitemon-webservice-task-thread-async-http-client-io-dispatch20-274" prio=5 Id=961874 RUNNABLE (in native)
	at [email protected]/java.net.SocketInputStream.socketRead0(Native Method)
	at [email protected]/java.net.SocketInputStream.socketRead(SocketInputStream.java:115)
	at [email protected]/java.net.SocketInputStream.read(SocketInputStream.java:168)
	at [email protected]/java.net.SocketInputStream.read(SocketInputStream.java:140)
	at [email protected]/java.io.BufferedInputStream.fill(BufferedInputStream.java:252)
	at [email protected]/java.io.BufferedInputStream.read1(BufferedInputStream.java:292)
	at [email protected]/java.io.BufferedInputStream.read(BufferedInputStream.java:351)
	-  locked java.io.BufferedInputStream@6c2b5fe7
	at [email protected]/sun.net.www.http.HttpClient.parseHTTPHeader(HttpClient.java:746)
	...

会不会是这个线程有问题? 但是从上面的线程堆栈可以看到Thread的线程堆栈没有打全, 这咋整? 这个线程十分可疑.

用OQL查看线程堆栈

方法1, 在dominator tree上搜索线程这个类, 然后可以右键: (这里输入的是类名Thread)
在这里插入图片描述
然后我们就看到了这个线程的完整堆栈:
在这里插入图片描述
方法2: 你可以使用如下的OQL查询: 同样可以找到对应的线程
在这里插入图片描述

可以看到: 在Reactor的Worker线程中, 不知处理了事件的分发, 实际上也会处理IO事件(consumeInput, inputReady), 最终还调用了XML相关解析,可以看到其中有很多DTD相关字眼, 然后这个好像触发了socket读取. 会不会是这样的url socket没有设置超时时间导致的?

验证怀疑

一般处理xml文件的代码:

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setValidating(false);
            factory.setNamespaceAware(false);
            InputSource inStream = new InputSource();
            inStream.setCharacterStream(new StringReader(rawInput));
            DocumentBuilder builder = factory.newDocumentBuilder();
            Document doc = builder.parse(inStream);

因为使用的是jdk11.0.3 亚马逊的JDK, 但是老版本已经只有源码, 无法本地运行了, 所以直接下载oracle的历史版本也是一样的这里.
这样我们就有了跟线上一样复现的资本. 现在只需要找到什么情况下会出现这个问题就可以了.

验证1

使用上面的代码, 解析一段普通的xml发现并不会发生这个问题. 会不会是跟dtd有关?

验证DTD

dtd 这里可以知道DTD应该是描述xml文件合法性的一个meta文件/定义文件. 所以会不会是有某个http 返回了一段xml, 然后里面又刚好包含dtd定义呢?

如何找到上面这样的http response?

这里需要结合实际的代码, 并结合强大的OQL才能找到. 比如我们的代码是像如下的方式存储的http response:

某个类的某个concurrenthashmap中有个key叫lastResponseBody.

那么最终我们写的OQL如下:

SELECT toString(r.key), toString(r.val) FROM java.util.concurrent.ConcurrentHashMap$Node r WHERE ((r.key != null) and (toString(r.key).indexOf("lastResponseBody") >= 0) and (r.val != null) and (toString(r.val).indexOf("dtd") > 0))

在这里插入图片描述
注意这里我们确实找到了很多这样的包含dtd定义的xml形式的http response. 随便拷贝一个, 然后用上面的代码验证, 发现会生成一样的堆栈效果 (单步debug即可), 并且我们找到了xml解析确实会(可能) 发起http请求, 代码如下:
在这里插入图片描述
我们可以看到这个链接的connectTimeout和readTimeout 都是0 (不超时)
在这里插入图片描述

根本原因

所以说目前比较清楚了,

  • (1) 为什么newChannels会不断变大? — 因为 Worker线程在执行IO input的时候 (实际上也会执行我们的异步回调)processEvents, 然后才会执行processNewChannels, 而这个被xml解析阻塞了. 所以这个线程是没有机会干别的了.
  • (2) 为什么内存增长是缓慢的? — 我们实际上有60个httpclient, 然后采用roundrobin的方式来周期性的分配某个httpclient给某个任务, 然后单个httpclient实际上也会有2个dispatch线程和1个reactor线程. 所以只影响了部分分到这个httpclient上的任务.
  • (3) 为什么其他机器没有问题? — 实际上也算一个高发问题了, 只是url连接block的问题本身也不是很常见.

学到的

(1) MAT实际上是非常强大和有用的.
(2) 有时候需要多方联合来还原内存中代码的运行方向. 我们可以结合已有的线程堆栈, 内存中的线程对象, 内存中对象的属性来判断有问题时的真实情况.
(3) 获取java heap的多种技术 (jmap, jcmd, jmx operation, embeded groovy engine + groovy)
(4) 不要在callback中做太重的任务, 除非那是运行在你自己的线程中.
(5) 最后留一个问题, 如何解决这个问题? 不去加载dtd

参考

  1. OQL的语法参考 - 可以在MAT上的菜单 Help -> Help Contents -> Querying Heap Objects (OQL)
  2. 我写的一份测试代码来演示如何在callback中block reactor: ReactorBlockedIssue.java
  3. DTD问题的解决办法和示例XmlDTDExample.java

猜你喜欢

转载自blog.csdn.net/scugxl/article/details/103701835