Java所承诺的自动内存管理主要是针对对象内存的回收和对象内存的分配。

在Java虚拟机的五块内存空间中,​​程序计数器、Java虚拟机栈、本地方法栈内存的分配和回收都具有确定性​​。一般都在编译阶段就能确定下来需要分配的内存大小,并且由于都是线程私有,因此它们的内存空间都随着线程的创建而创建,线程的结束而回收。也就是说这三个区域的内存分配和回收都具有确定性。

而Java虚拟机中的方法区因为是用来存储类信息、常量、静态变量等,这些数据的变动性较小,因此不是Java内存管理重点关注的区域。

而对于堆,所有线程共享,所有的对象都需要在堆中创建和回收。虽然每个对象的大小在类加载的时候就能确定,但对象的数量只有在程序运行期间才能确定,因此堆中的内存分配具有较大的不确定性。此外,对象的生命周期长短不一,因此需要针对不同生命周期的对象采用不同的内存回收算法,增加了内存回收的复杂性。

综上,Java自动内存管理最核心的功能是堆内存中对象的分配与回收。对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地在栈上分配)。对象主要分配在新生代的Eden区,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

下面描述的是在使用Serial/Serial Old收集器下(ParNew/Serial Old收集器组合的规则也基本一致)的内存分配和回收的策略。

Come on ! Java对象内存分配与回收策略_老年代

【1】对象优先在Eden区中分配

目前主流的垃圾收集器都会采用分代回收算法,因此将堆内存分为新生代和老年代。

在新生代中为了防止内存碎片问题,因此垃圾收集器一般都采用“复制”算法。因此,堆内存的新生代被进一步分为:Eden区+Survior1区+Survior2区。也有说法为Eden+From Survior+ To Survior。

  • 每次创建对象时,首先会在Eden区中分配;
  • 若Eden区空间不足,尝试在Survior1区中分配,仍然不足则发生MinorGC。
  • 若Eden区+Survior1区剩余内存太少,导致对象无法放入该区域时,就会启用“分配担保”,将当前Eden区+Survior1区中的对象转移到Survivor(或老年代)中,然后再将新对象存入Eden区。

新生代内存分配时,将内存分为一块较大的Eden空间和两块较小的Survior空间,每次使用Eden和其中一块Survior。新生代采用垃圾收集算法为复制算法,在回收时,将Eden和Survior中还存活的对象一次性地复制到另外一块Survior空间上,最后清理掉Eden和刚才用过的Survior空间。


【2】大对象直接进入老年代

​所谓“大对象”就是指一个需要占用大量连续存储空间的对象​​,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,更坏的消息则是遇到一群“朝生夕死”的“短命大对象”,写程序的时候应当避免。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

当发现一个大对象在Eden区+Survior1区中存不下的时候就需要分配担保机制把当前Eden区+Survior1区的所有对象都复制到老年代中区。

我们知道,一个大对象能够存入Eden区+Survior1区中的概率比较小,发生分配担保机制的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。

因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。

那么,什么样的对象才是“大对象”呢?

通过​​-XX:PretrnureSizeThreshold​​参数设置大对象。字节大小超过该参数的对象被认为是“大对象”,直接进入老年代。这样做的目的是避免在Eden区及两个Survior区之间发生大量的内存复制(新生代采用复制算法收集内存)。

需要注意的是,PretrnureSizeThreshold该参数只对Serial(串行收集器)和ParNew收集器有效。Parallel Scavenge收集器不认识这个参数,Parallel Scavenge 收集器一般不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew+CMS收集器组合。


【3】生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么如何判断一个对象的年龄呢?

新生代中每个对象都有一个​​年龄计数器​​,如果对象在Eden出生并经过第一次Minor GC后仍然存活并且能被Survior容纳的话,将被移动到Survior空间中,并且对象年龄设为1。对象在Survior区中每“熬过”一次MinorGC,年龄就增加1岁。当它的年龄增加到一定程度(默认为15岁),将会被晋升到老年代中。

使用​​-XXMaxTenuringThreshold​​设置新生代的最大年龄。设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。


【4】动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代。如果​​在Survior空间中相同年龄所有对象大小的总和大于Survior空间的一半​​,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。


【5】分配担保策略

在发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象空间。如果这个条件成立,那么minor GC可以确保是安全的。

如果上述条件不成立,则虚拟机会查看​​HandlePromotionFailure​​​设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的​​平均大小​​,如果大于,将尝试着进行一次MinorGC,尽管这次MinorGC是有风险的;如果小于或者HandlePromotionFailure设置不允许冒险,那这时要更改为一次Full GC。通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保

这个过程就是分配担保。那么冒险是冒了什么风险?

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survior空间来作为轮换备份。因此当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survior无法容纳的对象直接进入老年代。

而老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段。也就是说,如果某次MinorGC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePromotion Failure)。如果出现了担保失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

这个规则在JDK6 Update24之后变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行MinorGC,否则将进行Full GC。


【6】总结

① 分配担保是老年代为新生代作担保。

② 新生代中使用“复制”算法实现垃圾回收,老年代中使用“标记-清除”或“标记-整理”算法实现垃圾回收。

只有使用“复制”算法的区域才需要分配担保,因此新生代需要分配担保,而老年代不需要分配担保。

Come on ! Java对象内存分配与回收策略_内存回收_02

参考博文:
​JVM调优总结(二)之图解分代垃圾回收器 《深入理解Java虚拟机》