JVM是如何分配和回收内存?有实例!

时间:2022-05-10 21:27:30

上一篇博客我简单介绍了下如何手动计算一个Java对象到底占用多少内存?今天就想聊下这个内存JVM到底是是如何分配和回收的。

Java整体来说还是一个GC比较友好的语言,无论是分代的垃圾收集,还是基于GC Roots的可达性算法都是业界普遍的经典做法,关于Java的内存区域划分以及GC的一些基本知识,我这里就不赘述了,可以看我之前的博客:http://zhanjindong.info/category/note/dsbj/

《深入理解Java虚拟机第2版》这本书非常值得一看,最近几篇读博客都算这本书的读书笔记吧。本人文笔很烂,所以都是记流水账枯燥乏味的文章。进入正文之前还是要交代下环境:以下内容都是基于HotSpot虚拟机Server模式,垃圾收集器用的是默认的Serial和Serial Old。

JVM是如何分配和回收内存?有实例!

JVM内存分配和回收策略

话说一图胜千言,本也打算画张活动图就了事了:

JVM是如何分配和回收内存?有实例!

但是画完发现:一画图更麻烦,太大了看的累(想看的可以在新建窗口放大了看),其次感觉还是说不清楚(画的不对的地方欢迎批评),最后觉得还是文字描述一下整个流程:

1、当JVM给一个对象分配内存的时候,如果启动了本地线程分配缓存,将按线程优先在TLAB上分片,TLAB只是起缓存作用减少高并发下CAS带来的性能损失,跟GC的分代没有冲突。

2、当分配一个对象的时候会优先在Eden区域分配,如果Eden有足够的空间,那么内存分配很顺利的结束,不会触发任何GC操作;

3、当Eden区域空间不足的时候,会尝试着进行一次Minor GC,之所以说尝试是因为在进行Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象空间总和,如果是的那么可以保证这次Minor GC顺利进行;否则,虚拟机会检查HandlePromotionFailure这个参数是否设置为允许担保失败,如果允许那么虚拟机会根据经验值(这个经验值是历次晋升到老年代对象的平均大小)来决定是否尝试这次GC,如果小于或者JVM觉得不能冒险,那么会进行一次Full GC;

4、Minor GC时会采用复制算法将所有存活的对象复制到Survivor空间中(既包括Eden区域存活的对象,也包括另外一个Survivor存活下来的对象),如果这时发现Survivor空间不足,那么这些存活对象会直接进入老年代,这就是“空间分配”担保,前面说到冒险,是因为老年代的空间仍有可能不够,这时还是要进行一次Full GC,但是除了极端情况,大部分时候通过担保还是能有效避免频繁的Full GC的,如果Full GC后仍然没有足够空间,那只能抛出OutOfMemoryError;

5、对象在Eden空间出生,经过第一次Minor GC后能够顺利的被转移到Survivor的话,那么它的GC年龄就变成1,以后每在Survivor中熬过一次Minor GC,年龄就增加1,直到超过一定程度(-XX:MaxTenuringThreshold,默认15岁)则晋升到老年代;

6、规则是死的,人是活的,虚拟机开发人员还想到了一个“动态对象年龄判定”算法:如果Survivor区域中相同年龄所有对象大小总和超过Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进去老年代;

7、对象也可以直接分配在老年代,这主要是针对那些大对象,因为大对象的内存分配代价比较大(需要连续的内存空间),所以JVM提供了-XX:PretenureSizeThreshold这个参数。

为什么有两块survivor区域

我一开始很纳闷HotSpot虚拟机为什么要搞出两个Survivor区域内,只用一块有何不妥吗?最后在Stack Overflow找到一个答案:

http://*.com/questions/10695298/java-gc-why-two-survivor-regions 按照里面的说法是为了减少虚拟机对内存碎片的处理,我想了半天我的理解是:

因为survivor中的对象在达到“老年”(-XX:MaxTenuringThreshold)之前肯定有对象已经变成“垃圾”了,这时候必须要对其进行回收,如果只使用一个survivor的话,那么要不容忍survivor存在内存碎片,要么要对其进行内存整理,出于和对Eden区域同样的考虑,所以实际上对Survivor的GC也是基于复制算法的,不过是从一个Survivor到另外一个Survivor(这也是GC日志中为什么叫from space和to space),所以Survivor的两个区是对称的,没有先后关系,所以Survivor区中可能同时存在从Eden复制过来对象,以及从前一个Survivor复制过来的对象,某一次GC结束时肯定会有一个Survivor是空的。

实例说明

以上都是理论,下面结合一小段代码简单演示下上面的内容,这段代码引自《深入理解Java虚拟机第2版》3.6.3节,我简单展开说明下。为了方便解释,我先把设置的虚拟机参数贴出来:

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=5242880
  • -XX:+UseSerialGC这里使用默认的Serial GC进行说明;
  • -verbose:gc是为了打印出GC日志;
  • -Xms初始Java堆为20M;
  • -Xmx20M JVM最大使用的堆为20M不可扩展;
  • -Xmn10M,新生代的内存空间为10M;
  • -XX:SurvivorRatio=8 Eden与两个Survivor的比例是8:1:1;
  • XX:PretenureSizeThreshold直接在老年代区域分配对象的阈值为5M,其实可以不设置,这里为了明确变量。

测试代码如下:

JVM是如何分配和回收内存?有实例!

我们一步步来看,首先只执行48,49两行:

allocation1 = new byte[_1MB / 2];
allocation2 = new byte[6 * _1MB];

JVM是如何分配和回收内存?有实例!

通过GC日志发现没有发生任何GC,Eden区域够用(注意:关于一个Java对象导致占用多大内存,参看我前一篇博客。),接着执行51,52两行:

allocation2 = null;
allocation3 = new byte[2 * _1MB];

可以看到了引发了一次GC,这是一次Minor GC,同时可以看到使用的垃圾收集器是默认的(Def,关于GC日志的理解,可以参看《深入理解Java虚拟机第2版》一书)。引发GC的原因是Eden已经没有足够的内存容纳allocation3对象,发生GC之后allocation2对象占用的内存空间被回收了,而allocation1“幸存”下来被转移到了from survivor区域。

接下来我们再执行57,58,59三行代码:

allocation4 = new byte[4 * _1MB];
allocation3 = allocation4 = null;
allocation5 = new byte[2 * _1MB];

JVM是如何分配和回收内存?有实例!

第59行代码引发了第二次GC,仍然是一次Minor GC,可以看到allocation3和allocation4都被回收了,allocation5被顺利的分配到了Eden空间,但是为什么from space变成了0%而老年代区域却变成了6%,这6%应该是allocation1占用的,但是为什么跑到老年代了呢?显然它的“年龄”还没有到15岁啊。

啊哈!还记得吗JVM很聪明,它会“动态对象年龄判定”,从上一张图可以看到,Survivor区域已经使用超过了50%(67%),而且显然是同一年龄的对象(就一个对象嘛),所以在第二次GC的时候它晋升到了老年代,大家可以把allocation1对象分配为256kb再试试。

Ok,到目前为止都是Minor GC,想Stop-The-World很简单,我们直接分配一个很大的对象试试:

allocation6 = new byte[20*_1MB];

JVM是如何分配和回收内存?有实例!

不仅Full GC了,而且内存溢出了,因为我们设置了-Xmx20M。同时可以看到JVM被逼急了在不同区域进行了GC,首先在新生代(Eden+Survivor0)将内存全部回收,导致对象晋升到老年代,其次在老年代和“永久代(HotSpot)”也都进行了GC,但是一点收获都没有,最后只能OOM。关于JVM的一些其他规则,比如大对象的分配,以及其他虚拟机的分配策略,就留给有兴趣的同学自己试试了。

今天就写到这,最后祝大家“六一快乐”……靠,看了下时间已经不是6.1,童年已经过去了!

PS:文章同步发布在我的个人博客http://zhanjindong.info/2014/06/02/jvm-memory-and-gc/