天天看点

一次内存溢出排查优化实战

作者:架构师成长历程

前言

OutOfMemoryError 问题相信很多朋友都遇到过,相对于常见的业务异常(数组越界、空指针等)来说这类问题是很难定位和解决的。

本文以最近碰到的一次线上内存溢出的定位、解决问题的方式展开;希望能对碰到类似问题的同学带来思路和帮助。

主要从表现-->排查-->定位-->解决 四个步骤来分析和解决问题。

#表象

最近我们生产上的一个应用不断的爆出内存溢出,并且随着业务量的增长出现的频次越来越高。

该程序的业务逻辑非常简单,就是从 Kafka 中将数据消费下来然后批量的做持久化操作。

而现象则是随着 Kafka 的消息越多,出现的异常的频次就越快。由于当时还有其他工作所以只能让运维做重启,并且监控好堆内存以及 GC 情况。

重启大法虽好,可是依然不能根本解决问题。

#排查

于是我们想根据运维之前收集到的内存数据、GC 日志尝试判断哪里出现问题。

一次内存溢出排查优化实战

结果发现老年代的内存使用就算是发生 GC 也一直居高不下,而且随着时间推移也越来越高。

结合 jstat 的日志发现就算是发生了 FGC 老年代也已经回收不了,内存已经到顶。

一次内存溢出排查优化实战

甚至有几台应用 FGC 达到了上百次,时间也高的可怕。

这说明应用的内存使用肯定是有问题的,有许多赖皮对象始终回收不掉。

#定位

由于生产上的内存 dump 文件非常大,达到了几十G。也是由于我们的内存设置太大有关。

所以导致想使用 MAT 分析需要花费大量时间。

因此我们便想是否可以在本地复现,这样就要好定位的多。

为了尽快的复现问题,我将本地应用最大堆内存设置为 150M。

然后在消费 Kafka 那里 Mock 为一个 while 循环一直不断的生成数据。

同时当应用启动之后利用 VisualVM 连上应用实时监控内存、GC 的使用情况。

结果跑了 10 几分钟内存使用并没有什么问题。根据图中可以看出,每产生一次 GC 内存都能有效的回收,所以这样并没有复现问题。

一次内存溢出排查优化实战

没法复现问题就很难定位了。于是我们 review 代码,发现生产的逻辑和我们用 while 循环 Mock 数据还不太一样。

查看生产的日志发现每次从 Kafka 中取出的都是几百条数据,而我们 Mock 时每次只能产生一条。

为了尽可能的模拟生产情况便在服务器上跑着一个生产者程序,一直源源不断的向 Kafka 中发送数据。

果然不出意外只跑了一分多钟内存就顶不住了,观察左图发现 GC 的频次非常高,但是内存的回收却是相形见拙。

一次内存溢出排查优化实战

同时后台也开始打印内存溢出了,这样便复现出问题。

#解决

从目前的表现来看就是内存中有许多对象一直存在强引用关系导致得不到回收。

于是便想看看到底是什么对象占用了这么多的内存,利用 VisualVM 的 HeapDump 功能可以立即 dump 出当前应用的内存情况。

一次内存溢出排查优化实战

结果发现 com.lmax.disruptor.RingBuffer 类型的对象占用了将近 50% 的内存。

看到这个包自然就想到了 Disruptor 环形队列。

再次 review 代码发现:从 Kafka 里取出的 700 条数据是直接往 Disruptor 里丢的。

这里也就能说明为什么第一次模拟数据没复现问题了。

模拟的时候是一个对象放进队列里,而生产的情况是 700 条数据放进队列里。这个数据量是 700 倍的差距。

而 Disruptor 作为一个环形队列,再对象没有被覆盖之前是一直存在的。

我也做了一个实验,证明确实如此。

一次内存溢出排查优化实战

我设置队列大小为 8 ,从 0~9 往里面写 10 条数据,当写到 8 的时候就会把之前 0 的位置覆盖掉,后面的以此类推(类似于 HashMap 的取模定位)。

所以在生产上假设我们的队列大小是 1024,那么随着系统的运行最终肯定会导致 1024 个位置上装满了对象,而且每个位置是 700 个!

于是查看了生产上 Disruptor 的 RingBuffer 配置,结果是:1024*1024。

这个数量级就非常吓人了。

为了验证是否是这个问题,我在本地将该值换为 2 ,一个最小值试试。

同样的 128M 内存,也是通过 Kafka 一直源源不断的取出数据。通过监控如下:

一次内存溢出排查优化实战

跑了 20 几分钟系统一切正常,每当一次 GC 都能回收大部分内存,最终呈现锯齿状。

这样问题就找到了,不过生产上这个值具体设置多少还得根据业务情况测试才能知道,但原有的 1024*1024 是绝对不能再使用了。

实战内存溢出异常

大家好,相信大部分Javaer在code时经常会遇到本地代码运行正常,但在生产环境偶尔会莫名其妙的报一些关于内存的异常,StackOverFlowError,OutOfMemoryError异常是最常见的。今天就基于上篇文章JVM系列之Java内存结构详解讲解的各个内存区域重点实战分析下内存溢出的情况。在此之前,我还是想多余累赘一些其他关于对象的问题,具体内容如下:

文章结构

对象的创建过程 对象的内存布局 对象的访问定位 实战内存异常

1 . 对象的创建过程

关于对象的创建,第一反应是new关键字,那么本文就主要讲解new关键字创建对象的过程。

ini复制代码Student stu =new Student("张三","18");
           

就拿上面这句代码来说,虚拟机首先会去检查Student这个类有没有被加载,如果没有,首先去加载这个类到方法区,然后根据加载的Class类对象创建stu实例对象,需要注意的是,stu对象所需的内存大小在Student类加载完成后便可完全确定。内存分配完成后,虚拟机需要将分配到的内存空间的实例数据部分初始化为零值,这也就是为什么我们在编写Java代码时创建一个变量不需要初始化。紧接着,虚拟机会对对象的对象头进行必要的设置,如这个对象属于哪个类,如何找到类的元数据(Class对象),对象的锁信息,GC分代年龄等。设置完对象头信息后,调用类的构造函数。

其实讲实话,虚拟机创建对象的过程远不止这么简单,我这里只是把大致的脉络讲解了一下,方便大家理解。

2 . 对象的内存布局

刚刚提到的实例数据,对象头,有些小伙伴也许有点陌生,这一小节就详细讲解一下对象的内存布局,对象创建完成后大致可以分为以下几个部分:

  • 对象头
  • 实例数据
  • 对齐填充

对象头: 对象头中包含了对象运行时一些必要的信息,如GC分代信息,锁信息,哈希码,指向Class类元信息的指针等,其中对Javaer比较有用的是锁信息与指向Class对象的指针,关于锁信息,后期有机会讲解并发编程JUC时再扩展,关于指向Class对象的指针其实很好理解。比如上面那个Student的例子,当我们拿到stu对象时,调用Class stuClass=stu.getClass();的时候,其实就是根据这个指针去拿到了stu对象所属的Student类在方法区存放的Class类对象。虽然说的有点拗口,但这句话我反复琢磨了好几遍,应该是说清楚了。^_^

实例数据:实例数据部分是对象真正存储的有效信息,就是程序代码中所定义的各种类型的字段内容。

对齐填充:虚拟机规范要求对象大小必须是8字节的整数倍。对齐填充其实就是来补全对象大小的。

3 . 对象的访问定位

谈到对象的访问,还拿上面学生的例子来说,当我们拿到stu对象时,直接调用stu.getName();时,其实就完成了对对象的访问。但这里要累赘说一下的是,stu虽然通常被认为是一个对象,其实准确来说是不准确的,stu只是一个变量,变量里存储的是指向对象的指针,(如果干过C或者C++的小伙伴应该比较清楚指针这个概念),当我们调用stu.getName()时,虚拟机会根据指针找到堆里面的对象然后拿到实例数据name.需要注意的是,当我们调用stu.getClass()时,虚拟机会首先根据stu指针定位到堆里面的对象,然后根据对象头里面存储的指向Class类元信息的指针再次到方法区拿到Class对象,进行了两次指针寻找。具体讲解图如下:

一次内存溢出排查优化实战

4 .实战内存异常

内存异常是我们工作当中经常会遇到问题,但如果仅仅会通过加大内存参数来解决问题显然是不够的,应该通过一定的手段定位问题,到底是因为参数问题,还是程序问题(无限创建,内存泄露)。定位问题后才能采取合适的解决方案,而不是一内存溢出就查找相关参数加大。

概念

内存泄露:代码中的某个对象本应该被虚拟机回收,但因为拥有GCRoot引用而没有被回收。关于GCRoot概念,下一篇文章讲解。 内存溢出: 虚拟机由于堆中拥有太多不可回收对象没有回收,导致无法继续创建新对象。

在分析问题之前先给大家讲一讲排查内存溢出问题的方法,内存溢出时JVM虚拟机会退出,那么我们怎么知道JVM运行时的各种信息呢,Dump机制会帮助我们,可以通过加上VM参数-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时生成dump文件,然后通过外部工具(作者使用的是VisualVM)来具体分析异常的原因。

下面从以下几个方面来配合代码实战演示内存溢出及如何定位:

  • Java堆内存异常
  • Java栈内存异常
  • 方法区内存异常

Java堆内存异常

arduino复制代码/**
    VM Args:
    //这两个参数保证了堆中的可分配内存固定为20M
    -Xms20m
    -Xmx20m  
    //文件生成的位置,作则生成在桌面的一个目录
    -XX:+HeapDumpOnOutOfMemoryError //文件生成的位置,作则生成在桌面的一个目录
    //文件生成的位置,作则生成在桌面的一个目录
    -XX:HeapDumpPath=/Users/zdy/Desktop/dump/ 
 */
public class HeapOOM {
    //创建一个内部类用于创建对象使用
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        //无限创建对象,在堆中
        while (true) {
            list.add(new OOMObject());
        }
    }
}
           

Run起来代码后爆出异常如下:

java.lang.OutOfMemoryError: Java heap space

Dumping heap to /Users/zdy/Desktop/dump/java_pid1099.hprof ...

可以看到生成了dump文件到指定目录。并且爆出了OutOfMemoryError,还告诉了你是哪一片区域出的问题:heap space

打开VisualVM工具导入对应的heapDump文件(如何使用请读者自行查阅相关资料),相应的说明见图:

一次内存溢出排查优化实战

"类标签"

切换到"实例数"标签页

一次内存溢出排查优化实战

"实例数标签"

分析dump文件后,我们可以知道,OOMObject这个类创建了810326个实例。所以它能不溢出吗?接下来就在代码里找这个类在哪new的。排查问题。(我们的样例代码就不用排查了,While循环太凶猛了)

Java栈内存异常

老实说,在栈中出现异常(StackOverFlowError)的概率小到和去苹果专卖店买手机,买回来后发现是Android系统的概率是一样的。因为作者确实没有在生产环境中遇到过,除了自己作死写样例代码测试。先说一下异常出现的情况,前面讲到过,方法调用的过程就是方法帧进虚拟机栈和出虚拟机栈的过程,那么有两种情况可以导致StackOverFlowError,当一个方法帧(比如需要2M内存)进入到虚拟机栈(比如还剩下1M内存)的时候,就会报出StackOverFlow.这里先说一个概念,栈深度:指目前虚拟机栈中没有出栈的方法帧。虚拟机栈容量通过参数-Xss来控制,下面通过一段代码,把栈容量人为的调小一点,然后通过递归调用触发异常。

csharp复制代码/**
 * VM Args:
    //设置栈容量为160K,默认1M
   -Xss160k
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        //递归调用,触发异常
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}
           

结果如下:

stack length:751

Exception in thread "main" java.lang.StackOverflowError

可以看到,递归调用了751次,栈容量不够用了。

默认的栈容量在正常的方法调用时,栈深度可以达到1000-2000深度,所以,一般的递归是可以承受的住的。如果你的代码出现了StackOverflowError,首先检查代码,而不是改参数。

这里顺带提一下,很多人在做多线程开发时,当创建很多线程时,容易出现OOM(OutOfMemoryError),这时可以通过具体情况,减少最大堆容量,或者栈容量来解决问题,这是为什么呢。请看下面的公式:

线程数*(最大栈容量)+最大堆值+其他内存(忽略不计或者一般不改动)=机器最大内存

当线程数比较多时,且无法通过业务上削减线程数,那么再不换机器的情况下,你只能把最大栈容量设置小一点,或者把最大堆值设置小一点。

方法区内存异常

写到这里时,作者本来想写一个无限创建动态代理对象的例子来演示方法区溢出,避开谈论JDK7与JDK8的内存区域变更的过渡,但细想一想,还是把这一块从始致终的说清楚。在上一篇文章中JVM系列之Java内存结构详解讲到方法区时提到,JDK7环境下方法区包括了(运行时常量池),其实这么说是不准确的。因为从JDK7开始,HotSpot团队就想到开始去"永久代",大家首先明确一个概念,方法区和"永久代"(PermGen space)是两个概念,方法区是JVM虚拟机规范,任何虚拟机实现(J9等)都不能少这个区间,而"永久代"只是HotSpot对方法区的一个实现。为了把知识点列清楚,我还是才用列表的形式:

  • JDK7之前(包括JDK7)拥有"永久代"(PermGen space),用来实现方法区。但在JDK7中已经逐渐在实现中把永久代中把很多东西移了出来,比如:符号引用(Symbols)转移到了native heap,运行时常量池(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap.

    所以这就是为什么我说上一篇文章中说方法区中包含运行时常量池是不正确的,因为已经移动到了java heap;

  • 在JDK7之前(包括7)可以通过-XX:PermSize -XX:MaxPermSize来控制永久代的大小.
  • JDK8正式去除"永久代",换成Metaspace(元空间)作为JVM虚拟机规范中方法区的实现。
  • 元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但仍可以通过参数控制:-XX:MetaspaceSize与-XX:MaxMetaspaceSize来控制大小。

下面作者还是通过一段代码,来不停的创建Class对象,在JDK8中可以看到metaSpace内存溢出:

typescript复制代码/**
  作者准备在JDK8下测试方法区,所以设置了Metaspace的大小为固定的8M
 -XX:MetaspaceSize=8m
 -XX:MaxMetaspaceSize=8m
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            //无限创建动态代理,生成Class对象
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}
           

在JDK8的环境下将报出异常:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

这是因为在调用CGLib的创建代理时会生成动态代理类,即Class对象到Metaspace,所以While一下就出异常了。

提醒一下:虽然我们日常叫"堆Dump",但是dump技术不仅仅是对于"堆"区域才有效,而是针对OOM的,也就是说不管什么区域,凡是能够报出OOM错误的,都可以使用dump技术生成dump文件来分析。

在经常动态生成大量Class的应用中,需要特别注意类的回收状况,这类场景除了例子中的CGLib技术,常见的还有,大量JSP,反射,OSGI等。需要特别注意,当出现此类异常,应该知道是哪里出了问题,然后看是调整参数,还是在代码层面优化。

#总结

虽然到了最后也就改了一行代码(还没改,直接修改配置),但这排查过程我觉得是有意义的。

也会让大部分觉得 JVM 这样的黑盒难以下手的同学有一个直观的感受。

同时也得感叹 Disruptor 东西虽好,也不能乱用哦!

相关演示代码查看:

https://github.com/crossoverJie/JCSprout/tree/master/src/main/java/com/crossoverjie/disruptoropen in new window

你的点赞与转发是最大的支持。

原文链接:https://crossoverjie.top/2018/08/29/java-senior/OOM-Disruptor/

继续阅读