在前边的文章中我们介绍了Java的虚拟机是如何进行内存管理的,以及一个对象的内存分配过程。今天这篇文章将会介绍下关于jvm里面的垃圾回收过程相关细节点。
如何判断一个对象是否存活
简单来讲,判断一个对象是否存活的算法主要有以下两类:
- 引用计数算法
- 根可达算法
引用计数算法
根据一个引用计数器来计算对象被引用的次数,如果引用加一,则计数器+1,反之计数器-1。
这个算法虽然实现思路比较简单,但是默认的jvm并不使用这种算法,因为无法解决相互引用的问题。
根可达算法
通过一系列名为“GC Roots”的对象作为起始点,从“GC Roots”对象开始向下搜索,如果一个对象到“GC Roots”没有任何引用链相连,说明此对象可以被回收。
这里我通过这张图来带大家理解下什么是根可达。
这张图中,可以看到有一系列的对象存活于根集合当中,他们会和被使用的对象进行引用关联。从GC ROOT作为顶部顺藤摸瓜下去,如果能够找到相关引用的对象则不进行回收,如果没有引用,则该对象需要被回收掉处理。如对象e就是需要被回收处理。
怎么理解什么是ROOT呢
关于这一点的理解,我用一个简单的代码案例来介绍下:
public class Test { public static void main(String[] args) { List<String> test = new ArrayList<>(); } } 复制代码
代码里的test集合就可以理解为是一个ROOT对象。
被选择为GC Root对象可以是一些存储在公共内存区域的对象,例如:
- 方法区中的常量
- 虚拟机栈中引用的常量
- 局部变量
- 本地方法中的JNI
- 同步锁中绑定的对象
在Hotspot虚拟机中关于GC ROOT部分的包含了对于jvm中其他对象引用部分的数据存储和管理。内部的实现中使用了Oop Map(Ordinary Object Pointer Map)集合来进行管控,在类加载的时候,就直接将一些类信息的引用给记录到OopMap当中存储,后续jvm直接可以从这里获取所需要的数据内容。
垃圾收集
如今比较多的垃圾收集器都是基于分代假说来进行设计的,这种分代假说有什么不足点?
大多数情况下,使用简单的分代假说存在跨代引用的不足点。假设一个对象存在于年轻代,但是它在老年代也有被引用,那么就可能会有该对象存在跨代引用的情况发生。
算法演变思路
每次做年轻代对象回收的时候,都需要遍历老年代,判断是否有引用关系。这种方式虽然实现简单,但是实际运行起来比较损耗性能。
后期进行了设计改良,单独设置了一个全局内存区域,专门记录老年代的哪些模块存在跨代引用,当垃圾回收的时候,合适地进行回收即可。
三大垃圾收集思路
从早期的Hotspot虚拟机一直到现在,主要的垃圾收集思路一直是围绕着以下几点进行迭代。
Mark-Sweep (标记清除)
这个算法比较好理解,就是对每个对象都做一个标记,记录该对象是否可以进行回收。
不足点:
每个对象都需要标记是否可回收,当对象数目一旦变多了之后,性能损耗提升。
回收对象之后,内存碎片化严重。
优点:
简单容易理解,实现难度低。
Semispace Copying (标记复制)
标记复制算法主要是将存活的对象进行标记,然后将空闲内存移向一侧,另一侧用于存放存活对象。这个过程会涉及到较多的对象内存地址移动操作,所以性能损耗也不小。
不足点:
1.需要有两倍的内存空间用于进行垃圾回收,占用内存。假设我的jvm有2g空间,那么其中至多只有1g空间用于存储对象
当对象数目非常多的时候,移动频率会比较高,比较耗费性能。例如说老年代中的对象,大部分都是体积比较庞大,并且移动起来比较困难的对象,所以使用这种算法在老年代进行使用会比较吃亏。
优点:
解决了之前内存碎片化的一些问题
早期的时候,“Appel式回收算法”对标记复制进行了完善,也是较早提出分代设计的一次尝试。它将年轻代划分为了Eden区和Survivor区域。将Eden区和Survivor区中存活的对象直接一次性复制到另一个区域中。
ps:分代设计中不会直接沾满整个年轻代的内存空间,实际上会预留一部分空间大小用于做“冗余”,当minior gc 之后的存活对象在年轻代没有足够空间存放的时候就会被挪置老年代。
Mark Compact 标记整理
之前提到的垃圾回收方式是基于标记清除的算法,这是一种非移动式的回收算法。标记整理更多是将固定内存区域中存活的对象往同一个方向去挪动,尽量地保证内存区域中的内存碎片化概率给降低。
不足点:
需要有两倍的内存空间用于进行垃圾回收,占用内存。假设我的jvm有2g空间,那么其中至多只有1g空间用于存储对象
当对象数目非常多的时候,移动频率会比较高,比较耗费性能。例如说老年代中的对象,大部分都是体积比较庞大,并且移动起来比较困难的对象,所以使用这种算法在老年代进行使用会比较吃亏。
优点:
解决了之前内存碎片化的一些问题,早期的时候,“Appel式回收算法”对标记复制进行了完善,也是较早提出分代设计的一次尝试。它将年轻代划分为了Eden区和Survivor区域。将Eden区和Survivor区中存活的对象直接一次性复制到另一个区域中。
ps:分代设计中不会直接沾满整个年轻代的内存空间,实际上会预留一部分空间大小用于做“冗余”,当minior gc 之后的存活对象在年轻代没有足够空间存放的时候就会被挪置老年代。
Mark Compact 标记整理
之前提到的垃圾回收方式是基于标记清除的算法,这是一种非移动式的回收算法。标记整理更多是将固定内存区域中存活的对象往同一个方向去挪动,尽量地保证内存区域中的内存碎片化概率降低。
通常情况下,整理算法重排堆中对象时采用下述三种策略:
任意顺序(Arbitrary) :对象的移动方式与它们的原始排序顺序和引用关系无关,可以把对象移动到任意位置。其实现简单且执行快速,但这种整理方式可能会把原来相邻的对象分散到不同的高速缓存行或虚拟内存页,从而降低空间的局部性(locality)。
线性(Linearising) :将具有关联关系的对象排列在一起,比如具有引用关系的对象,或同一数据结构中的相邻对象。其最关键的问题是,很难评估将具有什么样关联关系的对象排在一起有更好的性能。所以,你几乎看不到使用这种策略实现的整理算法。
滑动(Sliding) :将存活对象滑动到堆的一端,挤出垃圾,保证原有顺序。由于它不改变对象的相对排列熟悉,不会影响赋值器的局部性,所以现代的标记-整理回收器均使用这一策略。
使用标记整理中关于整理对象的这种思路的算法实际上有很多类,这里我查阅了一些资料介绍其中的双指针算法:
双指针算法是 Robert A.Saunders 在1974年提出的,它采用任意顺序策略,需要遍历两次堆空间,第一次遍历的目的是整理内存,即移动对象;第二次遍历的目的则是更新所有指向被移动对象的指针。
双指针算法的最佳适用场景为只包含固定大小对象的区域。其实现原理非常简单,大致的示意图如下所示,图中字母表示内存地址,数字为对象标识。
算法的第一次遍历(如上图)
细节步骤如下:
在算法初始阶段,指针free指向区域开始,指针scan指向区域末尾,在第一次遍历过程中,指针free不断向前移动,指针scan不断向后移动;
指针free不断向前移动,直到遇到一个空闲位置,指针scan不断向后移动,直到遇到一个存活对象;
当指针free和指针scan分别指向空闲位置和存活对象时,准备将 scan 指向的对象移动到 free 的位置;
将 scan 指向的对象移动到 free的位置,scan 指向的位置记录下原对象移动到了哪里(图中为B位置),并将这块内存标记为空闲;
当 指针free和指针scan发生交错,遍历结束。
算法的第二次遍历初始化时,指针scan指向区域的起始位置;
然后开始遍历,如果指针scan指向的对象中包含指向空闲位置的指针p,则p指向的内存块中必定记录着对象移动后的地址,然后将p指向这个地址。比如,图中对象3有一个指针指向对象4,对象4移动后,其原来的内存块F中记录着其移动后的地址B,那么需要将这个指针修改为指向B;
继续遍历,直到指针scan指向的位置为空闲内存,遍历结束。
小结
双指针算法的优势是实现简单且执行速度快,但它打乱了堆中对象的原有顺序,这会破坏程序局部性,而且还对分配内存大小有严格限制,所以其应用范围有限。
这也算是采用任意顺序策略的整理算法的通病,可以想象一下,如果对象大小不固定,遍历存活对象并找到合适大小的空闲内存,该如何遍历堆,又会遍历多少次?到这儿也就能够理解为什么采用任意顺序策略的整理算法只能处理单一大小对象,或只能对不同大小的对象分别进行整理的原因了。