垃圾收集器与内存分配策略

5次阅读

共计 7634 个字符,预计需要花费 20 分钟才能阅读完成。

1. 概述

  程序计数器,虚拟机栈,本地方法栈 3 个区域随线程而生,随线程而灭。在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束,内存自然就跟着回收了。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的也是这部分内存。

2. 对象已死吗

  垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还 存活 着,哪些已经 死去(即不可能再被任何途径使用的对象)。

2.1 引用计数算法

2.1.1 定义

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

2.1.2 优势和缺陷

  优点:实现简单,判定效率也很高;

  缺点:很难解决对象之间相互循环引用的问题,这也是主流的 Java 虚拟机里面没有选用引用计数算法来管理内存的原因;

2.1.3 什么是对象循环引用

/**
 * 对象的循环引用
 */
public class CircularReference {
    Object instance;

    public static void main(String[] args) {CircularReference reference1 = new CircularReference();
        CircularReference reference2 = new CircularReference();
        reference1.instance = reference2;
        reference2.instance = reference1;
    }
}

2.2 可达性分析算法

2.2.1 定义

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

2.2.2 可作为 GC ROOTS 的对象

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

2.2.3 可达性分析算法不可达的对象一定非死不可吗

  真正宣告一个对象死亡,至少要经历两次标记过程:

  如果对象在进行可达性分析后发现没有与 GC ROOTS 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为 没有必要执行

  如果这个对象被判定为有必要执行 finalize() 方法,对象需要在 finalize() 方法中重新与引用链上的任何一个对象建立关联即可。如果对象没有做此操作,那么将对其进行第二次标记,它就真的被回收了。

2.2.3.1 系统会重复调用同一对象的 finalize() 方法吗

  任何一个对象的 finalize() 方法都只会被系统自动调用一次。

2.2.3.2finalize()方法性能如何

  finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize()能做的所有工作,使用 try-finally 或者其他方式都可以做得更好,更及时。因此应尽量避免使用它。

2.3 引用

2.3.1 定义

  在 JDK1.2 以前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。在JDK1.2 之后,将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  强引用:是指在程序代码之中普遍存在的,类似 Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象;

  软引用:描述一些还有用但非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常;

  弱引用:描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象;

  虚引用:它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知;

2.3.2Java为什么扩充了引用的概念

  我们希望能描述这样一类对象:当内存空间还足够时,能保留在内存之中。如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

2.4 回收方法区

  方法区的垃圾收集主要回收两部分内容:废弃常量和无用的类。

2.4.1 方法区如何回收废弃常量

  回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串 "abc" 已经进入了常量池中,但是当前系统没有任何 String 对象引用常量池中的 "abc" 常量,也没有其他地方引用了这个字面量,如果这时候发生内存回收,而且必要的话,这个 "abc" 变量就会被系统清理出常量池。常量池中的其他类(接口),方法,字段的符号引用也与此类似。

2.4.2 方法区如何判断一个类是否是无用的类

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

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是 可以,而并不是和对象一样,不使用了就必然会回收。

2. 垃圾收集算法

2.1 标记 - 清除算法

2.1.1 定义

  标记 - 清除(Mark-Sweep)算法分为 标记 清除 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

2.1.2 执行过程

2.1.3 不足

  • 效率问题:标记和清除两个过程的效率都不高;
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.2 复制算法

2.2.1 定义

  复制(Copying)算法为了解决效率问题,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样也就不用考虑内存碎片等复杂情况。

2.2.2 过程

2.2.3 优缺点

优点:实现简单,运行高效;

缺点:将内存缩小为原来的一半,代价有点大;

2.3 标记 - 整理算法

2.3.1 定义

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

2.3.2 过程

2.4 分代收集算法

  当前商业虚拟机的垃圾收集都采用 分代收集 Generation Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用 标记 - 清除 标记 - 整理 算法来进行回收。

3. 垃圾收集器

  如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商,不同版本的虚拟机所提供的垃圾收集器都可能有很大差别。

3.1Serial收集器

  Serial收集器是最基本,发展历史最悠久的收集器,这个收集器是一个单线程的收集器。它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

  跟其它单线程的收集器相比,它有着简单而高效的优点。

3.2ParNew收集器

  ParNew收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为都与 Serial 收集器完全一样。

  ParNew收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,当然随着可以使用的 CPU 数量增加,它对于 GC 时系统的有效利用还是很有好处的。

  并发和并行,这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态;
  • 并发(Concurrent):指用户线称与垃圾收集线程同时执行(但并不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。

3.3Parallel Scavenge收集器

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

  CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉一分钟,那吞吐量就是99%

  停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

3.4Serial Old收集器

  Serial OldSerial 收集器的老年代版本,它同样是一个单线程收集器,使用 标记 - 整理 算法。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

3.5Parallel Old收集器

  Parallel OldParallel Scavenge 收集器的老年代版本,使用多线程和 标记 - 整理 算法。

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

3.6CMS收集器

  CMSConcurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于 标记 - 清除 算法。CMS收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。目前很大一部分的 Java 应用集中在互联网或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

  CMS收集器收集过程分为 4 个步骤,包括:

  • 初始标记(CMS initial mark
  • 并发标记(CMS concurrent mark
  • 重新标记(CMS remark
  • 并发清除(CMS concurrent sweep

  初始标记,重新标记这两个步骤仍然需要 Stop The World。初始标记仅仅只是标记一下GC Roots 能直接关联到的对象,速度很快,并发标记阶段进行进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记稍长一些,但远比并发标记的时间短。并发清除阶段会开启用户线程,同时 GC 线程开始对标记的区域做清扫。

  优点:并发收集,低停顿;

  缺点:

  1. CMS收集器对 CPU 资源非常敏感;
  2. CMS收集器无法处理浮动垃圾(Floating Garbage);
  3. CMS由于是基于 标记 - 清除 算法实现的收集器,这意味着收集结束时会有大量空间碎片产生;

3.7G1收集器

  G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多颗处理器及大容器内存的机器,以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

  与其它 GC 收集器相比,G1具备如下特点:

  • 并行与并发(G1能充分利用 CPU,多核环境下的硬件优势,使用多个CPU 来缩短STW);
  • 分代收集(G1收集器能够管理整个 GC 堆,但保留了分代收集的概念);
  • 空间整合(G1从整体上看是基于 标记 - 整理 算法实现的,从局部上看是基于 复制 算法);
  • 可预测的停顿(建立可预测的停顿时间模型,便于使用者指定停顿时间);

  G1跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

  G1收集器的运作大致可分为以下几个步骤:

  • 初始标记(Initial Marking);
  • 并发标记(Concurrent Marking);
  • 最终标记(Final Marking);
  • 筛选回收(Live Data Counting and Evacuation);

  初始标记阶段仅仅是标记一下 GC Roots 能直接关联到的对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录。这阶段需要停顿线程,但是可并行执行。筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。

3.8HotSpot为什么要分为新生代和老年代

  根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择 标记 - 清除 标记 - 整理 算法进行垃圾收集。

4. 内存分配与回收策略

4.1 通过 GC 看堆空间的结构

  从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代。再细致一点有:Eden空间,From Survivor(s0)To Survivor(s1)Tentired空间。

  edens0,s1区都属于新生代,tentired区属于老年代。在大部分情况下,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s1("To"),并且对象的年龄还会加 1(Eden->Survivor 后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。经过这次 GC 后,EdenFrom 已经被清空。这个时候,FromTo 会交换他们的角色,也就是新的 To 就是上次 GC 前的 From,新的From 就是上次 GC 前的 To。不管怎样,都会保证名为ToSurvivor区域是空的。Minor GC会一直重复这样的过程,直到 To 区被填满,To区被填满之后,会将所有对象移动到年老代中。

4.2 对象优先在 Eden 分配

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

  Minor GCFull GC 有什么不一样吗?

  新生代 GCMinor GC):指发生在新生代的垃圾收集动作,因为Java 对象大多数都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快;

  老年代GCMajor GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC

4.3 大对象直接进入老年代

  大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。

  为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率

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

  虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每 熬过 一次Minor GC,年龄就增加一岁,但它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

4.5 动态对象年龄判定

  为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

4.6 空间分配担保

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

5. 参考

  1. 深入理解 Java 虚拟机(第 2 版)
  2. JavaGuide

正文完
 0