深入理解Java虚拟机读书笔记 三
垃圾回收需要解决的三个问题是:
- 哪些内存需要回收
- 何时回收
- 如何回收
哪些内存需要回收
对于Java
内存运行时区域,程序计数器\虚拟机栈\本地方法栈三个部分是线程私有的,随线程而生,随线程而灭.因此这几个区域的内存分配和回收都具有确定性,当方法或者线程结束时,内存会自然回收.
因此通常指的垃圾回收是针对堆和方法区两个部分:只有运行时,才能知道究竟会创建哪些对象,创建多少个对象,分配和回收是动态的.
确定了回收的区域后,就需要判定区域中哪些对象需要回收,通常有两种方法来判定:
- 引用计数法,当对象的引用计数器变为0时,则被回收.Python中使用的就是此种方法,缺点是如果对象之间相互引用,则无法被回收;
- 可达性分析算法,采用一系列
GC Roots
根对象根据引用关系搜索,如果某个对象不可达,则将其回收.Java
中采用的是此种方法.
GC Roots
是一系列对象,固定可以作为GC Roots
的对象包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 常量引用的对象
JNI
引用的对象- 虚拟机内部的引用对象,例如基本类型的
Class
对象,常驻异常对象等 - 被
synchronized
持有的对象 - 反映虚拟机内部情况的
JMXBean
、JVMTI
中注册的回调、本地代码缓存等
除此之外,还可以有其他对象临时加入.
书上说的比较难懂,按照书里的描述,
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的强引用、软引用、弱引用和虚引用
当在可达性分析算法中被判定为不可达的对象,还至少需要经历两次标记过程:
- 经历第一次标记后,进行第一次筛选,筛选是否需要执行
finalize()
方法.由于finalize()
方法只能被调用一次,因此,没有覆盖该方法的,或者已经被调用过的,不会被筛选出来. - 被筛选出来的对象是需要执行
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
: 分别代表了新生代和老年代的收集器.其中新生代采用复制算法,老年代采用整理算法.但它只采用一条收集线程完成垃圾收集,并且收集时必须暂停其他所有工作线程.即使如此,它相比其他单线程收集器,具有简单高效的优点,同时也是额外内存消耗最小的.
ParNew
:Serial
的多线程版本,是应用于新生代的收集器
Parallel Scavenge/Parallel Old
:Parallel Scavenge
是基于复制算法实现的多线程新生代收集器,与ParNew
不同在于它的关注点在吞吐量(处理器运行用户代码时间和处理器总消耗时间)上,也被成为吞吐量优先收集器.Parallel Old
是它的老年代版本.
在
Parallel Old
出现之前,Parallel Scavenge
只能与Serial Old
一起搭配
CMS
: 基于标记清除算法实现的老年代收集器,它有四个阶段: 初始标记,并发标记,重新标记,并发清除.其中初始标记和重新标记都需要stop the world
,但初始标记只标记GC Roots
能直接关联到的对象,而重新标记可以利用增量更新来对并发标记时产生的变动进行修正。相较于耗时最长的并发标记和并发清除,时间要短得多。而并发标记和并发清除,虽然耗时长,但可以与用户线程一起工作。
CMS
也被称为并发低停顿收集器,但是也仍然具有明显的缺点:
1.对处理器资源敏感(并发会占用处理器能力);2.无法处理浮动垃圾(垃圾回收时用户线程仍在进行,因此也需要预留内存空间给用户线程)。如果没有足够的内存空间,将临时启用serial old
来对老年代进行收集; 3.采用标记清除算法,容易产生大量碎片。
G1
:与前面所有的收集器不同,它采用的是Mixed GC
模式,也就是说,它可以面向堆任何部分来进行回收,而不是只关注某一块区域(新生代或者老年代)。将内存划分为大小相等的独立区域(Region
),每一个Region
都根据需要扮演新生代或者老年代的角色。同时还有一个专门存储大对象的Humongous
区域。
虽然
G1
仍然保留了新生代和老年代的概念,但它们不再是固定的,是一系列不要求连续的区域集合。
因为Region
是回收的最小单元,因此每次回收都是Region
的整数倍,所以G1
可以建立一个可以预测的停顿时间模型,根据回收可以获得的内存大小以及所需的经验时间,计算回收价值大的区域,也就是Garbage First
。
G1
运作过程大致可以分为四个步骤:初始标记,并发标记,最终标记,筛选回收。除了并发标记,其他步骤都需要暂停用户线程。与CMS
不同的是,它最终标记采用的是前文提到的SATB
,并且不会产生内存空间碎片。