面试官问为什么新生代不用标记清除算法

414 阅读7分钟

杭州某写字楼,安琪拉穿着新买的19.9的皮鞋走进玻璃隔间办公室,准备迎接一场新的表演。

面试官 :看你简历上有些熟悉JVM,是吧?

安琪拉:是的

面试官 :那你跟我讲讲堆内存的分区。

安琪拉:[心想]:这很easy嘛,来,算是回顾一下JVM的基础知识。

我们知道堆分为新生代和老年代,新生代就是我们说的Yong Generation,老年代是 Old Generation。

面试官 :然后呢?

安琪拉:然后什么?

面试官 :讲完啦?下面没有啦?新生代呢?

安琪拉:你想听你可以跟我说嘛,你不说我怎么知道你想听。

新生代又分为Eden区和Survivor区,Survivor由From区域和To区域组成,完整的内存结构,我给你画一下,别抽了,笔递给我一下,我画一下,如下图所示。

面试官 :哦,图可以,那为什么堆要分新生代和老年代呢?

安琪拉:当然是为了更有效的管理内存。

面试官 :怎么说?

安琪拉:假设一下,如果不分新老代,内存就一整块,垃圾收集器每次都要把那些长期存在的对象,和生命周期很短的对象放在一起回收,一般长生命周期的对象可能跟应用生命周期一致,你基本回收不掉的,比如Spring 框架里面的Bean管理相关的对象(ApplicationContext),整个应用运行期间都存在,这种一般经过几次回收最后都放在老年代,但是如果不区分新老代,每次都一起回收,性能消耗很大。

区分新老代之后,老年代放长期存活的对象,新生代就放生命周期短的对象,老年代对象很稳定,新生代回收不影响老年代,回收效率能大大提高。

面试官 :那为什么新生代还要分Eden、From、To区域呢?

安琪拉:[开始慢慢有点意思了]

首先大部分对象生命周期是很短的,如果新生代不分多个区域,新生代可能会有二种回收方案

第一种可能:每次回收都在新生代整块内存上进行,完整的垃圾回收过程分三步:

  1. 需要先找到需要清理的对象标记;

  2. 清理这些被标记的对象;

  3. 移动剩下的对象,对达到老年代晋升年龄的对象移动到老年代。

对象被回收掉后会产生很多内存碎片(被回收的对象很多),如果要解决内存碎片,需要移动剩下的对象(标记整理算法),整个回收流程效率很低。

第二种可能:如果没有Survivor区(From + To),Minor GC(新生代回收)过程中,存活的对象直接被送到老年代,这样的话老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC),Full GC频繁会影响程序的执行和响应速度。

新生代的回收叫Minor GC, 老年代的回收叫Major GC。

面试官 :为什么要设置两个Survivor区呢?From 和 To

安琪拉:我们来看一下, 如果只有一个Survivor区,新生代内存的回收流程。

我按照上面这张图画的讲,第一次Eden区域满了,内存回收很简单,直接把Eden区域存活对象放到Suvivor区域;

第二次内存回收,需要回收二个地方,Eden区域和Survivor区域。

  • 因为Survivor区域也会存活的对象需要被回收,对Survivor区要采用标记整理垃圾收集算法,(先标记需要清理的对象,然后回收,然后把剩下的存活对象放到一起);

  • Eden区域采用复制算法,把Eden区域存放的对象复制到Survivor区域,然后把整个Eden区清除。

看到网上有些文章说这里设置二个Survivor区域的原因是为了避免内存碎片,因为他假设第二次(以及后续)的回收,内存回收是先回收Eden区域,然后是Survivor区域,这样当然会有内存碎片,但是如果真是只有一个Survivor区域,垃圾回收设计者肯定是先回收Survivor区域,再回收Eden区域,等Survivor区回收整理好,再把Eden区存放对象搬到Survivor区,这样存活地址是连续的,没有内存碎片。所以真正的原因还是我下面说的效率问题。

面试官 :这样有什么问题呢?

安琪拉:这样做有几个问题:

  1. 经过几次回收之后,Survivor区域满了之后怎么办?直接搬到老年代?那老年代很快就爆炸了。搬到Eden区?那内存碎片产生了,可能Survivor区和Eden区回收完之后,还需要再整理一下内存去掉内存碎片,性能消耗也是很大的。

  2. 一般标记整理算法的性能消耗是比复制算法消耗要大的,尤其是在新生代98%的对象都是“朝生夕死”的,标记清楚的是98%的对象,剩下就2%对象,要整理内存,不然直接把这2%对象放到另一个地方,把整块内存清除,Eden整块内存清除效率很高的。

所以归根结底,二个Survivor区还是为了性能考虑,标记复制算法效率比标记整理效率高。

面试官 :那你跟我详细讲讲标记新生代除了Eden,另外采用二个Survivor区的标记复制算法。

安琪拉:新生代中的对象 98% 是“ 朝生夕死” 的, 所以并不需要按照 1: 1 的比例来划分Eden和Survivor的空间, 而是将新生代分为较大的一块Eden空间和两块较小的Survivor 空间,每次只使用 Eden 和 其中一块Survivor[0](From区域),留出Survivor[1](To区域)用来实现标记复制。

当回收时, 将 Eden 和 Survivor[0] 中还存活着的对象一次性地复制到另外一块 Survivor[1] (To)空间上, 最后清理掉 Eden 和 刚才用过的 Survivor 空间。

另外说明一点:From区域和To区域在每次Minor GC之后都会互转,From区域变成To区域,To区域变成From区域,这只是逻辑标识

HotSpot 虚拟机默认 将Eden 和 Survivor 的大小比例是 8: 1(CMS不适用), 也就是每次新生代中可用内存空间为整个新生代容量的 90%( 80%+ 10%),只有10%的内存会被“ 浪费”(一直有10%的内存(Survivor To区)不存东西)。

标记复制算法流程:

  1. Eden区域+Survivor From区满,进行存活对象标记,标记完,把存活对象复制到Survivor To区域;

  2. Survivor To区域变成From区域(一个逻辑标识),From区域变成To区域;

  3. 内存分配,继续步骤1,复制过程中有达到老年代晋升年龄(默认值15),移动到老年代。

面试官:刚才说了这么多,是不是来之前背题了?

安琪拉:【心想】回答不出来你说我对技术没追求,回答出来了你说我背题,WTF。。

耐心对面试官解释:怎么可能,我只不过是来之前把安琪拉的博客公众号上的文章都看了一遍,嘿嘿。

面试官:在哪看,你分享给我。

面试官:诶诶,还有老年代内存回收策略呢?还有标记整理算法呢?另外讲讲几种常见的垃圾回收器,CMS和G1。

安琪拉:不想讲了,累了,要不放在二面的时候讲吧。

面试官:没事,二面面试官还是我,你直接讲吧。

安琪拉:真不想讲了。

面试官:那今天先到这吧,回去等通知,您出了这个门左拐。

文章来源于读者的提问。

对于JVM参数的配置,可以参考历史文章:

安琪拉的博客,公众号:安琪拉的博客JVM日志参数十全大补丸