标记阶段
对象存活判断
- 在堆区存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有标记为已死亡的对象,GC才会在执行回收时,释放其内存空间,此阶段成为垃圾标记阶段
- 当一个对象已经不再被任何存货对象继续引用时,就可以宣判为已经死亡
- 判断对象存活一般有两种方式:引用计数算法和可达性分析算法
引用计数算法
- 对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
- 对于一个对象A,只要有任何一个对象引用了,则A的引用计数器加1,失效时,计数器减一。只要计数器的值为0,即表示对象A不可能再被使用,可进行回收
- 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
- 缺点:
需单独的字段存储计数器,增加了存储空间的开销
每次赋值都需更新计数器,伴随着加法减法操作,增加了时间开销
无法处理循环引用,这一致命缺陷导致Java的垃圾回收器中没有使用这类算法
小结
- 引用计数算法,是很多语言的资源回收选择,例如Python,它更是同时支持引用计数和垃圾收集机制
- 具体哪种算法最优要看场景,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试
- Java并没有选择引用计数,是因为 其存在一个基本的难题,处理循环引用的问题
- Python如何解决循环引用?
手动解除:就是在合适的时机,解除引用关系
使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用
可达性分析算法
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
- 相较于引用计数算法,可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫做追踪性垃圾收集
基本思路
- 可达性分析算法是以跟对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则为不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
- 在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
GC Roots
在Java中,GC Roots包括
虚拟机栈中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用
...
- 由于Root 采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
- 当垃圾回收器没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalization()方法
- finalization()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等
- 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括
在finalize()时可能会导致对象复活
finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下若不发生GC,则finalize()方法没有执行机会
一个糟糕的finalize()会严重影响GC的性能
- 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
可触及的:从根节点开始,可以到达这个对象
可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活
不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为==finalize()只会被调用一次==
- 以上3中状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收
注意:判定一个对象是否可回收,至少要经历两次标记过程
1、如果对象到GC Roots没有引用链,则进行第一次标记
2、进行筛选,判断此对象是否有必要执行finalize()方法
①如果对象没有重写finalize()方法,对象则被判定为不可触及的
②如果对象重写了finalize()方法,且还未执行过,那么对象会被插入到F-Queue队列中,由一个虚拟机自动创建的、优先级较低的Finalizer线程触发其finalize()方法执行
③finalize()方法是对象逃脱死亡的最后机会,GC会对F-Queue队列中的对象进行第二次标记,如果待会瘦的对象在finalize(0方法中与引用链上的任何一个对象建立了联系,那么第二次标记时,对象会被移出"即将回收"集合
GC Roots溯源
可以使用MAT与JProfiler来对GC Roots进行溯源
清除阶段
- 当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
- 目前在JVM中比较常见的三种垃圾收集算法是 标记-清除算法、复制算法、标记-压缩算法
标记-清除算法
执行过程
标记:Collector从根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象
清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
缺点
效率相对来说较低
在进行GC的时候,需要停止整个程序(STW),导致用户体验较差
这种方式清理出来的空间内存是不连续的,产生内存碎片。需要维护一个空闲列表
注意:何为清除?
这里所谓的清除并不是真的置空,而是把需要清楚的对象地址保存在空闲的地址列表里。下次有新的对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
复制算法
- 为了解决标记清除算法在垃圾收集效率方面的问题而实现的一种垃圾清除算法
核心思想
将活着的内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用
的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
优点
没有标记和清除过程,实现简单,运行高效
复制过去以后保证空间的连续性,不会出现"碎片"问题
缺点
此算法缺点是需要两倍的内存空间
对于G1这种拆分成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或时间开销也不小
需要注意
如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。所以说复制算法是建立
在存活对象少,垃圾对象多的前提下从而发现堆空间中新生代较为符合这一条件,所以出现了Survivor区
标记-压缩(整理)算法
基于老年代垃圾回收的特性,需要使用其他算法
执行过程
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有存活对象压缩到内存的一端,按顺序排放
之后清理边界外所有的空间
优点
消除了标记清除算法中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
消除了复制算法中,内存减半的高额代价
缺点
从效率上,低于复制算法
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
移动过程中,需要全程暂停用户应用程序。即:STW
小结
对比三种算法
效率上来说,复制算法当之无愧是最优选择,但是却浪费太多内存。
而为了兼顾速度、空间开销、移动对象三个指标,标记整理算法相对来说更平滑一些,但是效率并不是最优。
标记整理算法比复制算法多了一个标记的阶段,比标记清除算法多了一个整理内存的阶段。
分代收集算法
概述
每种算法都有各自的优势和特点,所以就产生了分代收集算法
不同生命周期的对象可以采取不同的收集方式,以便提高回收效率
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,提高垃圾回收的效率
目前几乎所有的GC都是采用分代收集算法执行垃圾回收的
增量收集算法
基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行
垃圾收集线程只手机一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成
增量收集算法的基础仍是传统的标记-清除算法和复制算法
是通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
缺点
增量收集算法在垃圾回收过程中,间断性还执行了应用程序代码,达到减少系统的停顿时间的效果
但由于线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
分区算法
- 一般来说,相同条件下,堆空间越大,一次GC时间越长,GC产生的停顿也越长,为了更好控制GC的停顿时间,将一块大的内存分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少GC所产生的停顿
- 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间
- 每个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间
总结
这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备