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

2021-05-30  本文已影响0人  寻找傅里叶

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

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

哪些内存需要回收

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

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

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

何时回收

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

      强引用>软引用>弱引用>虚引用

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

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

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

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

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

如何回收

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

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

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

回收算法

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

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

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

算法细节

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

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

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

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

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

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

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

CMS基于此做并发标记

G1/Shenandoah基于此做并发标记

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

经典垃圾收集器

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

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

Parallel Scavenge/Parallel Old垃圾收集器

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

CMS收集器

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

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

G1收集器
上一篇下一篇

猜你喜欢

热点阅读