本文主要学习如何回收垃圾,为什么需要分代回收,为什么需要区分年轻代和老年代,为什么Young GC会比Full GC快很多?我们带着这些问题,一起来学习一下垃圾回收算法
基础算法
标记
垃圾回收的第一步是需要找到死亡对象,之前我们在JVM如何判断一个对象是否可以被回收一文中,学习了GC Roots,根据GC Roots遍历所有可达对象,这个过程就叫做标记(Mark)。
最终结果为A,B,C,D,F为可达对象,E,G,H为不可达对象。
清除
标记出来可达对象,就可以开始清理不可达对象了,这个过程就叫做清除(Sweep)。
清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
整理
为了解决清除带来的问题,可以在清除之前,先将存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间,然后将末端内存地址以后的内存全部回收。
整理代价就是压缩算法的性能损耗
复制
复制算法将整个内存分为轮流使用的两块内存。一块内存为对象分配内存空间,称为工作内存,另外一块内存作为复制时备用,称为备用内存。当发生垃圾回收时,便把工作内存中存活的对象复制到备用内存中,并且两者的角色互换。
复制算法虽然也能解决内存碎片的问题,但同时它浪费了几乎一半的内存空间来做这个事情,如果资源本来就很有限,这就是一种无法容忍的浪费。如果对象的存活时间比较长,那么,对象会在两块内存之间来回复制多次,比较浪费时间。
总结
- Sweep指的是把垃圾清除了,但它不会移动活动对象,不过久了以后内存容易碎片化
- Compact除了丢弃垃圾对象外,还会移动活动对象,紧凑地放到一个新的地方,能解决碎片化问题,但可能需要先计算目标地址,修正指针再移动对象,速度较慢。
- Copy本质上和 Compact 是一样的,不过它的一些计算最会更少。但通常需要保留了一半的内存,移动时直接移动到另一半,空间开销会更大。
标记-清除 | 标记-整理 | 标记-复制 | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少,有碎片 | 少,无碎片 | 通常需要活动对象的 2 倍,无碎片 |
移动对象 | 否 | 是 | 是 |
分代回收算法
JVM内存分布,其中线程私有的内存区域为:程序计数器,虚拟机栈,本地虚拟机栈,都会随着线程的创建而创建,随着线程的销毁而销毁。所以这三个内存区域的对象会随着生命周期的结束而立刻被回收。线程共享的内存区域有:堆和方法区,这两个是垃圾回收的主要区域。
实际上,许多研究人员发现大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。应用程序所创建的对象的生命周期并不相同,比如方法内的对象,生命周期比较短,随着栈帧的创建而创建,随着栈帧的销毁而销毁。比如Spring里创建的单例对象,属于生命周期比较长的对象。
对于生命周期比较短的对象,我们希望能够以较高的频率并且耗时较短的算法进行垃圾回收,尽快释放内存占用空间。对于生命周期比较长的对象,我们希望能够以较低的频率进行垃圾回收,避免很多无效的垃圾回收。因此,JVM将堆又划分为年轻代(Young Generation)和老年代(Old Generation),年轻代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。这样JVM可以针对不同的分代,使用不同的垃圾回收算法,这就叫做分代垃圾回收。
Young GC
年轻代采用的是标记-复制算法,因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。我们通过上文了解到,标记-复制算法会将整个内存平均分为两块,同一时间只有一块内存在使用,内存利用率只有50%。为了提高内存利用率,JVM对标记-复制算法进行了优化,将年轻代分为不均等的三个分区:一个Eden区和两个大小相等的Survivor区。
JVM将Eden区和其中一个Survivor区作为分配对象所用,也就是工作分区,将另一个Survivor区作为复制备用,也就是备用分区。除此之外,我们将供分配对象所用的Survivor区叫做From Survivor区,将复制备用的Survivor区叫做To Survivor区。
Young GC又叫做Minor GC
工作流程
- 当Eden区和From Survivor区满了之后,JVM便执行标记-复制算法,将Eden区和From Survivor区中的存活对象,复制到To Survivor区。
- 当一次垃圾回收结束之后,两个Survivor区的角色互换,原来充当From Survivor的,现在充当To Survivor,原来充当To Survivor的,现在充当From Survivor。
Eden,From Survivor和To Survivor区默认比例是8:1:1。在Minor GC过程中,总会有一个Survivor区域是空置的,所以只有10%的内存空间被浪费了。当然也是可以通过-XX:SurvivorRatio
调整比例的。
TLAB
当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的,否则,将有可能出现两个对象共用一段内存的事故。所以Eden区被划分为多个线程本地分配缓冲区(Thread Local Allocation Buffer,简称 TLAB,对应虚拟机参数 -XX:+UseTLAB,默认开启) 。通过这种缓冲区划分,大部分对象直接由JVM在对应线程的TLAB中分配,避免与其他线程的同步操作。
TLAB的设计思想类似于Java中的ThreadLocal,避免对公共区域的操作,以及一些锁竞争。对象的分配优先在TLAB上分配,但TLAB通常都很小,所以对象相对比较大的时候,会在Eden区的共享区域进行分配。
空间分配担保机制
万一To Survivor区存不下一次垃圾回收之后的存活对象,该怎么办呢?
对于这种情况,虚拟机会借用老年代的部分空间,将存不下的对象存储在老年代中,老年代起到一个担保的作用,因此,这种处理机制叫做空间分配担保机制。
这种机制会导致两个的问题
- 部分生命周期比较短的对象存储到了老年代,即便这些对象早早已经死亡,依然需要等待很长时间才能被回收。
- 如果老年代也没有足够的空间来存储To Survivor区域存不在的对象,就会触发一次老年代Full GC,如果Full GC之后,还存不下,就只能抛出OOM错误了。
Old GC
前面讲到,年轻代使用标记-复制算法进行垃圾回收,原因是每次对年轻代垃圾回收之后,存活对象比较少,复制耗时少。而老年代正好相反,老年代中的对象生命周期比较长,每次垃圾回收之后,存活对象比较多,如果使用标记-复制算法进行垃圾回收,那么,就会涉及到大量对象的复制,执行效率比较低,因此,老年代一般不采用标记-复制算法,而是采用标记-整理算法或标记-清除算法进行垃圾回收。
一般来说,老年代存储的对象为新生代基于空间分配担保机制存不下的对象,大对象和长期存活的对象。
大对象指的是占用大量连续内存空间的对象,比如大的字符串或者数组,默认情况下,无论对象多大,都应该在新生代里创建,但是如果配置了-XX:PretenureSizeThreshold
,如果对象大小超过了这个值,则会直接在老年代里创建对象。这样做的目的是为了避免生命周期比较长的对象频繁在新生代里被反复复制。
长期存活的对象指的是新生代经过多次GC仍存活下来的对象,存活年龄超过某个阈值,则会被移动到老年代。这个阈值默认为15,可以通过‐XX:+MaxTenuringThreshold
配置。当然,人为设定的固定值显然不够灵活,于是,JVM设计了动态年龄判定机制,结合存活对象的多少来动态设置最大GC年龄。
动态年龄判定机制:统计YoungGC后,处于每个GC年龄值的对象占To Survivor区的比例。按照年龄从小到大累加对象所占比例,当累加到年龄为X的对象时,如果累计对象所占比例超过50%(此值可以通过JVM参数-XX:TargetSurvivorRatio来设置),那么,GC年龄>=X的对象都将直接进入老年代,并会不等到GC年龄大于15。
MinorGC && Full GC
YoungGC只针对年轻代进行垃圾回收,年轻代中的对象的存活率比较低,可达性分析需要遍历的对象和需要进行复制的对象比较少,所以,YoungGC比较快速,因此,YoungGC也被称为MinorGC。而FullGC针对年轻代、老年代、永久代(或元空间)进行垃圾回收,并且老年代和永久代(或元空间)中的对象的存活率比较高,可达性分析需要遍历的对象和垃圾回收需要处理的对象比较多,所以,FullGC比较慢,因此,FullGC也被称为MajorGC。
卡表
对象的引用关系非常复杂的,有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?
HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512byte的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的 GC Roots里。当完成所有脏卡的扫描之后,JVM便会将所有脏卡的标识位清零。