神刀安全网

认识JVM(下)——简单demo

本来写完前面两篇JVM,已经不再想写这类似的东西,因为很多知识点很难吃透,即使写出来也很难让人理解,即使理解还不如看官方资料,不过还是鼓起勇气写下这篇文章,本文主要是demo去理解一些JVM的内存知识,版本为hotspot的1.6.24版本,不过本文不讲指令,只是模拟一些东西,类似于出题目,和大家一起来做下;本文几个简单实验不能说明所有问题,仅仅是分享一下理解JVM的内在和一些不可告人的秘密,以及告诉分享一些方法,以后可以自己做实验去扩展。

1、首先来模拟一个简单的溢出,代码很简单,我们也把GC的过程拿出来看看:

import java.util.*;  public class Hello {      private final static int BYTE_SIZE = 4 * 1024 * 1024;      public static void main(String []args) {         List <Object> List = new ArrayList<Object>();         for(int i = 0 ; i < 10 ; i ++) {                 List.add(new byte[BYTE_SIZE]);                 System.out.println(i);         }     }     }

我们采用下面的命令运行一下:

C:/>javac Hello.java

C:/>java -Xmn4m -Xms20m -Xmx20m -XX:+PrintGCDetails Hello 0 1 2 [GC [DefNew: 266K->145K(3712K), 0.0012704 secs][Tenured: 12288K->12433K(16384K), 0.0078754 secs] 12554K->12433K(20096K), [Perm : 367K->367K(12288K)], 0.0097094 .01 secs] [Full GC [Tenured: 12433K->12420K(16384K), 0.0081298 secs] 12433K->12420K(20096K), [Perm : 367K->362K(12288K)], 0.0085821 secs] [Times: user=0.02 sys=0.00, rea Exception in thread "main" java.lang.OutOfMemoryError: Java heap space         at Hello.main(Hello.java:10) Heap  def new generation   total 3712K, used 133K [0x32690000, 0x32a90000, 0x32a90000)   eden space 3328K,   4% used [0x32690000, 0x326b1500, 0x329d0000)   from space 384K,   0% used [0x32a30000, 0x32a30000, 0x32a90000)   to   space 384K,   0% used [0x329d0000, 0x329d0000, 0x32a30000)  tenured generation   total 16384K, used 12420K [0x32a90000, 0x33a90000, 0x33a90000)    the space 16384K,  75% used [0x32a90000, 0x336b1338, 0x336b1400, 0x33a90000)  compacting perm gen  total 12288K, used 362K [0x33a90000, 0x34690000, 0x37a90000)    the space 12288K,   2% used [0x33a90000, 0x33aea960, 0x33aeaa00, 0x34690000)     ro space 10240K,  54% used [0x37a90000, 0x3800c510, 0x3800c600, 0x38490000)     rw space 12288K,  55% used [0x38490000, 0x38b2fb78, 0x38b2fc00, 0x39090000)

看到打印语句中,在输出2以后,也就是在下标3还没有输出来的时候(第四次),就出现了GC,也就是其实Eden始终是放不下4M的空间,而总的Heap只有20M,所以Tenured就是16M,当第4次放入4M内容到Tenured时,发现放不下,但是也回收不掉,所以就内存溢出了;我们看懂了为什么,但是奇怪的事情发生了,其实这个时候应该不需要前面的Young的GC,你看到它先发生了一次,这个算什么呢,这个算是一个BUG哈,不过知道就OK了,不算是什么大问题,后来在服务器端的一些回收就没有这个问题,不过有一些其他的问题,呵呵。

此时我们通过jps查看下进程号,然后通过,jstat跟踪下回收的过程,为了能够实时的跟踪到代码,在代码申请内存前(记住是在每次做new空间之前,也就是循环体前面),可以将其做一定时间的延时处理,代码细节就不多说了,一下为给出监控命令得到的结果:

C:/>jstat -gcutil 5024 1000 100  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT  0.00   0.00   8.01   0.00   2.99      0    0.000     0    0.000    0.000 0.00   0.00   8.01  25.00   2.99      0    0.000     0    0.000    0.000 0.00   0.00   8.01  50.00   2.99      0    0.000     0    0.000    0.000 0.00   0.00   4.00  75.81   2.95      1    0.004     2    0.022    0.025

大致得出的结果如上所示,可以看出,Eden区域的空间几乎没有什么变化,因为它本身就放不下东西,不过为什么还是有一些空间呢,因为程序本身的一些开销的一些内容会放在这里,而O指代Old区域,其实就是Tenured,这也是非常原始的说法,可以看到它随着时间的偏移,内存使用按照比例上升,而比例上也是我们预期的,但是奇怪的事情发生了,就是YGC做了一次,而FGC做了两次下面我先解释下这些参数后再说明这个问题:

在前序的文章中已经说明目前绝大部分的hotspot JVM,都是用Eden+S0+S1+Old组成了Heap区域,以及一个Perm组成JVM所管辖区域,当然还包含native的内存,通过jstat命令可以查看到非native的区域,也就是堆栈的内存监控,更多的监控工具在本文中不做介绍。

上述关键字E代表Eden、O就是代表Old、P代表Perm、YGC就是Yong区域发生的GC次数、YGCT就是发生GC的时间开销、FGC就是Full GC的次数,GCT就是FGCT就是Full GC的时间开销,GCT就是总体的GC时间开销延迟,注意:所谓时间开销延迟就是指影响应用业务运行的延迟动作,这段时间,这部分内存是无法工作的,但是YGC仅仅影响Yong区域的内存而已。

jstat -gcutil 5024 1000 100这个命令,前两个不用多说,可以携带的参数可以使用jstat –help进行查看,而5024为查看到的进程号,在Linux下可以直接通过动态参数赋值进去即可,而1000代表每秒采集一次数据,最后100代表最多采集100次,如果对应的进程结束,采集也会结束。

再来解释下GC的情况:

上面看到的DefNew、Tenured这些个关键字,其实就是代码的名称,从这里就可以看出来我使用的GC是最原始的GC方法,串行而且需要等待的一种GC方法,当你使用各种GC的模式的时候会发现每种GC输出的日志是不一样的,而开头就是他们的代码类的类名(本文后面会介绍并行GC);而类似于这种数据:[Tenured: 12288K->12433K(16384K), 0.0078754 secs]应该如何看呢?这里代表Tenured这个类对Old区域进行回收,回收前的内存使用是:12288K,回收后的内存是:12433K,Old区域的总大小为:16384K,本次回收对应用的延迟为0.0078754秒,发现回收的内存非常少,因为这些内存本身就没有得到释放,但是也回收了一点点,因为程序为了配合测试本身就有一些开销在里面。

这是一种非常原始的GC,为什么发生了一次YONG GC,而且发生了两次FULL GC,理论上Yong区域不会有内存进去,不会有任何东西在里面,所以不会发生任何的YGC才对,而且应该只有一次Full GC,但是奇怪的事情发生了,最后得出的结论是这种GC机制是非常原始的,这是GC这段代码本身存在的BUG,也算是一种十分悲观的一种做法。

付:其实你在监控中如果时间控制得不是很好的话,有可能最后这条信息采集不到,因为内存溢出的时候,进程就会结束,进程结束,采集的jstat程序也采集不到内容了,所以这个模拟在时间上要把控得比较好才可以。

2、如果你第一个实验看得很明白我说的是什么,那么我们来看看第二个实验,我们将GC的方法改成比较老的并行GC方法,但是代码也稍微修改下:

import java.util.*;  public class Hello {      private final static int BYTE_SIZE = 3 * 1024 * 1024;      public static void main(String []args) {         List <Object> List = new ArrayList<Object>();         for(int i = 0 ; i < 9 ; i ++) {        listInfo.add(new byte[BYTE_SIZE]);        if(i == 6) {                 listInfo.clear();        }        sleepTime(1000);      }     }     }

注意看下,这里循环10次,每次申请的内存变成3M,主要为了测试方便,另外sleepTime方法是我自己写的一个方法用于当前线程休眠,大家可以写一个方法来完成,代码很简单,这里就不给出源码了。

我们这里测试时为了方便,把Yong区域设置为10M,按照默认的话,Eden就会是8M,也就是最多装入2次循环的申请,也就是每两次后就让它晋升到Old区域,每次晋升6M。而Old区域我们设置为20M,也就是晋升3次后,就是18M了,此时i=6,我们将Old区域的内存清理掉,然后再申请内存看下有什么事情发生。本次测试用ParallelGC来完成,直接启用-server也是默认启动该参数。

运行时命令如下:

C:/>java -Xmn10m -Xms30m -Xmx30m -XX:+UseParallelOldGC -XX:+PrintGCDetails Hello  [GC [PSYoungGen: 6468K->176K(8960K)] 6468K->6320K(29440K), 0.0052248 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC [PSYoungGen: 6400K->160K(8960K)] 12544K->12448K(29440K), 0.0056272 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC [PSYoungGen: 6357K->160K(8960K)] 18645K->18592K(29440K), 0.0051462 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC [PSYoungGen: 160K->0K(8960K)] [PSOldGen: 18432K->18577K(20480K)] 18592K->18577K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0064599 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [Full GC [PSYoungGen: 6178K->0K(8960K)] [PSOldGen: 18577K->3204K(20480K)] 24756K->3204K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0067249 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] Heap  PSYoungGen      total 8960K, used 3225K [0x08ee0000, 0x098e0000, 0x098e0000)   eden space 7680K, 42% used [0x08ee0000,0x092066e8,0x09660000)   from space 1280K, 0% used [0x09660000,0x09660000,0x097a0000)   to   space 1280K, 0% used [0x097a0000,0x097a0000,0x098e0000)  PSOldGen        total 20480K, used 3204K [0x07ae0000, 0x08ee0000, 0x08ee0000)   object space 20480K, 15% used [0x07ae0000,0x07e01268,0x08ee0000)  PSPermGen       total 12288K, used 2086K [0x03ae0000, 0x046e0000, 0x07ae0000)   object space 12288K, 16% used [0x03ae0000,0x03ce9a30,0x046e0000)

此时你会发现进行了很多次GC,而GC的日志和我们第一个实验GC的日志输出不太一样,后面多出来一堆东西,而DefNew与Tenured已经不复存在,现在叫做:PSYoungGen、PSOldGen,同理你如果使用了-XX:+UseParallelOldGC打印出来的内容对Old的回首也会有所区别,你会看到ParOldGen这样的关键字出现,但是其它两个不会变化,注意:-XX:+UseParallelOldGC和-XX:+UseParallelGC两个参数不要混在一起使用,它们所调用的代码都是不一样的,另外还有CMS GC你看到的内容也是不一样的,那么我们这里阐述的关键性问题不是这个,而是在并行GC下的一个隐藏机制。

大家通过常规的笔算得到的结果应该是这样的(理论结果):

i(下标)  Yong   Old   YGC  FGC   备注

0            3          0       0        0       第一次申请3M

1            6          0       0        0       第二次申请3M,Eden区域已经存放6M

2            3          6       1        0       再申请3M,发现Eden放不下(Eden只有8M默认为Yong的80%),先YGC发现Survivor区域也放不下,晋升到Old。

3            6          6       1        0       重复步骤【1】

4            3          12      2        0       重复步骤【2】

5            6          12      2        0       重复步骤【1】

6            3          18      3        0       重复步骤【2】但是执行申请后,执行了clear,Old区域的内存将全部被认为是垃圾内存,不过当前肯定还没有回收。

7            6          18      3        0       重复步骤【1】

8            3          6       3        1       重复步骤【2】不过此处由于Old区域18M内存需要回收,所以发生一次FullGC操作,循环到此结束

那么在理论上就应该发生3次YGC,1次FullGC,但是看下日志的输出,竟然发生了2次FullGC,怎么回事了呢,用jstat监控下看下:

C:/ >jstat -gcutil 6088 1000 100   S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT   0.00   0.00   4.22   0.00  16.88      0    0.000     0    0.000    0.000   0.00   0.00  44.22   0.00  16.88      0    0.000     0    0.000    0.000   0.00   0.00  84.22   0.00  16.88      0    0.000     0    0.000    0.000   0.00  13.13  40.00  30.00  16.94      1    0.009     0    0.000    0.009   0.00  13.13  81.05  30.00  16.94      1    0.009     0    0.000    0.009  12.50   0.00  40.00  60.00  16.94      2    0.018     0    0.000    0.018  12.50   0.00  80.69  60.00  16.94      2    0.018     0    0.000    0.018   0.00   0.00  40.00  90.71  16.93      3    0.031     1    0.018    0.049   0.00   0.00  80.45  90.71  16.93      3    0.031     1    0.018    0.049   0.00   0.00  40.00  15.65  16.89      3    0.031     2    0.028    0.059

果然发生了两次FullGC,奇怪了,为什么发生了两次FullGC,看下日志详情,在第三次刚开始发生YGC的时候,它发生了一次FullGC,后面又出现了一次,第三次YongGC理论上发生完成后,Old区域也只有18M的内存,而Old区域有20M的空间,为什么会发生这个GC呢,再回到开始Full GC的第一个日志输出看下:

[Full GC [PSYoungGen: 160K->0K(8960K)] [PSOldGen: 18432K->18577K(20480K)] 18592K->18577K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0064599 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

此时看下PSOldGen中的内存的确和我们理论值一样18M,但是它做了一个从18M回收到18M,第一次我看到觉得很无奈而且很搞笑,后来知道了一个内部一个很神奇的原理,就是平均晋升大小的问题,当每次从Yong向Old区域晋升过程中,都有一个内存大小的记录,平均记录将会影响JVM对于当前内存稳定性的判别,它发现每次从YGC中每次平均向OLD晋升大概6M,此时当第三次YGC晋升后,它发现Old只剩下2M,平均每次是6M,剩余空间已经不足以支撑平均晋升的大小了,所以应该做一次Full GC才能让下一次YGC能够成功晋升,不过我不知道JVM为什么要这样做,因为下次晋升不够再做FullGC也不迟,为什么要这个时候做呢?这样往往导致了更多的FullGC。

在GC的参数上,前面已经有一些文章说明了,其实很多时候在服务器端设置的时候,只需要有一个-server就行了,而服务器端一般这个参数也是默认的,在绝大部分情况下,不要去设置过多的参数,除非你的应用场景非常的特殊,因为JVM给的默认值很多都是正确的,不过建议将一些自适应和手动GC关闭掉是比较好的,一般关心的部分主要是-Xms、-Xmx、-Xss、-Xmn这几个参数就可以了,其余的类似并行GC线程数、平均YGC次数晋升、自适应等这些内容用默认的一般就是对的,特殊场景比如:你的系统是用来做cache类的系统和做高并发访问的系统设置就有一些区别(经常做cache的内容,如果cache住的内容几乎是长驻的,那么我想让稍微大一点的内容直接进入Old,而不要在Yong区域来回倒腾;而高并发一般请求量较小,而且请求完成后基本都释放掉了,所以一般不让他进入Old,因为Old会导致FullGC,Yong内部每次倒腾只会寻找活着的节点,如果也就是Yong内部几乎都是挂掉的节点),如果你的系统用于做大内存OS也有很大的区别(这个时候你要考虑使用并发GC即CMSGC去解决回收时间的问题,现在的G1),而四不像的系统,或者叫什么都有的系统,也就是既有一些高并发,又有自己的cache,而且cache的时间不长也不短,真的就有点郁闷了,G1的出现希望可以解决掉这个问题,不过也曾有人自己通过应用的代码来解决这个问题,当然应用也有一定的特殊性,那就是应用的数据行,每行数据大概都是1M多,而且不会超过2M,这群人是将处理的数据全部划分为2M等长度的空间片段,将数据向内部填充,然后回收时,只找寻垃圾内存,并回收掉,但是他们不会去做compaction的操作,也就是不会去做重新排序的操作,这样节省了大量的FullGC的时间,并使得内存不会出现碎片的问题。

OK,JVM内存的浪费有很多种情况,我们讨论很多就是要讨论OOM的问题,运行时最关心的问题就是GC会暂停多久的运行业务,一般在内存溢出在线上运行由于代码造成的可能性为绝大部分,少部分是因为配置引起,除非配置参数全部乱写的,而代码就要从很多方面去说明了,从java代码本身的角度来说一般有的情况:

1、大量使用session存放数据,甚至于存放List、Map这些集合类,逐步嵌套,只要session没有注销,这些集合类以及集合类所引用的对象(可能是多层引用)导致这些内存不会被GC认为是垃圾内存;这种唯一的办法就是杀掉,谁写这种代码,就把谁杀掉,曾经有人提出过独立管理这类session,甚至于存入数据库中,不过这样放数据放在什么地方都会爆掉,而且放在数据库中也会极大的降低session的提取时间,甚至于可以说根本不用session存放数据了,因为我可以自己从数据库中拿,session应当只存放一些用户关键信息,可以使用独立的分布式缓存来存放,这样保证session是可以被切换的,如果通过服务器本身的session切换的话会有很多序列化的问题存在。

2、自定义的静态句柄使用不当,我并不是不推荐大家使用这个,主要是有人经常用这个指向一些集合类而且做一些内存管理过程中会有很多集合类来回引用,本来想带的GC来回引用GC已经可以识别出来它也是垃圾,但是如果你的顶层有一个静态句柄,那么就没法了,如果你不做clear,你永远释放不掉。

3、线程broken住或者被stop等操作或者waiting的时间过长,该线程内部的栈针指向的内存片段将不会被得到释放,其实从程序效率的角度来说,就是当你的线程在等待一个网络反馈结果之前的这个时间范围内,你在这行代码之前申请的内存,都不会被认为是垃圾内存,除非你自己做过一个 = null的操作,其实 = null或clear也是在这种情况下使用会提高性能,正常情况下这种操作意义并不大。

4、由于读取数据比较多,导致网络时间较长,这个时候,也和第三种情况差不多,而且这些数据也会被转入到内存中,甚至于进入old区域,那么也是会导致急速上涨,对于这类情况,如果业务的确经常有这种情况,可以适当调整Yong区域的大小,另外代码上要注意对数据提取的流量控制,甚至于拒绝单机上的高并发,以及每次提取的数量;在必要时应当控制每次输出的流量,适当情况可以选取断点传送。

5、文件下载,文件下载和上面类似,只是它是文件而不是基本的数据,这类内容在读取和输出过程中都会占用内存很多的开销,有人说用gzip压缩,的确,这个技术的确不错,不过gzip唯一解决的问题是服务器在输出时向客户端传送的网络流量,但是它本身也是需要占用CPU的,用过压缩工具的人都知道,压缩是非常占用CPU的工具之一,所以在业务允许的情况下,在预先处理之前就将一些必要的大文件进行压缩存放,输出式也是一个压缩包,不论在内存还是向客户端输出时都是一个压缩文件。

6、大量使用ThreadLocal来作为数据存放的中间量,如果经常使用这个内容的朋友请多多看下这个类的源码到底是怎么写的,其实ThreadLocal只是一个中间量,数据根本不是存放在这个类里面的,也就是即使ThreadLocal这个类结束掉,这些数据依然存在,它是存放在当前被访问的Thread中的,Thread中有一段源码就是这样定义了对应的Map结构来存放当前线程的参数信息,而ThreadLocal只是一个中间传输信息的工具,并自动判定当前线程信息,它根本什么数据都不存放,而绝大部分应用中,Thread都是一个队列池,也就是这些线程基本都是不会被释放的,那么这些线程所对应的Map以及下面的节点内容将永远得不到释放,所以要善用这个类。

7、滥用Thread,或-Xmx设置得过大,导致没有native内存,Thread本身是占用非JVM堆栈内存之外的native内存,-Xss决定其大小,1.5以后默认是1M的大小,它用于存放当前Thread所申请的对象的栈针信息,也就是通过它找到对象的,也就是没申请一个Thread就会从OS中开销1M的内存,这样不用多说,你懂的。

8、JNI的滥用,在使用native方法,也是使用native的内存,比如去调用一些C、C++语言执行,执行完成后,在C、C++中使用自己的malloc、realloc、new等方法申请的内存空间,没有使用free或delete去释放掉,那么它将永远释放不掉,除非该JVM进程停止掉,由OS判定出来这块内存是由这个进程所使用的。

9、网络输出,在网络输出时,会产生buffer,而buffer的大小,超越了OS级别的限制,它使用的也不是堆栈中的内存,而是外部的内存,在输出时需要注意网络流量的限制。

10、其他的注意点其实并不多,代码上的细节问题说起来就太多了,前面有说明过代码细节上的一些常见注意事项,以及通过javap来查看代码被编译后的命令到底是怎么执行的方法,通过这种方法就可以看出代码为什么会执行得很慢;另外代码要跑得快除了一些常见的注意事项外,还需要注意就是如果程序等待需要考虑什么内存可以释放掉,复杂的逻辑程序什么动作可以不做或者简化做,算法方面不要纠结太多,因为常规的应用业务不会存在太复杂的算法,当你遇到的时候再去纠结也是可以的。

代码内存溢出一般要么是并发上的确是扛不住了引起,不过这种一般是大型网站才会遇到,一般的系统是不会遇到的;另一类就是代码的确太难了,就和上面的情况比较符合,很多人问我,为什么JVM不能自动识别出来回收掉这些内存呢,我只能说,java真的还需要学下才行,所谓自动并非什么东西都是自动的,首先需要明白GC认为什么是垃圾才可以,如果你有东西只想他,只要这个内存通过栈针、final static句柄、以及native的空间他们是可达的,那么它就不是垃圾内存,如果放在一些常驻内存或根本不会被释放的内存的引用下或者被间接引用的子对象下,那么JVM永远也不会认为它是垃圾,这也没有什么办法让JVM自动知道它就是垃圾。

其余的不想多说,后面专门写几篇文章说明一个java的应用工程如何从服务器的前端到后端设计上提高的访问量以及性能,由于内容较多会分解为多篇文章说明不同的部分。

最后文章推荐大家使用一些监控工具,如:jconsole、virsualVM集成插件virsualGC、jmap、jstat以及异常强大的btree工具,由于工具说起来比较多,而且每种工具都有自己的特征和优势所在,在不同的场景下发挥作用,本文也不是重点,只是推荐使用,这里就简单截图一张看看图形如何说明GC的运行状态的,这里看下:virsualVM中的virsualGC插件对内存监控的效果是什么样的(对堆栈部分的内存使用和GC状态展现的非常的清晰,不过再次强调,这部分仅仅针对于Hotspot VM,即开源版本的Oracle的JVM):

认识JVM(下)——简单demo

本系列:

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » 认识JVM(下)——简单demo

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
分享按钮