理解jvm(二)--垃圾回收

jvm经过多年的发展,它的垃圾回收算法也经过了几个迭代。本文主要介绍垃圾回收算法及目前hotspot上实现的垃圾回收器,以及他们的优缺点。

1、对象分配

    java引以为傲的一点是java内存的管理完全由jvm管理,程序员不再需要显式的分配和回收内存。这样做的好处是显而易见的,提高了程序员们的效率,也减少了由于程序错误造成的内存溢出错误。当然这样做也有缺点,比如做相同的事它更占内存。消耗更多的资源。但是在入境人比机器贵的环境下,java显然更迎合时代的潮流,所有java才连续多年称为热门的计算机程序语言之一。jvm在垃圾回收的时候会有短时间的程序停摆(所有线程中断执行,进行垃圾回收)。java构造对象只需要一个new 关键字。

    我们知道我们在new一个对象时,java虚拟机会给这个对象分配内存,那么分配在哪里呢?新生对象一般会被分配在新生代的eden区。除非这个对象足够大,对于大对象jvm有可能会将它分配到老年代,这样可以避免大对象占满新生代,从而引发gc,因为新生代的区域相对较小,且eden只占其中的一部分(默认是eden和一个survivor比例饿为8:1,可通过-XX:SurvivorRatio参数设置。)。也可通过参数-XX:PretenureSizeThreshold来设置超过多大的对象在老年代分配。

2.如何判定对象的生死?

    判定对象是不是需要回收主要有两种方法,

    一种是引用计数法:为对象增加一个引用计数器,当一个地方引用了该对象时,计数器加1,当引用失效时,计数器减1,当引用计数器为0时,表明该对象永远不会被再使用,可以回收。但是HotSpot(其实绝大部分的虚拟机实现都不是这种方法)不是使用引用计数算法,因为解决不掉对象之间相互引用的情况。

    一种是根搜索算法:基本思想是通过一条“GC Roots”的对象座位起点,从这个节点向下搜索,搜索所经过的的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链链接时,则证明此对象不可达。是可以被回收的对象。但不可达的对象是非死不可的吗?其实不是,不可达的对象如果重写了finalize方法且没有被系统调用过(一个对象的finalize方法最多被调用一次),对象被第一次标记并进入F-QUEUE队列等待被执行finalize方法,如果finalize方法里重新与CG root建立可达性连接则此对象就不被回收,若没有建立可达性连接则第二次被标记,第二次被标记的对象就会被回收。若不可达对象没有重写finalize方法,则被两次标记后被回收。这种也是主流jvm垃圾回收使用的算法。

    方法区的垃圾回收:方法区的垃圾回收主要包含两部分,1.无用的类,2.废弃常量。那么哪些是无用的类,哪些是废弃常量呢。何判断废弃常量呢?以字面量回收为例,如果一个字符串“abc”已经进入常量池,但是当前系统没有任何一个String对象引用了叫做“abc”的字面量,那么,如果发生垃圾回收并且有必要时,“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。如何判断无用的类呢?需要满足以下三个条件1. 该类的所有实例都已经被回收,即Java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.垃圾回收算法

1.标记-清除算法(Mark-Sweep),首先标识出需要回收的对象,然后统一进行回收,这种算法效率不高,而且会产生大量的内存碎片,现在并不被主流垃圾回收器使用。

2.复制算法,它将可用的内存分为两块,每次只使用其中一块,每次其中一块快使用完时,就将这块内存中还存活的对象复制到另外一块内存中,另外一块内存区域集中清理,虽然这样做不会产生内存碎片,但是这样做的代价也是高昂的,因为可用内存只有真正内存的一半,所以这种算法只用来回收新生代 垃圾,新生代也不是分为两个区域,而是分为eden区,和两个Sruvivor区。 当新生代发生每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

3.标记-整理(Mark-Compact)算法,根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法没什么特别的,无非是上面内容的结合罢了,根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。

4.垃圾收集器

垃圾收集器就是上面讲的理论知识的具体实现了。不同虚拟机所提供的垃圾收集器可能会有很大差别,我们使用的是HotSpot.

Serial收集器

Serial收集器是最古老的收集器,它的缺点是当Serial收集器想进行垃圾回收的时候,必须暂停用户的所有进程,即stop the world。到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器,与其他收集器相比,对于限定在单个CPU的运行环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾回收自然可以获得最高的单线程收集效率。

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。这个收集器的主要意义也是被Client模式下的虚拟机使用。在Server模式下,它主要还有两大用途:一个是在JDK1.5及以前的版本中与Parallel Scanvenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。

通过指定-UseSerialGC参数,使用Serial + Serial Old的串行收集器组合进行内存回收。

ParNew收集器

ParNew收集器是Serial收集器新生代的多线程实现,注意在进行垃圾回收的时候依然会stop the world,只是相比较Serial收集器而言它会运行多条进程进行垃圾回收。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百的保证能超越Serial收集器。当然,随着可以使用的CPU的数量增加,它对于GC时系统资源的利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多(譬如32个,现在CPU动辄4核加超线程,服务器超过32个逻辑CPU的情况越来越多了)的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

-UseParNewGC: 打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收,这样新生代使用并行收集器,老年代使用串行收集器。

Parallel Scavenge收集器

Parallel是采用复制算法的多线程新生代垃圾回收器,似乎和ParNew收集器有很多的相似的地方。但是Parallel Scanvenge收集器的一个特点是它所关注的目标是吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能够提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,采用多线程和”标记-整理”算法。这个收集器是在jdk1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是如果新生代Parallel Scavenge收集器,那么老年代除了Serial Old(PS MarkSweep)收集器外别无选择。由于单线程的老年代Serial Old收集器在服务端应用性能上的”拖累“,即使使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,又因为老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合”给力“。直到Parallel Old收集器出现后,”吞吐量优先“收集器终于有了比较名副其实的应用祝贺,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

-UseParallelGC: 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行内存回收。-UseParallelOldGC: 打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行垃圾回收

CMS收集器

CMS(Concurrent Mark Swep)收集器是一个比较重要的回收器,现在应用非常广泛,我们重点来看一下,CMS一种获取最短回收停顿时间为目标的收集器,这使得它很适合用于和用户交互的业务。从名字(Mark Swep)就可以看出,CMS收集器是基于标记清除算法实现的。它的收集过程分为四个步骤:

初始标记(initial mark)

并发标记(concurrent mark)

重新标记(remark)

并发清除(concurrent sweep)

注意初始标记和重新标记还是会stop the world,但是在耗费时间更长的并发标记和并发清除两个阶段都可以和用户进程同时工作。

不过由于CMS收集器是基于标记清除算法实现的,会导致有大量的空间碎片产生,在为大对象分配内存的时候,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前开启一次Full GC。为了解决这个问题,CMS收集器默认提供了一个-XX:+UseCMSCompactAtFullCollection收集开关参数(默认就是开启的),用于在CMS收集器进行FullGC完开启内存碎片的合并整理过程,内存整理的过程是无法并发的,这样内存碎片问题倒是没有了,不过停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction参数用于设置执行多少次不压缩的FULL GC后跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

不幸的是,它作为老年代的收集器,却无法与jdk1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在jdk1.5中使用cms来收集老年代的时候,新生代只能选择ParNew或Serial收集器中的一个。ParNew收集器是使用-XX:+UseConcMarkSweepGC选项启用CMS收集器之后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

由于CMS收集器现在比较常用,下面我们再额外了解一下CMS算法的几个常用参数:

UseCMSInitatingOccupancyOnly:表示只在到达阈值的时候,才进行 CMS 回收。

为了减少第二次暂停的时间,通过-XX:+CMSParallelRemarkEnabled开启并行remark。如果ramark时间还是过长的话,可以开启-XX:+CMSScavengeBeforeRemark选项,强制remark之前开启一次minor gc,减少remark的暂停时间,但是在remark之后也立即开始一次minor gc。

CMS默认启动的回收线程数目是(ParallelGCThreads + 3)/4,如果你需要明确设定,可以通过-XX:+ParallelCMSThreads来设定,其中-XX:+ParallelGCThreads代表的年轻代的并发收集线程数目。

CMSClassUnloadingEnabled: 允许对类元数据进行回收。

CMSInitatingPermOccupancyFraction:当永久区占用率达到这一百分比后,启动 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。

CMSIncrementalMode:使用增量模式,比较适合单 CPU。

UseCMSCompactAtFullCollection参数可以使 CMS 在垃圾收集完成后,进行一次内存碎片整理。内存碎片的整理并不是并发进行的。

UseFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。

一些建议

对于Native Memory:

使用了NIO或者NIO框架(Mina/Netty)

使用了DirectByteBuffer分配字节缓冲区

使用了MappedByteBuffer做内存映射

由于Native Memory只能通过FullGC回收,所以除非你非常清楚这时真的有必要,否则不要轻易调用System.gc()。

另外为了防止某些框架中的System.gc调用(例如NIO框架、Java RMI),建议在启动参数中加上-XX:+DisableExplicitGC来禁用显式GC。这个参数有个巨大的坑,如果你禁用了System.gc(),那么上面的3种场景下的内存就无法回收,可能造成OOM,如果你使用了CMS GC,那么可以用这个参数替代:-XX:+ExplicitGCInvokesConcurrent。

此外除了CMS的GC,其实其他针对old gen的回收器都会在对old gen回收的同时回收young gen。

G1收集器

G1收集器是一款面向服务端应用的垃圾收集器。HotSpot团队赋予它的使命是在未来替换掉JDK1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:

并行与并发:G1能更充分的利用CPU,多核环境下的硬件优势来缩短stop the world的停顿时间。

分代收集:和其他收集器一样,分代的概念在G1中依然存在,不过G1不需要其他的垃圾回收器的配合就可以独自管理整个GC堆。

空间整合:G1收集器有利于程序长时间运行,分配大对象时不会无法得到连续的空间而提前触发一次GC。

可预测的非停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在使用G1收集器时,Java堆的内存布局和其他收集器有很大的差别,它将这个Java堆分为多个大小相等的独立区域,虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

虽然G1看起来有很多优点,实际上CMS还是主流。

与GC相关的常用参数

除了上面提及的一些参数,下面补充一些和GC相关的常用参数:

-Xmx: 设置堆内存的最大值。

-Xms: 设置堆内存的初始值。

-Xmn: 设置新生代的大小。

-Xss: 设置栈的大小。

-PretenureSizeThreshold: 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。

-MaxTenuringThrehold: 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就会加1,当超过这个参数值时就进入老年代。

-UseAdaptiveSizePolicy: 在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。

-SurvivorRattio: 新生代Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Suvivor= 8: 1。

-XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。

-XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。

-XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。

你可能感兴趣的:(理解jvm(二)--垃圾回收)