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的状态图:
Gc回收状态图,可以看到圈红的地方是老年代的占用,oc是分配的内存,ou是使用的内存,还远没有达到内存的的设置阈值。也可以查看更加详细的gc信息,由于内存并未使用完全,因此回收细节就不再关注了。查看了gc日志,断定服务重启问题并不是堆内存不足导致的。
为了验证我的猜想,我快速重启服务之后监控内存增长并定时dump内存快照,将内存dump下来之后(jmap -dump:format=b,file=/home/ssd/log/Scheduler/java.dump pid)使用mat分析,发现内存占用只用了50MB多,这个这个时候怀疑是堆外内存发生了溢出,于是就对堆外内存进行监控。如图为服务重启前的内存快照,堆内存设置的1.5g,服务重启内存的占用不到60MB:
到此为止已经确认是堆外内存造成的,开始监控堆外内存占用。首先对vm参数添加-XX:NativeMemoryTracking=detail,表示详细输出堆外内存占用,输出指令jcmd ProsessPID VM.native_memory summary scale=MB。可以使用cron定时任务将输出的结果输出到对应的文件中,我是使用的Java定时任务将结果输出到指定文件中分析,当然也可以肉眼观察变化,使用Runtime调用服务指令输出信息到文件。如图为其中一个时刻的堆外内存占用图,很明显看到Thread占用异常,线程数占用到达了18610个,并且还在不断增长。
发现线程数量异常,因此判断内存溢出极有可能是线程数增加导致内存溢出,下一步就是找出产生异常线程的原因,通过堆栈信息发现在内存溢出之前有大量的线程状态都是wating状态,并且等待的都是同一把锁,迅速判断这些线程都是什么线程,最后分析发现都是同一类线程,这些线程都有一个特点:
这些线程等待的都是同一把锁、而且都是同一个线程池产生的线程(这里还是吐槽一下之前的同事,为啥线程不加名字。。。哈哈,谁让他们增加了我的排查难度。)。根据堆栈信息找到了锁的位置,发现是心跳处理线程。线程不断增长的原因是服务之间存在超时处理,由于我们服务是一个管理节点,有服务注册中心功能,其它服务注册进来的心跳超时时间是2s,注册服务在2s之内没有收到心跳信息就会再次发出心跳请求,由于业务原因心跳必须一直保持,如此不断反复就导致了线程数量的增长。而在高峰期的时候由于访问量增大,心跳处理业务增加,对于锁的占用时间拉长,导致超时心跳增加,如此恶性循环。
首先解决心跳线程无限增长问题,心跳是通过thrift传输的,我们这边是服务端,我们限制了服务端得到线程池大小,保证线程数量在可控范围内;其次解决锁的问题,解决方案就是去锁操作,锁的对象是其他服务的运行信息,采用的list结构,后来决定采用map结构,在不修改数据的情况下直接从内存中获取对象信息;最后就是解决耗时多的问题,这里没有更好的办法,就只能是每个处理逻辑的地方都加打印,找出耗时的地方,然后一个一个地优化,优化之后一个心跳的处理耗时减少到50ms以内,大大小于2s的超时设置。
在解决这些问题值之后最内存的大小也做了一些调优,服务运行到现在就再也没有发生重启的问题了。
现场问题2
这个现场问题比较简单,只是堆内存溢出,完全就是内存不够用导致的,在这里也总结一下排查思路。
这个集群的规模是比较小的规模,服务的内存设置是1g,由于我们的业务模式的原因导致每次访问的数据的大小未做限制,为了保证处理速度我们会对消息进行缓存,在处理的时候全部都是内存处理,原来是按照5k报文和20000条消息的缓存设置的内存大小。一开始用户也是按照这种限制来操作的,后来用户对消息进行打包处理,每个请求由原来的1个报文合并到200个报文大小,直接导致原来设置的内存不够用,导致的内存不足溢出。
下面来看下排查过程,线上环境一直都没有出现过问题,突然间就出现了内存溢出的现象,而且是频繁的发生,几乎是1分钟内就会发生一次,现场业务全部暂停,现场技术支持表示没有任何改动。在接到这个问题之后第一时间就是观察线上环境,使用jstat –gc pid 5000也是就是每5s输出一次内存回收信息,如图1所示:
oc表示老年代的内存大小,ou表示老年代使用的内存大小,可以看出老年代已经满了,此时可以确认内存不足导致,因此将内存dump下来进行分析,查看是什么占用了大量的内存。如图2所示,使用mat进行分析:
经过分析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即可。输出的数据如下图:
可以看到整个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位优化前后面积图,横坐标表示用例,纵坐标表示耗时,单位ms。
具体修改点是将报文组装进行合并,在入库的时候有原来的单个入库改为批量入库的方式。
1.2 内存测试
1.2.1 内存占用测试
内存测试主要关注的是两个方面,一个是内存的限制大小和内存占用的情况;以上测试都是正向测试,采用的是模拟节点消费任务,在调度占用内存中,占用最大的还是任务节点的占用,因此,首先的测试维度就是报文大小对内存占用的影响。
表2是在保留20000条任务的内存占用情况:
图1是内存占用的曲线图,在实际的内存占用中Java内存的对象转换和内存对齐等原因会有一定的范围波动,总的来说有一种线性关系,也就是说内存占用跟任务的报文大小有直接关系。报文对应内存折线图:固定变量:内存保留20000条数据,内存设置大小1.5G;近视关系如下:
y=38x+39
x:报文大小;
y:占用内存
图2 报文大小和内存占用的关系
队列大小和内存的关系也是一种线性关系,表3和图3位队列大小对内存占用的影响。
表3 队列大小对内存内存的影响
图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之间的关系图
参考:
《深入理解Java虚拟机:Java高级特性与实战》
《Java虚拟机实战》