有木有小伙伴遇到内存溢出时很惊慌失措?频繁FGC不知道如何处理?今天小姐姐就带大家来熟悉熟悉我们的经常打交道的朋友----JAVA虚拟机
什么是JAVA虚拟机呢?
简单说:Java虚拟机(Java Virtual Machine 简称JVM)是运行所有Java程序的抽象计算机,是Java语言的运行环境。Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
JAVA内存区域及内存分配策略
下面让我们来看看JVM的组成。
程序计数器
一块很小的内存空间,它的作用就是用于保存当前线程执行的内存地址,JVM的多线程是通过线程轮流切换分配执行时间来实现的,在任何时刻,每个处理器都只会执行一个线程中的指令,当线程进行切换的时,为了线程能恢复当正确的位置,所以每个线程必须有个独立的线程计数器,这样才能保证线程之间不互相影响。
这里注意一下:该内存区域是Java虚拟机唯一没有规定任何OutOfMemoryError的区域。
虚拟机栈
也是一个线程私有的,生命周期与线程是同步的,一个线程一个栈,一个方法一个栈桢。每个方法在执行时,都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出入口等信息,每个方法的调用到执行完成的过程就是一个栈帧入栈到出栈的过程。
这里注意一下:虚拟机栈规定了2种异常情况,一种是线程请求栈的深度大于虚拟机栈所允许的深度,这时候将会抛出StackOverflowError异常。即XSS配置小了,这个时候可以调大xss的初始化值,当然由于内存是一定的,调大了xss,可产生的线程数也就相应减少。
另一种当Java虚拟机允许动态扩展虚拟机栈的时候,当扩展的时候没办法分配到内存的时候就会报OutOfMemoryError异常;理解了上一个问题,这个问题就不难理解啦,假如虚拟机内存是2个G,减去堆内存(Xmx),方法区(MaxPermSize),剩下程序计数器占用很小,剩下的就是本地方法栈和虚拟机栈,一个栈的大小被xss定义了,那可以生成的线程数就是有限的。当建立的线程越多就越容易将剩余内存耗尽。
本地方法栈
本地方法栈,与虚拟机栈执行的基本相同,唯一的区别就是虚拟机栈是执行Java方法的,本地方法栈是执行native方法的。
方法区(也有人称为永久代)
是线程共享的内存区域,存储被虚拟机加载的类信息、常量、静态变量、即时编译的代码数据等。
这里简单提下,到了JAVA 8去除了方法区,被移至Metaspace,Metaspace与PermGen之间最大的区别在于:Metaspace并不在虚拟机中,而是使用本地内存。
当方法区无法满足内存分配需求时将抛出OutOfMemoryError。方法区溢出也是一种常见的溢出,由于它是存放class相关信息的,当产生大量的类时可能就会造成方法区内存溢出。当然方法区也有参数可以设置大小:就是-XX:PermSize和-XX:MaxPermSize。如果加载的类都是必须存活的,就通过增大方法区的空间来解决报错啦!
JAVA堆:
讲了那么多,才讲到我们今天的主角啦!JAVA堆也是我们常说道的Heap区。是虚拟机内存划分最大的一块。也是所有JAVA线程共享的一块区域。此区域存放的就是对象实例啦!java堆也是GC回收的主要区域,让我们通过下面的图来了解下JAVA堆。
这个图相信大家都不陌生吧?
JAVA堆被分成新生代和老年代,新生代又被分为伊甸园和幸存者区。JAVA堆大小控制参数为:-Xms(初始化大小),-Xmx(最大扩展到大小)
通常当一个对象被创建时会先分配到伊甸园,待伊甸园满了就将发生一次YoungGC,将不再被引用的对象回收,而回收后仍然被引用的就将存入幸存者区S0,当伊甸园再次满了发生YGC时,幸存者就会从S0被转移到S1了。后面就是这么相互甩锅啦~哈哈哈。
那么什么对象才到老年代呢?
-
1.大对象直接进入老年代,啥才叫大对象呢?-XX:PretenureSizeThrold这个参数可以设置阈值,当超过这个值,那就算大对象啦。
-
2.年纪太大的进入老年代,没经过一次YGC存活下来的对象,年龄就加1,那什么样算大呢。一样的套路,小姐姐向你抛来一个参数:
MaxTenuringThreshold,当年龄大于该参数设置的值就是进入老年代。默认为15。
-
3.动态年龄分配
当Survivor区中相同年龄的对象大小加起来总和大于survivor的一半,则大于或等于这个年龄的对象都将进入老年代,无需等MaxTenuringThreshold。
说了这么多参数,可能大家会想,我怎么知道系统参数配的是啥呢?
再支个招,可以使用命令jps –lvm或者jinfo 命令就能把jvm的相关参数调出来啦。
垃圾收集器
在以上多次提到GC,那什么情况会发生GC呢?
YGC(Mirror GC):新生代的初始化大小由-Xmn控制,而伊甸园和幸存者区按照参数-XX:SurvivorRateio来分配,默认-XX:SurvivorRateio=8,即伊甸园和幸存区8:1,若-Xmn20M,则eden为16M,S0=S1=2M。当新生代存活对象大于这个值时将发生一次YGC。
FGC:当老年代的内存无法在扩张时,就将触发FGC,这里注意,FGC的次数不宜频繁。因为FGC是一个单独的线程,当进行FGC时,用户线程将被暂停(为什么要暂停呢?小姐姐一会告诉大家),所以频繁的FGC一方面会消耗CPU,一方面会影响线程的处理速度(也就是响应会慢)那么老年代的大小是如何得来的呢?JVM没有专门这只老年代的参数,但是有设置整个JAVA堆的参数-Xms,-Xmx,那聪明的你肯定知道了,老年代只要用这个参数减去-Xmn就可以啦~!
这里请注意:当java堆发生内存泄露时,就得排查泄露的对象是通过怎样的引用链(可以通过内存分析工具查看引用链如MAT-eclipse)到达GC root的,若不存在泄露,也就是内存的对象必须存活,一般的套路就是调整上述的参数啦(Xmx)!
上面说到垃圾回收,大家肯定又有疑问,垃圾回收是怎么进行的?为什么FGC需要暂停用户进程呢?这就要说到垃圾回收的算法啦!
1.Mark-Sweep(标记-清除)算法
这是最基础的垃圾回收算法,是最容易实现的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新一次垃圾收集动作。
2.Copying(复制)算法
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。(这种算法是不是很熟悉,没错,YGC幸存者区回收垃圾用的就是这种啦~)
3.Mark-Compact(标记-整理)算法
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。(老年代用的就是这种算法啦)具体过程如下图所示:
既然新生代和老年代的垃圾回收算法不同,自然用到的垃圾收集器也不同啦。
新生代垃圾收集器:
1.serial收集器
咳咳一堆定义就不啰嗦啦,直接划重点~它是单线程收集器,他进行垃圾回收时会暂停其他所有的工作线程(STW)。听上去是不是很可怕?如果垃圾回收很频繁那系统还工不工作了?因为垃圾回收的原理也跟大家介绍过,它需要先标记,试想如果一边标记,用户线程一边跑(相当于在扔垃圾),那垃圾收集器啥时候标记完?
垃圾收集的线程图如下:
它适合于client模式下的虚拟机,或者单核的环境,因为它没有线程切换的开销呀,简单而高效。而对于多核环境来说效率就太低啦。
2.ParNew收集器
这个收集器就是Serial的多线程版本~其他特性跟Serial一样啦~上图!(这个介绍是不是很简单粗暴)
它默认开启的线程数与CPU核数相同,也可以通过-XX:ParallelGCThreads控制线程数,那的优势当然是在多核机器时收集更高效啦~
3.Parallel Scavenge收集器
它与ParNew基本没差别啦,一点点差别就在于,它能够控制吞吐量,吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。要知道高吞吐率就能高效利用CPU时间。控制参数是:-XX:MaxGCPauseMillis(大于0的毫秒数)控制垃圾回收停顿的最大时间,-XX:TimeRatio(1-99的整数). 设置吞吐量大小。看到这个,读者会不会露出邪恶的笑容,那我不是把停顿时间设置的越小,吞吐越大性能就越好?(PS:神经病啊,当然不是啦)停顿时间短是以缩小新生代的大小为代价的。收集200M的新生代当然比500M的快啦。(嗯?可是之前不是说有参数把新生代都设置好了吗?)这边需要再介绍一个开关参数:-XX:UserAdaptivePolicy,当设置了这个参数时,Xmn,-XX:SurvivorRatio等参数就没用啦。收集器会根据需要调节新生代,老年代的大小。这也是Parallel Scavenge收集器自适应的特点。
4.Srial Old收集器
它与SerialNew相似都是单线程的收集器,但是它是老年代的收集器,采用的“标记-整理”算法。
5.Parallel Old收集器
它是Parallel Scavenge的老年版~多线程版“标记-整理”就不多解释啦,他俩是黄金搭档咯。
6.CMS收集器
这个得啰嗦两句,它是基于“标记-清除”算法。它的运作较复杂,分为四个阶段:
初始标记(CMS initialmark),标记可直接到达GC Root的对象
并发标记(CMS currentmark),由初始标记的对象顺藤摸瓜得到间接与GC
root关联的对象,这个线程与用户线程并行的。那顺腾摸瓜时又出现了新的对象呢?
重新标记(CMS remark)标记新产生的可回收的对象
并发清除(CMS concurrent sweep),清除被标记的对象
初始标记和重新标记仍要停止用户线程,但时间很短暂这样GC过程只会有2个短暂的停顿~比起前面介绍的垃圾收集器还是很优秀的哦~
缺点:
1.CMS较消耗CPU资源所以适合CPU核数较多的。CMS默认启动线程数是(CPU数量+3)/4,当CPU不足4个时,CMS对用户程序影响就很大。
2.CMS并发清除时,由于与用户进程并发,清除后依然会存在部分垃圾,称为浮动垃圾。只能待下次GC再清除。也是因为垃圾收集阶段用户进程还要进程,所以CMS收集器不能等老年代满了采取回收,而是预留部分空间,默认内存占用达到68%就将触发,这个标准还是骗保守的。当然这个指标也是可以通过参数-XX:CMSInitiatingOccupancyFranction(范围:1-99)进行调高。倘若运行CMS期间内存不够,就会抛出Concurrent Mode Failure,此时会临时启用Serial Old。当然本次收集用户线程暂停时间也就长了。所以这个参数也不能调太高
3.CMS是基于标记清除的算法,虽然快,但是会产生大量空间碎片,也会导致虽然老年代空间足够但是没有连续的空间,从而触发了FGC。所以CMS附赠一个参数-XX:UseCMSCompactAtFullCollection,用于收集后的空间碎片整理。
7.G1收集器
他是当今收集器技术发展的最前沿的成果之一,G1是一款面向服务器端应用的垃圾收集器。
采用标记-整理算法,非常精确地控制停顿,在基本不牺牲吞吐量的前提下完成低停顿的内存回收。
初始标记(标记一下GC Roots能直接关联的对象并修改TAMS值,需要STW但耗时很短)
并发标记(从GC Root从堆中对象进行可达性分析找存活的对象,耗时较长但可以与用户线程并发执行)
最终标记(为了修正并发标记期间产生变动的那一部分标记记录,这一期间的变化记录在Remembered
SetLog里,然后合并到RememberedSet里,该阶段需要STW但是可并行执行)
筛选回收(对各个Region回收价值排序,根据用户期望的GC停顿时间制定回收计划来回收);
GC日志解读:
说了这么多,我们怎么看GC的日志呢?
JavaGC日志可以通过+PrintGCDetails开启,这边直接盗个图,因为觉得说的很详细啊。
新生代:
老年代:
如何看GC的情况呢?
可使用命令:jstat -gccause pid 1s pid为进程号, 1s为每隔1秒刷新一次。
S0,S1:为幸存者区的内存占用百分比
E:为Eden区占用百分比
O:老年代占用百分比(一般达到100%就会发生一次FGC啦)
M:方法区的使用情况
CCS压缩类空间使用情况
YGC,YGCT:前者就是YOUNG GC的次数,后者是所有次YOUNG GC的累积时间(s)
FGC,FGCT:前者是FULL GC的次数,后者是FGC的总时间(s)
GCT:从应用程序启动到采样时gc用的总时间(s)