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

简介: 前一篇从整体上了解了一下JVM的运行时数据区,它由_线程私有的栈内存_和_线程共享的堆内存、方法区_组成。本章节将详细了解一下堆内存又被分为哪些区域,或者说JVM是如何把对象分配到这些区域上的 JVM根据对象在内存中存活时间的长短,把堆内存分为新生代(包括一个Eden区、两个Survivor区)和老年代(Tenured或Old)。

前一篇从整体上了解了一下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](http://upload-images.jianshu.io/upload_images/6423761-bad6d5c9ff99db64.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象obj2创建之后各区使用情况: Eden[obj1,obj2]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45](http://upload-images.jianshu.io/upload_images/6423761-10c3d79b93943702.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象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](http://upload-images.jianshu.io/upload_images/6423761-b21140f9bf41f6c9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象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](http://upload-images.jianshu.io/upload_images/6423761-aedc42eed3dbd0a9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象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](http://upload-images.jianshu.io/upload_images/6423761-79c5ed7bd567cf32.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

动态对象年龄判断

虚拟机并不是永远的要等到对象年龄达到阈值后才能晋升到老年代,当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](http://upload-images.jianshu.io/upload_images/6423761-d1da3806cf24547b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象obj2创建之后各区使用情况: Eden[obj1,obj2]: 8/15, S0: 0/15, S1: 0/15, Old: 0/45](http://upload-images.jianshu.io/upload_images/6423761-b7184c060f87a5d3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象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](http://upload-images.jianshu.io/upload_images/6423761-e51bf980e8676d9d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![对象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](http://upload-images.jianshu.io/upload_images/6423761-cf6a06679aeb7c33.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

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

相关文章
|
17天前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
150 1
|
7天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
11天前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
39 5
|
16天前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
16天前
|
存储 监控 Java
合理设置JVM堆大小
合理设置JVM堆大小
19 4
|
17天前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
18 3
|
17天前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
42 1
|
27天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
25天前
|
Java Linux Windows
JVM内存
首先JVM内存限制于实际的最大物理内存,假设物理内存无限大的话,JVM内存的最大值跟操作系统有很大的关系。简单的说就32位处理器虽然可控内存空间有4GB,但是具体的操作系统会给一个限制,这个限制一般是2GB-3GB(一般来说Windows系统下为1.5G-2G,Linux系统下为2G-3G),而64bit以上的处理器就不会有限制。
19 1
|
2月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
73 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS