继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

内存泄露分析总结和tomcat调优

Qyouu
关注TA
已关注
手记 453
粉丝 87
获赞 413

    写在最前面,运行环境:tomcat8,jdk1.8,windows  server 2008内存16G,软件LoadRunner11,MAT和JProfile9.1。

    问题描述:前段时间遇到一个很奇怪的问题,开发的WEB应用,经常会毫无症状的宕掉,然后抓了线程栈看下,发现之前写的数据库链接池出现了阻塞的问题,后面分析代码发现同步锁那个地方有一些问题,出现异常可能导致锁不释放,造成堵塞,然后其他线程全block住了,然后应用卡住了,最后就挂了。后面换成了开源的DBPC连接池,获取数据库链接卡住的问题就解决了。但是又发现了一个新问题,用LoadRunner做压力测试时发现tomcat占用的内存持续上升,压了一段时间停了再继续压,tomcat占用内存不会释放,继续往上涨,第一反应就是怀疑存在内存泄露,于是继续往下研究。

     由于之前毫无分析内存泄露的经验,对JVM的内存分配和回收机制也不算了解,纯小白一个,所以只能看《深入理解Java虚拟机》和从网上查各种资料。

Part1.JVM内存组成介绍

这里先介绍一下JVM的内存组成,如下图所示:

https://img.mukewang.com/5d2dde7e00018fa205530351.jpg

JVM 将内存区域划分为 MethodArea(Non-Heap)(方法区),Heap(堆),Program Counter Register(程序计数器), VM

Stack(虚拟机栈,也有翻译成JAVA 方法栈的),Native Method Stack (本地方法栈),其中Method Area和Heap是线程共享的,VMStack,Native

Method Stack 和Program Counter Register是非线程共享的。

那我们的程序是怎么在这些内存上运行的呢,概括地说来,JVM初始运行的时候都会分配好Method Area(方法区)Heap(堆),而JVM 每遇到一个线程(当前情景下WEB应用前台的一个数据请求发送到后台对应就是启动了一个线程),就为其分配一个Program Counter Register(程序计数器),VM

Stack(虚拟机栈)和Native Method Stack (本地方法栈),当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器)所占用的内存空间也会被释放掉。非线程共享的那三个区域的生命周期与所属线程相同,而线程共享的区域与JAVA程序运行的生命周期相同,所以这也是系统垃圾回收的场所只发生在线程共享的区域(实际上对大部分虚拟机来说知发生在Heap上)的原因。

1.程序计数器

程序计数器是一块较小的内存区域,作用可以看做是当前线程执行的字节码的位置指示器。分支、循环、跳转、异常处理和线程恢复等基础功能都需要依赖这个计算器来完成。

2.VM Strack

  先来了解下JAVA指令的构成:

  JAVA指令由 操作码 (方法本身)和 操作数 (方法内部变量) 组成,其实底层都是体系结构和组成原理里面学的东西。

  1)方法本身是指令的操作码部分,保存在Stack中;

  2)方法内部变量(局部变量)作为指令的操作数部分,跟在指令的操作码之后,保存在Stack中(实际上是简单类型(int,byte,short 等)保存在Stack中,对象类型在Stack中保存地址(相当于指针里面的地址),在Heap 中保存值);

  虚拟机栈也叫栈内存,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束,该栈就 Over,所以不存在垃圾回收。也有一些资料翻译成JAVA方法栈,大概是因为它所描述的是java方法执行的内存模型,每个方法执行的同时创建帧栈(Strack Frame)用于存储局部变量表(包含了对应的方法参数和局部变量),操作栈(Operand Stack,记录出栈、入栈的操作),动态链接、方法出口等信息,每个方法被调用直到执行完毕的过程,对应这帧栈在虚拟机栈的入栈和出栈的过程。

  局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象的引用(reference类型,不等同于对象本身,根据不同的虚拟机实现,可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或者其他与对象相关的位置)和 returnAdress类型(指向下一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,在方法在运行之前,该局部变量表所需要的内存空间是固定的,运行期间也不会改变。

  栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,当一个方法 A 被调用时就产生了一个栈帧 F1,并被压入到栈中,A 方法又调用了 B 方法,于是产生栈帧 F2 也被压入栈,执行完毕后,先弹出 F2栈帧,再弹出 F1 栈帧,遵循“先进后出”原则。如下图所示:

https://img2.mukewang.com/5d2dde890001ccc704240675.jpg

3.Heap

  Heap(堆)是JVM的内存数据区。Heap 的管理很复杂,是被所有线程共享的内存区域,在JVM启动时候创建,专门用来保存对象的实例。在Heap 中分配一定的内存来保存对象实例,实际上也只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(以帧栈的形式保存在Stack中),在Heap 中分配一定的内存保存对象实例。而对象实例在Heap 中分配好以后,需要在Stack中保存一个4字节的Heap 内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例,是垃圾回收的主要场所。java堆处于物理不连续的内存空间中,只要逻辑上连续即可。下面我们还会着重介绍一下这块区域。

4.Method Area

  Object Class Data(加载类的类定义数据) 是存储在方法区的。除此之外,常量、静态变量、JIT(即时编译器)编译后的代码也都在方法区。正因为方法区所存储的数据与堆有一种类比关系,所以它还被称为 Non-Heap。方法区也可以是内存不连续的区域组成的,并且可设置为固定大小,也可以设置为可扩展的,这点与堆一样。

  垃圾回收在这个区域会比较少出现,这个区域内存回收的目的主要针对常量池的回收和类的卸载。

5.运行时常量池(Runtime Constant Pool

  方法区内部有一个非常重要的区域,叫做运行时常量池(Runtime Constant Pool,简称 RCP)。在字节码文件(Class文件)中,除了有类的版本、字段、方法、接口等先关信息描述外,还有常量池(Constant Pool Table)信息,用于存储编译器产生的字面量和符号引用。这部分内容在类被加载后,都会存储到方法区中的RCP。值得注意的是,运行时产生的新常量也可以被放入常量池中,比如 String 类中的 intern() 方法产生的常量。

  常量池就是这个类型用到的常量的一个有序集合。包括直接常量(基本类型,String)和对其他类型、方法、字段的符号引用.例如:

类和接口的全限定名;

字段的名称和描述符;

方法和名称和描述符。

  池中的数据和数组一样通过索引访问。由于常量池包含了一个类型所有的对其他类型、方法、字段的符号引用,所以常量池在Java的动态链接中起了核心作用。

6.NativeMethod Stack

与VM Strack相似,VM Strack为JVM提供执行JAVA方法的服务,Native

Method Stack则为JVM提供使用native 方法的服务。

7.直接内存区

直接内存区并不是 JVM 管理的内存区域的一部分,而是其之外的。该区域也会在 Java 开发中使用到,并且存在导致内存溢出的隐患。如果你对 NIO 有所了解,可能会知道 NIO 是可以使用 Native Methods 来使用直接内存区的。

Part2.Heap(堆)和CMS垃圾回收算法

下面我们要详细分析一下Heap,Heap(堆)又可以细分成三部分,Old Gen(老年堆),Eden Space(年轻堆也叫伊甸园),Survivor Space(S0+S1)。我们可以通过配置参数控制Heap的大小,具体设置在后面调优会讲。当程序运行时,大多数情况new的一些对象,最开始都会存放Par Eden Space,然后多次回收(Young GC)之后仍然存活的对象就会挪到CMS Old Gen(老年堆)。需要注意的是除此之外,大的数组对象且对象中无外部引用的对象,和通过启动参数设置的-XX:PretenureSizeThreshold=1024(字节),超过这个大小的对象都会直接分配到CMS Old Gen(老年堆)。下面我们要讲的垃圾回收算法就是发生在这个地方。在我们应用环境中,由于我们配置了CMS GC(并发GC)的回收方法,所以对Eden Space使用的GC算法默认就是ParNew(并行GC)。这里供Par Eden Space和Old Gen选择的GC算法有很多种,可以根据自己的环境选择,一般多核CPU都会选择CMS(并发GC),这样更高效。

CMS执行过程可以分成:初始标记,并发标记,并发预处理,重标记,并发清理,重置六个阶段,这里需要注意的是初始标记和重标记两个阶段是需要Stop-the-world,其他阶段都是和程序其他进程并发执行的,System.gc()调用的Full GC的整个过程都是Stop-the-world,这也是为什么说CMS是对系统影响最小的垃圾回收方法。

初始标记:该阶段进行可达性分析,标记GC ROOT可以直接关联的对象。注意这里是直接关联,间接关联的将在第二阶段进行标记。那么什么可以作为GC ROOT呢,一般是:①虚拟机栈中的引用对象。②方法区中类静态属性引用的对象③方法区中常量引用对象④本地方法栈中JNI引用对象。

并发标记阶段:该阶段进行GC ROOT

Tracing(大家可以把这个想象成由一个Root构成的树,树上除了Root节点,存在引用关系的其他节点到Root都有可达路径。),在第一阶段被暂停的线程全部恢复执行,然后从上一阶段mark的对象出发,对所有可达的对象进行标记。

并发预处理:这一步就是CMS算法的精髓所在,因为CMS是以获取最短的停顿时间为目的的GC算法。在mark和remark两个阶段都需要Stop-the-world,所以并发预处理的目的就是提前做一些remark做的事情,减短remark阶段的耗时。这一阶段,将标记从Eden Space晋升的对象、从Eden Space分配到Old Gen的对象,以及在并发标记阶段被修改的对象。怎么确定一个对象是否存活,即通过追踪GC ROOT Tracing有可达路径的对象就是活着的。举个例子吧,就比如说一个在Old Gen中存在对象B,在并发标记阶段没被标记成alive,眼看就要小命不保了,就在这个时候程序进程又New了一个对象A,此时A对象又引用了Old Gen中的B对象(因为并发标记阶段并不是Stop-the-world,所以程序进程和标记进程是并发执行的)。那么这个对象B就不应该被回收掉,因为被A捞了一把,手牵手进入了GC ROOT Trace。这个B在并发预处理阶段就会被标记成alive。

重标记:这个阶段也是要Stop-the-world的,重新扫描堆中的对象,再次进行可达性分析,标记alive的对象。

并发清理:重新激活用户线程,然后清理哪些dead Objects(不存在引用的对象)。

重置:CMS清楚内部状态,准备下一次回收。

为了更好地说明CMS回收的过程,这里贴一段实际场景中的GC日志:

-----------------------------------------初始标记(Stop-the-world)---------------------------------------------

135140.215: [GC (CMS Initial Mark) [1CMS-initial-mark: 195002K(3375104K)]207465K(3989504K), 0.0053961 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

-----------------------------------------并发标记---------------------------------------------

135140.221: [CMS-concurrent-mark-start]135140.287: [CMS-concurrent-mark:0.066/0.066 secs] [Times: user=0.50 sys=0.00, real=0.07 secs]

-----------------------------------------并发预处理---------------------------------------------

135140.287: [CMS-concurrent-preclean-start]135140.295: [CMS-concurrent-preclean:0.009/0.009 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

-----------------------------------------重标记(Stop-the-world)---------------------------------------------

135140.298: [GC (CMS Final Remark) [YG occupancy: 13058 K (614400 K)]//这里最前面的135140.298是JVM运行时间,单位是S。YG就是Young Gen(Eden Space),前面的数字是占用大小,括号里是总大小

135140.298: [Rescan (parallel) , 0.0071866

secs]//这里要对Young Gen重新扫描

135140.305: [weak refs processing,0.1143667 secs]135140.420: [class unloading, 0.1829570secs]135140.603: [scrub symbol table, 0.0194112secs]135140.622: [scrub string table, 0.0019222secs][1 CMS-remark: 195002K(3375104K)] 208060K(3989504K), 0.4727087 secs][Times: user=0.47 sys=0.00, real=0.47 secs] //这里195002K(3375104K)表示的是Old Gen的使用情况,208060K(3989504K)是整个Heap的使用情况

-----------------------------------------并发清理---------------------------------------------

135140.771: [CMS-concurrent-sweep-start]135140.845: [CMS-concurrent-sweep:0.073/0.073 secs] [Times: user=0.09 sys=0.03, real=0.07 secs]

-----------------------------------------重置---------------------------------------------

135140.850: [CMS-concurrent-reset-start]135140.856: [CMS-concurrent-reset:0.006/0.006 secs] [Times: user=0.01 sys=0.02, real=0.01 secs] 。

关于CMS算法的优缺点,还有具体实现的的一些细节,这里就不做过多叙述了,有兴趣的可以自行查阅资料。

Part3.MAT分析工具和Jprofile分析工具

一顿操作猛如虎,基本了解了JVM的内存的组成和垃圾回收相关的基础信息。然后就要来分析一下WEB应用到底问题出在哪了。工欲善其事,必先利其器,然后我就下载了MAT和Jprofile。

MAT是专门用来分析内存dump文件的工具,需要有Eclipse才能跑。先用jmap指令可以抓到dump文件,具体指令格式如下:

jmap-dump:format=b,file=output pid

pid就是你的java进程id,需要注意的是使用这个方法抓dump时,tomcat要用startup.bat去启动,如果以服务的方式启动,这个指令会报错,可能是权限问题。这里有个要注意的,抓Heap dump时会先进行垃圾回收,再生成dump文件。

直接用MAT打开Dump文件,然后工具还会帮你生成一份分析报告,告诉你可能存在内存泄露的地方。废话不多说,上图,下图是对内存泄露的分析报告,点进去可以看到详情信息

https://img1.mukewang.com/5d2dded4000133cf07250403.jpg

MAT最重要的功能就是可以分析Heap里的Object、类和引用关系。

下图列出了Heap中所有的对象,这里是以Class的方式展示的,然后这里末尾两列需要关注一下,Shallow Heap是自身在Heap里面占用的大小,Retained Heap是引用的对象总工占用的大小,单位都是字节。因为一开始就是怀疑代码存在内存泄露,担心代码里面定义的静态变量占用内存太多。刚开始看到String类型占用了大部分堆内存,然后进去可以看到String类里面由哪些对象引用占用了。

https://img4.mukewang.com/5d2dded700016d0a07230383.jpg

下图是查看String类的所有引用对象,然后点最后一个Retained Heap可以按从大到小排列,可以看到,最大的那几个果然是我自己代码里面定义的静态变量,图中圈红的地方就是类的堆栈地址,因为这个静态缓存的类是单例实现的,所以在这里出现这个类名的地方后面跟的堆栈地址都是一样的。

https://img4.mukewang.com/5d2ddedc000121c607260460.jpg

MAT还支持查询,根据类名,查询某个地址上的对象,下图就是通过地址查找某个对象。

https://img4.mukewang.com/5d2ddee20001518a07080487.jpg

通过一些分析我发现之前担心静态变量太多导致内存占用太多其实是多虑了,因为缓存的那几个静态变量都只存了一份,并且他们的Retained Heap也不大,对整个内存没多大影响。虽然对内存泄露分析没什么进展但是还是有一些发现,我看到了很多在代码中手动调用logger.info()打印的日志信息都存在堆中。这些logger.info当初都是为了调试或者分析问题时添加的,后面也就放着没删,有一些打印输出的内容还挺大,这些后面都会占用Heap。

MAT还有个很重要的骚操作就是可以添加两个Dump来对比,现在回想我觉得这个其实就可以确定是否存在内存泄露。抓Dump时要注意的是抓两个Dump中间最好要有一定的时间间隔,这个时间间隔中最好应用要经过一定的压力测试。下图就是两个Dump的对比图,第二个Dump就是我用LoadRunner压了一晚上之后再抓的。这里#0就是第二个Dump,#1就是第一个Dump。可以发现,一个晚上的压力测试之后,在GC之后生成的Heap Dump文件里面Shallow Heap的大小变化不是很大,Object的数量变化也不是很大,说明了GC对Heap里面对象回收状况差不多,如果存在内存泄露,存在不能被回收的对象,那第二个Dump文件里面应该会出现比第一个Dump文件大很多的类和Object,可是经过这么长时间的压力测试,并没有出现这种扎眼球的对象和类,所以基本可以断定应用不存在内存泄露的状况。

https://img3.mukewang.com/5d2ddee6000150f807200410.jpg

用MAT分析后感觉还是有点不确定,然后又用Jprofile实时监控了一下JVM状态,不得不说这个工具真的很强大,可以监控本地的JVM进程,也可以监控远程的JVM进程,监控内容从内存,对象,GC到线程,CPU,数据库连接状态覆盖面很广。

具体的使用有兴趣的可以去下载下来玩玩,下图是内存监控的视图,这里支持对整个Heap的监控,也可以分开监控Eden Space和Old Gen。从这个回收视图可以看出对Eden Space的回收基本每次都可以很彻底(主要看波谷有多低),如果存在内存泄露的情况,不会每次回收都能触及波谷,而且波谷会慢慢升高,因为内存泄露会导致一些对象无法被回收,而且随着软件运行时间和压力增大,泄露的对象会慢慢积累,所以GC完之后蓝色显示Used size是不可能达到最低点几乎为0的大小。

https://img3.mukewang.com/5d2ddef10001aca707270507.jpg

下面这张图是对GC状态的监控

https://img.mukewang.com/5d2ddf3a0001679307120491.jpg

分析到这里基本可以确定应用不存在内存泄露的情况。然后用这个软件也有一些其他发现。它有个线程分析视图可以抓取到线程的状态,主要是查看压力测试下线程阻塞的状态,我发现很多线程都block在写日志文件的地方,各进程间对日志文件的写操作肯定是互斥的,一次只允许一个进程对日志文件进行写操作,同一时间如果后台有几百个进程同时需要对日志文件进行写操作,这时就进入了阻塞状态,如下图所示:

https://img4.mukewang.com/5d2ddf3e0001933307290496.jpg

从之前的MAT分析Heap中对象中也发现很多打印的日志数据都存在Heap中,到现在看到这么多线程阻塞在log4j的地方,控制好日志的输出对高并发的WEB应用影响还是挺大的。

除了视图还可以监控数据库连接的情况,事务的完成时间,连接池的状况,连接串的状况,还可以根据一些筛选条件进行筛选,功能十分强大。

最后再放一张总的监控视图:

https://img3.mukewang.com/5d2ddf4200016ce207340505.jpg

由于这个两个软件都是临时下载初次使用,可能还有很多强大的功能没尝试,以后还可以继续研究研究。

虽然对内存泄露的研究有了明确的结果,可是tomcat占用内存持续升高得不到释放的问题还是没有答案。现在通过监控软件可以明确看到Heap的内存使用回收很正常,可是通过任务管理器监控看到的Tomcat占用内存却只升不减。

Tomcat是Java写的,运行在JVM之上,所以tomcat的使用的堆内存大小是不可能超过JVM定义的堆大小。所以Jprofile监控的Heap使用情况和从任务管理器看到Tomcat使用内存肯定不完全一样,除了堆内存肯定还有之外的内存。那么还有什么内存呢?上面介绍内存组成时有个直接内存,当发现还有直接内存这个东西时,感觉发生了新大陆一样,隐隐约约感觉问题的关键就在这里。

Part4.直接内存和NIO

又是一顿查资料了解直接内存和NIO相关的内容。根据官方文档的描述:

A byte buffer is either direct ornon-direct. Given a direct byte buffer, the Java virtual machine will make abest effort to perform native I/O operations directly upon it. That is, it willattempt to avoid copying the buffer's content to (or from) an intermediatebuffer before (or after) each invocation of one of the underlying operatingsystem's native I/O operations.

byte byffer可以是两种类型,一种是基于直接内存(也就是非堆内存);另一种是非直接内存(也就是堆内存)。

对于直接内存来说,JVM将会在IO操作上具有更高的性能,因为它直接作用于本地系统的IO操作。而非直接内存,也就是堆内存中的数据,如果要作IO操作,会先复制到直接内存,再利用本地IO处理。

从数据流的角度,非直接内存是下面这样的数据链:

本地IO-->直接内存-->非直接内存-->直接内存-->本地IO

而直接内存是:

本地IO-->直接内存-->本地IO

很明显,再做IO处理时,比如网络发送大量数据时,直接内存会具有更高的效率。

NIO(New IO)是基于基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区,或者从缓冲区写到通道中。具体信息有兴趣可以自行上网查。

这个时候突然看到一个帖子里的回复说Tomcat8默认通信方式就是采用NIO方式,这个时候感觉看到希望之光了,立马就去看哪里用到了了NIO。后来在tomcat-coyote.jar中找到了,这个coyote是用来处理Tomcat底层的socket,并将http请求、响应等字节流层面的东西,包装成Request和Response两个类,供容器使用。所以意味着每一个前端请求都会经过这个处理了。下图源码中被圈中的代码就是分配直接内存的代码。

https://img3.mukewang.com/5d2ddf480001682807150360.jpg

我们再点进去可以看到在jdk的源码中分配直接内存有个reserveMemory的函数,在每次分配直接内存的时候都会执行这个清理函数,然后再点进去有个大发现,在这个清理内存函数里面居然有手动调用gc的代码。

https://img4.mukewang.com/5d2ddf4c0001f9bd07400412.jpg

https://img3.mukewang.com/5d2ddf500001c9ef07180371.jpg

这个时候灵光一闪突然回想起我们的tomcat的JVM配置参数里面好像有一个是忽略代码里面调用system.gc()的配置参数。因为这个tomcat容器是被其他单位下发的已经做过优化的,所以里面有一堆的配置参数。其中被我标绿的这个-XX:DisableExplicitGC的作用就是不响应代码里面手动调用system.gc(),看下图:

https://img3.mukewang.com/5d2ddf8a000167af07270236.jpg

上面我们可以看到一堆的配置,里面配置含义我们后面再讲。这个时候我以为我已经找到了问题的本质,由于direct Memory在堆外,所以对young gen 的gc过程中是不会回收的。JVM只会在old gen GC(full GC/major GC或者concurrent GC都算)的时候才会对old gen中的对象做reference processing,而在young GC时只会对young gen里的对象做reference processing。也就是说,做full GC的话会对old gen做reference processing,进而能触发Cleaner对已死的DirectByteBuffer对象做清理工作。而如果很长一段时间里没做过GC或者只做了young GC的话则不会在old gen触发Cleaner的工作,那么就可能让本来已经死了的、但已经晋升到old gen的DirectByteBuffer关联的direct Memory得不到及时释放,这么分析看来这里就是问题的根本了。。。

那我们直接把那个参数删掉不就好了吗,我想了想自己的代码里面也没写过system.gc所以应该影响不大,然后就果断去掉了那个参数顺手也设置了个直接内存大小,配置参数是-XX:MaxDirectMemorySize。这个直接内存不设置时,默认大小是最大堆大小,看下图源码。

https://img4.mukewang.com/5d2ddf8e00011a0407310411.jpg

这么修改完之后又开始做压力测试,这次看到内存很稳定,测试二十多个小时,内存基本增长到两个多G就没再涨了,心情相当开心。然后我再去检查GC日志发现果然出现了很多system.gc的日志,这个之前都是没见过的,看下图:

https://img.mukewang.com/5d2ddf930001956807150066.jpg

本以为这次分析到这就结束了,可是没想到后面还有新发现,把我本以为下了定论的答案又推翻了。。。有点自己打自己脸的感觉?‍

Part5.Tomca配置和调优

在分析GC日志的时候,我看到压力测试中GC日志中Full gc的次数有点频繁,而且这种Full gc是Stop-the-world的,很影响应用的响应时间,从GC日志中可以看到基本一次Full GC耗时要一秒多,频率高的话很影响性能。然后我又开始搜资料,搜资料的过程中发现Tomcat8默认用NIO是指在linux服务器下,而我是在Windows的服务器上跑的。。。然后我发现Tomcat支持三种接收请求的模式,分别是:BIO,NIO,APR,其中NIO就是我们上面提到的在linux服务器上默认的模式。网上有人对这三种模式分别作了性能测试,发现APR模式是三种模式里面性能最好的,这种方式是从操作系统级别解决异步IO问题,也是Tomcat运行高并发应用的首选。但是开启比较麻烦,需要一些额外的jar包,有兴趣的也可以自行查资料了解一下。我上面也提到了我这里用的Tomcat是经过优化的,然后我打开server.xml看了一眼,结果两眼一黑,我用的Tomcat疑似采用的就是APR这种模式,因为我看到Server.xml中包含这么一句配置:

https://img3.mukewang.com/5d2ddf990001eff907220113.jpg

然后为了确定我这个猜测我又去看了启动日志,在启动日志中看到了:

https://img4.mukewang.com/5d2ddf9c0001c1e907200155.jpg

这不赤裸裸的告诉我们开启了APR的模式嘛。。。这就意味着上面根据Tomcat8默认NIO模式用到了直接内存,得出的关于我的应用部署的Tomcat为什么占用内存持续上升的结论是不成立的!虽然Tomcat确实有NIO的模式,NIO也确实会用到直接内存,分配直接内存时确实会手动调用system.gc(),然后tomcat里面配置-XX:DisableExplicitGC确实会影响内存分配导致直接内存堆积,可是和我这并没什么关系啊。。此时心里万马奔腾,但我冷静一想当我去掉-XX:DisableExplicitGC时,GC日志里面出现了很多Full GC的日志,那不是因为分配直接内存引起的还有谁再调用呢。没办法,只能一直手动抓线程栈来分析,用下面的指令就可以把当前线程栈输出到一个txt文档中。

jstack-l pid>C:\Users\Administrator\Desktop\log\ThreadStack.txt

         这个pid就是你的java线程id。果然抓了几次就被我抓到了现场,线程栈中果然有个线程在执行gc操作,看下图:

https://img4.mukewang.com/5d2ddfa20001838e07140073.jpg

这次感觉自己应该是找到了问题本质了,光看这个也看不出什么然后上网搜了下,发现也是Tomcat配置引起的,真相只有一个就是下面这个配置:

https://img1.mukewang.com/5d2ddfdb00013d8007140081.jpg

这个看名字就知道是用来检测是否存在内存泄露的,后面看到Tomcat管理页面自带一个Find Leaks的功能,不知道是不是和这个有关系。然后我进这个类看了下就有了新发现,在这个类里面我看到GC日志里面执行system.gc的那个类名!看下图:

https://img1.mukewang.com/5d2ddfde0001264a07270433.jpg

这里这个gcDaemonProtection的参数在这个类的上面已经定义了,默认是true。这就意味着如果不手动修改配置文件,肯定会进这个判断。里面用反射调用了sun.misc.GC的requestLatency方法。我点进这个sun.misc.GC类里面,看到了GC日志里面那个run的地方,看下图:

https://img3.mukewang.com/5d2ddfe30001242907230446.jpg

看名字这是一个守护进程,里面调用了system.gc(),这下可以肯定的是GC日志里频繁出现的Full GC操作就是这里引起的(后面我又抓了很多次线程栈发现调用GC的只有这一个类)。到这里我终于可以确定为什么GC日志里面那么多Full GC了,都是因为Tomcat配置里面加载这个内存检测的Class导致的。那有什么办法可以避免这个呢,后来网上查了下,有这么几种方法:

① 直接去掉这个配置

②  将上面那个默认配置true的参数改成false,将Server.xml里面的对应那条配置中增加下面的一段:

gcDaemonProtection=”false”



③ 增加-XX:+ExplicitGCInvokesConcurrent配置,这个参数不会像DisableExplicitGC一样强行忽略手动调用system.gc,而是在遇到调用system.gc时调用CMS垃圾回收方法。因为上面提过CMS是停顿最短的GC方法,这样就可以避免由full

GC带来的长GC pause引起的性能问题。

经过测试我是采用的第三个方法,到最后内存增长的问题得到了解决。最后我们看一下GC日志中CMS的耗时,看下图:

https://img.mukewang.com/5d2ddfe90001180707370188.jpg

这里被我用红线划得就是mark和remark的两个阶段,因为这有这两个阶段是Stop-the-world的,可以看到耗时和Full GC比起来要短很多。

最后Tomcat的JVM配置参数被我修改为下图:

https://img4.mukewang.com/5d2ddfee0001f83d07130092.jpg

这里面有几个比较重要好用的我大概说一下:

-Xloggc:gc.log -XX:+PrintGCDetails,这个参数会设置打印GC日志,这次问题,靠GC日志分析出了很多有用的东西。

-XX:+UseConcMarkSweepGC ,选用CMS作为垃圾回收方法

-XX:+ ExplicitGCInvokesConcurrent,用这个替换了DisableExplicitGC,每次遇到system.gc时调用一次CMS回收,并不是直接Stop-the-world。

-XX:+UseCMSCompactAtFullCollection,在每次CMS收集器在完成垃圾回收之后做一次内存碎片整理。

Tomcat的线程池也是可以自己配置的,包括可接受的连接数之类的,这里我就不展开说了,码字也不轻松…

其他还有很多有兴趣的可以自行了解。

总结:这次遇到这个奇怪的问题到解决查了很多资料也收获了很多新知识,特别是发现Full GC并不是直接内存引起而是因为另一个配置导致的时候,有种柳暗花明又一村的感觉。其实真正需要做的改动只是增加了两个配置参数,删除了一个配置参数,但需要了解的东西却十分庞杂,性能调优涉及的东西太多了,这次很多东西这次只是浅浅的接触了解了一下,还需要继续努力。这次解决完问题感觉很有必要写下来,一个是担心以后忘了,还有就是顺便锻炼一下自己的总结能力。有时候你会发现,你很抗拒的事情在等你完成之后回头看也不过如此。最后,与君共勉。



作者:邱定
链接:https://www.jianshu.com/p/d9f877772fd6

打开App,阅读手记
1人推荐
发表评论
随时随地看视频慕课网APP