垃圾回收

  • VM 的垃圾回收机制并不是时时刻刻保持运行的。由于垃圾回收器是一个优先级很低的线程,所以 JVM 的垃圾回收器是「间歇性、间歇性地起来干活」的。
    • 你即便手动调用** System.gc(); **JVM 也不保证垃圾回收器立刻起来干活。
  • 垃圾回收涉及3个问题:

    • 哪些内存需要回收?(答案是没人用就应该回收,因此问题本质是如何判断对象无人用)
    • 什么时候回收?
    • 如何回收?

      垃圾回收器总结

  • CMS 垃圾回收器解决对象丢失采用的是增量更新CMS只有老年代收集期。cms是jdk1.5开始的默认垃圾回收器


  • G1垃圾回收器解决对象丢失采用的是原始快照。G1为混合收集期,G1是 JDK 9 开始的默认垃圾回收器

    判断对象是否被使用

    引用计数

  • 引用一次计数器+1,引用失效计数器-1。但是无法解决循环引用的问题(弱引用就是为了解决循环引用问题,但是需要正确使用弱引用)

    • 主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,因为引用计数方案需要考虑很多例外情况

      可达性分析

  • 主流的具有垃圾回收机制的编程语言(Java、C#、甚至古老的第一个具有垃圾回收机制编程语言的 List)都是使用可达性分析来判断对象是否已死。

  • 这个算法的基本思路就是通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为『引用链』(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
  • 在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
    • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
    • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
    • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
    • 所有被同步锁(synchronized 关键字)持有的对象。
    • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

image.png

并发的可达性分析

为了降低 JVM 的垃圾回收器的造成的停顿,JVM 的在分析堆中对象的可达性时,是并发执行的。即,在这个环节,用户线程是没有冻结的。(并行执行分析即降低用户线程的停顿)

  • 降低停顿所带来的副作用就是在这个分析过程中,一个对象的可达性会发生变化,从而造成一种『对象丢失』的特殊情况。
  • 为了解决这个问题,我们引入『三色标记』(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照『是否访问过』这个条件标记成以下三种颜色:
    • 白色:表示对象尚未被垃圾收集器访问过(初始默认状态)。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
    • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了『黑色对象,无须重新扫描一遍』。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。对象只有被黑色对象引用才能存活
      • GC Roots默认为黑色
    • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。(即灰色引用的对象还未被扫描)
  • 并发中的可达性分析就是一个利用三色标记将所有直接/间接引用对象访问并标记(变为黑色)的过程

image.png image.png

三色标记的不足与解决

  • 三色标记极端情况下还是会出现对象丢失(也叫消失),如下2个条件同时满足时,会产生『对象消失』的问题,即原来本应该是黑色的对象被误标注为白色
    1. 赋值器插入了一条(或多条)从黑色对象到白色对象的新引用;
    2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
  • 只需破坏这两个条件的任意一个即可,因此有2种方式:增量更新(破坏a),原始快照(破坏b)
    • 无论是『增量更新』还是『原始快照』都将可达性扫描分成了 2 次,并在『第二次扫描』中去修正可能在第一次扫描中因为并发的用户线程运行所造成的引用关系的变动。
    • 增量更新:当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
      • 特点 :灰色对象的引用关系是在『用户线程运行期间』解除的;白色对象是『重新扫描』时才被涂黑的。
    • 原始快照:当灰色对象要删除指向白色对象的引用关系时,进行逻辑上的删除:并未真实删除引用关系,但是所引用对象并不会变黑。在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,在这次扫描中去解除引用关系。
      • 特点:白色对象是在『用户线程运行期间』建立引用关系时被涂黑的。灰色对象的引用关系是在『重新扫描』时才解除的。在『用户线程运行期间』这个引用关系并未真正解除。

image.png

何时回收

Full GC

  • 理论上,在通过『可达性分析』判断出哪些对象可回收之后,垃圾回收器便可以周期性扫描、分析堆空间中的对象,销毁已死对象。对堆空间的所有对象进行扫描、分析、销毁的垃圾回收行为,被称为『全扫描,基于这种行为的垃圾回收器,就被称为 Full GC 。

    • 全扫描的代价是很大的。垃圾回收器的每轮的回收中都对每一个对象进行可达性分析,那么 JVM 的停顿时间会变得很长。有没有可能在每一次的回收过程中,只取分析部分对象的可达性,而对于之前已经判断的可达的对象网开一面,不再分析、回收它们?
      • 抽取部分进行分析的方案就是『分代收集』理论,即为解决Full GC全扫描问题的改进

        分代收集理论

  • 大多数对象的存活期不会很长。在一两轮的垃圾回收周期中,经垃圾回收器判断发现其不可达、已死,就会被垃圾回收器销毁。大概有个90%多的对象熬不过第一轮,即90%多的对象都是新生代对象

    • 占据总量 98% 的对象不一定会占据内存总量的 98% !因为这些对象都会在短时间内被删除,回收内存。因此,只需要远小于 98% 的内存,就能存放 98% 的内存。这98%是从开始到结束的总数
    • 分代收集就是根据对象的生存时长的不同,将不同对象放于堆内存的新生代区/老生代区以避免全堆扫码的理论
  • 而小部分对象会熬过多次垃圾回收周期。
    • 熬过越多次垃圾收集过程的对象就越难以消亡,在可预见的未来它大概率仍会被用到。
    • 基于分代收集理论,如果我们将难以消亡的对象集中存放,那么 JVM 只需要用很低的频率来扫描、回收这个区域中的对象。
  • 一个对象被新创建出来时,自然是在堆空间的新生代区域中为其分配内存空间。
    • 当垃圾回收器跟踪发现这个对象熬过多轮(默认是 15 轮)回收周期,变将其移入到老年代区域。
    • 垃圾回收器绝大多数情况下只分析新生代中的对象的可达性,少数情况下区分析老年代中的对象的可达性。
  • 分代收集理论有如下时期,但是具体实现虚拟机不一定严格遵循
    • 新生代收集期:只扫描、收集 Java 堆中的新生代内存区中的对象。
    • 老年代收集期:只扫描、收集 Java 堆中根的老年代内存区中的对象。目前只有 CMS(1.5 开始的 JVM 默认垃圾收集器)有这种收集期。
    • 混合收集期:扫描、收集 Java 堆中的新生代内存其和『部分』老年代内存区中的对象。目前只有 G1(1.9 开始的 JVM 默认垃圾收集器)有这种收集器。
    • 整堆收集:也就是 Full GC,扫描、收集整个 Java 堆中的对象。
  • 大部分虚拟机的大多数垃圾回收周期是进行新生代收集;少数回收周期是进行老年代收集/混合收集;只有极少数周期是进行整堆收集。具体的虚拟机又有所不同

    跨代引用

  • 当新生代和老年代对象存在引用关系,不做处理的话会带来麻烦。分2种情况:

    • 一个新生代对象,引用了一个老年代对象:如果新生代对象所引用的那个老年代对象,只被这个新生代对象所引用,而没有被任何其它老生代的对象引用。那么,如果不作处理,当垃圾回收器『分析老生代区的对象的可达性』时,会因为没有老年代对象引用它,而将该对象标注为不可达,并在未来将其删除。
    • 一个老年代对象,引用了一个新生代对象:如果一个老年代对象所引用的新生代对象,只被这个老年代对象所引用,而没有被其它任何新生代的对象引用。那么,如果不作处理,当垃圾回收器『分析新生代区的对象的可达性时,会因为没有新生代对象引用它,而将该对象标注为不可达,并在未来将其删除
  • 但是,在回收新生代区的内存时,为了避免跨代引用引起的误删,而去扫描整个老年代对象;在回收老年代区的内存时,为了避免跨代引用引起的误删,而去扫描整个新生代对象,这种解决办法显然时不可取的。意味着本质上就是 Full GC,完全违背了分代理论。因此又出现了记忆集的概念

image.png

记忆集

  • 考虑到跨代引用只是极少数的情况,Java 虚拟机提出了 记忆集,就是将跨代引用的对象记录下来。避免跨代引用时的全扫描
    • 如果一个新生代对象被一个老生代对象引用,那么就将这个老生代对象记录在记忆集中;
    • 如果一个老生代对象被一个新生代对象引用,那么就将这个新生代对象记录在记忆集中。
    • 当删除一个新生代对象时,因为存在跨代引用的可能,因此,需要去记忆集中查询有没有老生代对象引用它,如果有,则保留这个新生代对象,不删除它。同理,删除一个老生代对象时,也是相应的处理方式
  • 跨代 引用最终有2种结局:

    • 随着跨代引用老年代对象的新生代对象的删除,这个被引用的老年代对象未来也终将被删除;随着跨代引用新生代对象的老生代对象的删除,这个被引用的新生代对象未来也终将被删除。
      • 总结即引用方的删除最后导致被引用方也删除
    • 另一种可能是,新生代对象熬过足够多轮(默认 15 轮)回收周期,变成老生代对象,这样跨代引用的问题自然也就不存在了。

      如何回收

  • 垃圾回收器被唤醒进行回收时,并不是简单地『判断对象的可达性,对于不可达的已死的对象回收其内存空间』。在回收内存过程中,实际上它是要通过 『垃圾回收算法』分若干步操作才能实现回收内存。

    标记-清除算法 Mark-Sweep

  • 首先标记处于已死状态的对象,在标记完成之后,统一回收掉所有被标记的对象。(也可以反过来,标记出存活的对象,统一回收所有未标记对象)

    • 标记过程就是『对象是否属于垃圾』的判定过程,至于判定的标准就是我们前面所说的『是否可达』。(这里有点不严谨,不可达不一定就是已经死亡)
  • 商用 JVM 实际上并不会使用 Mark-Sweep 算法,因为它的缺点很明显:
    • 执行效率不稳定。标记和清除过程的时长会随着堆中对象的增多而变长。
    • 标记、清除之后会产生大量不连续的内存碎片
  • CMS 就是基于标记-清除算法的,不过它并非原始的标记-清除算法,它是 Concurrent Mark-Sweep(并发式标记清除)算法

image.png

标记-复制算法

  • 标记-复制算法提出了一种半区复制的垃圾收集算法。是一种空间换时间的策略,是对于Mark-Sweep 算法的改进:第二阶段的交换操作替换为复制操作。
  • 半区复制的思路是:将内存分为大小相等的两块,每次只使用其中的一块。即,每次只是用堆空间的一半进行标记。标记完这一半然后将存活的对象复制到另一半内存上,然后直接整体清空正在使用的这一半内存。
    • 虽然表面上看起来 Mark-Copy 算法要消耗时间进行对象的拷贝,但是实际上只有很少一部分对象需要拷贝(大概只有百分之几是永久代对象需要拷贝,90%多的都是第一轮淘汰都过不了的新生代,只需拷贝可达的对象
  • 在标记-复制算法中,当前被使用的那一半内存,叫做 Eden 空间。准备留着用于复制的另外一半内存,叫做 Survivor 空间
    • 在分配内存-内存回收的周期性循环中,Eden 空间和 Survivor 空间的身份会不停地互相切换。
    • Eden 空间的可访问对象被复制到 Survivor 空间后,Eden 空间被整体清空,在下一个周期中,它将变为 Survivor 空间。
    • Survivor 空间被拷贝内存后,在下个周期中,它将变为 Eden 区,新创建的内存的所需内存,就在这里面分配。
      • 一个对象这么来回复制足够多次(15 次)就意味着它将变为老年代对象

image.png

标记-复制再改进

  1. 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,大小比例是 8 : 1 : 1
  2. 每次分配内存只使用 Eden 和其中一块 Survivor
  3. 在标记阶段,判断 Eden 和当前正在使用的 Survivor 中的存活对象,拷贝到另一块 Survivor 中。然后直接清除 Eden 和当前的 Survivor
  4. 熬过若干次(15 次)垃圾回收周期的 Survivor 中的对象,就被移入老年代区

8:1:1的比例下,只需浪费 10% 的内存的内存用于作拷贝中转的区域,比原来的标记-复制要使用一半内存作拷贝区域节约很多

标记-整理算法

  • 这个用的很少,它是对一个特殊情况的特殊处理方案:老年代对象极少(比 2% 都要低很多很多)近乎没有时(这种情况下时使用下面的方法,就只需移动很少的老年代对象)
  • 标记-整理的标记过程仍然与『标记-清除』算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
  • 标记整理算法的存在价值在于:
    • 移动对象,会增加内存回收的时长但是连续的内存空间会减少内存分配的时长。由于内存分配操作的频次要远高于垃圾回收,因此,(在需要移动移动的对象很少的情况下)时间开销上的『赚的』和『亏的』以抵消,发现还是有的赚。