关于JVM介绍以及CPU占用过高的问题定位及解决实战经验

By | 2018年10月30日
目录
[隐藏]

1、概述

作为一个程序员,除了要会码代码外,还应该知道代码在内存中执行时,如何使用内存,保证程序执行过程中,高效率的使用内存。

2、JVM的内存模型

(1)、方法区,又称Non-Heap,线程共享,主要用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等,在HotSpot虚拟机上又称为“永久代”(permanent generation),但是并非数据进入了方法区就如永久代名字一样“永久”存在,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是对于类型的卸载,条件相当苛刻,所以回收并不太令人满意,但是回收是必要的。其中包括运行时常量池,存放编译期生成的各种字面常量和符号引用,同时也会存储运行时期产生新的常量,典型例子如String类的intern()方法。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError。

(2)、堆(Heap),线程共享,用于存放实例对象,随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙变化发生,所以并非所有对象都是在堆上分配。

堆的划分如下图:

因为是线程共享,所以在堆上可能划分出多个线程私有的分配区,但是存储的是对象的实例。所以Java堆是垃圾收集器管理的主要区域。

这个区域会存在OutOfMemoryError 错误异常。

(3)、java虚拟机栈,线程私有,生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,一个指向对象起始地址的引用指针,一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

这个区域存在两种异常:StackOverflowError(线程请求的栈深度大于虚拟机所允许的深度),OutOfMemoryError(扩展时无法申请到足够的内存)

(4)程序计数器,线程私有,当前线程所执行的字节码的行号指示器。如果线程执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(undefined)。

(5)本地方法栈,线程私有,与虚拟机栈的作用相似,区别在于虚拟机栈为虚拟机执行java方法(字节码)服务,本地方法栈为虚拟机使用到native方法服务。Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。该区域会抛出StackOverflowError和OutOfMemoryError。

3、JVM垃圾回收机制

(1)引用计数,引用计数是最简单直接的一种方式,这种方式在每一个对象中增加一个引用的计数,这个计数代表当前程序有多少个引用引用了此对象,如果此对象的引用计数变为0,那么此对象就可以作为垃圾收集器的目标对象来收集。

优点:简单,直接,不需要暂停整个应用

缺点:1.需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作;2.不能处理循环引用的问题

因此这种方法是垃圾收集的早期策略,现在很少使用。Sun的JVM并没有采用引用计数算法来进行垃圾回收,是基于根搜索算法的。

(2)根搜索算法

通过一系列的名为“GC Root”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连时,则该对象不可达,该对象是不可使用的,垃圾收集器将回收其所占的内存。

在java语言中,可作为GCRoot的对象包括以下几种对象:

a. java虚拟机栈(栈帧中的本地变量表)中的引用的对象。

b.方法区中的类静态属性引用的对象。

c.方法区中的常量引用的对象。

d.本地方法栈中JNI本地方法的引用对象。

判断无用的类:

(1).该类的所有实例都已经被回收,即java堆中不存在该类的实例对象。

(2).加载该类的类加载器已经被回收。

(3).该类所对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

(3)标记-清除算法
标记清除收集器停止所有的工作,从根扫描每个活跃的对象,然后标记扫描过的对象,标记完成以后,清除那些没有被标记的对象。

优点:

1 解决循环引用的问题

2 不需要编译器的配合,从而就不执行额外的指令

缺点:

1. 每个活跃的对象都要进行扫描,收集暂停的时间比较长。

2.标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如上图所示。

标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

(4)  复制算法
复制收集器将内存分为两块一样大小空间,某一个时刻,只有一个空间处于活跃的状态,当活跃的空间满的时候,GC就会将活跃的对象复制到未使用的空间中去,原来不活跃的空间就变为了活跃的空间。

优点:

1.只扫描可以到达的对象,不需要扫描所有的对象,从而减少了应用暂停的时间

缺点:

1.需要额外的空间消耗,某一个时刻,总是有一块内存处于未使用状态

2.复制对象需要一定的开销

 

复制算法采用从根集合扫描,并将存活对象复制到一块新的,没有使用过的空间中,这种算法当空间存活的对象比较少时,极为高效,但是带来的成本是需要一块内存交换空间用于进行对象的移动。

(5)  标记-整理算法
标记整理收集器汲取了标记清除和复制收集器的优点,它分两个阶段执行,在第一个阶段,首先扫描所有活跃的对象,并标记所有活跃的对象,第二个阶段首先清除未标记的对象,然后将活跃的的对象复制到堆得底部

该算法极大的减少了内存碎片,并且不需要像复制算法一样需要两倍的空间。

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

(6) 分代回收算法
垃圾分代回收算法(GenerationalCollecting)基于对对象生命周期分析后得出的垃圾回收算法。

因为我们前面有介绍,内存主要被分为三块,新生代、旧生代、持久代。三代的特点不同,造就了他们所用的GC算法不同,新生代适合那些生命周期较短,频繁创建及销毁的对象,旧生代适合生命周期相对较长的对象,持久代在Sun HotSpot中就是指方法区(有些JVM中根本就没有持久代这中说法)。首先介绍下新生代、旧生代、持久代的概念及特点。

Young(年轻代、新生代):JVM specification中的 Heap的一部份年轻代分三个区。一个Eden区,两个Survivor区。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制旧生代。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。

新生代使用复制算法和标记-清除垃圾收集算法,新生代中98%的对象是朝生夕死的短生命周期对象,所以不需要将新生代划分为容量大小相等的两部分内存,而是将新生代分为Eden区,Survivor from(Survivor 0)和Survivor to(Survivor1)三部分,其占新生代内存容量默认比例分别为8:1:1,其中Survivor from和Survivor to总有一个区域是空白,只有Eden和其中一个Survivor总共90%的新生代容量用于为新创建的对象分配内存,只有10%的Survivor内存浪费,当新生代内存空间不足需要进行垃圾回收时,仍然存活的对象被复制到空白的Survivor内存区域中,Eden和非空白的Survivor进行标记-清理回收,两个Survivor区域是轮换的。

如果空白Survivor空间无法存放下仍然存活的对象时,使用内存分配担保机制,直接将新生代依然存活的对象复制到年老代内存中,同时对于创建大对象时,如果新生代中无足够的连续内存时,也直接在年老代中分配内存空间。

Java虚拟机对新生代的垃圾回收称为Minor GC,次数比较频繁,每次回收时间也比较短。

使用java虚拟机-Xmn参数可以指定新生代内存大小。

 

Tenured(年老代、旧生代):JVMspecification中的 Heap的一部份年老代存放从年轻代存活的对象。一般来说年老代存放的都是生命期较长的对象。

年老代中的对象一般都是长生命周期对象,对象的存活率比较高,因此在年老代中使用标记-整理垃圾回收算法。

Java虚拟机对年老代的垃圾回收称为MajorGC/Full GC,次数相对比较少,每次回收的时间也比较长。

java虚拟机-Xms参数可以指定最小内存大小,-Xmx参数可以指定最大内存大小,这两个参数分别减去Xmn参数指定的新生代内存大小,可以计算出年老代最小和最大内存容量。

Perm(持久代、永久代): JVM specification中的 Method area 用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

java虚拟机内存中的方法区在SunHotSpot虚拟机中被称为永久代,是被各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。永久代垃圾回收比较少,效率也比较低,但是也必须进行垃圾回收,否则会永久代内存不够用时仍然会抛出OutOfMemoryError异常。

永久代也使用标记-整理算法进行垃圾回收,java虚拟机参数-XX:PermSize和-XX:MaxPermSize可以设置永久代的初始大小和最大容量。

(7)垃圾回收过程
上面我们看了JVM的内存分区管理,现在我们来看JVM的垃圾回收工作是怎样运作的。

首先当启动J2EE应用服务器时,JVM随之启动,并将JDK的类和接口,应用服务器运行时需要的类和接口以及J2EE应用的类和接口定义文件也及编译后的Class文件或JAR包中的Class文件装载到JVM的永久存储区。在伊甸园中创建JVM,应用服务器运行时必须的JAVA对象,创建J2EE应用启动时必须创建的JAVA对象;J2EE应用启动完毕,可对外提供服务。

JVM在伊甸园区根据用户的每次请求创建相应的JAVA对象,当伊甸园的空间不足以用来创建新JAVA对象的时候,JVM的垃圾回收器执行对伊甸园区的垃圾回收工作,销毁那些不再被其他对象引用的JAVA对象(如果该对象仅仅被一个没有其他对象引用的对象引用的话,此对象也被归为没有存在的必要,依此类推),并将那些被其他对象所引用的JAVA对象移动到幸存者0区。

如果幸存者0区有足够空间存放则直接放到幸存者0区;如果幸存者0区没有足够空间存放,则JVM的垃圾回收器执行对幸存者0区的垃圾回收工作,销毁那些不再被其他对象引用的JAVA对象,并将那些被其他对象所引用的JAVA对象移动到幸存者1区。

如果幸存者1区有足够空间存放则直接放到幸存者1区;如果幸存者1区没有足够空间存放,则JVM的垃圾回收器执行对幸存者1区的垃圾回收工作,销毁那些不再被其他对象引用的JAVA对象,并将那些被其他对象所引用的JAVA对象移动到养老区。

如果养老区有足够空间存放则直接放到养老区;如果养老区没有足够空间存放,则JVM的垃圾回收器执行对养老区区的垃圾回收工作,销毁那些不再被其他对象引用的JAVA对象,并保留那些被其他对象所引用的JAVA对象。

如果到最后养老区,幸存者1区,幸存者0区和伊甸园区都没有空间的话,则JVM会报告“JVM堆空间溢出(java.lang.OutOfMemoryError: Java heap space)”,也即是在堆空间没有空间来创建对象。

这就是JVM的内存分区管理,相比不分区来说;一般情况下,垃圾回收的速度要快很多;因为在没有必要的时候不用扫描整片内存而节省了大量时间。

(8) 对象的空间分配和晋升
(1)对象优先在Eden上分配

(2)大对象直接进入老年代

虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个参数值的对象将直接分配到老年代中。因为新生代采用的是标记-复制策略,在Eden中分配大对象将会导致Eden区和两个Survivor区之间大量的内存拷贝。

(3)长期存活的对象将进入老年代

对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会晋升到老年代中。

(9) 垃圾回收器
(1)串行回收器(Serial Collector)

 

单线程执行回收操作,回收期间暂停所有应用线程的执行,client模式下的默认回收器

 

(2)并行回收器(Parallel Collector)

 

使用多个线程同时进行垃圾回收,多核环境里面可以充分的利用CPU资源,减少回收时间,增加JVM生产率,Server模式下的默认回收器。与串行回收器相同,回收期间暂停所有应用线程的执行。

 

(3)并行合并收集器(Parallel Compacting Collection)

 

年轻代和年老代的回收都是用多线程处理。与并行回收器相比,年老代的回收时间更短,从而减少了暂停时间间隔(Pause time)。

 

(4)并发标记清除回收器(Concurrent Mark-Sweep Collector)

 

又名低延时收集器(Low-latencyCollector),通过各种手段使得应用程序被挂起的时间最短。基本与应用程序并发地执行回收操作,没有合并和复制操作。

4 、CPU占用过高的解决

(1)  问题现象:
线上环境,CPU占用很高

(2)  处理步骤
1、使用top,jstack等命令定位进程和线程,如下:

主要是垃圾回收线程占用CPU资源比较高。

2、使用jmap,jstat查看内存占用情况,如下是在测试环境重现

从上图中可以看到jdbc连接中StatementImpl,JDBC4ResutSet,Field的实例多,占用内存高。而connection相对较少,与代码中的初始值一致。


通过gc日志也可以清楚的看到,在不停的做CMS回收,但是内存并没有回收,反而增长。

第一次CMS:

2017-04-07T23:14:28.415+0800: 25915.174:[GC [1 CMS-initial-mark: 560464K(699072K)] 597988K(990336K), 0.2906220 secs][Times: user=0.20 sys=0.00, real=0.29 secs]

2017-04-07T23:14:28.706+0800: 25915.465:[CMS-concurrent-mark-start]

2017-0=                               4-07T23:14:29.305+0800:25916.063: [CMS-concurrent-mark: 0.586/0.598 secs] [Times: user=2.99 sys=0.01,real=0.59 secs]

2017-04-07T23:14:29.305+0800: 25916.063:[CMS-concurrent-preclean-start]

2017-04-07T23:14:29.321+0800: 25916.079:[CMS-concurrent-preclean: 0.016/0.016 secs] [Times: user=0.01 sys=0.00,real=0.02 secs]

2017-04-07T23:14:29.321+0800: 25916.079:[CMS-concurrent-abortable-preclean-start]

CMS:abort preclean due to time 2017-04-07T23:14:34.487+0800: 25921.245:[CMS-concurrent-abortable-preclean: 4.053/5.166 secs] [Times: user=4.44sys=0.03, real=5.17 secs]

2017-04-07T23:14:34.487+0800: 25921.246:[GC[YG occupancy: 53437 K (291264 K)]2017-04-07T23:14:34.487+0800: 25921.246:[Rescan (parallel) , 0.0774910 secs]2017-04-07T23:14:34.565+0800: 25921.323:[weak refs processing, 0.0011920 secs]2017-04-07T23:14:34.566+0800: 25921.324:[scrub string table, 0.0014960 secs] [1 CMS-remark: 560464K(699072K)]613901K(990336K), 0.0804980 secs] [Times: user=1.37 sys=0.00, real=0.08 secs]

2017-04-07T23:14:34.568+0800: 25921.326:[CMS-concurrent-sweep-start]

2017-04-07T23:14:34.811+0800: 25921.569:[CMS-concurrent-sweep: 0.237/0.243 secs] [Times: user=0.24 sys=0.00, real=0.24secs]

2017-04-07T23:14:34.811+0800: 25921.569:[CMS-concurrent-reset-start]

2017-04-07T23:14:34.813+0800: 25921.571:[CMS-concurrent-reset: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00secs]

 

第二次CMS:

2017-04-07T23:14:34.815+0800: 25921.574:[GC [1 CMS-initial-mark: 559265K(699072K)] 616098K(990336K), 0.2477620 secs][Times: user=0.24 sys=0.00, real=0.25 secs]

2017-04-07T23:14:35.063+0800: 25921.822:[CMS-concurrent-mark-start]

2017-04-07T23:14:35.555+0800: 25922.313:[CMS-concurrent-mark: 0.481/0.492 secs] [Times: user=2.47 sys=0.00, real=0.49secs]

2017-04-07T23:14:35.555+0800: 25922.313:[CMS-concurrent-preclean-start]

2017-04-07T23:14:35.557+0800: 25922.315:[CMS-concurrent-preclean: 0.002/0.002 secs] [Times: user=0.00 sys=0.00,real=0.01 secs]

2017-04-07T23:14:35.557+0800: 25922.315:[CMS-concurrent-abortable-preclean-start]

CMS:abort preclean due to time 2017-04-07T23:14:40.814+0800: 25927.573:[CMS-concurrent-abortable-preclean: 4.496/5.257 secs] [Times: user=4.90sys=0.01, real=5.25 secs]

2017-04-07T23:14:41.097+0800: 25927.856:[GC[YG occupancy: 74828 K (291264 K)]2017-04-07T23:14:41.097+0800: 25927.856:[Rescan (parallel) , 0.2060890 secs]2017-04-07T23:14:41.303+0800: 25928.062:[weak refs processing, 0.0000910 secs]2017-04-07T23:14:41.304+0800: 25928.062:[scrub string table, 0.0011510 secs] [1 CMS-remark: 559265K(699072K)]634094K(990336K), 0.2075950 secs] [Times: user=3.60 sys=0.01, real=0.21 secs]

2017-04-07T23:14:41.305+0800: 25928.064:[CMS-concurrent-sweep-start]

2017-04-07T23:14:41.810+0800: 25928.568:[CMS-concurrent-sweep: 0.498/0.505 secs] [Times: user=0.55 sys=0.00, real=0.51secs]

2017-04-07T23:14:41.810+0800: 25928.569:[CMS-concurrent-reset-start]

2017-04-07T23:14:41.813+0800: 25928.572:[CMS-concurrent-reset: 0.003/0.003 secs] [Times: user=0.01 sys=0.00, real=0.00secs]

2017-04-07T23:14:41.817+0800: 25928.575:[GC [1 CMS-initial-mark: 559257K(699072K)] 637481K(990336K), 0.2543890 secs][Times: user=0.26 sys=0.00, real=0.26 secs]

….

最后CMS回收失败:

2017-04-08T00:55:34.733+0800:31981.491: [GC2017-04-08T00:55:34.733+0800: 31981.491: [ParNew (promotionfailed): 267307K->267271K(291264K), 0.3985040secs]2017-04-08T00:55:35.131+0800: 31981.890: [CMS2017-04-08T00:55:35.144+0800:31981.902: [CMS-concurrent-abortable-preclean: 3.286/4.206 secs] [Times:user=5.02 sys=0.09, real=4.20 secs] (concurrent mode failure):698209K->112241K(699072K), 1.3834250 secs] 964254K->112241K(990336K),[CMS Perm : 27584K->27305K(46044K)], 1.7821670 secs] [Times: user=2.78sys=0.08, real=1.78 secs]

 

3、跟踪和分析代码

(1)、从代码中看到,代码中创建了数据库连接池,并初始化大小,与内存中的实例数相同,如下图


该连接池被初始化到方法区中,即会长久存储,如下图:


(2)、通过调用代码查看,也并没有问题


(3)通过查看jdbc中connection的Statement createStatement()的方法,在调用publicStatementImpl(MySQLConnection c, String catalog) throws SQLException方法创建Statement时,将Statement关联到了connection中,如下:


其中connection中的dontTrackOpenResources这个属性默认为false,到目前为止,问题已经定位清楚。

4、原因分析

Statement虽然是在线程中创建,生命周期按照理论应该和线程一致,但是在创建Statement实例的时候,将Statement实例与方法区中的connection做了强引用,而connection是“GC roots”可达的,导致Statement也是可达,因而导致了内存泄漏。而JVM的配置如下, -XX:+UseCMSInitiatingOccupancyOnly  -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseConcMarkSweepGC;当Oldgeneration的占比达到80%将会触发CMS进行回收,由于Statement实例不会被回收掉,所以CMS不能将Old generation的内存回收,所以回收线程一致处于运行状态,占用CPU高。

 

5、解决问题

方法1:最简洁的办法,将dontTrackOpenResources设置为true,jdbc配置文件中的url链接即可;

方法2:修改代码,再调用之后,关闭statement;

6、验证

采用方法1,经过验证,在运行过程中,内存正常分配和回收。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注