JVM基础(5)-垃圾回收机制

一、对象引用的类型

Java 中的垃圾回收一般是在 Java 堆中进行,因为堆中几乎存放了 Java 中所有的对象实例。谈到 Java 堆中的垃圾回收,自然要谈到引用。在 JDK1.2 之前,Java 中的引用定义很很纯粹:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但在 JDK1.2 之后,Java 对引用的概念进行了扩充,将其分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,引用强度依次减弱。

  • 强引用:如“Object obj = new Object()”,这类引用是 Java 程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用:它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2 之后提供了 SoftReference 类来实现软引用。
  • 弱引用:它也是用来描述非需对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存岛下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了 WeakReference 类来实现弱引用。
  • 虚引用:最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2 之后提供了 PhantomReference 类来实现虚引用。

其中软引用和弱引用经常被用来实现缓存,比如Guava中的缓存实现:

  public static <K,V> Cache<K , V> callableCached() throws Exception {
        Cache<K, V> cache = CacheBuilder
                .newBuilder()
                .maximumSize(10000)
                .softValues() //使用软引用
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build();
        return cache;
    }

二、判定垃圾对象

如何判断一个对象是垃圾对象?一般有2种方法,引用计数和引用链法。

引用计数器

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1,当引用失效时,计数器值就减1,任何时刻计数器都为 0 的对象就是不可能再被使用的。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的选择,当 Java 语言并没有选择这种算法来进行垃圾回收,主要原因是它很难解决对象之间的相互循环引用问题

引用链法

Java 和 C# 中都是采用根搜索算法来判定对象是否存活的。这种算法的基本思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。在 Java 语言里,可作为 GC Roots 的兑现包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中 JNI(Native 方法)的引用对象。

实际上,要真正宣告对象私网,至少要经历2次标记。对象还有最后一次逃脱死亡的机会,java中的finalize()方法,只要在finalize()中和一个对象建立引用就可以逃脱,但是非常不建议这么做。

三、垃圾回收算法

复制算法

将可用内存按容量划分为大小相等的"两块",每次只使用其中一块,用空间换时间。当其中一块用完之后,就将存活的对象一次性全部移动到另外一块上,再把这一块清空作为备用。由于是针对整个的内存块进行回收,不会有内存碎片问题,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

新生代一般都采用这种算法!IBM公司的专门研究表明98%的对象都是"朝生夕死"的,不需要按照1:1来划分内存空间,因此将一块大内存分为3块,1块Eden和2块survior,比例是8:1:1,因此每次最多浪费10%的空间。

当然还需要内存担保机制来保证特殊情况,不一定每次都是98%那么多对象死亡。

 

标记—清除算法

标记—清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。

这种算法容易导致清除后由于空间不连续容易引起碎片,且没有足够的连续的空间去安排新对象而频繁引发GC。

标记—清除算法的执行情况如下图所示:

回收前状态:

回收后状态:

标记—整理算法

复制算法比较适合于新生代,在老年代中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。标记—整理算法的回收情况如下所示:

回收前状态:

回收后状态:

 

实际-分代收集算法

当前商业虚拟机的垃圾收集 都采用分代收集,它根据对象的存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。

 

四、垃圾收集器

要了解java垃圾回收机制前必须知道java怎么分配给对象内存的,根据上面运行时数据区域的划分可以知道,几乎所有的对象都在堆上分配,而类信息、常量、静态变量在方法区分配。堆内存是分代管理的,

  • 对象优先在Eden分配;
  • 大对象(所谓的大对象是指需要连续内存空间的java对象,如很长的字符串或者数组)直接进入老年代;
  • 长期存活的对象将进入老年代,在垃圾回收时在Survivor中每熬过一次youngGC,他的年龄就增加1,直到到达指定的年龄就会被放入老年代。

Java大多数的垃圾回收器都是分代的,即有的收集器适合于新生代,有的适合于老年代,比较特殊有G1收集器。

新生代的GC往往很正常,速度也非常快,老年代的则不同,老年代的GC称为Major GC/Full GC:

  • 新生代 GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC时,会顺便清理掉 Direct Memory 中的废弃对象。

下图展示了JDK中常见的GC收集器以及他们的组合,注意到CMS无法跟Parallel Scavenge一起工作...

常见收集器简介:

  • Serial(串行GC)收集器 :Serial收集器是一个新生代收集器,单线程执行,使用复制算法。它在进行垃圾收集时,必须暂停其他所有的工作线程(用户线程)。是Jvm client模式下默认的新生代收集器。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。 
  • ParNew(并行GC)收集器 :ParNew收集器其实就是serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为与Serial收集器一样。 
  • Parallel Scavenge(并行回收GC)收集器 :Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行多线程收集器。parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%。 
  • Serial Old(串行GC)收集器 :Serial Old是Serial收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在Client模式下的虚拟机。 

 关于CMS和G1会在后面详细介绍。

下面来做个小实验,如何显示的进行垃圾回收?一般不建议在代码进行显示垃圾回收。

public class SlotGc{  
    public static void main(String[] args){  
        byte[] holder = new byte[32*1024*1024];  
        System.gc();  
    }  
}  

使用java -verbose:gc 打印简单的gc信息

[GC 208K->134K(5056K), 0.0017306 secs]

[Full GC 134K->134K(5056K), 0.0121194 secs]

[Full GC 32902K->32902K(37828K), 0.0094149 sec

发现没有变化.没有将32M内存回收。

上面的原因是gc的时候holder的作用域仍然有效,继续修改代码:

public class SlotGc{  
    public static void main(String[] args){  
        {  
        byte[] holder = new byte[32*1024*1024];  
        }  
        System.gc();  
    }  
}  
[GC 208K->134K(5056K), 0.0017100 secs]

[Full GC 134K->134K(5056K), 0.0125887 secs]

[Full GC 32902K->32902K(37828K), 0.0089226 secs]

修改作用域之后,发现依旧没有回收. 继续修改代码:

public class SlotGc{  
    public static void main(String[] args){  
        {  
        byte[] holder = new byte[32*1024*1024];  
        holder = null;  
        }  
        System.gc();  
    }  
}  
[GC 208K->134K(5056K), 0.0017194 secs]

[Full GC 134K->134K(5056K), 0.0124656 secs]

[Full GC 32902K->134K(37828K), 0.0091637 secs]

终于回收了,为什么?

首先明确一点:holder 能否被回收的根本原因是局部变量表中的 Slot 是否还存有关于 holder 数组对象的引用

在第一次修改中,虽然在 holder 作用域之外进行回收,但是在此之后,没有对局部变量表的读写操作,holder 所占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍保持者对它的关联。这种关联没有被及时打断,因此 GC 收集器不会将 holder 引用的对象内存回收掉。

在第二次修改中,在 GC 收集器工作前,手动将 holder 设置为 null 值,就把 holder 所占用的局部变量表中的 Slot 清空了,因此,这次 GC 收集器工作时将 holder 之前引用的对象内存回收掉了。

当然,我们也可以用其他方法来将 holder 引用的对象内存回收掉,只要复用 holder 所占用的 slot 即可,比如在 holder 作用域之外执行一次读写操作。

为对象赋 null 值并不是控制变量回收的最好方法,以恰当的变量作用域来控制变量回收时间才是最优雅的解决办法。另外,赋 null 值的操作在经过虚拟机 JIT 编译器优化后会被消除掉,经过 JIT 编译后,System.gc()执行时就可以正确地回收掉内存,而无需赋 null 值。

五、常见的垃圾收集介绍

 5.1 串行收集器

 新生代串行收集器 

在串行收集器进行垃圾回收时,Java 应用程序中的线程都需要暂停,等待垃圾回收的完成,这样给用户体验造成较差效果。

虽然如此,串行收集器却是一个成熟、经过长时间生产环境考验的极为高效的收集器。新生代串行处理器使用复制算法,实现相对简单,逻辑处理特别高效,且没有线程切换的开销。

强调一点:诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,它的性能表现可以超过并行回收器和并发回收器

在 HotSpot 虚拟机中,使用-XX:+UseSerialGC 参数可以指定使用新生代串行收集器和老年代串行收集器。当 JVM 在 Client 模式下运行时,它是默认的垃圾收集器。一次新生代串行收集器的工作输出日志类似如清单 1 信息 (使用-XX:+PrintGCDetails 开关) 所示。

一次新生代串行收集器的工作输出日志:

[GC [DefNew: 3468K->150K(9216K), 0.0028638 secs][Tenured:
  1562K->1712K(10240K), 0.0084220 secs] 3468K->1712K(19456K),
  [Perm : 377K->377K(12288K)],
  0.0113816 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

简单解释:
最前面的GC:表示垃圾收集的停顿类型,特别注意不是用来来区分Full GC的,如果有Full,表明的是发生"Stop the world"的,新生代GC也会出现Full。如果是调用System.gc()触发的收集,会出现[Full GC (System)]

接下来的DefNew, Tenured, Perm表示GC发生的区域,这里显示的名字和垃圾回收器密切相关,DefNew: Default New Generation表示是Serial新生代收集器,如果是ParNew,Parllel New Generation表示是新生代并行回收器,还有PSYoungGen。

所有的类似于 3468K->150K(9216k),表示是GC前java堆使用容量->GC后java堆使用容量(Java堆总容量)。

老年代串行收集器

老年代串行收集器使用的是标记-压缩算法

和新生代串行收集器一样,它也是一个串行的、独占式的垃圾回收器。由于老年代垃圾回收通常会使用比新生代垃圾回收更长的时间,因此,在堆空间较大的应用程序中,一旦老年代串行收集器启动,应用程序很可能会因此停顿几秒甚至更长时间。

虽然如此,老年代串行回收器可以和多种新生代回收器配合使用,同时它也可以作为 CMS 回收器的备用回收器。若要启用老年代串行回收器,可以尝试使用以下参数:-XX:+UseSerialGC: 新生代、老年代都使用串行回收器

5.2 并行收集器

新生代的并行收集器

新生代的垃圾收集器,它只简单地将串行回收器多线程化。它的回收策略、算法以及参数和串行回收器一样。

并行回收器也是独占式的回收器,在收集过程中,应用程序会全部暂停。但由于并行回收器使用多线程进行垃圾回收,因此,在并发能力比较强的 CPU 上,它产生的停顿时间要短于串行回收器,而在单 CPU 或者并发能力较弱的系统中,并行回收器的效果不会比串行回收器好,由于多线程的压力,它的实际表现很可能比串行回收器差。

老年代的并行收集器

 老年代的并行回收收集器也是一种多线程并发的收集器。和新生代并行回收收集器一样,它也是一种关注吞吐量的收集器。老年代并行回收收集器使用标记-压缩算法,JDK1.6 之后开始启用。

  • -XX:+UseParNewGC,该参数设置新生代使用并行收集器,老年代使用串行收集器。
  • -XX:+UseConcMarkSweepGC 可以要求新生代使用并行收集器,老年代使用 CMS。

设置并行线程数量:

并行收集器工作时的线程数量可以使用-XX:ParallelGCThreads 参数指定。一般,最好与 CPU 数量相当,避免过多的线程数影响垃圾收集性能。在默认情况下,当 CPU 数量小于 8 个,ParallelGCThreads 的值等于 CPU 数量,大于 8 个,ParallelGCThreads 的值等于 3+[5*CPU_Count]/8]。

5.3 Parallel Scavenge收集器

  • -XX:+UseParallelGC:新生代使用并行回收收集器,老年代使用串行收集器。
  • -XX:+UseParallelOldGC:新生代和老年代都是用并行回收收集器。

Parallel Savenge也是一个新生代的收集器,它也是使用复制算法的收集器,又是并行的收集器。

它的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可以控制的吞吐量。

吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%。

该收集器提供了2个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

除了这2个参数之外,该收集器还支持自动的细节参数,即-XX:+UseAdaptiveSizePolicy,虚拟机会根据当前系统的运行情况来收集性能监控信息,动态调整虚拟机参数以提供最合适的参数,这种方式成为虚拟机的GC自适应调节策略。这是一个不错的选择,只需要把基本的内存数据设置好,比如最大堆再开启该参数即可。

六、CMS收集器详解

周志明:《深入理解JVM虚拟机》

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
 
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
  • 初始标记(CMS initial mark): 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快 Stop the world!
  • 并发标记(CMS concurrent mark):并发标记阶段就是进行GC Roots Tracing的过程。
  • 重新标记(CMS remark): 重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,Stop the world!
  • 并发清除(CMS concurrent sweep): 
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。,而由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间。

 

 CMS是一款优秀的收集器,它的最主要优点在名字上已经体现出来了:并发收集、低停顿,Sun的一些官方文档里面也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但是CMS还远达不到完美的程度,它有以下三个显著的缺点:

  • CMS收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程最多占用不超过25%的CPU资源。但是当CPU不足4个时(譬如2个),那么CMS对用户程序的影响就可能变得很大,如果CPU负载本来就比较大的时候,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,这也很让人受不了。为了解决这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记和并发清理的时候让GC线程、用户线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,速度下降也就没有那么明显,但是目前版本中,i-CMS已经被声明为“deprecated”,即不再提倡用户使用。
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好留待下一次GC时再将其清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数以获取更好的性能。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
  • 还有最后一个缺点,在本节在开头说过,CMS是一款基于“标记-清除”算法实现的收集器,如果读者对前面这种算法介绍还有印象的话,就可能想到这意味着收集结束时会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费附送一个碎片整理过程,内存整理的过程是无法并发的。空间碎片问题没有了,但停顿时间不得不变长了。虚拟机设计者们还提供了另外一个参数-XX: CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不压缩的Full GC后,跟着来一次带压缩的。
相关参数:
  1. CMSInnitiatingOccupancyFraction: 设置CMS收集器在老年代空间被使用多少后出发垃圾收集。默认值是68%,仅仅在CMS收集器时生效。
  2. UseCMSCompactAtFullCollection: 设置CMS收集器在完成垃圾收集后是否要进行一次的内存碎片整理。仅仅在CMS收集器时生效。
  3. CMSFullGCsBeforeCompaction: 设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅仅在使用CMS收集器生效。

七、G1收集器

http://ifeve.com/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3g1%E5%9E%83%E5%9C%BE%E6%94%B6%E9%9B%86%E5%99%A8/

posted @ 2016-11-19 18:40  carl_ysz  阅读(340)  评论(0编辑  收藏  举报