线上堆内存溢出和堆外内存溢出问题的解决和思考

1.前言

由于项目的人员变动,接手了现在的项目,项目前期的部署和试点运营都没有出现什么重大问题,就在年初的项目全部铺开的时候,由于并发量上来之后出现了频繁的服务重启的现象。后面经过排查发现是内存发生溢出导致的服务隔段时间就会重启。

在讲内存溢出之前先讲回顾一些Java内存结构:

    1)程序计数器:当前字节码所执行的字节码的行号指示器;

    2)Java虚拟机栈:线程私有,用于存储局部变量、操作栈、动态链接、方法出口等信息;

    3)本地方法栈:与虚拟机栈的区别就是虚拟机栈运行的是Java方法,本地方法栈运行的是本地方法;

    4)Java堆:存储Java对象实例;

    5)方法区:线程共享,存储已经被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据;

    6)运行常量池:方法区的一部分,用于存放编译期生成的各种字面量和符号引用。

    7)直接内存:直接内存的分配不会受到Java堆的限制,在Java1.4之后引入的一种基于通道和缓冲的方式,它可以使用Native方法分配堆外内存。

现场问题1

问题现象:

现场正常运行一段时间之后服务就会发生重启,并且发生的时候总是在访问高峰期,一开始怀疑是内存设置太小(事实证明内存也确实设置的小了),在调整内存之后还是会发生服务重启的现象。

排查思路:

这个时候意识到可能是内存发生了溢出,于是增加gc日志(在虚拟机启动参数中添加-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:home/ssd/log/gc.log -XX:GCLogFileSize=1024M参数),运行一段时间服务发生重启,查看gc回收日志。gc日志在发生服务重启的时候回收也是正常的,设置的内存并没有消耗完全。为了快速出现内存溢出的现象,我们把内存调整为1.5g,采用默认的参数设置。

    如图是发生内存溢出gc的状态图:
image

Gc回收状态图,可以看到圈红的地方是老年代的占用,oc是分配的内存,ou是使用的内存,还远没有达到内存的的设置阈值。也可以查看更加详细的gc信息,由于内存并未使用完全,因此回收细节就不再关注了。查看了gc日志,断定服务重启问题并不是堆内存不足导致的。

为了验证我的猜想,我快速重启服务之后监控内存增长并定时dump内存快照,将内存dump下来之后(jmap -dump:format=b,file=/home/ssd/log/Scheduler/java.dump pid)使用mat分析,发现内存占用只用了50MB多,这个这个时候怀疑是堆外内存发生了溢出,于是就对堆外内存进行监控。如图为服务重启前的内存快照,堆内存设置的1.5g,服务重启内存的占用不到60MB:

image

到此为止已经确认是堆外内存造成的,开始监控堆外内存占用。首先对vm参数添加-XX:NativeMemoryTracking=detail,表示详细输出堆外内存占用,输出指令jcmd ProsessPID VM.native_memory summary scale=MB。可以使用cron定时任务将输出的结果输出到对应的文件中,我是使用的Java定时任务将结果输出到指定文件中分析,当然也可以肉眼观察变化,使用Runtime调用服务指令输出信息到文件。如图为其中一个时刻的堆外内存占用图,很明显看到Thread占用异常,线程数占用到达了18610个,并且还在不断增长。

image

发现线程数量异常,因此判断内存溢出极有可能是线程数增加导致内存溢出,下一步就是找出产生异常线程的原因,通过堆栈信息发现在内存溢出之前有大量的线程状态都是wating状态,并且等待的都是同一把锁,迅速判断这些线程都是什么线程,最后分析发现都是同一类线程,这些线程都有一个特点:

image

这些线程等待的都是同一把锁、而且都是同一个线程池产生的线程(这里还是吐槽一下之前的同事,为啥线程不加名字。。。哈哈,谁让他们增加了我的排查难度。)。根据堆栈信息找到了锁的位置,发现是心跳处理线程。线程不断增长的原因是服务之间存在超时处理,由于我们服务是一个管理节点,有服务注册中心功能,其它服务注册进来的心跳超时时间是2s,注册服务在2s之内没有收到心跳信息就会再次发出心跳请求,由于业务原因心跳必须一直保持,如此不断反复就导致了线程数量的增长。而在高峰期的时候由于访问量增大,心跳处理业务增加,对于锁的占用时间拉长,导致超时心跳增加,如此恶性循环。

首先解决心跳线程无限增长问题,心跳是通过thrift传输的,我们这边是服务端,我们限制了服务端得到线程池大小,保证线程数量在可控范围内;其次解决锁的问题,解决方案就是去锁操作,锁的对象是其他服务的运行信息,采用的list结构,后来决定采用map结构,在不修改数据的情况下直接从内存中获取对象信息;最后就是解决耗时多的问题,这里没有更好的办法,就只能是每个处理逻辑的地方都加打印,找出耗时的地方,然后一个一个地优化,优化之后一个心跳的处理耗时减少到50ms以内,大大小于2s的超时设置。

在解决这些问题值之后最内存的大小也做了一些调优,服务运行到现在就再也没有发生重启的问题了。

现场问题2

这个现场问题比较简单,只是堆内存溢出,完全就是内存不够用导致的,在这里也总结一下排查思路。

这个集群的规模是比较小的规模,服务的内存设置是1g,由于我们的业务模式的原因导致每次访问的数据的大小未做限制,为了保证处理速度我们会对消息进行缓存,在处理的时候全部都是内存处理,原来是按照5k报文和20000条消息的缓存设置的内存大小。一开始用户也是按照这种限制来操作的,后来用户对消息进行打包处理,每个请求由原来的1个报文合并到200个报文大小,直接导致原来设置的内存不够用,导致的内存不足溢出。

下面来看下排查过程,线上环境一直都没有出现过问题,突然间就出现了内存溢出的现象,而且是频繁的发生,几乎是1分钟内就会发生一次,现场业务全部暂停,现场技术支持表示没有任何改动。在接到这个问题之后第一时间就是观察线上环境,使用jstat –gc pid 5000也是就是每5s输出一次内存回收信息,如图1所示:

image

oc表示老年代的内存大小,ou表示老年代使用的内存大小,可以看出老年代已经满了,此时可以确认内存不足导致,因此将内存dump下来进行分析,查看是什么占用了大量的内存。如图2所示,使用mat进行分析:

image

经过分析a占用了大部分内存,而这部分正是我们缓存的消息数据。正好接近10000条消息,因此我们怀疑是消息的数据太大导致的内存不足。

为了验证我的猜想我在代码中增加了对象大小的输出RamUsageEstimator.sizeOf(),替换上去运行一段时间之后发现有大量91032字节的对象,此时就可以完全判断是由于对象太大导致的内存溢出,在不影响运行规格的情况下我们限制了缓存的大小为5000条,并且对报文大小进行了控制,到此问题就算是完全解决了。

Java内存思考

经过上面的两个问题,我重新思考了一下程序的内存设置和一些边界问题。

这里将Java内存分为堆内存(heap)和堆外内存两个部分。堆内存是在虚拟机启动的时候设置的虚拟机参数,因此这部分内存我们是很容器控制的,那么堆外内存对于我们来说就相对更加隐蔽一些,如果不是一些内存限制严格或者堆外内存溢出的情况发生,很难注意到这部分内存。那么什么是堆外内存呢?

与堆内存对应,堆外内存就是内存对象分配在Java虚拟机的堆以外的内存,这些内存是直接从操作系统申请的。如我们经常使用的java.nio.DirectByteBuffer类就是使用堆外内存管理的,一些通信框架的内存如netty也存在的堆外内存;垃圾回收使用的一部分内存也属于堆外内存;线程占用的内存;即时编译器编译之后的字节码等都属于堆外内存管理。对于堆外内存的监控可以使用vm参数,-XX:NativeMemoryTracking=detail,

输入指令:

jcmd ProsessPID VM.native_memory summary scale=MB即可。输出的数据如下图:

image

可以看到整个memory主要包含了Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other、Symbol、Native Memory Tracking、Arena Chunk这几部分;

其中reserved表示应用可用的内存大小,committed表示应用正在使用的内存大小

--Java Heap部分表示heap内存目前占用了1536MB;

--Class部分表示已经加载的classes个数为7472,其metadata占用了70MB;

--Thread部分表示目前有137个线程,占用了137MB;

--Code部分表示JIT生成的或者缓存的instructions占用了42MB;

--GC部分表示目前已经占用了83MB的内存空间用于帮助GC;

--Internal部分表示命令行解析、JVMTI等占用了116MB;

--Symbol部分表示诸如string table及constant pool等symbol占用了9MB;

--Native Memory Tracking部分表示该功能自身占用了5MB;

Arena Chunk部分表示arena chunk占用了63MB,一个arena表示使用malloc分配的一个memory chunk,这些chunks可以被其他subsystems做为临时内存使用,比如pre-thread的内存分配,它的内存释放是成bulk的。commit是实际使用的内存大小。

堆内存溢出问题比较容易排查,直接查看堆快照,找出占用内存对象,很容易就可以找出溢出的原因;堆外内存溢出问题比较难排查。

一个Java对象到底占用多大内存

上面讲到一个内存计算组件RamUsageEstimator.sizeOf(),通过计算可以输出单个对象占用的内存大小,也可以采用openjdk的jol或者Instrumentation,那么一个Java对象占用的内存大小是多少呢?Java对象内存构成是有三部分组成:

1)对象头

Java对象头由两部分组成:一部分是Java对象运行的数据(mark word),比如hashCode、gc分带年龄、锁状态标志等,这部分占用的内存在32位虚拟机和64位虚拟机的大小分别是4B和8B;另一部分是Java对象指针(kclass),Java虚拟机通过这个指针判断这个对象是哪个类的实例,如果是数组,对象头中还必须有一块表示数组大小的数据,因为Java虚拟机可以直接访问对象元数据指导对象的大小,但是不能访问数组的元数据来计算数组的大小。这部分大小在32位虚拟机和64位虚拟机的大小分别是4B和8B。

另外64位虚拟机中存在指针压缩的问题,在阅读elasticsearch官方文档的时候有提到一个32g的概念,意思就是Java虚拟机的堆内存申请在32g以内可以启动指针压缩达到节省空间的效果,如果超过32g以上的指针压缩就会失效,实际占用的内存就会增多,也就是说超过32g的内存不一定会比32g内存存储更多的对象。

关于指针压缩可以通过-XX:+UseCompressedOops来支持,如果是打开的你们指针就会压缩。

Java指针是用来存放内存地址的,在32位机器上内存是通过一个32位二进制数来存储内存地址,操作系统中1个内存单位长度是1byte=8bit,所以一个指针占用的内存大小就是4个字节,那么64位的二进制数就是8个字节的大小。至于为什么是32g,主要原因是指针存储的不再是内存地址而是内存地址的偏移量,所有在32g之前都是可以用32位指针表示的。

那么在使用指针压缩的情况下,64位的对象头就变成了12字节=Mark Work(8B) + kclass(4B).

2)实例数据:

Java对象实例数据中有两种:一种是8种基本类型;一种实例数据也是对象;

这里的实例变量要与栈中的实例变量进行区分,Java栈里面保存的是局部变量表、动态链接、操作数栈、方法返回地址、附加信息,其中的局部变量表存储的就是局部变量,而如果基本类型是对象 的实例变量则直接存储到对象中,也就是堆内存中,当然静态变量final修饰的变量会存储在方法区。需要注意的是引用类型的大小是4字节。

3)对齐填充:

Java规定内存对象的其实地址必须是8的倍数,因此对象的大小必须是8的倍数,举个例子上面讲的对象头,通过指针压缩之后的大小是12B,按照Java的规范,实际占用的内存就是16B,不够的部分就是进行填充。数组由于存在一个数组大小的计算,比普通对象多出4B,数组的对象头为16B。

4)内存计算实例

 public class ClassDemo {
    private boolean bo;
    private char ch;
    private int in;
    private float fl;
    private long lo;
    private double dou;

    public ClassDemo(boolean bo,char ch,int in,float fl,long lo,double dou) {
        this.bo = bo;
        this.ch = ch;
        this.in = in;
        this.fl = fl;
        this.lo = lo;
        this.dou = dou;
    }
}

内存计算:1B(boolean)+2B(char)+4B(int)+4B(float)+8B(double)+8B(long)+12B(对象头)=39B,再加上对齐的1B为40B,时间监控结果也是符合我们的计算的。
如下结果:

    com.hikvision.cache.ClassDemo object internals:
     OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
          0     4           (object header)                           01 db ec 2d (00000001 11011011 11101100 00101101) (770497281)
          4     4           (object header)                           18 00 00 00 (00011000 00000000 00000000 00000000) (24)
          8     4           (object header)                           18 02 a7 16 (00011000 00000010 10100111 00010110) (380043800)
         12     4       int ClassDemo.in                              2
         16     8      long ClassDemo.lo                              3
         24     8    double ClassDemo.dou                             3.0
         32     4     float ClassDemo.fl                              3.0
         36     2      char ClassDemo.ch                              c
         38     1   boolean ClassDemo.bo                              false
         39     1           (loss due to the next object alignment)
    Instance size: 40 bytes

内存溢出关注点主要有以下几种:

1)堆内存

可以通过-Xmx和-Xms参数控制,如果堆内存中没有完成实例分配,并且堆内存也无法扩展,将会跑出OutOfMemoryError;

2)java虚拟机栈

栈内存溢出有两种情况:如果线程的栈深度大于虚拟机允许的深度,将抛出SrackOverFlowError异常;如果虚拟机可以动态扩展,当扩展无法盛情难到足够的内存是就会抛出StackOverflowError异常。

栈深度——虚拟机栈最多存储的变量和计算结果值占用的深度大小。

3)本地方法

本地方法会抛出StackOverflowError和OutOfMemoryError异常。

4)方法区

当方法区无法分配内存时,将抛出OutOfMemoryError异常。

5)Direct Memory

可以通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOfMemoryError或者OutOfMemoryError:Direct buffer memory

6)Socket 缓存区

java创建socket默认的缓冲区也占用一定内存(recieve和send分别默认37k和25k),当连接数多时无法分配会抛出IOException:Too many open files异常。

上面就是在发生两次内存溢出之后的总结,后面也对边界的一些问题进行了修改以及对之前部分代码进行重构,接口限流,服务降级,线程池优化等。

关于实际项目接口和内存的一些优化和测试

1.1 接口性能测试

针对项目中的实时比对接口的性能较差的问题进行了优化,接口通过批量的方式进行处理,处理之后也是按照批量入库的方式,在相同条件下的测试接口显示,优化后的接口耗时平均比优化之后的1/4的耗时,性能提升大概是在75%左右。以上的测试结果的在正向测试得出的。在模拟的环境下得出的结果,在实际项目中还有待验证,例如在cpu不足的情况下的耗时问题以及在内存不足的情况下的耗时问题,以及在现场的环境下的耗时问题。

下面是按照220个任务打包的测试数据,数据使用从0开始持续派发任务到达数据库限制阈值为止,表1 是原始的数据,图1是两份数据的折线图,实际的比例大概是在1/4;性能提升较为明显:

优化之前:

提交测试用例总的次数:1232次;总耗时:1661407;平均耗时:1348.5ms。

优化之后:

提交测试用例总的次数:1232次;总的耗时:422646;平均耗时:343ms。

                              表1优化前后数据统计
表1.PNG

如图1位优化前后面积图,横坐标表示用例,纵坐标表示耗时,单位ms。


图1.png

具体修改点是将报文组装进行合并,在入库的时候有原来的单个入库改为批量入库的方式。

1.2 内存测试

1.2.1 内存占用测试

内存测试主要关注的是两个方面,一个是内存的限制大小和内存占用的情况;以上测试都是正向测试,采用的是模拟节点消费任务,在调度占用内存中,占用最大的还是任务节点的占用,因此,首先的测试维度就是报文大小对内存占用的影响。

                   表2是在保留20000条任务的内存占用情况:
表2.PNG

图1是内存占用的曲线图,在实际的内存占用中Java内存的对象转换和内存对齐等原因会有一定的范围波动,总的来说有一种线性关系,也就是说内存占用跟任务的报文大小有直接关系。报文对应内存折线图:固定变量:内存保留20000条数据,内存设置大小1.5G;近视关系如下:

y=38x+39

x:报文大小;

y:占用内存

                               图2 报文大小和内存占用的关系
图2.jpg

队列大小和内存的关系也是一种线性关系,表3和图3位队列大小对内存占用的影响。

                             表3 队列大小对内存内存的影响
表3.PNG
                       图3 任务队列大小对内存占用影响

队列大小对内存的影响:固定变量,1.5内存,10k报文大小;

近视关系:

y=0.021z-6

z:任务条数

y:内存占用大小

实际测试中还测试了其它维度得到数据,具体详细数据可以查看测试文档《内存测试》。

以上内存都是针对对内存大小进行测试,测试过程中也针对总的内存进行了监控,具体数据在《内存测试》中都有,可以自行观察,总的来说这部分内存占用变化不大,和内存设置、线程数等有关。

在8g内存情况下,项目分配的内存为4g,依照数据推算出的调度的内存设置大小如下表:

堆内存设置:1.5g;队列大小控制5000条;接口限制5并发(20个任务打包tps超过10000);

1.2.2 内存回收测试

在内存设置的情况下内存回收频率比较高,跟提交的任务的并发有关系;如图5tps越大回收频率越高。具体数据请参考《内存测试》

                   图4 内存每s回收次数和tps之间的关系图
图4.jpg

参考:

《深入理解Java虚拟机:Java高级特性与实战》

《Java虚拟机实战》

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