这里针对新生代的垃圾回收算法,叫做复制算法
3.1复制算法
我们先来回顾下之前讲堆内存的结构分配
存储在JVM中的Java对象可以被划分为两类:
➷ 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,生命周期短的,及时回收即可。
➷ 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
这里大家需要去思考,为什么JVM会分成年轻代和老年代,以及年轻代里面又为什么要再划分出三个区域,这样做的好处是什么?
我们先来分析新生代(年轻代)的复制算法以及所带来的的优劣
1969年Fenichel提出了一种称为“半区复制”(Semispace Copying) 的垃圾收集算法, 它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。
简单点来说,就是把新生代的内存分为两块,如下图所示:
这时比如我们的代码如下:
public class Test {
public static void main(String[] args) {
registUser();
}
public static void registUser(){
User user = new User();
}
}
那么对应内存中的分配就如下:
那么如果我们假设我们的程序不停止,依然在运行,这时不停的调用registUser()方法生产大量的User对象,对应栈帧已经退出,没有指向对应的对象,那么就会在堆内存中产生大量的垃圾对象:
当新生代第一块区域内容已满,装不下的时候,就会触发Minor GC回收垃圾。
这时,如果我们仅仅是采用标记算法,标记哪些对象是可回收的,哪些对象是不可回收的,然后针对可回收的内容进行回收,那么会导致一个不好的后果,就是产生大量的内存碎片。
内存碎片一般是由于空闲的连续空间比要申请的空间小,导致这些小内存块不能被利用。产生内存碎片的方法很简单,举个例:
假设有一块一共有100个单位的连续空闲内存空间,范围是0~99。如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0~9区间。这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10~14区间。
如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。
现在整个内存空间的状态是0~9空闲,10~14被占用,15~24被占用,25~99空闲。其中0~9就是一个内存碎片了。如果10~14一直被占用,而以后申请的空间都大于10个单位,那么0~9就永远用不上了,造成内存浪费。
如果你每次申请内存的大小,都比前一次释放的内村大小要小,那么就申请就总能成功。
如果内存碎片过多,就会造成大量的内存浪费,随着回收的次数越多,这样的碎片可能更多更杂乱,因此这样直接针对一块内容空间回收的做法是不可取的。
因此JVM采用了复制算法,我们图中有一块一直未使用的空间可以派上用场了。当真正发生垃圾回收的时候,JVM会将第一块空间中哪些对象是可回收的,不能回收的进行标记,然后将不可回收的对象统统复制到下面那块区域中,并且复制的时候可以紧凑的排列在一起,最大化利用内存空间:
那么我们可以直接一次性回收掉上面空间的所有垃圾对象,同时有新的对象产生的时候,直接放在下面这块区域进行存储即可。 那么这时上面空间就会腾出,下面空间就月会越来越多:
当下面区域装满的时候,同样按照刚才的逻辑复制存活对象到上面区域,一次性回收下面区域内存。两块区域内存就可以一直重复循环使用。
复制算法的缺点
那么复制算法确实可以解决内存碎片的问题,也使得我们的回收工作更加效率,不过其缺点也是显而易见的。这种复制回收算法的代价是将可用内存缩小为了原来的一半, 空间浪费未免太多了一点 。
如果我们给新生代内存分配一个G的大小,那么两块区域平均分配,各自占512MB内存,从始至终就只有一半的内存可用,这样的算法对内存的使用效率就太低了!
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1∶ 1的比例来划分新生代的内存空间。
在1989年, Andrew Appel针对具备“朝生夕灭”特点的对象, 提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。 HotSpot虚拟机的Serial、 ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。 Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶ 1, 也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%) , 只有一个Survivor空间, 即10%的新生代是会被“浪费”的。 当然, 98%的对象可被回收仅仅是“普通场景”下测得的数据, 任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活, 因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计, 当Survivor空间不足以容纳一次Minor GC之后存活的对象时, 就需要依赖其他内存区域(实际上大多就是老年代) 进行分配担保(Handle Promotion) 。
内存的分配担保好比我们去银行借款, 如果我们信誉很好, 在98%的情况下都能按时偿还, 于是银行可能会默认我们下一次也能按时按量地偿还贷款, 只需要有一个担保人能保证如果我不能还款时, 可以从他的账户扣钱, 那银行就认为没有什么风险了。 内存的分配担保也一样, 如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象, 这些对象便将通过分配担保机制直接进入老年代, 这对虚拟机来说就是安全的。
小结
本章节我们介绍了JVM垃圾回收的算法-标记复制算法,以及复制算法的缺点。下一节我们将继续介绍JVM内存的分配以及回收策略,比如:对象优先在Eden分配,大对象直接进入老年代,以及长期存活的对象将进入老年代,动态对象的年龄判断以及空间分配担保原则。