浅谈JVM内存分配与垃圾回收

大家好,我是微尘,最近又去翻了周志明老师的《深入理解Java虚拟机》这本书。已经看了很多遍了,每次都感觉似乎看懂了,但没过多久就忘了。这次翻了第三章的垃圾收集器与内存分配策略,感觉有了新的认识,整理一下分享出来。
内容有点多,并且我没怎么配图,一方面是懒,一方面是我想如果在没有图的情况下你都能看懂,那肯定是真正的懂了。就像是上学的时候做的练习册,即便没有后面那几页写着"略"的参考答案你也能把题目做好做完,那才是真的牛批。

以下是正文

Java技术体系中所提倡的自动内存管理最终应该可以归结为自动化的解决两个问题,即给对象动态分配内存和回收分配给对象的内存。通常情况下Java对象在JVM堆上分配内存,但也可以在JVM堆外分配内存。这是因为JVM堆作为最主要的存储对象实例的内存区,同时也是垃圾回收(GC)的重点区域。GC的频率和效率就很可能成为虚拟机性能上的瓶颈。为了降低GC的频率和提升GC的效率,逃逸分析、栈上分配等优化技术就出现了,JVM堆区便不再是Java对象动态内存空间分配的唯一选择。扯的有点远了,先了解一下就行。

从生命周期的角度上来看,存储在JVM堆中的对象大致可以分为两类。一类是生命周期较短的瞬时对象,它伴随这线程的启动而创建,随着线程的运行结束而消亡。另一类是是生命周期较长的对象,能够在每次GC中存活下来,甚至某些极端情况下与JVM的生命周期保持一致。因此对于不同生命周期的对象应该采取不同的垃圾收集策略,于是分代收集算法应运而生。
在这样的情况下JVM堆被分为新生代和老年代,其中新生代默认占 1/3堆空间,老年代默认占 2/3堆空间。这时就可以根据各个年代中生命周期特点采用最适合的垃圾回收算法。比如新生代中绝大多数的对象为上面讲到的瞬时对象,就适合采用基于复制算法的垃圾收集器进行回收。老年代中的对象通常由新生代中长生命周期的对象晋升进去的,就适合采用基于标记-清除算法或者标记-整理算法的垃圾收集器进行回收。

在JVM堆中,新生代是给新对象分配内存空间最多的地方,自然也是回收垃圾对象最频繁的地方。在新生代中的垃圾回收动作叫做Young GC,也有的叫Minor GC。前面讲到新生代适合采用基于复制算法的垃圾收集器进行垃圾对象回收。复制算法的原理是将内存容量划分为大小相同的两块(以下称为AB块),每次只使用其中一块。当A块用完了之后新生代就触发一次Minor GC,将A块中的存活对象复制到B块,然后将A块清理干净好给之后新创建的对象腾出内存空间。
基于这个原理,新生代被划分出Eden区、From Survivor区、To Survivor区。默认情况下Eden区和Survivor区的内存空间占比为8:1:1,可以通过-XX:SurvivorRatio参数调整Eden区的比例。这个参数我是不太不建议修改,因为JVM堆中绝大部分(98%以上)的对象都是朝生夕死,只有少部分对象能够存活下来,所以8:1:1的比例算是比较保守的了。

确确的说给新对象分配内存空间最多的地方是新生代中的Eden区,这个很好理解,Eden区占到了新生代80%的内存空间,是最有可能拿得出连续的内存空间的。当Eden区中的可用连续内存空间不足以分配给新对象时,新生代就会触发一次Minor GC来回收这里面的垃圾对象。
这里面的细节需要注意一下
第一次Minor GC的时候Eden区中存活的对象会被复制到From Survivor区,然后清空Eden区,这时To Survivor区是空的。之后的每一次Minor GC,则是将Eden区和From Survivor区中的存活对象一起复制到To Survivor区中,然后清空Eden区和From Survivor区的内存空间。最后,From Survivor区和To Survivor区角色上会进行对换。即原本被清空内存空间的From Survivor区会变成了To Survivor区,而原本接收了Eden区和From Survivor区中存活对象的To Survivor区变成了From Survivor区。
听起来似乎有些奇怪,这样一来From Survivor区不是显得有些多余吗?每一次Minor GC要从两个区中把存活对象找出来复制到Sruvivor To区中,然后一起被清空,直接一个To Survivor区不行吗?

那么我们就来看看假如将From Survivor区和To SurvivorTo区合并成Survivor区会发生什么情况!
第一次Minor GC的时候,Eden区的可用连续内存空间不足,而Survivor区是空的。Minor GC时垃圾收集器将Eden区中的存活对象按照顺序复制到Survivor区中,然后清空Eden区。
下一次Minor GC的时候,Eden区中有大量的对象死去,只有少量的存活下来。Survivor区中原本接收了上一次Minor GC存活下来的对象。到了这一步同样有大量的对象死去,仅存在少量的存活对象了。我们都知道接下来Minor GC的时候垃圾收集器要将Eden区中的存活对象要复制到Survivor区中,那么Survivor区中的存活对象应该如何处理呢?按照空间分配担保机制直接复制到老年代中,然后先清空Survivor区吗?这样存活下来的对象晋升进入老年代的门槛将大大降低,导致老年代的可用连续内存很快用完,触发老年代的Major GC。直接把Eden区中的存活对象复制到Survivor区中呢?这样的话Survivor区中的垃圾对象一直不回收,将持续占用着宝贵的内存空间,直到Survivor区内存不足了,导致内存泄漏,那Survivor区不就形同摆设了吗?
所以我的理解就是,两个Survivor区的存在可以起到一个调节和缓冲的作用。基于新生代中绝大部分的对象都是朝生夕死这样的事实,将这些瞬时对象暂时留在新生代中,同时尽可能扼杀在新生代中,避免频繁或者延迟老年代的Major GC。在老年代中的垃圾回收动作叫做Old GC,也有的叫Major GC,不过老年代的Major GC通常伴随着新生代一次Minor GC,所以老年代的垃圾回收动作通常也称为Full GC。总之就是Eden区永远存放上一次Minor GC后创建的新对象,From Survivor区永远存放着历史上从Eden区以及曾经是From Survivor区中存活下来的对象,而To Survivor区则在Minor GC之后永远都是空的。

需要注意的是,极端情况下可能Eden区和From Survivor区中存活的对象比较多(超过10%),To Survivor区中没有足够的连续内存空间(有且仅有10%)分配给存活下来的对象。这时无法从To Survivor分配到内存空间的存活对象将直接晋升进入老年代,这是JVM内存空间分配担保机制的体现。
实际上,在新生代触发Minor GC之前,虚拟机会先比较一下老年代的可用连续内存空间大小与新生代中所有对象(包括可回收对象)的总大小。如果大于的话,那么新生代可以放心的进行Minor GC。这样做主要是考虑到虚拟机在运行一段时间后,新生代和老年代中都存在着大量的对象。在极端情况下,可能新生代中所有的对象都是存活对象,甚至这些存活对象中最小的连To Survivor区都无法为其分配内存空间。按照计划这些存活对象是将全部晋升进入老年代。所以如果的确老年代的可用连续内存空间足以分配这些存活对象的话,新生代的Minor GC就正常。
否则虚拟机还会去比较老年代中可用连续内存空间大小与历史上从新生代晋升进入老年代的对象的平均大小。如果小于,那很显然当前老年代的可用的连续内存空间已经不多了,虚拟机将不得不在老年代中触发一次Major GC来回收垃圾对象释放出内存空间。否则虚拟机会很不负责任的认为老年代中的可用连续内存足以分配给接下来新生代的Minor GC之后存活下来的对象,于是冒险的触发Minor GC从新生代中回收垃圾对象。
冒险可能带来的后果就是事实上老年代的可用内存空间不多,分配不了,那么新生代的Minor GC就会失败。最后老年代再不情愿的通过Major GC进行垃圾对象的回收。老年代的垃圾收集器通常是基于标记-整理算法实现的,它的原理是将内存空间的存活对象移动到一端,然后清空端边界以外的空间,相比标记-清除算法的原地清除可以保证不会产生内存碎片,提高内存空间利用率。
可能有的人会怀疑如此冒险的做法的意义,但其实老年代在触发Major GC之前,极有可能仍然存储着很多存活的对象或者大对象,同时老年代的内存空间比较大,前面讲过老年代的Major GC通常还会伴随着一次新生代的Minor GC,所以回收垃圾对象的速度特别慢,大概是Minor GC的十倍以上。试想一下,业务执行过程中在极端情况下突然暂停了几秒钟是什么体验。所以这么冒险是希望避免频繁或者延迟老年代的Major GC,同时尽可能的将垃圾对象扼杀再新生代中,从而提高代码的执行效率。所以我认为这是一个很合理的设计。

上面的内容涉及到了虚拟机自动内存管理中分配内存的时候遵循的两种策略,一个是对象优先在Eden区中分配内存,一个是内存空间分配担保机制。
既然是优先在Eden区中分配内存,那就应该有偏偏不在Eden区中分配内存的。JVM提供了-XX:PretenureSizeThreshold参数,用于设置当对象大于某个容量值时就直接进入老年代,而无需在新生代中折腾一段时间有幸存活下来后再晋升进入老年代。不过它的默认值是0,即无论多大对象都在Eden区中创建。如果一个很长很长的字符串对象或者数组仍然从Eden区中给他分配内存空间的话,Eden区的可用连续内存可能很快就不够分配了,这时新生代就要进行Minor GC,导致新生代的Minor GC过于频繁。甚至说如果这个大对象在每次Minor GC之后还是存活下来,那么前面说到了Eden区存活下来的对象会复制到To Survivor区,然后To Survivor变成From Survivor,再存活下来就继续重复复制。于是这个大对象就在Survivor区中不断的复制来复制去,导致Minor GC效率大大降低。

当然,新生代中存活下来的对象也不可能在Survivor区中一直复制来复制去不消停。事实上,新对象在Eden区中被创建的时候,JVM会给每一个对象定义一个对象年龄计数器(Age)。当对象经过第一次Minor GC从Eden区存活下来进入From Survivor区的时候,Age就会被设置为1,即一岁。当之后的每次Minor GC仍然能够存活下来从From Survivor去复制到To Survivor区的时候,Age就+1,直到成年(默认是15岁,可通过XX:MaxTenuringThreshold参数进行设置)的时候就晋升进入老年代。也就是说,被判定为长生命周期的对象也将晋升进入老年代。这个跟年龄有关,而跟对象大小无关。
不过也不一定非得到15岁后才能晋升进入老年代,因为当From Survivor区中相同年龄的对象的大小总和超过From Survivor区内存空间大小的一半时,这些存活对象无论大小就会晋升进入老年代,这是动态对象年龄判定的体现。

到这里Java虚拟机的自动内存管理实现动态内存分配和垃圾回收基本讲的七七八八了,从中我们最起码可以了解到:
1、由于对象的生命周期长短的特点,分代收集算法应运而生,于是将JVM堆分成了新生代和老年代,其中新生代的垃圾收集器采用复制算法,老年代采用标记-清除算法或标记-整理算法;
2、新生代的垃圾收集器基于复制算法实现,于是新生代又可以分为Eden区、From Survivor区、To SurvivorTo区,并且新生代Minor GC后From Survivor区和To SurvivorTo区会交换角色;
3、动态内存分配遵循优先在Eden区中分配内存、大对象直接进入老年代、存活时间长的对象晋升进入老年代、动态对象年龄判定,空间分配担保机制五大策略;
好像没了吧!但其实完整的JVM内存分配与垃圾回收的知识体系远不止这些,如下图所示,标记垃圾对象没讲,垃圾收集器没讲,感兴趣的可以自己去翻翻书,有空的话我再专门整理一下。

另外,本篇只是轻描淡写的讲了内存分配的思路、设计原理、遵循的策略以及垃圾对象在什么场景下回收,如何回收等。更深层一点的知识点如对象是如何创建的,谁去创建的,分配内存的时候具体是怎么分配的都没讲到,也是有空的时候专门讲一讲。
相比周志明老师的《深入理解Java虚拟机》以及网上的博客那种分点式、按类型式的去科普。我在看的时候感觉每一个点都能看懂,但就是结合不起来,于是没过几天就忘光了。或者看到一些关于JVM的问题的时候,不知道如何去讲述。
所以本篇尝试打破这种传统的方式,根据我自己的理解,把整一个思路用文字的形式串联起来进行表达。或许你看完再回过头去看书,会进一步的理解这些知识点。当然这里面也肯定有我理解错误的地方,所以如果你有什么不同的见解,希望不吝赐教,感谢!

posted @ 2022-01-01 02:20  程序员微尘  阅读(311)  评论(8编辑  收藏  举报