JVM 内存分配

内存的静态分配和动态分配的区别主要是两个:一是时间不同。静态分配发生在程序编译和连接的时候。动态分配则发生在程序调入和执行的时候。

二是空间不同。堆都是动态分配的,没有静态分配的堆。栈有 2 种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由函数 malloc 进行分配。不过栈的动态分配和堆不同,他的动态分配是由编译器进行释放,无需我们手工实现。

对于一个进程的内存空间而言,可以在逻辑上分成 3 个部份:代码区,静态数据区和动态数据区。动态数据区一般就是“堆栈”。“栈(stack)”和“堆(heap)”是两种不同的动态数据区,栈是一种线性结构,堆是一种链式结构。进程的每个线程都有私有的“栈”,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。一个堆栈可以通过“基地址”和“栈顶”地址来描述。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。

一般,用 static 修饰的变量,全局变量位于静态数据区。函数调用过程中的参数,返回地址,EBP 和局部变量都采用栈的方式存放。

Memory Allocation

Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们已经使用了大量的篇幅去介绍虚拟机中的垃圾收集器体系及其运作原理,现在我们再一起来探讨一下给对象分配内存的那点事儿。

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

  1. 新的对象和数组被创建并放入老年代。
  2. Minor 垃圾回收将发生在新生代。依旧存活的对象将从 eden 区移到 survivor 区。
  3. Major 垃圾回收一般会导致应用进程暂停,它将在三个区内移动对象。仍然存活的对象将被从新生代移动到老年代。
  4. 每次进行老年代回收时也会进行永久代回收。它们之中任何一个变满时,都会进行回收。

接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。本节中的代码在测试时使用 Client 模式虚拟机运行,没有手工指定收集器组合,换句话说,验证的是使用 Serial / Serial Old 收集器下(ParNew / Serial Old 收集器组合的规则也基本一致)的内存分配和回收的策略。读者不妨根据自己项目中使用的收集器写一些程序去验证一下使用其他几种收集器的内存分配策略。

虚拟地址

在 OS 层面一般是由逻辑地址映射到线性地址,如果线性地址管理,如果启动了分页,那么线性地址就会转换到相应的物理地址上,否则就直接认为是物理地址;程序设计中所用到的地址单元就是逻辑单元,如在 C 语言中的&表示指定的地址就是逻辑地址;而物理地址也并非我们所认为的 RAM,还应该包括网卡、显存、SWAP 等相关内容,也就是由 OS 所管理所有可以通过顶层逻辑单元映射到的目标地点,不过绝大部分情况下只需要考虑 RAM 即可,尤其是在服务器上;JVM 的虚拟内存地址和操作系统的虚拟内存地址不是一个概念,操作系统的虚拟内存地址相当于在磁盘上划分的一个 SWAP 交换区,用于内存,内存与之做 page out 和 page in 的操作,这种用于物理内存本身不够,而地址空间够用的情况,一旦程序出现 page out 这些情况的时候,程序将会变得非常缓慢,而 JVM 的虚拟内存是在有效的空间内分配一个连续的线性地址空间,因为 JVM 想要自己管理内存,分配的堆内存都是在自己的 heapSize 内部,因为它要实现一些脱离于存储器本身对非连续堆处理的管理而导致的复杂性,也就是 JVM 去初始化的时候就会加载一块很大的内存单元,然后内部的操作都是内部自己完成的。

内存分配

一般 C 语言分配内存是初始化将相应的基本内容和代码段进行加载,但是不会加载运行时候的堆栈内存分配,也就是在运行到某个具体的函数时通过 malloc、callloc、realloc 等方方申请的区域,这些区域必须从操作系统中重新来分配,使用完成后必须进行 free,C++中必须使用 delete 方法来释放,大家发现没有,OS 的堆在内存不断申请和释放的过程中,必然会产生许多的内存碎片,从而导致你在申请一块大内存的时候,需要进行 逻辑连接,导致在申请的速度减小,当然 Linux 采用了将内存块划分为多个不同大小的板块,来较好的处理这个问题,不过片段还是存在的,不过这种思想的确 是很好的,而 JVM 是如何完成碎片的处理的呢,后面章节会说到;JVM 在初始化的时候就会向 OS 申请一块大内存,JVM 要求这块内存在地址空间上是连续的 (物理上未必连续),让所有的程序在这个内部区分配,由自己来管理,所以它内部相当于做了一个小的 OS 对内存的管理,所以 JVM 是想让 Java 程序员不用 关心在哪一个平台上写代码,但是你一定要关心 Java 怎么管理内存的; 线性地址随着实际物理内存的增加,将会导致页表非常大,甚至于导致多层页表,如内存达到 96G 这一类,那么这样管理起来将会非常麻烦(正常情况下一 个页只有 4K,可以自己算一下需要多少个管理地址来指向这个 4K,这个管理地址太大的时候,又需要其他的管理地址来管理这个地址,就会导致多层地址,可能 到最后,一个大内存有 40%都是用于管理内存的,真正使用的可想而知),所以在 Linux 高版本中对于内存寻址方面做了改进,就是支持大页面来支持(其实 是通过一个套件完成的,并非 OS 本身),如一个页的大小为 1M 这样的,但是有一些风险在里面,它要求大页面内存要么放得下你的内存,但是你不能将你的进程 一部分放在大页面内存中,一部分放在 OS 管理的小页面内存中,也就是说要么这块放得下,要么就放在其他地方,可能会导致两边正好都差那么一点点的问题,在 OS 这边可以使用 SWAP,但是系统会很慢,而且 SWAP 很多的情况下肯定会宕机掉。

内存分配状态

一个大的进程如果初始化需要分配一块大的内存空间,内存空间一般会经历两个状态的转换过程,首先内存必须是 free 状态才可以被分配,如果的确是该状态并 且空间是够用的,那么它首先会占用那么大一个坑,在 Java 的 heapSize 中,就是-Xmx 参数指定的,也就是 JVM 虚拟机最大的内存空间(注意这里 -Xmx 并没有包含 PermSize 的空间),这个坑是不允许其它进程所占用的,内存的状态为:reserved 的状态,当需要使用的空间时,内存将会被 commited 状态,在 JVM 初始化时也就是-Xms 状态的内存空间,处于这个状态的内存如果发现不够使用(物理内存),此时就会发生 swap 区域,程 序将会变得非常缓慢,但是不会造成宕机,而很多时候在这个时候定位不出原因,所以我们为了让物理内存不够用的现象暴露出来可以被发现,至于可以定位不是程 序代码的问题,我们就直接将 swap 内存禁用掉;有个问题就是既然被 reserved 的内存就不能被其他进程所占用,为什么要在这两个状态之间来回倒腾 呢?这不是多一个开销吗?JVM 在来回倒腾的过程中会导致每个区域的容量发生相应的变化,必然导致的是 FullGC 的过程,那么 JVM 一般在服务器端如何 设置呢?文章后面逐步细化说明。

Eden:优先分配新生成对象

新生成对象在 HeapSize 中变化

Java 中常见的创建对象的方式有使用 new 关键字进行创建,也可以使用java.lang.Class.forName进行动态加载。JVM 在向 OS 请求地址列表后,即需要进行初始化,启动进程或者其他子进程。譬如 C 或者 C中会将相应的全局变量以及代码段等内容在内存中进行编译为相应的指令集,而 JVM 中会将 Main 类以及通过 import 引入的关联项形成大的 JVM 网状结构,保证每个 Class 都有一份自己的私有池并且放在 PermSize 或者 MetaSpace 中。不过动态装在的 Class 不是在 JVM 初始化的时候存入 PermSize 或者 MetaSpace,而是在运行时动态装载进去。动态装载负责的是运行时装载一些类的定义,而不是初始化,当然,当你通过全名去加载的时候,他们会从 符号向量中寻找这个类是否已经加载,如果已经加载则直接使用,否则从相应的包中获取这个 Class 定义,然后装载起来,装载的单位也是以 Class 为单位,并不是以 jar 包为单位,这里请大家如果不要滥用动态加载,一个是造成 Perm 的不稳定,另一个是它的效率肯定没有 new 高,因为它需要先去通过符号 向量寻找是否存在,不存在再加载,然后再通过 newInstance 实例化一个或多个实例,当然在某些特殊的时候,利用它可以为你的程序带来极高的灵活 性。 在 JVM 的初衷中,它希望新申请的内存是连续的,虽然堆的定义是让内存是随机分配的,但是对 于整个 JVM 来说,它希望分配的内存是较为连续的,也就是按照较为条带化的方式进行分配,好处有好几个,一个是这样非常的简单,经过精简后的情况目前一个 new 翻译为机器码只需要 10 条左右的指令码,近乎与 C 语言,所以在高版本的 JDK 中,new 的开销不再是 Java 虚拟机慢的一个原因,大家也没有必要去 尽量减少 new,但是也不要滥用,业绩虽乱定义不必要的对象;其次,另一个好处,当内存较为连续后,内存在分配上就没有类似的大量碎片的问题,造成运行一 段时间后,大量碎片,当需要申请一个大内存的时候,需要寻找非常多的地方才能将其逻辑上组成,而导致分配空间上不必要的浪费;而一个简单内存分配 String a = new String("abc");,这样一条代码,会做什么动作呢?a 相当于是对象的一个指针一样的东西,这个空间的大小为一个 long 的长度,也就是可以支持到可以想象的任何内存大小,它 并不是存放在 heapSize 中的,而是放在 stack 中的,由 OS 来调度管理,也就是当 a 的作用区域完成,这个指针将会断开,Java 中的 String 不再是 C 或者 C中的一个指针指向的一个字符数组,而是一个被包装后的对象,也就是 Java 为什么说自己都是对象,因为它把原生态的内容进行了包装,让 程序编写更加简单;这里顺便提及一下:在较早期的 JDK 中,jvm 并不是由一个指针直接指向分配堆中的首地址,而是先有一个 handle 空间,这个空间存 放了开始说的一些对象的定义和结构信息,也就是找到该位置,然后由该位置转换到对应的对象上,但是那个时候的对象头部信息就没有现在的那么全,也就是以前 是将一部分 handle 内容放置在独立的空间上,现在的 JDK 已经没有那样的了。

Minor GC

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。 虚拟机提供了-XX:+PrintGCDetails 这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候 输出当前内存各区域的分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不多,直接阅读就能看得很清楚。 代码清单 3-3 的 testAllocation()方法中,尝试分配 3 个 2MB 大小和 1 个 4MB 大小的对象,在运行时通过-Xms20M、-Xmx20M 和 -Xmn10M 这 3 个参数限制 Java 堆大小为 20MB,且不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。- XX:SurvivorRatio=8 决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8 比 1,从输出的结果也能清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为 9216KB(Eden 区+1 个 Survivor 区的总容量)。 执行 testAllocation()中分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、2、3 三个对象都是存活的,虚拟 机几乎没有找到可回收的对象)。这次 GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保 机制提前转移到老年代去。 这次 GC 结束后,4MB 的 allocation4 对象被顺利分配在 Eden 中。因此程序执行完的结果是 Eden 占用 4MB(被 allocation4 占用),Survivor 空闲,老年代被占用 6MB(被 allocation1、2、3 占用)。通过 GC 日志可以证实这一点。 注意 作者多次提到的 Minor GC 和 Full GC 有什么不一样吗? 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。 老年代 GC(Major GC / Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 ParallelScavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。MajorGC 的速度一般会比 Minor GC 慢 10 倍以上。

    private static final int _1MB = 1024 * 1024;
    /**
    * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
    */
    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];  // 出现一次Minor GC
    }Copy to clipboardErrorCopied

运行结果:

    [GC [DefNew: 6651K->148K(9216K), 0.0070106 secs]
    6651K->6292K(19456K), 0.0070426 secs] [Times:
    user=0.00 sys=0.00, real=0.00 secs]
    Heap
    def new generation   total 9216K, used 4326K
    [0x029d0000, 0x033d0000, 0x033d0000)
     eden space 8192K,  51% used [0x029d0000,
    0x02de4828, 0x031d0000)
     from space 1024K,  14% used [0x032d0000,
    0x032f5370, 0x033d0000)
     to   space 1024K,   0% used [0x031d0000,
    0x031d0000, 0x032d0000)
    tenured generation   total 10240K, used 6144K
    [0x033d0000, 0x03dd0000, 0x03dd0000)
      the space 10240K,  60% used [0x033d0000,
    0x039d0030, 0x039d0200, 0x03dd0000)
    compacting perm gen  total 12288K, used 2114K
    [0x03dd0000, 0x049d0000, 0x07dd0000)
      the space 12288K,  17% used [0x03dd0000,
    0x03fe0998, 0x03fe0a00, 0x049d0000)
    No shared spaces configured.Copy to clipboardErrorCopied

大对象直接进入老年代

所谓大对象就是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串及数组(笔者例子中的 byte[]数组就是典型的大对 象)。大对象对虚拟机的内存分配来说就是一个坏消息(替 Java 虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对 象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。 虚拟机提供了一个-XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存拷贝(复习一下:新生代采用复制算法收集内存)。 执行代码清单 3-4 中的 testPretenureSizeThreshold()方法后,我们看到 Eden 空间几乎没有被使用,而老年代 10MB 的空间被使用了 40%,也就是 4MB 的 allocation 对象直接就分配在老年代中,这是因为 PretenureSizeThreshold 被设置为 3MB(就是 3145728B,这个参数不能与-Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代中进行分配。 注意  PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。 代码清单 3-4  大对象直接进入老年代

    private static final int _1MB = 1024 * 1024;
    /**
    * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
    * -XX:PretenureSizeThreshold=3145728
    */
    public static void testPretenureSizeThreshold() {
     byte[] allocation;
     allocation = new byte[4 * _1MB];  //直接分配在老年代中
    }Copy to clipboardErrorCopied

运行结果:

    Heap
    def new generation   total 9216K, used 671K
    [0x029d0000, 0x033d0000, 0x033d0000)
     eden space 8192K,   8% used [0x029d0000,
    0x02a77e98, 0x031d0000)
     from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
     to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
    tenured generation   total 10240K, used 4096K
    [0x033d0000, 0x03dd0000, 0x03dd0000)
      the space 10240K,  40% used [0x033d0000,
    0x037d0010, 0x037d0200, 0x03dd0000)
    compacting perm gen  total 12288K, used 2107K
    [0x03dd0000, 0x049d0000, 0x07dd0000)
      the space 12288K,  17% used [0x03dd0000,
    0x03fdefd0, 0x03fdf000, 0x049d0000)
    No shared spaces configured.Copy to clipboardErrorCopied

长期存活的对象直接进入老年代

虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每 个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。 读者可以试试分别以-XX:MaxTenuringThreshold=1 和-XX:MaxTenuringThreshold=15 两种设置来执 行代码清单 3-5 中的 testTenuringThreshold()方法,此方法中 allocation1 对象需要 256KB 的内存空 间,Survivor 空间可以容纳。当 MaxTenuringThreshold=1 时,allocation1 对象在第二次 GC 发生时进入老年代,新生 代已使用的内存 GC 后会非常干净地变成 0KB。而 MaxTenuringThreshold=15 时,第二次 GC 发生后,allocation1 对象则还 留在新生代 Survivor 空间,这时候新生代仍然有 404KB 的空间被占用。

    private static final int _1MB = 1024 * 1024;
    /**
    * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
    * -XX:+PrintTenuringDistribution
    */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold() {
     byte[] allocation1, allocation2, allocation3;
     allocation1 = new byte[_1MB / 4];
      // 什么时候进入老年代取决于XX:MaxTenuringThreshold设置
     allocation2 = new byte[4 * _1MB];
     allocation3 = new byte[4 * _1MB];
     allocation3 = null;
     allocation3 = new byte[4 * _1MB];
    }Copy to clipboardErrorCopied

以 MaxTenuringThreshold=1 的参数设置来运行的结果:

    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 1 (max 1)
    - age   1:     414664 bytes,     414664 total
    : 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K
    (19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 1 (max 1)
    : 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K
    (19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    Heap
    def new generation   total 9216K, used 4178K
    [0x029d0000, 0x033d0000, 0x033d0000)
     eden space 8192K,  51% used [0x029d0000,
    0x02de4828, 0x031d0000)
     from space 1024K,   0% used [0x031d0000,
    0x031d0000, 0x032d0000)
     to   space 1024K,   0% used [0x032d0000,
    0x032d0000, 0x033d0000)
    tenured generation   total 10240K, used
    4500K [0x033d0000, 0x03dd0000, 0x03dd0000)
      the space 10240K,  43% used [0x033d0000,
    0x03835348, 0x03835400, 0x03dd0000)
    compacting perm gen  total 12288K, used 2114K
    [0x03dd0000, 0x049d0000, 0x07dd0000)
      the space 12288K,  17% used [0x03dd0000,
    0x03fe0998, 0x03fe0a00, 0x049d0000)
    No shared spaces configured.Copy to clipboardErrorCopied

以 MaxTenuringThreshold=15 的参数设置来运行的结果:

    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 15 (max 15)
    - age   1:     414664 bytes,     414664 total
    : 4859K->404K(9216K), 0.0049637 secs] 4859K->
    4500K(19456K), 0.0049932 secs] [Times: user=
    0.00 sys=0.00, real=0.00 secs]
    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 15 (max 15)
    - age   2:     414520 bytes,     414520 total
    : 4500K->404K(9216K), 0.0008091 secs] 8596K->
    4500K(19456K), 0.0008305 secs] [Times: user=
    0.00 sys=0.00, real=0.00 secs]
    Heap
    def new generation   total 9216K, used 4582K
    [0x029d0000, 0x033d0000, 0x033d0000)
     eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
     from space 1024K,  39% used [0x031d0000, 0x03235338, 0x032d0000)
     to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
    tenured generation   total 10240K, used 4096K
    [0x033d0000, 0x03dd0000, 0x03dd0000)
      the space 10240K,  40% used [0x033d0000,
    0x037d0010, 0x037d0200, 0x03dd0000)
    compacting perm gen  total 12288K, used 2114K
    [0x03dd0000, 0x049d0000, 0x07dd0000)
      the space 12288K,  17% used [0x03dd0000,
    0x03fe0998, 0x03fe0a00, 0x049d0000)
    No shared spaces configured.Copy to clipboardErrorCopied

动态对象年龄

 在说明下,以下三种情况对象会被晋升到old区域:
 1、在eden和survivor中可以来回被minor gc多次,这个次数超过了-XX:MaxTenuringThreshold
 2、在发生minor gc时,发现to survivor无法放下这些对象,就会进入old。
 3、在新申请对象,大于eden区域的一半大小时直接进入old,也可以专门设置参数-XX:PretenureSizeThreshold这个参数指定当超过这个值就直接进入old。
 当上面的对象被移动到了Tenured区域,这个区域一般非常大,占用了HeapSize的绝大部分空间,此时若它发生一次内存回收,就不能像刚才那样来 回拷贝了,那样代价太大,而且这个区域可以说是经得起考验的对象才会被移动过来,在概率上是不容易被销毁掉的对象才会被移动过来;那么,我们很此时想到的 就是反过来计算,也就是找到需要销毁的对象,将其销毁,关于算法也是下面第三章要说的内容,总之对象会在这里存放着。Copy to clipboardErrorCopied

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。 执行代码清单 3-6 中的 testTenuringThreshold2()方法,并设置参数-XX: MaxTenuringThreshold=15,会发现运行结果中 Survivor 的空间占用仍然为 0%,而老年代比预期增加了 6%,也就是说 allocation1、allocation2 对象都直接进入了老年代,而没有等到 15 岁的临界年龄。因为这两个对象加起来已经达到了 512KB,并且 它们是同年的,满足同年对象达到 Survivor 空间的一半规则。我们只要注释掉其中一个对象的 new 操作,就会发现另外一个不会晋升到老年代中去了。

    private static final int _1MB = 1024 * 1024;
    /**
    * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
    * -XX:+PrintTenuringDistribution
    */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold2() {
     byte[] allocation1, allocation2, allocation3, allocation4;
     allocation1 = new byte[_1MB / 4];
      // allocation1+allocation2大于survivor空间的一半
     allocation2 = new byte[_1MB / 4];
     allocation3 = new byte[4 * _1MB];
     allocation4 = new byte[4 * _1MB];
     allocation4 = null;
     allocation4 = new byte[4 * _1MB];
    }Copy to clipboardErrorCopied

运行结果为:

    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 1 (max 15)
    - age   1:     676824 bytes,     676824 total
    : 5115K->660K(9216K), 0.0050136 secs] 5115K->
    4756K(19456K), 0.0050443 secs] [Times: user=0.00
    sys=0.01, real=0.01 secs]
    [GC [DefNew
    Desired Survivor size 524288 bytes, new threshold 15 (max 15)
    : 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K
    (19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    Heap
    def new generation   total 9216K, used 4178K
    [0x029d0000, 0x033d0000, 0x033d0000)
     eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
     from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
     to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
    tenured generation   total 10240K, used 4756K
    [0x033d0000, 0x03dd0000, 0x03dd0000)
      the space 10240K,  46% used [0x033d0000,
    0x038753e8, 0x03875400, 0x03dd0000)
    compacting perm gen  total 12288K, used 2114K
    [0x03dd0000, 0x049d0000, 0x07dd0000)
      the space 12288K,  17% used [0x03dd0000,
    0x03fe09a0, 0x03fe0a00, 0x049d0000)
    No shared spaces configured.Copy to clipboardErrorCopied

空间分配担保

在发生 Minor GC 时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次 Full GC。如果小于,则查看 HandlePromotionFailure 设置是否允许担保失败;如果允许,那只会进行 Minor GC;如果不允许,则也要改为进行一次 Full GC。 前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况时(最极端就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,让 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来,在实际完成内存回收之前是无法 明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。 取平均值进行比较其实仍然是一种动态概率的手段,也就是说如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁,参见代码清单 3-7。

    private static final int _1MB = 1024 * 1024;
    /**
    * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M
    -XX:SurvivorRatio=8 -XX:-
    HandlePromotionFailure
    */
    @SuppressWarnings("unused")
    public static void testHandlePromotion() {
     byte[] allocation1, allocation2, allocation3,
    allocation4, allocation5, allocation6, allocation7;
     allocation1 = new byte[2 * _1MB];
     allocation2 = new byte[2 * _1MB];
     allocation3 = new byte[2 * _1MB];
     allocation1 = null;
     allocation4 = new byte[2 * _1MB];
     allocation5 = new byte[2 * _1MB];
     allocation6 = new byte[2 * _1MB];
     allocation4 = null;
     allocation5 = null;
     allocation6 = null;
     allocation7 = new byte[2 * _1MB];
    }Copy to clipboardErrorCopied

以 HandlePromotionFailure = false 的参数设置来运行的结果:

    [GC [DefNew: 6651K->148K(9216K), 0.0078936 secs]
    6651K->4244K(19456K), 0.0079192 secs] [Times:
    user=0.00 sys=0.02, real=0.02 secs]
    [GC [DefNew: 6378K->6378K(9216K), 0.0000206 secs]
    [Tenured: 4096K->4244K(10240K), 0.0042901 secs]
    10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)],
    0.0043613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    以MaxTenuringThreshold= true的参数设置来运行的结果:
    [GC [DefNew: 6651K->148K(9216K), 0.0054913 secs]
    6651K->4244K(19456K), 0.0055327 secs] [Times:
    user=0.00 sys=0.00, real=0.00 secs]
    [GC [DefNew: 6378K->148K(9216K), 0.0006584 secs]
    10474K->4244K(19456K), 0.0006857 secs] [Times:
    user=0.00 sys=0.00, real=0.00 secs]