通过之前的学习,我们知道了JVM会通过可达性算法来筛选出哪些对象是可回收的,哪些对象是不可回收的,GCRoots对象是哪些,java的引用类型有哪些以及finlize()方法的作用。同时我们也知道了当一个对象在创建的时候是存放在堆内存中的新生代里的,那么当新生代内存满了后就会触发Minor GC;但是问题是我们如何针对新生代内存进行管理,以及如何进行回收这也是一个值得分析和探讨的问题。
这里针对新生代的垃圾回收算法,叫做复制算法
3.1复制算法
我们先来回顾下之前讲堆内存的结构分配
存储在JVM中的Java对象可以被划分为两类:
➷ 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,生命周期短的,及时回收即可。
➷ 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
这里大家需要去思考,为什么JVM会分成年轻代和老年代,以及年轻代里面又为什么要再划分出三个区域,这样做的好处是什么?
我们先来分析新生代(年轻代)的复制算法以及所带来的的优劣
1969年Fenichel提出了一种称为“半区复制”(Semispace Copying) 的垃圾收集算法, 它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块。当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。
简单点来说,就是把新生代的内存分为两块,如下图所示:
这时比如我们的代码如下:
<pre data-tool="mdnice编辑器" style="margin: 10px 0px; padding: 0px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">public class Test { public static void main(String[] args) { registUser(); } public static void registUser(){ User user = new User(); } }
</pre>
那么对应内存中的分配就如下:
那么如果我们假设我们的程序不停止,依然在运行,这时不停的调用registUser()方法生产大量的User对象,对应栈帧已经退出,没有指向对应的对象,那么就会在堆内存中产生大量的垃圾对象:
当新生代第一块区域内容已满,装不下的时候,就会触发Minor GC回收垃圾。
这时,如果我们仅仅是采用标记算法,标记哪些对象是可回收的,哪些对象是不可回收的,然后针对可回收的内容进行回收,那么会导致一个不好的后果,就是产生大量的内存碎片。
内存碎片一般是由于空闲的连续空间比要申请的空间小,导致这些小内存块不能被利用。产生内存碎片的方法很简单,举个例:假设有一块一共有100个单位的连续空闲内存空间,范围是099。如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为09区间。这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为1014区间。如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。现在整个内存空间的状态是09空闲,1014被占用,1524被占用,2599空闲。其中09就是一个内存碎片了。如果1014一直被占用,而以后申请的空间都大于10个单位,那么09就永远用不上了,造成内存浪费。如果你每次申请内存的大小,都比前一次释放的内村大小要小,那么就申请就总能成功。
如果内存碎片过多,就会造成大量的内存浪费,随着回收的次数越多,这样的碎片可能更多更杂乱,因此这样直接针对一块内容空间回收的做法是不可取的。
因此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分配,大对象直接进入老年代,以及长期存活的对象将进入老年代,动态对象的年龄判断以及空间分配担保原则。