垃圾回收可以说是JVM最关键的知识点。
基础知识
- 可达性分析
在jvm中,做为GCRoots的对象:
- 虚拟机栈(栈桢中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI的引用的对象
-
安全点
为什么需要安全点,是因为,新建一个对象的操作并不是原子性的。需要分配内存然后把引用存到例如线程的本地变量表中。如果在刚完成新建一个对象,但是对象的引用还在寄存器中,此时线程终止,则刚刚建好的对象就会作为不可达对象被删除。
安全点的选择既不能太少,让GC等待太长,也不能太多,增加运行负荷;安全点的选择以是否让程序长时间执行的特征;一般在方法调用;循环跳转;异常跳转等才会产生安全点。
GC中STW的操作,必须等到所有线程都进入安全点才行。 -
对象
每个对象都有一个对象头(MarkWord)。执行标记的时候就是将标记加入对象头。
JVM的分代管理策略
HOTSPOT JVM内存管理采取分代的策略:
-
年轻代(Young Gen)
中间包含一个Eden Space和两个Suvivor Space(S0,S1)
JVM为了加快GC回收速度,会在Eden区为每一个线程分配一个很小的TLAB空间,用于存储线程独享的小对象。需要打开-XX:+UseTLAB来启动这一机制。另外,这和逃逸分析栈上分配不是一回事。逃逸分析是直接进行栈上分配。 -
老年代(Tenured Gen)
-
持久代(Perm Gen)/元数据区。
虽然号称持久代,但实际上也可以被垃圾收集。
JDK7之前,所谓的Permanet Generation内存区域其实包含了两个部分:
- 方法区,存储class文件,具体包括:类的方法,类的名称,常量池。方法区是JVM规范里写出要实现的。方法区( Method Area) 是可供各条线程共享的运行时内存区域。
- Internded String
JDK7中,开始了减轻持久代的问题。做了如下工作。
- 符号引用(Symbols)转移到了native heap
- 字面量(interned strings)转移到了java heap
- 类的静态变量(class statics)转移到了java heap。
常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。包含以下内容
- 代码中所定义的各种基本类型
- 构造函数和普通方法的字节码内容
Oracle JDK8的HotSpot VM去掉“持久代”,以“元数据区”(Metaspace)替代之。
区别就是原来的持久代直接属于GC heap(受GC管理。)即GC HEAP =JAVA HEAP + PERM。移到Metaspace的原因是PermGen很难调整。PermGen中类的元数据信息在每次FullGC的时候可能会被收集,但成绩很难令人满意。而且应该为PermGen分配多大的空间很难确定,因为PermSize的大小依赖于很多因素,比如JVM加载的class的总数,常量池的大小,方法的大小等。
Metaspace VM利用内存管理技术来管理Metaspace。这使得原来由不同的垃圾收集器来处理类元数据的工作,现在仅仅由Metaspace VM自己进行管理。Metaspace背后的一个思想是,类和它的元数据的生命周期是和它的类加载器的生命周期一致的。也就是说,只要类的类加载器是存活的,在Metaspace中的类元数据也是存活的,不能被释放。对其讨论已经超出了本文的范畴。
废弃永久代意味着我们永远告别了java.lang.OutOfMemoryError: PermGen。理论上Metaspace的最大大小取决于操作系统,但是也可以设置参数来进行限制。
JVM垃圾回收的策略
- 复制(Copying)算法
Eden/S0/S1三个区更合理,空间比例为Eden:S0:S1==8:1:1
- Eden+S0可分配新生对象;
- 对Eden+S0进行垃圾收集,存活对象复制到S1。清理Eden+S0。一次新生代GC结束。
- Eden+S1可分配新生对象;
- 对Eden+S1进行垃圾收集,存活对象复制到S0。清理Eden+S1。二次新生代GC结束。
- goto 1。
- 标记-紧凑(Mark-Compact)
- 标记:标记可回收对象(垃圾对象)和存活对象。
- 紧凑(也称“整理”):将所有存活对象向内存开始部位移动,称为内存紧凑(相当于碎片整理)。完毕后,清理剩余内存空间。
- 标记-清除(Mark-Sweep)算法
- 先判定对象是否可回收,对其标记。
- 统一回收(简单地删除对垃圾对象的内存引用)。
优点:简单直观容易实现和理解。缺点:效率不高,内存空间碎片化。
JVM提供的几种垃圾收集器
通过JVM启动参数来确定该使用哪种垃圾收集器。
Serial/Serial Old
最古老的收集器,是一个单线程收集器,用它进行垃圾回收时,必须暂停所有用户线程。Serial是针对新生代的收集器,采用Copying算法;而Serial Old是针对老生代的收集器,采用Mark-Compact算法。优点是简单高效,缺点是需要暂停用户线程。Serial至今仍然是新生代默认的垃圾处理器。
ParNew
Seral/Serial Old的多线程版本,使用多个线程进行垃圾收集。
默认和CMS搭配的年轻代收集器。
[GC (Allocation Failure) [ParNew: 48254K->6783K(61440K), 0.6221046 secs] 48254K->57192K(198016K), 0.6221474 secs] [Times: user=1.05 sys=0.11, real=0.62 secs]
Parallel Scavenge
新生代的并行收集器,回收期间不需要暂停其他线程,采用Copying算法。该收集器与前两个收集器不同,主要为了达到一个可控的吞吐量。
JDK7和JDK8中默认垃圾收集器。
##注意多核CPU的real可能会小于sys+user。
[GC (Allocation Failure) [PSYoungGen: 47508K->8176K(59904K)] 47508K->45648K(196608K), 0.1189809 secs] [Times: user=0.29 sys=0.05, real=0.12 secs]
如果说非要说和ParNew有什么区别,就是paralel scavenge可以设置最大停顿时间的。他的优化也是以减少停顿时间为目的的。而ParNew是以最大吞吐为目的的。
Parallel Old
Parallel Scavenge的老生代版本,采用Mark-Compact算法和多线程。
JDK8老年代默认。
CMS(适用于老年代)
Current Mark Sweep收集器是一种以最小回收时间停顿为目标的并发回收器,因而采用Mark-Sweep算法。算法牺牲吞吐量为代价来获得最短回收停顿时间。
##实际上是 "ParNew" + "CMS" + "Serial Old"。
##如果发生concurrent mode failure,CMS就会变成Serial Old,从而显著增加停顿时间。
-XX:+UseConcMarkSweepGC
concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。
##在一次CMS的阶段中,突然一次ParNew发起的年轻代清理产生了对象要放入老年代,
##此时,老年代空间不足,触发了concurrent mode failure
##终止CMS过程,使用Serial Old,最终造成了2.88s的停滞。
[GC (Allocation Failure) [ParNew: 32720K->6782K(61440K), 0.2004625 secs][CMS[CMS-concurrent-abortable-preclean: 0.485/0.696 secs] [Times: user=1.26 sys=0.03, real=0.69 secs]
(concurrent mode failure): 413819K->378732K(414620K), 2.6777082 secs] 417417K->378732K(476060K), [Metaspace: 8656K->8656K(1056768K)], 2.8795875 secs] [Times: user=2.97 sys=0.18, real=2.88 secs]
##promotion failed是在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的。promotion failed会导致Concurrent mode failure。
106.641: [GC 106.641: [ParNew (promotion failed): 14784K->14784K(14784K), 0.0370328 secs]106.678: [CMS106.715: [CMS-concurrent-mark: 0.065/0.103 secs] [Times: user=0.17 sys=0.00, real=0.11 secs]
(concurrent mode failure): 41568K->27787K(49152K), 0.2128504 secs] 52402K->27787K(63936K), [CMS Perm : 2086K->2086K(12288K)], 0.2499776 secs] [Times: user=0.28 sys=0.00, real=0.25 secs]
一次CMS包含如下阶段
- 初始标记(STW initial mark) 暂停应用
做两个事情
1)从GC Roots遍历可直达的老年代对象;
2)遍历被新生代存活对象所引用的老年代对象。 - 并发标记(Concurrent marking)
并发标记阶段的主要工作是,通过遍历第一个阶段(Initial Mark)标记出来的存活对象,继续递归遍历老年代,并标记可直接或间接到达的所有老年代存活对象。
由于在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:
新生代的对象晋升到老年代;
直接在老年代分配对象;
老年代对象的引用关系发生变更;
等等。
对于这些对象,需要重新标记以防止被遗漏。为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。
- 并发预清理(Concurrent precleaning)
在并发预清洗阶段,将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。
- 重新标记(STW remark) 暂停应用
因此,需要有一个stop-the-world的阶段来完成最后的标记工作,这就是重新标记阶段(CMS标记阶段的最后一个阶段)。主要目的是重新扫描之前并发处理阶段的所有残留更新对象。
主要工作:
遍历新生代对象,重新标记;(新生代会被分块,多线程扫描)
根据GC Roots,重新标记;
遍历老年代的Dirty Card,重新标记。这里的Dirty Card,大部分已经在Preclean阶段被处理过了。
- 并发清理(Concurrent sweeping)
并发清理阶段,主要工作是清理所有未被标记的死亡对象,回收被占用的空间。
- 并发重置(Concurrent reset)
典型日志如下
//初始标记(STW initial mark)
//STW(快速),扫描和根对象直接关联的对象。
[GC (CMS Initial Mark) [1 CMS-initial-mark: 97625K(136576K)] 121111K(198016K), 0.0190166 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
//并发标记(Concurrent marking)。和用户线程一同进行,继续扫描可达对象
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.608/0.620 secs] [Times: user=0.76 sys=0.06, real=0.62 secs]
//并发预清理(Concurrent precleaning),查找所有在并发标记阶段进入老年代的对象
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.151/0.449 secs] [Times: user=0.83 sys=0.06, real=0.45 secs]
[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.498/0.951 secs] [Times: user=2.19 sys=0.07, real=0.95 secs]
//重新标记(STW remark)。对并发预清理的对象判断是否可达,作出标记(短暂STW)
[GC (CMS Final Remark) [YG occupancy: 7825 K (61440 K)][Rescan (parallel) , 0.1830990 secs][weak refs processing, 0.0000214 secs][class unloading, 0.0011215 secs][scrub symbol table, 0.0011397 secs][scrub string table, 0.0002518 secs][1 CMS-remark: 260692K(261316K)] 268518K(322756K), 0.1858328 secs] [Times: user=0.46 sys=0.00, real=0.19 secs]
//并发清理(Concurrent sweeping)
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.067/0.067 secs] [Times: user=0.16 sys=0.01, real=0.07 secs]
//并发重置(Concurrent reset)
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.008/0.008 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
- -XX:SurvivorRatio 新生代和老年代的比值。一般算成1:8。显然,新生代越大,晋升到老年代的就越少,可以提高CMS对老年代回收的性能。(相应的等于说降低了新生代的回收性能)
- -XX:+UseParNewGC, -XX:+UseConcMarkSweepGC 这俩几乎是默认搭配了
- -XX:ParallelGCThreads, -XX:ParallelCMSThreads 这俩的线程数量
- -XX:MaxTenuringThreshold 新生代晋升为老年代的年龄阈值
- -XX:+UseCMSCompactAtFullCollection CMS的时候不可避免会造成内存碎片。所以在不得不进行Serail Old Full GC的时候应该顺便进行内存压缩。
- -XX:+UseCMSInitiatingOccupancyOnly , -XX:CMSInitiatingOccupancyFraction 这俩一般一起使用,表示老年代使用多少比例后开始进行CMS回收。如果不使用UseCMSInitiatingOccupancyOnly,那么JVM会在他认为合适的时候自动触发
- -XX:+CMSClassUnloadingEnabled 表示CMS同时对永久代也进行垃圾回收。
G1
G1(Garbage First)收集器技术的前沿成果,是面向服务端的收集器,能充分利用CPU和多核环境。是一款并行与并发收集器,它能够建立可预测的停顿时间模型。g1收集器是一个面向服务端的垃圾收收集器,适用于多核处理器、大内存容量的服务端系统。 它满足短时间gc 停顿的同时达到一个高的吞吐量。G1的处理阶段类似于CMS。目标也和CMS一致。
比如阿里内部的ZenGC就是基于G1的
G1的改变还是革命性的,打破了年轻代和老年代的连续分区(但是仍然区分来年代和新生代)。G1的收集也不区分年轻代和老年代。
一般每个region大小预设为1M或者即使M。如果对象大于半个reagion。那就直接放在O块。如果大于一个region,那么分配多个连续的O块。每个region维护一个
- RSET 本region引用其他region的。
- CSET 本次GC需要清理的垃圾集合
完整的过程如下:
- 对于YGC,仍然是复制算法
- 对于Old区的GC,在G1中被称为Mix GC(就是Old区GC 顺便完成YGC)
-
- 初次标记。除了标记GCRoot,还标记GCRoot所在的region。STW。根据找到的rootRegion,找到所有和rootRegion相关的region。
-
- 并发标记。遍历上步骤中所有找到的region
-
- 重新标记。SATB算法。STW。https://www.pianshen.com/article/74861031970/ 这个算法还是蛮复杂的。其实对我们来说,没有找到一个需要清理的对象并无伤大雅,完全可以下次清理,但是把一个有效对象标记成垃圾清理就是完全不可接受了。这个算法可以看作是在新的region数据结构下的一种优化。
-
- 清理。复制算法。STW。只选择垃圾较多的区域进行。所谓Garbage-First。活的越久的老年代被选中进行回收的可能性越高。
用户在使用G1的时候,可以指定期望的暂停时间。G1将尽量在期望暂停时间内做更多的事。既然叫做【Garbage-First】,就是要在并发标记和最终标记时,找到最高收益(也就是最多垃圾)的分区进行回收。回收的方式是将存活的对象移动到其他的分区,相当于同时完成紧凑的工作。
一些常见的调优参数
- XX:InitiatingHeapOccupancyPercent。默认45%。当整个堆的使用比超过这个值的时候,就会开始Concurrent Marking Cycle Phases,也就是提前触发了一个GC周期。这个值越低,GC越频繁,越高,越容易出现Full GC(Serail old清理)。
- XX:G1HeapWastePercent。在上述并发标记后,计算可被回收的垃圾的比值。默认5%。如果超过这个值,那么就会开始mixed gc。这个GC有助于更多的回收老年代。
ZGC
JDK11中推出的垃圾处理器。oracle的JDK11收费,一般大家都用OpenJDK11。宣传上说停顿时间不大于1ms。这可以大幅度提高一些互联网应用的999线。
可以堪称是G1的进一步优化。
直接在老年代分配对象
1、分配的对象大小大于eden space。适合所有收集器。
2、eden space剩余空间不足分配,且需要分配对象内存大小不小于eden space总空间的一半,直接分配到老年代,不触发Minor GC。适合-XX:+UseParallelGC、-XX:+UseParallelOldGC,即适合Parallel Scavenge。
3、大对象直接进入老年代,使用-XX:PretenureSizeThreshold参数控制,适合-XX:+UseSerialGC、-XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,即适合Serial和ParNew收集器。
JVM的清理过程
(1)minorGC:
清理Eden,S0,S1
(2)majorGC
清理老年代
http://www.importnew.com/15820.html
事实上majorGC和full gc的定义十分令人困惑,建议忽略major GC的说法。重点从什么GC对什么空间做了什么入手。
(3)FullGC
清理所有堆空间。full gc的耗时事件往往非常长。
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(798208K)] [ParOldGen: 2623370K->2622981K(2796544K)] 2623370K->2622981K(3594752K), [Metaspace: 8908K->8761K(1056768K)], 17.6905044 secs] [Times: user=58.44 sys=0.75, real=17.69 secs]
如下操作可能会触发Full GC:
-
旧生代空间不足
旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误:
java.lang.OutOfMemoryError: Java heap space
为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。 -
Permanet Generation空间满
PermanetGeneration中存放的为一些class的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
java.lang.OutOfMemoryError: PermGen space
为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。 -
CMS GC时出现promotion failed和concurrent mode failure
对于采用CMS进行旧生代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。
promotionfailed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。
应对措施为:增大survivorspace、旧生代空间或调低触发并发GC的比率,但在JDK 5.0+、6.0+的版本中有可能会由于JDK的bug29导致CMS在remark完毕后很久才触发sweeping动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为ms)来避免。 -
统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间
这是一个较为复杂的触发情况,Hotspot为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行Minor GC时,做了一个判断,如果之前统计所得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发Full GC。
例如程序第一次触发MinorGC后,有6MB的对象晋升到旧生代,那么当下一次Minor GC发生时,首先检查旧生代的剩余空间是否大于6MB,如果小于6MB,则执行Full GC。
当新生代采用PSGC时,方式稍有不同,PS GC是在Minor GC后也会检查,例如上面的例子中第一次Minor GC后,PS GC会检查此时旧生代的剩余空间是否大于6MB,如小于,则触发对旧生代的回收。
所有GC都会触发STW。
方法区的内存回收
方法区的垃圾回收主要回收两部分内容:1. 废弃常量。2. 无用的类。既然进行垃圾回收,就需要判断哪些是废弃常量,哪些是无用的类。
如何判断废弃常量呢?以字面量回收为例,如果一个字符串“abc”已经进入常量池,但是当前系统没有任何一个String对象引用了叫做“abc”的字面量,那么,如果发生垃圾回收并且有必要时,“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
如何判断无用的类呢?需要满足以下三个条件
-
该类的所有实例都已经被回收,即Java堆中不存在该类的任何实例。
-
加载该类的ClassLoader已经被回收。
-
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
对于哪些需要动态加载卸载外部代码的类,显然你需要保证三点
- 使用单独的CL加载外部代码,卸载时将CL销毁
- 卸载时销毁所有需要的代码引用
- 不要允许外部应用加载过于复杂的类
FAQ
- java.lang.OutOfMemoryError:GC overhead limit exceeded
java内存溢出,简单而言就是内存不足了,因为是error,按照java异常机制最初设计直接程序就应该完蛋了。显然,这种情况不利于保存现场,所以,JVM定义了一个提前预测的内存不足的机制,并抛出该error。
你可以用catch(throwable e)来catch住该错误,但是,建议你只进行保存现场的的工作,然后退出程序。
JVM参数优化
如果是因为CMS产生碎片过多,导致大对象无法在老年代分配,从而引起concurrent mode failure,可以指定在CMS在运行若干次标记清除算法后运行一次标记整理算法。
-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
##也就是CMS在进行5次Full GC(标记清除)之后进行一次标记整理算法,从而可以控制老年代的碎片在一定的数量以内,甚至可以配置CMS在每次Full GC的时候都进行内存的整理。
如果是突然的大对象无法在老年区分配,则思路还有提前触发老年区的垃圾收集,以保证老年区总有足够的空间。解决这个问题的通用方法是调低触发CMS GC执行的阀值,CMS GC触发主要由CMSInitiatingOccupancyFraction值决定,默认情况是当旧生代已用空间为68%时,即触发CMS GC,在出现concurrent mode failure的情况下,可考虑调小这个值,提前CMS GC的触发,以保证旧生代有足够的空间。
调大新生代的S0,S1区域的值适合那些对象大而且瞬间生死的应用。避免使用老年区装载对象。
一些反例代码
如果现在互联网和传统软件业有什么不同的话,那么就是什么人都能写代码,而且完全没有必要的培训,事先设计和事后验证。所以,我们可以有很多神奇的代码,令人哭笑不得。
- 例子一 线程池的滥用。作者似乎是考虑到读取大文件,所以采用Scanner类。没有字符串拼接,使用String和StringBuffer都无所谓。但是最关键的!作者忘记了线程池的工作队列!试想以下,程序跑起来后,文件读取速度会非常的快,而网络IO则相对很慢,线程池的消费速度如果赶不上生产速度,那么所有任务会堆积在工作队列中,会导致大量的内存溢出。
inputStream = new FileInputStream(fileName);
sc = new Scanner(inputStream, "UTF-8");
while (sc.hasNextLine()) {
final String innerRow = sc.nextLine();
exec.execute(new Runnable() {
public void run() {
if (count.incrementAndGet() % 10000 == 0) {
logger.info(count + " certs have been scanned..");
}
String key = innerRow.split("-")[0];
producer.send("UMETRIP_PUT_HBASE_TOPIC", innerRow, key);
logger.info(innerRow);
}
});
}