Java内存管理

内存溢出

理论学习

问题解决

垃圾回收

问题

理论学习

垃圾回收过程

常用垃圾回收器

工具篇

GC日志

命令行工具

可视化工具

问题解决

内存溢出

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

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

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

理论学习

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

这里写图片描述

程序计数器:存储当前线程执行字节码指令地址。谢天谢地,这部分不会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。

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

这里写图片描述

问题解决

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

java.lang.OutOfMemoryError: PermGen space

1

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

-XX:PermSize=256M -XX:MaxPermSize=256M

1

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

Exception in thread "pool-1-thread-6" java.lang.StackOverflowError

at com.baidu.rigel.hina.dws.ss.engin.QueryEngine.visit(QueryEngine.java:108)

at com.baidu.rigel.hina.dws.ss.engin.QueryEngine.visit(QueryEngine.java:108)

at com.baidu.rigel.hina.dws.ss.engin.QueryEngine.visit(QueryEngine.java:108)

at com.baidu.rigel.hina.dws.ss.engin.QueryEngine.visit(QueryEngine.java:108)

……

垃圾回收

问题

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

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

理论学习

垃圾回收过程

以GC Roots为起始点搜索引用链,不可达的对象标记为需要回收。常见的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倍以上。

常用垃圾回收器

这里写图片描述

几点说明:

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日志

-Xloggc:logs/gc.log -XX:+PrintHeapAtGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails

下面具体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左右的新生代性能还算凑合。

2015-08-21T12:04:49.733+0800: 66.180: [GC2015-08-21T12:04:49.733+0800: 66.180: [ParNew: 2097152K->48180K(2621440K), 0.0486990 secs] 2097152K->48180

K(12058624K), 0.0488530 secs] [Times: user=0.31 sys=0.05, real=0.05 secs]

Heap after GC invocations=1 (full 0):

par new generation total 2621440K, used 48180K [0x00000004fae00000, 0x00000005bae00000, 0x00000005bae00000)

eden space 2097152K, 0% used [0x00000004fae00000, 0x00000004fae00000, 0x000000057ae00000)

from space 524288K, 9% used [0x000000059ae00000, 0x000000059dd0d280, 0x00000005bae00000)

to space 524288K, 0% used [0x000000057ae00000, 0x000000057ae00000, 0x000000059ae00000)

concurrent mark-sweep generation total 9437184K, used 0K [0x00000005bae00000, 0x00000007fae00000, 0x00000007fae00000)

concurrent-mark-sweep perm gen total 21248K, used 20983K [0x00000007fae00000, 0x00000007fc2c0000, 0x0000000800000000)

}

{Heap before GC invocations=1 (full 0):

par new generation total 2621440K, used 2145332K [0x00000004fae00000, 0x00000005bae00000, 0x00000005bae00000)

eden space 2097152K, 100% used [0x00000004fae00000, 0x000000057ae00000, 0x000000057ae00000)

from space 524288K, 9% used [0x000000059ae00000, 0x000000059dd0d280, 0x00000005bae00000)

to space 524288K, 0% used [0x000000057ae00000, 0x000000057ae00000, 0x000000059ae00000)

concurrent mark-sweep generation total 9437184K, used 0K [0x00000005bae00000, 0x00000007fae00000, 0x00000007fae00000)

concurrent-mark-sweep perm gen total 22208K, used 22172K [0x00000007fae00000, 0x00000007fc3b0000, 0x0000000800000000)

命令行工具

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

通过jps查看java进程:

[work@nmg01-crm-hina60.nmg01.baidu.com ~]# jps -l

39195 org.apache.hadoop.hbase.regionserver.HRegionServer

33096 sun.tools.jps.Jps

21105 org.apache.hadoop.hdfs.server.datanode.DataNode

57774 org.apache.zookeeper.server.quorum.QuorumPeerMain

36790 start.jar

9761 kafka.Kafka

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

[work@nmg01-crm-hina60.nmg01.baidu.com ~]# jstat -gcutil 36790

S0 S1 E O P YGC YGCT FGC FGCT GCT

0.00 2.97 84.69 45.30 99.48 3605 135.501 0 0.000 135.501

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

可视化工具

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

这里写图片描述

问题解决

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

java_opt="-Xms14192m -Xmx14192m -Xmn8000m -XX:SurvivorRatio=3 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:CMSFullGCsBeforeCompaction=4 -XX:PretenureSizeThreshold=20485760"

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

java_opt="-Xloggc:logs/gc.log -XX:+PrintHeapAtGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xmn3G -Xmx12G-Xms12G -XX:SurvivorRatio=4 -XX:CMSInitiatingOccupancyFraction=60 -XX:+UseParNewGC-XX:+UseConcMarkSweepGC"

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

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

public class QueryComponent extends SearchComponent{
    ……
    private void  mergeIds(ResponseBuilder rb, ShardRequest sreq) {
        // Merge the docs via a priority queue so we don't have to sort *all* of the
        // documents... we only need to order the top (rows+start)
        ShardFieldSortedHitQueue queue;
        queue = new ShardFieldSortedHitQueue(sortFields, ss.getOffset() + ss.getCount(), rb.req.getSearcher());
    ……

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

solrQuery.setStart(query.getOffset());

solrQuery.setRows(query.getRows());

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

原文发布于微信公众号 - Java帮帮(javahelp)

原文发表时间:2016-12-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏猿人谷

Java性能调优

一、JVM内存模型及垃圾收集算法  1.根据Java虚拟机规范,JVM将内存划分为: New(年轻代) Tenured(年老代) 永久代(Perm)   其中N...

314110
来自专栏ml

linux下如何优雅的挂载一个外界设备(比如优盘)

        最近从事linux,实验室一个破服务器,能连上网,但是输入这样的命令:        yum -y install gcc yum -y in...

39060
来自专栏小灰灰

jvm调优的工具介绍

jvm调优实战笔记之基础知识简介 I. 背景 java后端,提供了一个svg渲染的服务,在qps较大时,会出现频繁的gc,而此时的服务器性能本身并没有达到瓶颈...

56670
来自专栏公众号_薛勤的博客

深入理解JAVA虚拟机之JVM调优长篇

JVM虚拟机将内存数据分为程序计数器、虚拟机栈、本地方法栈、Java堆和方法区等部分。

33810
来自专栏刘望舒

Android内存优化(二)DVM和ART的GC日志分析

前言 在Java虚拟机(三)垃圾标记算法与Java对象的生命周期这篇文章中,提到了Java虚拟机的GC日志。DVM和ART的GC日志与Java虚拟机的日志有较大...

26850
来自专栏无题

GC优化案例3:CMS Remark之前强制minorGC

对GC优化的案例进行的系列总结(三): 请求高峰期发生GC,导致服务可用性下降 确定目标 GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s...

48430
来自专栏北京马哥教育

tomcat调优之启动参数

Linux系统中tomcat的启动参数 export JAVA_OPTS="-server -Xms1400M -Xmx1400M -Xss512k -XX:+...

387100
来自专栏个人分享

JVM知识学习与巩固

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模...

12310
来自专栏小勇DW3

JVM调优(二)经验参数设置

    JVM 中最大堆大小有三方面限制:相关操作系统的数据模型(32-bt还是64-bit)限制;系统的可用虚拟内存限制;系统的可用物理内存限制。32位系统下...

21360
来自专栏精讲JAVA

周末福利大放送,免费领取付费星球;jvm研究所的入场券,先到先得

知道各位周六日不太爱看文章,哈哈,小编周末也不想写文章,所以,我就把这周的几个星球里面出现的问题贴出来吧 Q1 Action:项目部署到生产环境中用...

36170

扫码关注云+社区

领取腾讯云代金券