深入理解Java虚拟机读书笔记 三

垃圾回收需要解决的三个问题是:

  1. 哪些内存需要回收
  2. 何时回收
  3. 如何回收

哪些内存需要回收

对于Java内存运行时区域,程序计数器\虚拟机栈\本地方法栈三个部分是线程私有的,随线程而生,随线程而灭.因此这几个区域的内存分配和回收都具有确定性,当方法或者线程结束时,内存会自然回收.
因此通常指的垃圾回收是针对方法区两个部分:只有运行时,才能知道究竟会创建哪些对象,创建多少个对象,分配和回收是动态的.
确定了回收的区域后,就需要判定区域中哪些对象需要回收,通常有两种方法来判定:

  • 引用计数法,当对象的引用计数器变为0时,则被回收.Python中使用的就是此种方法,缺点是如果对象之间相互引用,则无法被回收;
  • 可达性分析算法,采用一系列GC Roots根对象根据引用关系搜索,如果某个对象不可达,则将其回收.Java中采用的是此种方法.

GC Roots是一系列对象,固定可以作为GC Roots的对象包括:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 常量引用的对象
  • JNI 引用的对象
  • 虚拟机内部的引用对象,例如基本类型的Class对象,常驻异常对象等
  • synchronized持有的对象
  • 反映虚拟机内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等
    除此之外,还可以有其他对象临时加入.

书上说的比较难懂,按照书里的描述,root应该是一个对象,而按照其他地方的描述,譬如R大在知乎上的回答是这样说的:一组活跃的对象引用(是引用,不是对象,因为对象处在堆里,是要被回收的区域).参照:
java的gc为什么要分代? - RednaxelaFX的回答 - 知乎.而我认为root是引用比较好理解一点,比如int p = new Person(),如果再设置p=null,那么最初p指向的Person对象就会标记为不可达,从而被回收.或许不用纠结具体的文字,只要是该对象的引用是活跃的,那么回收它一定会影响运行,因此可以将活跃的引用作为root,参照:GC root

何时回收

由此可以看出,我们一直以"引用"来衡量对象是否可达.为了让引用具有除了被引用,未被引用这两种状态之外有更加多的状态(比如单纯的说一个对象被引用,但是在内存比较紧张的情况下,是否可以将其也回收掉),自JDK 1.2后,引用的概念变得更加丰富,按照引用强度,可以分为四种:

      强引用>软引用>弱引用>虚引用
  • 强引用(Stongly Reference): 永远不会被回收
  • 软引用(Soft Reference): 内存不足时,会被回收
  • 弱引用(Weak Reference): 无论内存是否充足,都会被回收
  • 虚引用(Phantom Reference): 不影响对象生存周期,也不能获得对象,无论内存是否充足,都会被回收,唯一的用途是在回收时获得通知.

参考资料: 理解Java的强引用、软引用、弱引用和虚引用

当在可达性分析算法中被判定为不可达的对象,还至少需要经历两次标记过程:

  1. 经历第一次标记后,进行第一次筛选,筛选是否需要执行finalize()方法.由于finalize()方法只能被调用一次,因此,没有覆盖该方法的,或者已经被调用过的,不会被筛选出来.
  2. 被筛选出来的对象是需要执行finalize方法的,它们会被放置在F-Queue队列中,并等待着被虚拟机自动建立的低调度优先级的线程去执行finalize方法.所以如果对象在finalize时让自己重新被引用链上的某个对象引用,那么便可以逃脱被回收的命运.

因此,使用finalize要慎重,尽量不要使用它.因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序.

如果说堆中的垃圾回收比较清晰,那么方法区的垃圾回收就复杂得多.虚拟机规范提到可以不在方法区中实现垃圾收集.因为它的回收条件较为苛刻:
方法区主要回收废弃常量和不再使用的类型.废弃常量的判定较为简单:当一个常量不再被引用时,可以被清除出常量池.而不再使用的类型的判定条件就比较严苛:

  • 该类所有实例都被回收
  • 加载该类的类加载器也被回收
  • 对应的java.lang.Class对象没有被引用,无法通过反射访问该类的方法

如何回收

当前虚拟机的垃圾回收大多数遵循了"分代回收"的理论.由此可以得出一个设计原则:将堆划分出不同的区域,然后将回收对象依据熬过垃圾收集过程的次数分配到不同的区域中.不同区域的回收频率不一样,可以兼顾时间开销和内存空间的有效利用.
具体实现时,至少会有两代:新生代(Young Generation)和老年代(Old Generation):新生代经历了回收后存活的对象,会逐步晋升到老年代.针对特定区域的收集因此也可以被分为:

  • 部分收集Partial GC: 不是完整收集整个堆的垃圾
    • 新生代收集(Minor GC/ Young GC): 新生代的垃圾收集
    • 老年代收集(Major GC/ Old GC): 老年代的垃圾收集, 只有CMS收集器有此行为
    • 混合收集(Mixed GC): 收集整个新生代和部分老年代,只有G1收集器有此行为
  • 整堆收集(Full GC): 收集整个堆和方法区

对象并不是孤立的,对象之间会存在跨代引用.为了避免在新生代中进行了扫描后,又需要在老年代中进行扫描来确认扫描的结果准确性,引入了记忆集(Remembered Set,从非收集区域指向收集区域),用以记录老年代哪一块内存引用了新生代的对象.这样当进行Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描,避免了扫描整个老年代.

如果记录跨代指针的所有细节,空间占用和维护成本都变得十分高昂.因此,可以采取更粗粒度的记录,比如采用内存分块的方式记录,只要内存块中存在跨代引用,就可以标记为dirty,从而可以轻易筛选出哪些内存块中包含跨代指针.也可称之为卡表(Card Table).卡表是记忆集的一种实现.参照:jvm的card table数据结构

回收算法

  • 标记-清除(Mark Sweep):最基础的算法,后续算法大多以此为基础.
    分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
    主要缺点是:1. 随着要回收对象的增加,标记和清除的效率都会降低; 2. 会导致内存碎片化.当需要分配较大对象无法找到足够的连续内存时,不得不提前触发另一次垃圾收集动作.

CMS收集器采用了这种思想.

  • 标记-复制(Mark Copy): 现在虚拟机大多采用此回收新生代.
    它将可用内存按容量划分为两块, 每次只使用其中的一块. 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉.对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象, 而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可.这样实现简单,运行高效.缺点是浪费的空间比较大.

Serial/ParNew收集器都采用了这种思想.将新生代分为一块较大的Eden区域和两块较小的Survivor。每次分配内存只使用Eden和其中一块Survivor。当另一块Survivor区域不足以容纳存活对象时,将会触发分配担保(Handle Promotion),存活对象直接进入老年代。

  • 标记-整理(Mark Compact): 老年代有大量存活的对象,Mark Copy就不适用了.所以针对老年代的特点,可以采用此种算法.
    其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存.因为老年代存在大量的存活对象,对存活对象的移动将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序(stop the world)才能进行。

Parallel Scavenge收集器基于了这种思想.

算法细节

HotSpot为例,垃圾回收开始于根节点枚举,而所有收集器在扫描根节点集合时都需要暂停用户线程,因此如何快速地,正确地枚举出根节点至关重要:
比如通过OopMap(扫描时可以得知哪些位置是引用)的协助快速完成GC Roots枚举,而不用去查询所有执行上下文和全局的引用位置,然而OopMap可能被很多指令影响,因此引入了安全点,安全区域:

  • 安全点: 线程只有到达安全点才能够暂停进行垃圾收集.

当到达安全点时,可以将OopMap看做是当前内存的快照.这个快照既不能出现的太频繁,也不能很久也不生成,也就是说安全点位置的选取需要进行特别地考虑.

  • 安全区域: 当线程处于休眠或者阻塞状态时,无法进入安全点挂起自己,所以引入了安全区域.线程需要离开安全区域时,需要检查虚拟机是否完成了根节点枚举.

同时,还有上文提到的记忆集,它也可以缩减GC Root的扫描范围.虚拟机采用写屏障(Write Barrier)的方式来即时地让卡表元素变成dirty.

类似于切面编程,可以在赋值的前后,进行额外的操作.虽然会产生额外的开销,但是胜过在进行Minor GC时扫描整个老年代.多个卡表元素(一个卡表元素占1个字节)会共享一个缓存行,因此不同线程的操作对卡表的影响是可能会带来伪共享问题的.

假设已经获得了GC Roots后,需要根据可达性算法来获得引用链来判定对象是否存活,也就是标记阶段.它要求全过程都基于能保障一致性的快照上才能进行分析,意味着需要全程冻结用户线程的运行.

如果不一致,可能会有两种后果:1. 将死亡的对象标记为存活;2.将存活的对象标记为死亡,而这种错误是致命的

标记阶段是所有追踪式垃圾收集算法的特征,当堆变大,标记阶段显然会因此变长,因此在这个阶段,降低线程的停顿时间也能带来很大的增益:

  • 增量更新: 将并发扫描时新增加的引用记录下来,等扫描结束后,再重新扫描一遍(让增加了引用的对象重新扫描一遍,保证了它引用的对象是存活的,即让增加了引用的对象变成灰色).

CMS基于此做并发标记

  • 原始快照(SATB): 将并发扫描时删除的引用记录下来,等扫描结束后,再重新扫描一遍引用记录(保证此次垃圾回收时本该存活的对象,由于引用被删除仍然是存活的,可以理解为让被删除指向它的引用的节点变成灰色).

G1/Shenandoah基于此做并发标记

这两种解决方案都是基于写屏障实现的.通常采用三色法描述会更加直观.参照:JVM-垃圾回收-三色标记算法.

经典垃圾收集器

对于如何发起内存回收(根节点枚举,并发扫描标记),如何加速回收(降低停顿),如何保证回收正确性(增量更新和原始快照),前文已经有简单介绍.但垃圾回收的具体动作因不同的垃圾收集器而异,以HotSpot虚拟机为例,垃圾收集器有以下几种:

  • Serial/Serial Old: 分别代表了新生代和老年代的收集器.其中新生代采用复制算法,老年代采用整理算法.但它只采用一条收集线程完成垃圾收集,并且收集时必须暂停其他所有工作线程.即使如此,它相比其他单线程收集器,具有简单高效的优点,同时也是额外内存消耗最小的.
    Serial/Serial Old垃圾收集器
  • ParNew: Serial的多线程版本,是应用于新生代的收集器
    ParNew/Serial Old收集器
  • Parallel Scavenge/Parallel Old: Parallel Scavenge是基于复制算法实现的多线程新生代收集器,与ParNew不同在于它的关注点在吞吐量(处理器运行用户代码时间和处理器总消耗时间)上,也被成为吞吐量优先收集器.Parallel Old是它的老年代版本.

Parallel Old出现之前,Parallel Scavenge只能与Serial Old一起搭配

Parallel Scavenge/Parallel Old垃圾收集器

  • CMS: 基于标记清除算法实现的老年代收集器,它有四个阶段: 初始标记,并发标记,重新标记,并发清除.其中初始标记和重新标记都需要stop the world,但初始标记只标记GC Roots能直接关联到的对象,而重新标记可以利用增量更新来对并发标记时产生的变动进行修正。相较于耗时最长的并发标记和并发清除,时间要短得多。而并发标记和并发清除,虽然耗时长,但可以与用户线程一起工作。

CMS也被称为并发低停顿收集器,但是也仍然具有明显的缺点:
1.对处理器资源敏感(并发会占用处理器能力);2.无法处理浮动垃圾(垃圾回收时用户线程仍在进行,因此也需要预留内存空间给用户线程)。如果没有足够的内存空间,将临时启用serial old来对老年代进行收集; 3.采用标记清除算法,容易产生大量碎片。

CMS收集器

  • G1:与前面所有的收集器不同,它采用的是Mixed GC模式,也就是说,它可以面向堆任何部分来进行回收,而不是只关注某一块区域(新生代或者老年代)。将内存划分为大小相等的独立区域(Region),每一个Region都根据需要扮演新生代或者老年代的角色。同时还有一个专门存储大对象的Humongous区域。

虽然G1仍然保留了新生代和老年代的概念,但它们不再是固定的,是一系列不要求连续的区域集合。

因为Region是回收的最小单元,因此每次回收都是Region的整数倍,所以G1可以建立一个可以预测的停顿时间模型,根据回收可以获得的内存大小以及所需的经验时间,计算回收价值大的区域,也就是Garbage First
G1运作过程大致可以分为四个步骤:初始标记,并发标记,最终标记,筛选回收。除了并发标记,其他步骤都需要暂停用户线程。与CMS不同的是,它最终标记采用的是前文提到的SATB,并且不会产生内存空间碎片。
G1收集器

posted @ 2021-05-30 16:45  yuyinzi  阅读(55)  评论(0编辑  收藏  举报