GC与内存分配策略

355 阅读16分钟

三、垃圾收集


垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。

判断一个对象是否可被回收


1、引用计数算法 创建一个引用计数器,当对象增加一个引用计数器就加1,引用失效时计数器减1,。引用计数器为0的对象可被回收。 在两个对象出现循环引用的情况下,此时引用计数器永远不为0,导致无法对他们进行回收。正式因为循环引用的存在,java虚拟机不使用引用计数算法。 2、可达性分析算法 以GC Roots 为起始点开始向下搜索,当一个对象到GC Roots没有任何引用链相连时则证明此对象是不可用的,将会被判定为可回收的对象。

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

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

3、方法区的垃圾回收 因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。 主要是对常量池的回收和对类的卸载。 为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。 类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

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

4、finalize 即使是可达分型分析算法中不可达的对象也不是非死不可的,要宣告一个对象的死亡至少要经过两次标记的过程。

  1. GC进行第一次标记并进行一次筛选(筛选那些覆盖了finalize方法并且finalize方法是第一次调用的对象);
  2. 另一个低优先级的Finalizer线程去调用那些被筛选出来的对象的finalize方法;
  3. GC进行第二次标记,如果在前一步中那些筛选出来的对象没有在finalize拯救自己,此时,那些未被筛选到的和这些这些筛选到的但是没有拯救自己的对象都将会回收。

引用类型


1. 强引用 被强引用关联的对象不会被回收。使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

2. 软引用 被软引用关联的对象只有在内存不够的情况下才会被回收,通常用在对内存敏感的程序中。使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

3. 弱引用 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4. 虚引用 又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

垃圾收集算法


1、标记-清除算法

  • 最基础的收集算法,分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
  • 缺点:
    1. 效率问题:标记和清除两个过程的效率都不高。 2.空间问题:标记清除之后会产生大量不连续的内存碎片,残留空间碎片太多,可能继续导致GC。

2、复制算法

  • 1:1复制:把内存分为对等的两块,每次只是用其中的一块,当GC之后,将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

    • 缺点:要消耗一半内存造成浪费,对象多时,容易触发GC。
  • 8:1复制:现在虚拟机的内存分配,一般采用一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1。

    • 缺点:如果另一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3、标记-整理算法

  • 标记过程与标记-清除算法相同,整理是让所有存活着的的对象都向一端移动,然后直接清理掉端边界以外的内存。
  • 缺点:需要移动大量对象,处理效率比较低。
  • 优点:不会产生内存碎片

4、分代收集

  • 一般将堆分为新生代和老年代
    • 新生代:复制算法
    • 老年代:标记-整理算法或者标记-清除算法

HotSpot的算法实现


1、准确式GC

  • 当执行系统停顿下来之后,并不需要一个不漏的检查所有的执行上下文和引用位置,在虚拟机中,使用一组被称为 OopMap 的数据结构来达到目的。在类加载完的时候,HotSpot (虚拟机的一种)就已经把对象内的什么偏移量上是什么类型的数据计算出来了,即位置也会被 OopMap 记录下来。

2、安全点

  • 在OopMap 的帮忙下,可以快速且准确的找到 GC Roots 枚举,但可能导致引用关系的变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap ,那将会需要大量的额外空间,这样GC成本也会变高。
  • 实际上系统并没有为每条指令都生成OopMap,而是在“特定的位置”记录了这些信息,比如方法的调用、循环跳转、异常跳转等,这些位置被称为安全点,只有在达到安全点才会去停顿。
    • 跑到安全点的防范有两种:
      1. 抢断式中断:在GC发生时,首先把所有的线程全部中断,如果发现有线程中断的地方不在安全点上,则恢复线程,让它“跑”在安全点上。不过现在没有虚拟机采用这种方式。
      2. 主动式中断:线程给自己设置一个标志位,当轮询到这个标志位,就主动挂起,标志位和安全点在同一个位置。

3、安全区域

  • 当如果线程处于 sleep 或者 Blocked 状态时,这时线程无法响应JVM的中断请求,就需要安全区域来解决了。
  • 安全区域是指在一段代码中,引用关系不会发现变化。在这个区域 GC 都是安全的,当在这个区域发现GC,线程可以不管自己的安全点的状态,当要离开这段区域时,需要检查是否完成根节点枚举,没有则等待,只到收到可以离开安全点的信号为止。

垃圾收集器


如果收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图中两个虚拟机之间存在连线说明可以搭配使用。

  • 串行:垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序。
  • 并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
  • 并发:用户线程与垃圾收集线程同时执行(但不一定是并行的,可能交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上。
  • 吞吐量:CPU用于运行代码所需要的时间与CPU总消耗时间的比值,即吞吐量=运行代码时间/(运行用户代码时间+垃圾收集时间) **注:**除了CMS和G1之外,其他垃圾收集器都是以串行方式执行。

1、Serial收集器(新生代)

  • 最基本、历史最悠久的一个单线程垃圾收集器,它在垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
  • 它依然是虚拟机运行在Client模式下的默认新生代收集器。
  • 它简单高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的效率。

2、ParNew收集器(新生代)

  • 就是Serial收集器的多线程版本。
  • 是许多运行在Server模式下的虚拟机中首选的新生代收集器,与性能无关但很重要的一个原因是,除了Serial收集器之外,目前只有它能与CMS收集器配合工作。

3、Parallel Scavenge收集器(新生代)

  • 使用复制算法,并行的多线程收集器。也被称为“吞吐量优先”收集器。其关注点是达到一个可控制的吞吐量,其他收集器关注点是尽可能的缩短垃圾收集时用户线程的停顿时间。
  • 停顿时间越短越适合需要用户交互的程序,良好的响应速度能提升用户体验吞吐量小则可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合后台运算而且不需要太多交互的任务。

4、Serial Old(老年代)

  • 是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。
  • 如果用在 Server 场景下,它有两大用途:
    1. 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
    2. 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

5、Parallel Old收集器(老年代)

  • 是 Parallel Scavenge 收集器的老年代版本。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6、CMS收集器(老年代)

  • 是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法实现的,它的运作包含4个步骤:

    1. 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
    2. 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
    3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    4. 并发清除:不需要停顿。
  • 在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

  • 缺点:

    1. 对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
    2. 无法处理浮动垃圾,可能出现 “Concurrent Mode Failure”失败而导致另有一次Full GC的产生。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    3. 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

7、G1收集器(整个堆)

  • G1是一款面向服务端的垃圾收集器。
  • 它将整个java堆划分成为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但不再是物理隔离的的了。他们都是一部分Region(不需要连续的)的集合。
  • 每个 Region 都有一个 Remembered Set,用来记录该 Region 之间的对象引用以及其他收集器新生代与老年代之间的对象引用。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
  • 如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
    1. 初始标记:初始标记仅仅只是标记一下GC Roots能直接关联到的对象。需要停顿线程,速度很快。
    2. 并发标记:从GC Roots开始对堆中的对象进行可达性分析,找出存活的对象。耗时较长,可与用户程序并发执行。
    3. 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
    4. 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
  • 特点:
    1. 并行与并发:使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
    2. 分代收集:新生代和老年代不再是物理隔离的的了。他们都是一部分Region(不需要连续的)的集合。
    3. 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    4. 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

四、内存分配与回收策略


Minor GC与Full GC

  • Minor GC:回收新生代,指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特征,所以Minor GC非常频繁,一般回收速度也比较快。
  • Full GC:回收老年代和新生代,一般老年代对象存活时间比较长,因此Full GC的速度一般会比Minor GC慢10倍以上。

对象优先在Eden分配

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

大对象直接进入老年代

  • 大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
  • 经常出现大对象容易导致内存还有不少内存空间时就提前触发垃圾收集以获取足够的连续空间来安置它们。
  • 提供了一个-XX:PretenureSizeThreshoid参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存复制。

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

  • 定义一个年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话。将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,年龄增加到一定程度就会被晋升到老年代。
  • 对象年龄的阈值可以通过参数,-XX:MaxTenuringThreshold进行设置,默认年龄为15。

对象年龄判定

  • 如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间担保

  • 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。