java 内存 管理_Java内存管理实战

原标题:Java内存管理实战

感谢Hadoop系,最近项目中Java用得越来越多,兄弟们开发起来糙快猛很爽,但一到性能测试阶段,数据量&并发一上去,系统响应各种变慢、线程死锁hung住甚至直接崩溃。赶紧补补课,理论指导实践解决问题。

内存溢出

首先是比较”常见”的内存溢出,先解决两个小问题热热身:

1. 项目H代码量逐渐丰满,某天启动时果断抛出OutOfMemoryError

2. RD自己的单测一路畅通,QA构造的复杂测试case果断报StackOverflowError

理论学习

简单复习一下,虚拟机运行时管理的内存分为图中这几部分

84369ac095c49e236dd0687c72658d3a.png

程序计数器:存储当前线程执行字节码指令地址。谢天谢地,这部分不会OOM

Stack栈:分为虚拟机栈和本地方法栈(HotSpot中这两是一体的),存储编译期就确定好的局部变量,包括基本数据类型和引用,还有方法返回地址等。这部分如果栈深度溢出,会抛出StackOverflowError。理论上,无法申请到足够内存时,也会抛出OutOfMemoryError。64位jdk7下默认大小为1024字节。

Heap堆:对象都分配在堆上,这部分是垃圾回收管理的主要区域。

Method Area方法区:在HostSpot中用Perm永久代来实现,存储虚拟机加载的class信息,还有常量和静态变量等。

Direct Memory直接内存:不在JVM中,Nio中的DirectByteBuffer对象通过引用Native堆中内存,避免Java堆和Native堆内存复制的方式来提升性能。如果这部分内存加上Java堆内存大于服务器物理内存限制,也会导致JVM出现OOM。

栈、堆和方法区的逻辑关系如下:

bf5ebd63db44ae3693d12946e4e0a44c.png

问题解决

先看问题1,启动错误日志很明了

根据理论学习Perm永久代爆掉了,初步分析原因,P同学使用了强大的CGLib做类增强,动态生成的类略多。修改启动脚本增大PermSize后恢复正常

再来看问题2,相对来讲,StackOverflowError的问题比较好定位,因为异常日志中记录了详细的方法调用栈信息。原来是某解析方法中递归调用层次太深(递归调用数千次)溢出。QA构造的复杂测试输入条件触发了这一潜在bug。把实现方式从递归修改为迭代,问题解决。

ca704d387335580738f3297d8550be9f.png

垃圾回收 问题

项目H的一些特性吸引了隔壁部门的兄弟,要求使用的需求很强烈。首先面临的问题是隔壁部门主推C++,所以我们使用thrift提供跨语言服务。ThriftServer中转层在QPS20000+的访问量下会OOM。

项目D中使用SolrCloud,在高并发性能测试环节,大部分情况下性能良好,每隔一段时间部分请求出现性能严重下降。理论学习 垃圾回收过程

以为起始点搜索引用链,不可达的对象标记为需要回收。常见的GC Roots有栈中局部变量、方法区中静态变量引用的对象等。

如果对象首次不可达并且其finalize方法被覆写,对象会被放进F-Queue队列中,由JVM中低优先级的finalizer线程来调用对象的finalize方法后再判断是否需要回收。其他情况下,对象会直接被GC过程回收。

最基础的回收算法采用Mark-Sweep标记-清理过程,标记阶段标记需要回收的对象,清理阶段进行统一回收。缺点是标记清理之后内存存在碎片,不利于后续分配较大对象。针对Mark-Sweep算法的不足,Mark-Compact算法标记阶段与Mark-Sweep保持一致,清理阶段移动存活对象,进行碎片整理。

Copying复制算法的基本思路是将内存划分成大小相等的两块,当其中一块用完之后,将存活对象复制到另外一块。缺点是可用内存变为原来的一半,而且如果垃圾回收是对象存活率高,复制的成本相应也很高。

分代收集:Hotspot根据对象存活周期将堆划分为新生代和老年代。新生代对象存活周期短,垃圾回收使用复制算法,针对复制算法空间利用率不足的问题,又将新生代细分为一个Eden和两个Survivor区,Eden区和Survivor默认大小比例是8:1,内存分配时使用Eden区和一个Survivor区,垃圾回收时复制到另外一个Survivor区,将空间利用率提升到90%(如果Survivor区空间不够,存活对象通过分配担保机制进入老年代)。而老年代对象存活率高,垃圾回收使用标记-清理或者标记-整理算法,一般来讲,老年代GC要比较新生代GC慢10倍以上。常用垃圾回收器

df1489d4b7022e07f1c70e094fd0ed94.png

几点说明:

1. 横线之上为新生代垃圾回收器,之下为老年代垃圾回收器,之间的连线表示他们可以配合使用。

2. Serial代表单线程,在新生代复制和老年代标记-清理过程中都是单线程运作,特点是简单高效,常用于客户端模式下桌面应用,几百兆的内存新生代回收几十毫秒的停顿基本用户是无感知的。Par(Parallel)通过多线程回收来缩短停顿时间,适应于多CPU服务器环境。

3. CMS代表Concurrent Sweep,真正实现了应用线程和垃圾回收线程并发运行,特点是停顿时间最短,是现在强调低延迟响应的互联网应用中最常用的老年代回收器。有这么几个特点:

- 分为initial mark(初始标记,找出GC Root直接关联对象,耗时很短)–>concurrent mark(并发标记,进行引用链搜索耗时很长)–>remark(重新标记,修正并发标记期间用户线程并发运行导致的)–>concurrent sweep(并发清理)四个阶段,在最耗时的并发标记和并发清理阶段都实现了和应用线程的并发。

- 缺点1:CPU占用率略高。默认回收线程为(CPU+3)/4,所以更适合于CPU个数4以上的服务器环境。

- 缺点2:回收过程中应用线程还在运行,会产生新的垃圾,所以需要预留足够空间。一般应用会通过适当调高 -XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,但是如果预留内存不够,会出现“Concurrent Mode Failure”失败,导致虚拟机启动Serial Old回收器再次进行老年代垃圾回收。

4. G1次时代回收器,略高级,目前JDK7生产环境下应用不多,下回切到JDK8再研究。

工具篇 GC日志

在java启动命令中添加以下参数,可以输出更为详细的gc日志

下面具体gc日志还是比较好理解的,要关注的有几点:

-XX:+PrintHeapAtGC选项用于输出gc前后堆内存具体信息,可以看出本次gc回收eden区内存效果很明显。因为使用了ParNew和CMS垃圾回收器的组合,所以日志中带有par new和concurrent mark-sweep generation字样。

每次GC都统计了用户cpu时间(0.31s)、系统cpu时间(0.05s)和实际耗时(0.05s)。因为多cpu的缘故,实际耗时反倒小于cpu时间。整个GC耗时50ms,对于3g左右的新生代性能还算凑合。

7edc0e6c2e1ee464027142b32ef115d2.png命令行工具

JDK自带了一系列非常有用的命令行工具。

通过jps查看java进程:

46505ab671547311e9f1164fa6833fb6.png

通过jstat查看jvm运行统计状态,比如下面通过gcutil选项可以得知虚拟机中内存占用比率(老年代O 45.30%)、gc次数(新生代3605次)和gc耗时(135s)等,性能还算能接受。

f854f2dd6f365ea23dbd1e02a2c6ddd4.png

使用jps -v查看用户启动java程序时显示指定的参数;使用jinfo查看和调整各种参数,包括默认参数;使用jmap dump堆;使用jstack查看线程快照等。

可视化工具

jdk还自带jconsole和jvisualvm两大杀器。jconsole基于jmx进行监控和管理,已经很好用了;jvisualvm比jconsole要新一些,功能也更强大,不仅包含了大部分命令行工具具备的功能,还能通过插件机制扩展性能,比如profiing性能分析等。不多说截张图,图中jvisualvm使用Visual GC插件,新生代锯齿形(内存占用缓慢上升,回收干脆)说明GC情况还是比较理想的。。

61448b127fcfdbc00b48b42413784c66.png

问题解决

在项目H中,客户端请求并发比较高,达到2000+,服务端线程飙升,所以先把ThriftServer类型改为TThreadedSelectorServer,以select方式处理IO请求,超出处理能力的请求会被等待,OOM现象有一定缓解,但依然会出现。进一步分析,因为ThriftServer仅仅做服务转发,高压力下产生的大量对象都需要被回收掉,我们期望这些临时对象都在新生代就被回收掉,所以我们适当调大了新生代大小,再根据每次新生代GC前后内存占用情况,适当调小了SurvivorRatio参数,最终服务变得稳定起来。

项目D中的这种性能间歇性下降,怀疑是GC导致。查看solr的GC日志,果然发现后台频繁触发full gc,每次full gc停顿2s以上,而且和前台并发查询性能下降时间点吻合。按照Solr官方手册推荐,我们将JVM垃圾回收起调整为ParNew+CMS组合,并根据我们的数据量和查询场景,调整了相关参数:

e5d743a55ecd546bf86b49c73130dcd4.png

调整完发现垃圾回收效果不明显,gc停顿的问题也没有明显好转。使用jvisualvm查看SolrCloud的堆增长曲线,很直很陡,3G的新生代和12G的整个堆空间很快就接近满载。使用内存抽样,结果也很抽象,占用比例最高的是byte[],而且查询并发上去之后,内存突增非常明显,有点诡异,因为我们的查询场景并不复杂,而且大部分情况下返回结果只有几十条。是不是什么地方存在内存泄露?

跟踪一下代码发现一个比较有意思地方:SolrCloud分布式搜索中结果由各个Shard分片结果合并排序生成,在QueryComponent中会初始化一个优先级队列用于排序,队列的大小等于用户指定的起始偏移量offset+返回结果rows(而不是实际的查询结果数量)。

a26eb40ca2cba0056ab047c20458cf5b.png

而在我们项目代码中,在用户没有指定返回结果数量时,使用默认值Integer.MAX_VALUE,这个时候即使后台查询结果只有1条,QueryComponent也会老老实实初始化一个巨大的优先级队列,占用很大的内存空间。

cec2d4ab19f25850c357b237b2a33ebb.png

在请求压力小的时候,这部分浪费的内存会被GC回收掉没有什么问题;在请求压力大时,内存回收的速度越来越跟不上内存挥霍速度,不得不每隔一段停下来进行full gc。

那么,在不知道Solr查询结果数量时,如何更有效率的一次性返回所有结果呢?解决方案可以参考

参考资料

打完收工,特别推荐周老师的书,深入浅出。

《深入理解Java虚拟机:JVM高级特性与最佳实践》周志明

《Java Virtual Machine Specification (Java SE 7)》

责任编辑:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值