二、深入理解JAVA虚拟机之详解GC

 2019-12-22 10:39  阅读(652)
文章分类:JVM

一、概述

GC要完成3件事:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

1、两种算法

1、引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1;当引用失效时,计数器的值就减1;任何时刻计数器为0的对象就是不可能再被使用的。它非常高效,但是怒能解决对象间相互引用的问题。例如如下的例子:

public class RefenceCountingGC {

        public Object instance = null;

        public static void main(String[] args) {
            RefenceCountingGC a = new RefenceCountingGC();
            RefenceCountingGC b = new RefenceCountingGC();
            a.instance = b;
            b.instance =a ;
            a = null;
            b = null;
            System.gc();
        }
    }

a和b两个对象已经不能再被访问了,但因为他们互相引用着对方,导致它们的引用计数器不为0。

2、可达性算法

Java中使用可达性分析( Reachability Analysis) 来判定对象是否存活的。

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的 路径称为引用链( Reference Chain) ,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

2019120001183\_1.png
在Java语言中,可作为GC Roots的对象包括下面几种:

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

2、引用的分类

引用分为强引用( Strong Reference) 、软引用( Soft Reference) 、弱引用( Weak Reference) 、虚引用( Phantom Reference) 4种,这4种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引 用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将 要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这 次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了 SoftReference类来实现软引用。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的 对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足 够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引 用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统 通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

3、方法区的回收

方法区一般可以不回收,回收效率很低。在堆中,新生代的垃圾收集效率70%-90%,而永久代的垃圾回收效率远低于此。

永久代的垃圾回收主要回收两部分内容:废弃常量和无用的类。“废弃常量”判断比较简单,但 是“无用的类”的判断复杂一些,需要满足下面3个条件:

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收
  • 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

二、垃圾收集算法

1、标记-清除算法

标记-清除(Mark-Sweep)算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所 有被标记的对象,它的标记过程就是使用可达性算法进行标记的。

主要缺点有两个:

  • 效率问题,标记和清除两个过程的效率都不高
  • 空间问题,标记清除之后会产生大量不连续的内存碎片
    2019120001183\_2.png

2、复制算法

**复制(Copying)**算法是为了解决标记-清除算法产生的那些碎片。

首先将内存分为大小相等的两部分(假设A、B两部分),每次呢只使用其中的一部分(这里我们假设为A区),等这部分用完了,这时候就将这里面还能活下来的对象复制到另一部分内存(这里设为B区)中,然后把A区中的剩下部分全部清理掉。

HotSpot虚拟机将内存分为一块较大的Eden空间和两块较小的Survivor空间(默认占比是8:1:1),每次使用Eden和其中一块Survivor,当回首时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor上,最后清理掉刚才的Eden和Survivor空间。如果还存活的对象站的内存空间多余10%,就需要依赖其他内存空间(老年代)了。

2019120001183\_3.png

3、标记-整理算法

标记整理算法( Mark-Compact) ,标记过程仍然和“标记-清除”一样,但后续不走不是直接对 可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。

2019120001183\_4.png

4、分代收集算法

根据对象存活周期的不同将内存分为几块。一般把Java堆分为新生代和老年代,根据各个年 代的特点采用最合适的收集算法。在新生代中,每次垃圾收集时有大批对象死去,只有少量 存活,可以选用复制算法。而老年代对象存活率高,使用标记清理或者标记整理算法。

三、垃圾收集器

2019120001183\_5.png

1、Serial收集器

Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;JDK1.3.1前是HotSpot新生代收集的唯一选择;

Serial收集器是单线程收集器,是分代收集器。它进行垃圾收集时,必须暂停其他所有的工作 线程,直到它收集结束。

新生代:单线程复制收集算法;老年代:单线程标记整理算法。

2019120001183\_6.png

应用场景

依然是HotSpot在Client模式下默认的新生代收集器;

也有优于其他收集器的地方:

简单高效(与其他收集器的单线程相比);

对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;

在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的

设置参数

“-XX:+UseSerialGC”:添加该参数来显式的使用串行垃圾收集器;

2、ParNew收集器

ParNew垃圾收集器是Serial收集器的多线程版本。

Parallel收集器和Serial收集器的主要区别是新生代的收集, 一个是单线程一个是多线程。 老年代的收集和Serial收集器是一样的。

2019120001183\_7.png

应用场景

在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;

但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。

设置参数

“-XX:+UseConcMarkSweepGC”:指定使用CMS后,会默认使用ParNew作为新生代收集器;

“-XX:+UseParNewGC”:强制指定使用ParNew;

“-XX:ParallelGCThreads”:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;

3、Parallel Scavenge收集器

一个新生代收集器,使用复制算法的收集器,又是并行( 用户线程阻塞) 的多线程收集器。

目标是达到一个可控制的吞吐量。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

应用场景

高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;

当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;

例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;

设置参数

Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:

“-XX:MaxGCPauseMillis”

控制最大垃圾收集停顿时间,大于0的毫秒数;

MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;

因为可能导致垃圾收集发生得更频繁;

“-XX:GCTimeRatio”

设置垃圾收集时间占总时间的比率,0

4、Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是单线程的。使用“标记-整理”算法。

5、Parallel Old收集器

Parallel old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个 收集器是在JDK1.6中才开始提供的。

2019120001183\_8.png

应用场景

JDK1.6及之后用来代替老年代的Serial Old收集器;

特别是在Server模式,多CPU的情况下;

在注重吞吐量以及CPU资源敏感的场景,可以优先考虑Parallel Scavenge加Parallel Old收集器。

设置参数

“-XX:+UseParallelOldGC”:指定使用Parallel Old收集器;

6、CMS收集器

并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;

cms是针对老年代,基于”标记-清除”算法,以获取最短回收停顿时间为目标的垃圾收集器;

是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;

第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

运作过程:

1)、初始标记(initial mark)

仅标记一下GC Roots能直接关联到的对象;

速度很快;

需要”Stop The World”;

2)、并发标记(concurrent mark)

进行GC Roots Tracing的过程;

在步骤一产生的集合中标记出存活对象;

由于应用程序也在运行,并不能保证可以标记出所有的存活对象;

3)、重新标记(remark)

为了修正步骤二期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;

需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;

采用多线程并行执行来提升效率;

4)、并发清除(CMS concurrent sweep)

回收所有的垃圾对象;

和应用程序同时运行。

整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;

所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;

2019120001183\_9.png

缺点:

1)、对CPU资源非常敏感

并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。

CMS的默认收集线程数量是=(CPU数量+3)/4;

当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能不大;但CPU数量不足4个时,就会影响较大,用户可能无法接受。

2)、无法处理浮动垃圾,可能出现”Concurrent Mode Failure”失败

浮动垃圾:在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;

这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;

“-XX:CMSInitiatingOccupancyFraction”:设置CMS预留内存空间;

JDK1.5默认值为68%:当老年代使用了68%的空间后,CMS就会被激活

JDK1.6变为大约92%:当老年代使用了92%的空间后,CMS就会被激活

“Concurrent Mode Failure”失败

如果CMS预留内存空间无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败;

这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;

这样的代价是很大的,所以CMSInitiatingOccupancyFraction不能设置得太大。

3)、产生大量内存碎片

采用标记清除算法,因此会产生很多不连续的内存碎片。

产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。

解决方法:

“-XX:+UseCMSCompactAtFullCollection”

使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;但合并整理过程无法并发,停顿时间会变长;默认是开启的。

“-XX:+CMSFullGCsBeforeCompaction”

设置执行多少次不压缩的Full GC后,来一次压缩整理;

为减少合并整理过程的停顿时间;

默认为0,也就是说每次都执行Full GC,不会进行压缩整理;

7、G1收集器

G1(Garbage-First)是JDK7-u4才推出的收集器;

特点

1)并行与并发

能充分利用多CPU、多核环境下的硬件优势;

可以并行来缩短”Stop The World”停顿时间;

也可以并发让垃圾收集与用户程序同时进行;

2)分代收集,收集范围包括新生代和老年代

能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;

能够采用不同方式处理不同时期的对象;

虽然保留分代概念,但Java堆的内存布局有很大差别;

将整个堆划分为多个大小相等的独立区域(Region);

新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;

3)结合多种垃圾收集算法,空间整合,不产生碎片

从整体看,是基于标记-整理算法;

从局部(两个Region间)看,是基于复制算法;

4)可预测的停顿:低停顿的同时实现高吞吐量

G1除了追求低停顿处,还能建立可预测的停顿时间模型;

可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;

应用场景

面向服务端应用,针对具有大内存、多处理器的机器;

最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;

用来替换掉JDK1.5中的CMS收集器;

设置参数

“-XX:+UseG1GC”:指定使用G1收集器;

“-XX:InitiatingHeapOccupancyPercent”:当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45;

“-XX:MaxGCPauseMillis”:为G1设置暂停时间目标,默认值为200毫秒;

“-XX:G1HeapRegionSize”:设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region;

G1收集器可以实现可预测的停顿的原因:

使用G1时,它讲整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留着新生代和老年代的概念,但新生代和老年代不再是物理相隔了,它们都是一部分Region(不需要连续)的集合。

G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;

每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);

这就保证了在有限的时间内可以获取尽可能高的收集效率;

可以有计划地避免在Java堆的进行全区域的垃圾收集;

一个对象被不同区域的Region中的对象引用:

一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代?这样的话会降低Minor GC的效率;

解决方法:

无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:

每个Region都有一个对应的Remembered Set;

每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;

然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);

如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;

当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

G1收集器运作过程

1)初始标记(Initial Marking)

仅标记一下GC Roots能直接关联到的对象;

且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新象;

需要”Stop The World”,但速度很快;

2)并发标记(Concurrent Marking)

进行GC Roots Tracing的过程;

刚才产生的集合中标记出存活对象;

耗时较长,但应用程序也在运行;

并不能保证可以标记出所有的存活对象;

3)最终标记(Final Marking)

为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;

上一阶段对象的变化记录在线程的Remembered Set Log;

这里把Remembered Set Log合并到Remembered Set中;

需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;

采用多线程并行执行来提升效率;

4)筛选回收(Live Data Counting and Evacuation)

首先排序各个Region的回收价值和成本;

然后根据用户期望的GC停顿时间来制定回收计划;

最后按计划回收一些价值高的Region中垃圾对象;

回收时采用”复制”算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;

可以并发进行,降低停顿时间,并增加吞吐量;

2019120001183\_10.png

四、内存分配和回收策略

1、对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

Eden和Survivor:

  1. jvm将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间(from和to);
  2. 每次使用Eden和其中一块Survivor;
  3. 当回收时,将Eden和使用中的Survivor中还存活的对象一次性复制到另外一块Survivor;
  4. 而后清理掉Eden和使用过的Survivor空间;
  5. 后面就使用Eden和复制到的那一块Survivor空间,重复步骤3;

默认Eden:Survivor=8:1,即每次可以使用90%的空间,只有一块Survivor的空间被浪费;

当Eden区没有足够空间进行分配时,JVM将发起一次Minor GC(新生代GC);

Minor GC时,如果发现存活的对象无法全部放入Survivor空间,只好通过分配担保机制提前转移到老年代

2、大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,如,很长的字符串、数组;

经常出现大对象容易导致内存还有不少空间就提前触发GC,以获取足够的连续空间来存放它们,所以应该尽量避免使用创建大对象;

虚拟机提供了一个-XX:PretenureSizeThreshold(只对Serail和ParNew两款收集器有效 )参数,令大于这个设置值的对象直接在老年代 分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

3、长期存活的对象将进入老年代

JVM给每个对象定义一个对象年龄计数器,其计算流程如下:

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被 Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。

对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度( 默认为15岁) ,就将 会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数- XX:MaxTenuringThreshold(默认15)设置。

4、动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代。

如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象就可以直接进入老年代;

5、空间分配担保

当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion);

分配担保的流程如下:

  1. 在发生Minor GC前,JVM先检查老年代最大可用的连续空间是否大于新生代所有对象空间;

  2. 如果大于,那可以确保Minor GC是安全的;

  3. 如果不大于,则JVM查看HandlePromotionFailure值是否允许担保失败;

  4. 如果允许,就继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;

  5. 如果大于,将尝试进行一次Minor GC,但这是有风险的;

  6. 如果小于或HandlePromotionFailure值不允许冒险,那这些也要改为进行一次Full GC;

    尝试Minor GC的风险–担保失败:

    因为尝试Minor GC前面,无法知道存活的对象大小,所以使用历次晋升到老年代对象的平均大小作为经验值;

    假如尝试的Minor GC最终存活的对象远远高于经验值的话,会导致担保失败(Handle Promotion Failure);

    失败后只有重新发起一次Full GC,这绕了一个大圈,代价较高;

    但一般还是要开启HandlePromotionFailure,避免Full GC过于频繁,而且担保失败概率还是比较低的;

JDK6-u24后,JVM代码中已经不再使用HandlePromotionFailure参数了;

规则变为:
只要老年代最大可用的连续空间大于新生所有对象空间或历次晋升到老年代对象的平均大小,就会进行Minor GC;否则进行Full GC;
即老年代最大可用的连续空间小于新生所有对象空间时,不再检查HandelPromotionFailure,而直接检查历次晋升到老年代对象的平均大小;

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 二、深入理解JAVA虚拟机之详解GC

相关推荐