理解JVM(4)- 堆内存的分代管理

作者: 小鱼爱小虾 | 来源:发表于2017-12-14 14:03 被阅读921次

前一篇从整体上了解了一下JVM的运行时数据区,它由线程私有的栈内存线程共享的堆内存、方法区组成。本章节将详细了解一下堆内存又被分为哪些区域,或者说JVM是如何把对象分配到这些区域上的

JVM根据对象在内存中存活时间的长短,把堆内存分为新生代(包括一个Eden区、两个Survivor区)和老年代(Tenured或Old)。Perm代(永久代,Java 8开始被“元空间”取代)属于方法区了,而且仅在Full GC时被回收。大致如下图


Heap Generation

为对象分配空间,就是把一块确定大小的内存从堆中划分出来(有一种例外情况,就是有可能经过JIT优化编译后,对象被拆分成标量类型从而变成了栈上分配)。新创建的对象主要分配在新生代的Eden区上,如果JVM启动了本地线程分配缓冲(TLAB,Thread Local Allocation Buffer),则对象将按线程优先分配在TLAB上,此区域仍然位于新生代的Eden区内。

关于TLAB

创建对象需要从堆中划分出一块确定大小的区域,那分配内存就是把指针从可用空闲区域挪动一段与对象大小相等的距离。而对象的创建是很频繁的行为,在并发情况并不是线程安全的,可能出现在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。为了解决这个问题,一个可行的方案就是TLAB,即把内存分配的动作按照线程划分在不同的空间内进行,即每个线程在堆内预先分配一小块内存,称为“本地线程分配缓冲”。哪个线程要给对象分配内存,就在自己的TLAB上分配,当自己的TLAB用完再去申请新的TLAB,这个时候再去进行指针的同步锁定,从而减小开销。


TLAB

对象优先分配在Eden区

大部分情况下,对象会在新生代的Eden区中分配空间,当Eden区没有足够大小的连续空间来分配给新创建的对象时,JVM将会触发一次Minor GC

HotSpot的开发人员将GC执行分为比较模糊的三种模型:

  • Minor GC:发生在新生代,回收新生代中的垃圾,速度很快但也很频繁
  • Major GC:发生在老年代,比Minor GC慢10倍以上;通常会伴随一次Minor GC
  • Full GC:回收所有区域,包括堆内存、方法区(Java 8之前的“永久代”,Java 8开始取代永久代的“元空间”)和直接内存,速度慢,工作线程的暂停时间长

绝大多数对象所占的内存空间会在Minor GC中被回收(IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的),那些存活下来的对象会被分配到某一个Survivor(幸存区,名字也很形象),但如果Survivor的空间不足以安置存活对象的话,JVM会通过“空间分配担保机制”提前转移这些对象到老年代去。

  1. 新生代中为什么有两个Survivor区?为什么每次只使用其中一个呢?

这跟新生代采用的垃圾回收算法有关,新生代用的是“复制”算法,该算法的特点是牺牲一定的空间成本,来换取高效率的垃圾回收,此算法不会产生内存碎片,回收后内存比较规整。关于各回收算法的细节,下一个章节再介绍,这里就不累赘了。

  1. “空间分配担保”是什么?

在发生Minor GC之前,JVM会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则JVM会查看HandlePromotionFailure设置值是否允许担保失败。若允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时要改为进行一次Full GC。

下面这个示例代码演示了Survivor区空间不足,对象通过分配担保机制被提前转移到老年代去。Debug执行三条对象创建语句,通过JDK自带的Java VisualVM工具jvisualvm(同时安装Visual GC插件),可以直观的看到各个内存区的变化情况。

/**
 *  -Xms90m -Xmx90m -XX:+UseParNewGC
 *
 * 固定堆大小:90m
 *     - Young Gen: 1/3 * 90 = 30m (默认 Tenured / Young = 2)
 *         - Survivor * 2 : 1/10 * 30 = 3m * 2 (两个Survivor,默认 Eden / Survivor = 8)
 *         - Eden: 8/10 * 30 = 24m
 *     - Tenured: 2/3 * 90 = 60m  (默认 Tenured / Young = 2)
 */
public class HandlePromotionDemo {

    public static void main(String[] args) {
        byte[] obj1 = new byte[1024 * 1024 * 2];
        byte[] obj2 = new byte[1024 * 1024 * 10];
        byte[] obj3 = new byte[1024 * 1024 * 20];
    }

}

以下三个截图分别展示了三个对象依次创建后的内存各区情况


第一行代码执行完,2MB的对象obj1创建成功后,Eden消耗2.480MB,这里大于obj1的大小是因为VisualVM GC监测工具自身也会创建一些临时对象。不管如何,还是可以直观的看到obj1分配到了Eden区 第二行代码执行完,10MB的对象obj2创建成功后,Eden消耗的空间增加到了12.480MB,注意图中圈出来的陡升区域,obj2就是在那一刻创建成功的 第三行代码执行完,20MB的对象obj3创建成功后,Eden消耗的空间变成了20MB。在创建obj3之前,JVM检测到容量为24MB的Eden已经消耗了12.480MB,剩下的空间不足以安置obj3,所以触发Minor GC,obj1被移到幸存区S1,但S1不能再容纳大对象obj2,通过“空间分配担保”,obj2被提前转移到老年代。此时被清空了的Eden区域可以用来分配新对象obj3了,创建成功后,Eden消耗了20MB即为obj3的大小

大对象直接进去老年代

大对象就是那些需要大量连续内存空间的对象,比如数组及很长的字符串。过多的大对象容易导致当内存空间仍然还有不少时就会提前触发GC以获取足够连续的空间来分配给这些大对象。
虚拟机提供了一个参数-XX:PretenureSizeThreshold,那些大于这个参数值的对象将直接在老年代分配,避免在Eden区和两个Survivor区之间发生大量的内存复制(新生代采用的是“复制”垃圾回收算法)。

下面这个示例代码指定一个Survivor区域容量大小为4MB,同时设置-XX:PretenureSizeThreshold=3145728,即3MB,之后创建一个略大于3MB的对象。运行此程序后,从VisualVM GC中可以看到此对象被分配到了老年代。

/**
 * -Xmn16m -Xms30m -Xmx30m -XX:SurvivorRatio=2 -XX:+UseParNewGC -XX:PretenureSizeThreshold=3145728 -XX:-UseTLAB
 *
 * Fixed Heap: 30MB
 *    - Survivor * 2: 4MB * 2
 *    - Eden: 8MB
 *    - Tenured: 14MB 
 */
public class BiggerThanPretenureSizeThresholdObjToOld {

    public static void main(String[] args) throws Exception {
        System.gc(); // 尝试清除由监测工具生成的临时对象
        Thread.sleep(10000L);

        byte[] obj = new byte[1024 * 1024 * 3 + 1];
        boolean flag = true;
        while(flag) {
            Thread.yield();
        }
    }

}
对象obj创建成功后,被分配在老年代

对于极端情况,参数-XX:PretenureSizeThreshold未设置,而对象大于Eden空间的话,则同样直接在老年代分配空间

长期存活的对象会被晋升到老年代

虚拟机在进行内存回收的时候,为了能够识别哪些对象应该继续留在新生代(某一个Survivor区)、哪些对象应该被晋升(转移)到老年代,它给每个对象定义了一个对象年龄(Age)计数器。所有在新生代出生的对象,年龄可以认为是0,此时的数值没有任何意义。当对象经过第一次Minor GC后任然存活,并且Survivor有足够的空间来容纳它的话,对象被顺利转移到Survivor中,此时对象开始拥有实际意义的年龄,为1岁。在此之后,Survivor中的对象每“熬过”一次Minor GC,年龄就会增加1岁,当达到一定的年龄阈值(默认是15岁,可通过参数-XX:MaxTenuringThreshold设置),对象就会被晋升到老年代中。老年代中的对象就没有年龄的意义了。

下面我们通过一个示例来演示一下:对象年龄达到阈值后被晋升到老年代。设置参数,固定堆大小为90MB,新生代45MB,其中Eden和Survivor各占15MB、15MB、15MB,年龄阈值为2岁。

/**
 *  -XX:+PrintGCDetails -Xmn45m -Xms90m -Xmx90m -XX:SurvivorRatio=1 -XX:MaxTenuringThreshold=2 -XX:+UseParNewGC
 *
 *  Fixed Heap: 90M
 *      - Survivor *2: 15M *2
 *      - Edeb: 15M
 *      - Tenured(Old): 45M
 */
public class AgeOlderThanTenuringThresholdObjToOld {

    public static void main(String[] args) throws Exception {
        System.gc(); //尝试清除由监测工具生成的临时对象

        byte[] obj1 = new byte[1024 * 1024 * 2]; //执行完这一行之后各区使用情况: Eden[obj1]: 2/15, S0: 0/15, S1: 0/15, Old: 0/45
        byte[] obj2 = new byte[1024 * 1024 * 2]; //执行完这一行之后各区使用情况: Eden[obj1,obj2]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45
        byte[] obj3 = new byte[1024 * 1024 * 12]; //对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 4/15, Old: 0/45
        obj3 = null; //obj3不再有任何引用关联,下次GC的时候将会被回收
        byte[] obj4 = new byte[1024 * 1024 * 4]; //对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次Minor GC来释放空间给obj4,这次GC中obj3会被回收,之后各区使用情况: Eden[obj4]: 4/15, S0:[obj1(age=2),obj2(age=2)]: 4/15, S1: 0/15, Old: 0/45
        byte[] obj5 = new byte[1024 * 1024 * 12]; //对象obj5创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj5,因此触发第三次MinorGC来释放空间给obj5,这次GC中由于obj1,obj2的年龄都到达了阈值2岁,所以这两个对象将被晋升到老年代,之后各区使用情况:Eden[obj5]: 12/15, S0: 0/15, S1[obj4(age=1)]: 4/15, Old[obj1,obj2]: 4/45
    }

}

Debug逐行执行上面5个对象的创建语句,每个对象创建成功后的各区使用情况如下各图:

对象obj1创建成功之后各区使用情况: Eden[obj1]: 2/15, S0: 0/15, S1: 0/15, Old: 0/45 对象obj2创建之后各区使用情况: Eden[obj1,obj2]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45 对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 4/15, Old: 0/45 对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次Minor GC来释放空间给obj4,这次GC中obj3会被回收,之后各区使用情况: Eden[obj4]: 4/15, S0:[obj1(age=2),obj2(age=2)]: 4/15, S1: 0/15, Old: 0/45 对象obj5创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj5,因此触发第三次MinorGC来释放空间给obj5,这次GC中由于obj1,obj2的年龄都到达了阈值2岁,所以这两个对象将被晋升到老年代,之后各区使用情况:Eden[obj5]: 12/15, S0: 0/15, S1[obj4(age=1)]: 4/15, Old[obj1,obj2]: 4/45

动态对象年龄判断

虚拟机并不是永远的要等到对象年龄达到阈值后才能晋升到老年代,当Survivor中相同年龄(比如N)的所有对象的大小总和大于Survivor的一半的时候,那些年龄大于等于N所有对象将会直接提前进入老年代。

示例代码如下:固定堆大小为90MB,新生代45MB,其中Eden和Survivor各占15MB、15MB、15MB,未设置最大年龄阈值,使用默认值15

/**
 *  -XX:+PrintGCDetails -Xmn45m -Xms90m -Xmx90m -XX:SurvivorRatio=1 -XX:+UseParNewGC
 *
 *  Fixed Heap: 90M
 *      - Survivor *2: 15M *2
 *      - Edeb: 15M
 *      - Tenured(Old): 45M
 */
public class DynamicAge {

    public static void main(String[] args) throws Exception {
        System.gc(); //尝试清除由监测工具生成的临时对象

        byte[] obj1 = new byte[1024 * 1024 * 4]; //执行完这一行之后各区使用情况: Eden[obj1]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45
        byte[] obj2 = new byte[1024 * 1024 * 4]; //执行完这一行之后各区使用情况: Eden[obj1,obj2]: 8/15, S0: 0/15, S1: 0/15, Old: 0/45
        byte[] obj3 = new byte[1024 * 1024 * 12]; //对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-8=7)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 8/15, Old: 0/45
        obj3 = null; //obj3不再有任何引用关联,下次GC的时候将会被回收
        byte[] obj4 = new byte[1024 * 1024 * 4]; //对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次MinorGC来释放空间给obj4,这次GC中由于Survivor区中的obj1和obj2的大小之和8超过了Survivor大小15的一半,所以这两个对象将被提前晋升到老年代,而对象obj3由于没有任何引用,直接被回收了,之后各区使用情况: Eden[obj4]: 4/15, S0: 0/15, S1: 0/15, Old[obj1,obj2]: 8/45
     
    }

}

Debug逐行执行上面4个对象的创建语句,每个对象创建成功后的各区使用情况如下各图:

对象obj1创建之后各区使用情况: Eden[obj1]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45 对象obj2创建之后各区使用情况: Eden[obj1,obj2]: 8/15, S0: 0/15, S1: 0/15, Old: 0/45 对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-8=7)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 8/15, Old: 0/45 对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次MinorGC来释放空间给obj4,这次GC中由于Survivor区中的obj1和obj2的大小之和8超过了Survivor大小15的一半,所以这两个对象将被提前晋升到老年代,而对象obj3由于没有任何引用,直接被回收了,之后各区使用情况: Eden[obj4]: 4/15, S0: 0/15, S1: 0/15, Old[obj1,obj2]: 8/45

至此,关于对象在堆内各区分配的几种情况就大致讲解到这里。下一章将了解一下垃圾收集器的原理。

上一篇:理解JVM(3)- 运行时数据区

相关文章

  • 理解JVM(4)- 堆内存的分代管理

    前一篇从整体上了解了一下JVM的运行时数据区,它由线程私有的栈内存和线程共享的堆内存、方法区组成。本章节将详细了解...

  • Java垃圾回收机制

    Java的内存分布 在JVM中,内存是按照分代进行组织的。 其中,堆内存分为年轻代和年老代,非堆内存主要是Perm...

  • jvm垃圾回收策略

    一、jvm堆内存的分代划分在基于分代的内存回收策略中,堆空间通常都被划分为3个代,年轻代,年老代(或者tenure...

  • jvm参数

    JVM内存结构主要有三大块:堆内存、方法区和栈。堆内存是JVM中最大的一块由年轻代和老年代组成,而年轻代内存又被分...

  • JVM堆分代笔记

    JVM内存分代策略 JVM 根据对象的存活周期不同,把堆内存划分为三块,一般为新生代、老年代、永久代(对于HotS...

  • 【干货】java面试核心知识点精讲---jvm运行内存

    JVM的运行时内存 JVM的运行时内存也叫作JVM堆,从GC的角度可以将JVM堆分为新生代、老年代和永久代。其中新...

  • 新生代和老年的垃圾回收算法介绍

    垃圾回收算法 上一篇文章我们介绍了堆内存分配、jvm分代模型、垃圾回收触发条件以及jvm的一些核心参数设置,理解了...

  • 2018-03-24

    Java学习随笔4 JVM的内存分配: 首先,jvm的内存主要分为三大块:堆,栈,方法区。 堆:jvm内存中最大的...

  • 初见JVM内存区域

    初见JVM内存区域 JVM一个重要的机制就是自动内存管理机制,为了深入理解JVM的内存管理机制,了解JVM的内存...

  • JVM学习——运行时数据区

    一,JVM内存结构 JVM内存主要分为:堆内存、方法区和栈 堆内存存储对象实例,由新生代和老年代组成 方法区存储类...

网友评论

  • 登高且赋:补充一点内容吧,将GC分为:Minor GC Major GC和Full GC其实不太严谨,针对现在HotSpot VM的实现,它里面的GC其实准确分类只有两大种:Partial GC(Young/Old、Mixed)和Full GC。HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,现在Major GC通常是跟full GC是等价的,因为绝大多数 Major GC 是由 Minor GC 触发的,差不多也是收集整个GC堆。当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。
    小鱼爱小虾:赞同您的观点,所以这里描述成“比较模糊的三种模型”,因为大家都这么习惯性的叫了。
    关于监测工具,友情提示一下,当使用64-bit的工具去监测跑在64-bit JDK上的程序时,会出现不断有小的新对象生成的情况,这是因为监测工具本身会生成对象来收集信息,所以你看到的图表可能没有我上面贴出来的那么稳定。我用的是64-bit去监测32-bit的jvm,这样干扰会少一点,效果更直观。这里仅做参考,真正使用的时候还是对应版本比较妥。
  • 登高且赋:写的很详细,配图十分用心,受教了。推荐的VisualVM很好用!
    小鱼爱小虾:很高兴能帮到您:smile:
  • seg4757:写的很好
    小鱼爱小虾:@seg4757 谢谢支持😀

本文标题:理解JVM(4)- 堆内存的分代管理

本文链接:https://www.haomeiwen.com/subject/zeahwxtx.html