对象存活还是死亡(哪些对象需要回收?)
引用计数算法
- 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用值失效时,计数器值减1;任何时刻计数器为0的对象就是不可能再被使用的。
- 主流JVM没有选用该算法管理内存,最主要的原因在于其很难解决对象之间相互循环引用的问题。譬如两个互相引用的对象不可能再被访问时,它们的引用计数都不为0,导致引用计数算法无法通知GC收集器回收它们。
可达性分析算法
- 通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(图论的角度:不可达)时,则证明此对象是不可用的。
- 在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈(Native方法)引用的对象
引用分类
引用级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用
- 强引用:指在程序代码之中普遍存在的类似“Object obj = new Object()”这类的引用。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用:用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。通过SoftReference类来实现软引用。
SoftReference ref = new SoftReference(new Object());
- 弱引用:也是用来描述非必需对象的,其强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。通过WeakReference类来实现弱引用。
- 虚引用:最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。通过PhantomReference类来实现虚引用。
生存还是死亡
即使在可达性分析算法中不可达的对象,也并非一定会死。真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果对象被判定有必要执行finalize()方法,那么这个对象将会被放置在F-Queue队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。此处执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。因为一个对象finalize()方法执行缓慢或发生死循环可能会导致F-Queue队列中其他对象永久处于等待,导致内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联(譬如把自己赋值给某个类变量或对象的成员变量),那么在第二次标记时它将被移除出“即将回收”的集合。
值得注意的是任何一个对象的finalize()方法都只会被系统自动调用一次。其运行代价高昂,不确定性大,无法保证各个对象的调用顺序。因而实际中不推荐使用finalize()方法。
回收方法区
方法区(或HotSpot虚拟机中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。
- 回收废弃常量和回收Java堆中的对象非常类似。如果没有任何对象引用了常量池中的常量,那么发生内存回收时如果有必要的话该常量会被系统清理出常量池。
- 判断常量是否是“废弃常量”比较简单,而判定一个类是否是“无用的类”则要满足下面3个条件:
- 该类所有的实例都已经被回收,Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述条件的无用类进行回收,但并不是必然会回收。是否对类进行回收HotSpot虚拟机提供了 -Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。
在大量使用反射、动态代理的场景都需要虚拟机具备类卸载功能,以保证方法区不会溢出。
垃圾收集算法
标记-清除算法(Mark-Sweep)
算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,其不足有以下两点:
- 效率问题:标记和清除的效率都不高。
- 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致之后分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(Copying)
复制算法将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。
然而算法将内存缩小为原来的一半,代价有很高。IBM研究表明新生代中的对象98%都是”朝生夕死”的。因而现在的商用虚拟机新生代的内存不再是1:1划分,而是被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。
标记-整理(Mark-Compact)算法
复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。
分代收集算法
现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法一般将Java堆分为新生代和老年代。根据各个年代的特点采用最适当的收集算法。
- 新生代:每次GC时有大量对象死去,只有少量存活,选用复制算法。
- 老年代:对象存活率高、没有额外空间进行担保,选用”标记-清除”或”标记-整理”算法。
HotSpot算法实现
枚举根节点
从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(”Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
由于目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
下面是HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。
[Verified Entry Point]
0x026eb730:mov%eax,-0x8000(%esp)
……
;;ImplicitNullCheckStub slow case
0x026eb7a9:call 0x026e83e0 ;OopMap{ebx=Oop [16]=Oop off=142}
;*caload
;-java.lang.String:hashCode@48(line 1489)
;{runtime_call}
0x026eb7ae:push$0x83c5c18 ;{external_word}
0x026eb7b3:call 0x026eb7b8
0x026eb7b8:pusha
0x026eb7b9:call 0x0822bec0 ;{runtime_call}
0x026eb7be:hlt
安全点
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。
这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
- 抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
下面的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0x160100的内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。
0x01b6d627:call 0x01b2b210 ;OopMap{[60]=Oop off=460}
;*invokeinterface size
;-Client1:main@113(line 23)
;{virtual_call}
0x01b6d62c:nop ;OopMap{[60]=Oop off=461}
;*if_icmplt
;-Client1:main@118(line 23)
0x01b6d62d:test %eax,0x160100 ;{poll}
0x01b6d633:mov 0x50(%esp),%esi
0x01b6d637:cmp %eax,%esi
安全区域
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
垃圾收集器
HotSpot的垃圾收集器:两个收集器之间存在连线就说明它们可以搭配使用。
Serial/Serial Old收集器
- Serial收集器:最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程的收集器,单线程一方面意味着它只会使用一个CPU或一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。后者意味着,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。
- Serial Old收集器:Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理算法”,这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。CMS收集器是一款几乎可以认为有划时代意义的垃圾收集器,因为它第一次实现了让垃圾收集线程与用户线程基本上同时工作。
ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分之百保证可以超越Serial收集器。当然,随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
- 并行:多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发:用户线程与垃圾收集线程同步执行(不一定并行,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
Parallel Scavenge收集器
Parallel Scavenge收集器也是一个新生代收集器,也是用复制算法的收集器,也是并行的多线程收集器,但是它的特点是它的关注点和其他收集器不同。介绍这个收集器主要还是介绍吞吐量的概念。CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量的意思就是CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。另外,Parallel Scavenge收集器是虚拟机运行在Server模式下的默认垃圾收集器。
停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率利用CPU时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。
虚拟机提供了-XX:MaxGCPauseMillis和-XX:GCTimeRatio两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。
- -XX:MaxGCPauseMillis:允许的值是一个大于0的毫秒数,收集器将尽可能保证内存回收时间不超过设定值。但并非越小越好,GC停顿时间的缩短是以牺牲吞吐量和新生代空间换取的。譬如系统将新生代调小,收集空间变小了速度相应就会提高。但也导致了垃圾收集发生得更频繁。譬如原来10秒收集一次,每次停顿100毫秒。现在5秒收集一次,每次停顿70毫秒。停顿时间在下降,但吞吐量也在下降。
- -XX:GCTimeRatio:参数的值是一个大于0且小于100的整数,是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。默认值为99,就是允许最大1%(1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge收集器也被称为“吞吐量优先收集器”。Parallel Scavenge收集器有一个-XX:+UseAdaptiveSizePolicy参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden区和Survivor参数等细节参数了,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式被称为GC自适应的调节策略。如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。
Parallel Old收集器
Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。运行过程如下图所示:
CMS收集器
CMS(Conrrurent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器。使用“标记 - 清除”算法,收集过程分为如下四步:
- 初始标记:标记GCRoots能直接关联到的对象,时间很短。
- 并发标记:进行GCRoots Tracing(可达性分析)过程,时间很长。
- 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间比初始标记稍长一些,远比并发标记时间短。
- 并发清除:回收内存空间,时间很长。
其中初始标记和重新标记阶段仍然需要停顿所有Java执行线程。并发标记与并发清除两个阶段耗时最长,但这两个收集器线程可以与用户线程一起工作,所以从总体上说CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS具有以下缺点:
- CMS对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并随着CPU数量的增加而下降。但当CPU不足4个时,譬如2个,此时CMS对用户程序的影响可能变得很大。如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%。
- CM无法处理浮动垃圾。因为在CMS并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾。同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。CMS可以通过参数-XX:CMSInitiatingOccupancyFraction值来设置触发CMS收集器的老年代占用百分比。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启动SerialOld收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。因而-XX:CMSInitiatingOccupancyFraction参数设置的太高很容易导致大量“Concurrent Mode Failure”失败,反而导致性能降低。默认值为92%。
- 由于CMS基于“标记-清除”算法,收集结束时会有大量碎片空间产生,不利于大对象的分配,可能会提前触发一次Full GC。虚拟机提供了-XX:+UseCMSCompactAtFullCollection开关参数,用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并过程,该过程无法并发,因而这样会使得停顿时间变长。虚拟机还提供了一个参数-XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,接着来一次带压缩的GC。(默认值为0,即每次Full GC时都进行碎片整理)。
G1收集器
G1是一款面向服务端应用的垃圾收集器,相比其他GC收集器具有如下特点:
- 并发与并行:使用多个CPU来缩短”Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
- 分代收集:G1收集器可以不需要其他收集器配合就能独立管理整个GC堆(既管理新生代有管理老年代),采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。
- 空间整合:G1从整体上看是基于”标记-整理”算法实现的收集器,从局部(两个Region之间)上看是基于复制算法实现的。这意味着G1运作期间不会产生内存空间碎片。
- 可预测的停顿:G1和CMS都关注降低停顿时间,但G1能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值较大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
存在的问题:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保证准确性?
在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(Final Marking):把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收(Live Data Counting and Evacuation):对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。可以与用户程序一起并发执行,但是停顿用户线程将大幅提高收集效率。
理解GC日志
每种收集器的日志形式都是由它们自身的实现所决定的,换言之,每种收集器的日志格式都可以不一样。不过虚拟机为了方便用户阅读,将各个收集器的日志都维持了一定的共性,来看下面的一段GC日志:
[GC [DefNew: 310K->194K(2368K), 0.0269163 secs] 310K->194K(7680K), 0.0269513 secs] [Times: user=0.00 sys=0.00, real=0.03 secs]
[GC [DefNew: 2242K->0K(2368K), 0.0018814 secs] 2242K->2241K(7680K), 0.0019172 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System) [Tenured: 2241K->193K(5312K), 0.0056517 secs] 4289K->193K(7680K), [Perm : 2950K->2950K(21248K)], 0.0057094 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 2432K, used 43K [0x00000000052a0000, 0x0000000005540000, 0x0000000006ea0000)
eden space 2176K, 2% used [0x00000000052a0000, 0x00000000052aaeb8, 0x00000000054c0000)
from space 256K, 0% used [0x00000000054c0000, 0x00000000054c0000, 0x0000000005500000)
to space 256K, 0% used [0x0000000005500000, 0x0000000005500000, 0x0000000005540000)
tenured generation total 5312K, used 193K [0x0000000006ea0000, 0x00000000073d0000, 0x000000000a6a0000)
the space 5312K, 3% used [0x0000000006ea0000, 0x0000000006ed0730, 0x0000000006ed0800, 0x00000000073d0000)
compacting perm gen total 21248K, used 2982K [0x000000000a6a0000, 0x000000000bb60000, 0x000000000faa0000)
the space 21248K, 14% used [0x000000000a6a0000, 0x000000000a989980, 0x000000000a989a00, 0x000000000bb60000)
No shared spaces configured.
1、日志的开头“GC”、“Full GC”表示这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有Full,则说明本次GC停止了其他所有工作线程(Stop-The-World)。看到Full GC的写法是“Full GC(System)”,这说明是调用System.gc()方法所触发的GC。
2、“GC”中接下来的“[DefNew”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。
3、后面方括号内部的“310K->194K(2368K)”、“2242K->0K(2368K)”,指的是该区域已使用的容量->GC后该内存区域已使用的容量(该内存区总容量)。方括号外面的“310K->194K(7680K)”、“2242K->2241K(7680K)”则指的是GC前Java堆已使用的容量->GC后Java堆已使用的容量(Java堆总容量)。
4、再往后“0.0269163 secs”表示该内存区域GC所占用的时间,单位是秒。最后的“[Times: user=0.00 sys=0.00 real=0.03 secs]”则更具体了,user表示用户态消耗的CPU时间、内核态消耗的CPU时间、操作从开始到结束经过的墙钟时间。后面两个的区别是,墙钟时间包括各种非运算的等待消耗,比如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以如果看到user或sys时间超过real时间是完全正常的。
5、“Heap”后面就列举出堆内存目前各个年代的区域的内存情况。
生成GC日志:
设置Run configurations的VM参数:
- -verbose:gc (开启打印垃圾回收日志)
- -Xloggc:eclipse_gc.log (设置垃圾回收日志打印的文件,文件名称可以自定义)
- -XX:+PrintGCTimeStamps (打印垃圾回收时间信息时的时间格式)
- -XX:+PrintGCDetails (打印垃圾回收详情)
运行程序后刷新工程即可看到GC日志
垃圾收集器参数总结
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在Client 模式下的默认值,打开此开关后,使用Serial +Serial Old 的收集器组合进行内存回收 |
UseParNewGC | 打开此开关后,使用ParNew + Serial Old 的收集器组合进行内存回收 |
UseConcMarkSweepGC | 打开此开关后,使用ParNew + CMS + Serial Old 的收集器组合进行内存回收。Serial Old 收集器将作为CMS 收集器出现Concurrent Mode Failure失败后的后备收集器使用 |
UseParallelGC | 虚拟机运行在Server 模式下的默认值,打开此开关后,使用ParallelScavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收 |
UseParallelOldGC | 打开此开关后,使用Parallel Scavenge + Parallel Old 的收集器组合进行内存回收 |
SurvivorRatio | 新生代中Eden 区域与Survivor 区域的容量比值, 默认为8, 代表Eden :Survivor=8∶1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC 之后,年龄就加1,当超过这个参数值时就进入老年代 |
UseAdaptiveSizePolicy | 动态调整Java 堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden 和Survivor 区的所有对象都存活的极端情况 |
ParallelGCThreads | 设置并行GC 时进行内存回收的线程数 |
GCTimeRatio | GC 时间占总时间的比率,默认值为99,即允许1% 的GC 时间。仅在使用Parallel Scavenge 收集器时生效 |
MaxGCPauseMillis | 设置GC 的最大停顿时间。仅在使用Parallel Scavenge 收集器时生效 |
CMSInitiatingOccupancyFraction | 设置CMS 收集器在老年代空间被使用多少后触发垃圾收集。默认值为92%,仅在使用CMS 收集器时生效 |
UseCMSCompactAtFullCollection | 设置CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS 收集器时生效 |
CMSFullGCsBeforeCompaction | 设置CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS 收集器时生效 |
内存分配与回收策略
以下内容验证的是Serial/Serial Old收集器(ParNew/Serial Old收集器也基本一致)的内存分配与回收策略。
对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- Minor GC:又名新生代GC。指发生在新生代的垃圾收集动作,因为Java对象大多具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Major GC/Full GC:又名老年代GC。指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC(非绝对)。Major GC的速度一般会比Minor GC慢10倍以上。
测试代码:
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
}
运行结果:
[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs] 6651K->6292K(19456K), 0.0070426 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4326K [0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 14% used [0x032d0000, 0x032f5370, 0x033d0000)
to space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
tenured generation total 10240K, used 6144K [0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 60% used [0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
代码的testAllocation()方法中,尝试分配3个2MB大小和1个4MB大小的对象,在运行时通过-Xms20M、 -Xmx20M、 -Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8∶1,从输出的结果也可以清晰地看到eden space 8192K、from space 1024K、to space 1024K的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。
执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次GC的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少(因为allocation1、allocation2、allocation3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。
这次GC结束后,4MB的allocation4对象顺利分配在Eden中,因此程序执行完的结果是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。通过GC日志可以证实这一点。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
测试代码:
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold() {
byte[] allocation;
allocation = new byte[4 * _1MB]; //直接分配在老年代中
}
运行结果:
1.Heap
2.def new generation total 9216K, used 671K [0x029d0000, 0x033d0000, 0x033d0000)
3.eden space 8192K, 8% used [0x029d0000, 0x02a77e98, 0x031d0000)
4.from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
5.to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
6.tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
7.the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
8.compacting perm gen total 12288K, used 2107K [0x03dd0000, 0x049d0000, 0x07dd0000)
9.the space 12288K, 17% used [0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
10.No shared spaces configured.
执行代码中的testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数一样直接写3MB),因此超过3MB的对象都会直接在老年代进行分配。
注意:PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。
长期存活的对象将进入老年代
为了在内存回收时能识别哪些对象应放在新生代,哪些对象应放在老年代中。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
测试代码:
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
byte[] allocation1, allocation2, allocation3;
allocation1 = new byte[_1MB / 4];
// 什么时候进入老年代取决于XX:MaxTenuringThreshold设置
allocation2 = new byte[4 * _1MB];
allocation3 = new byte[4 * _1MB];
allocation3 = null;
allocation3 = new byte[4 * _1MB];
}
以MaxTenuringThreshold=1参数来运行的结果:
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K(19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
: 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K(19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K, used 4500K [0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
重点关注以下两行:
from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
the space 10240K, 43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
以MaxTenuringThreshold=15参数来运行的结果:
1.[GC [DefNew
2.Desired Survivor size 524288 bytes, new threshold 15 (max 15)
3.- age 1: 414664 bytes, 414664 total
4.: 4859K->404K(9216K), 0.0049637 secs] 4859K->4500K(19456K), 0.0049932 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
5.[GC [DefNew
6.Desired Survivor size 524288 bytes, new threshold 15 (max 15)
7.- age 2: 414520 bytes, 414520 total
8.: 4500K->404K(9216K), 0.0008091 secs] 8596K->4500K(19456K), 0.0008305 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
9.Heap
10.def new generation total 9216K, used 4582K [0x029d0000, 0x033d0000, 0x033d0000)
11.eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
12.from space 1024K, 39% used [0x031d0000, 0x03235338, 0x032d0000)
13.to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
14.tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
15.the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
16.compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
17.the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
18.No shared spaces configured.
重点关注以下两行:
from space 1024K, 39% used [0x031d0000, 0x03235338, 0x032d0000)
the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行代码中的testTenuringThreshold()方法,此方法中的allocation1对象需要256KB内存,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后非常干净地变成0KB。而MaxTenuringThreshold=15时,第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时新生代仍然有404KB被占用。
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
测试代码:
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB / 4];
// allocation1+allocation2大于survivo空间一半
allocation2 = new byte[_1MB / 4];
allocation3 = new byte[4 * _1MB];
allocation4 = new byte[4 * _1MB];
allocation4 = null;
allocation4 = new byte[4 * _1MB];
}
运行结果:
1.[GC [DefNew
2.Desired Survivor size 524288 bytes, new threshold 1 (max 15)
3.- age 1: 676824 bytes, 676824 total
4.: 5115K->660K(9216K), 0.0050136 secs] 5115K->4756K(19456K), 0.0050443 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
5.[GC [DefNew
6.Desired Survivor size 524288 bytes, new threshold 15 (max 15)
7.: 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K(19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
8.Heap
9.def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
10.eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
11.from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
12.to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
13.tenured generation total 10240K, used 4756K [0x033d0000, 0x03dd0000, 0x03dd0000)
14.the space 10240K, 46% used [0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
15.compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
16.the space 12288K, 17% used [0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
17.No shared spaces configured.
代码中eden space 8192K, 51% used代表了最后加入的4MB对象存储在新生代Eden中,the space 10240K, 46% used代表了application1、2、3三个对象存储在老年代中。
执行代码中的testTenuringThreshold2()方法,并设置-XX:MaxTenuringThreshold=15,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了6%,也就是说,allocation1、allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。JDK6之后代码不再使用HandlePromotionFailure参数,规则变为只要老年代连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。