深入理解java虚拟机----->垃圾收集器与内存分配策略(下)

1.  前言

  • 内存分配与回收策略

    • JVM堆的结构分析(新生代、老年代、永久代)
    • 对象优先在Eden分配
    • 大对象直接进入老年代
    • 长期存活的对象将进入老年代
    • 动态对象年龄判定
    • 空间分配担保

 2.  垃圾收集器与内存分配策略

  Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:

  • 给对象分配内存;
  • 回收分配给对象的内存。

  对象的内存分配,往大方向上讲就是在堆上的分配,对象主要分配在新生代的Eden区上。少数也可能分配在老年代,取决于哪一种垃圾收集器组合,还有虚拟机中的相关内存的参数设置。下面先介绍一下JVM中的年代划分:新生代、老年代、永久代(JDK1.8后称为元空间)。

2.1 JVM堆的结构分析(新生代、老年代、永久代)

  HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from(S1)和to(S2)),具体可参下面的JVM内存体系图。Eden和Survival的默认分配比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理,后面会说到),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

  因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

  在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

   在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
  永久代主要用于存放静态文件,Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应
用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持永久代空间来存放这些运行过程中新增的类。永久代大小通过-XX: MaxPermSize = <N> 进行设置。

2.2 对象在Eden上分配

  大多数新生代对象都在Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

  下面做一个测试程序demo,详细说明,新生代对象在Eden区的内存分配情况。尝试分配3个2MB大小和一个4MB大小的对象,在运行时候通过VM参数设置(看代码注释),限制java堆大小为20MB,不可扩展,其中10M分配给新生代,10M分给老年代,需要注意的是Eden区与一个Survivor区的空间比例是8:1,从输出结果也可以看出"eden space 8192K,from space 1024K,to space 1024K"的信息,新生代的总空间为9216KB(endn区+1个survivor区的总容量)。测试代码如下:

public class Minor_GC {
    private static final int _1MB = 1024 * 1024;

    /*
     * VM 参数配置: -Xms20M
     *             -Xmx20M
     *             -Xmn10M
     *             -XX:+PrintGCDetails
     */

    public static void main(String args[]){
        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];   // 出现一次GC回收

    }
}

  输出GC日志如下:

  上述参数可以看出: 执行main函数中,分配给allocation4对象时候发生了一次Minor GC(新生代回收),这次GC的结果是新生代内存7684k---->365k,然而堆上总内存的占用几乎没有改变,因为allocation1、allocation2、allocation3都存活,本次回收基本上没有找到可回收的对象。分析如下:

  1. 新生代一共被分配10M,其中Enden:8M,survivor:2M(From:1M,To:1M);
  2. 给allocation4分配内存时,Eden已经被占用6M(allocation1、2、3共6M,所以剩下2M),所以内存已经不够用了---->发生GC;
  3. 然而,6M放不进Survivor的From(只有1M),所以只能通过分配担保机制提前转移到老年代

  这次GC结束后,Eden中有4M的allocation4对象(一共8M,被占用50%左右),survivor为空闲,老年代为6M(被allocation1、2、3占用),日志中显示为6146k,其中老年代采用Mark-sweep(标志清除)回收的方法。

  [注意]区别新生代(Minor GC)和老年代(Full GC):

  1. 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数都具备朝生夕灭的特性,所以Minor GC非常的频繁,一般回收速度也比较快;
  2. 老年代GC(Major GC/Full GC):指发生在老年代的垃圾回收动作,出现Major GC,经常会有至少一次的MinorGC(因为对象大多数都是先在Eden分配空间的,但是并非绝对)。Major GC回收的速度会比Minor GC慢十倍以上(因为Minor GC回收一般都是大面积的回收采用复制算法;而Major GC没有额外空间为他担保,只能采用标记-清理方法),这两者的回收思路是相反的,是一个空间换时间和时间换空间的关系。

2.2 大对象直接进入老年代

  大对象是指需要大量内存空间的Java对象,最典型的大对象就是那种很长的字符串和数组(byte[ ]就是典型的大对象)。出现达对象很容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

  虚拟机提供了一个-XX:pretenureSize Threshold()参数,令大于这个设置直的对象直接在老年代分配。这样做的目的是避免Eden和Survivor区之间发生大量的内存复制(新生带采用复制的方法完成GC)。下面做个测试demo说明问题:

public class Major_GC {
        private static final int _1MB = 1024 * 1024;
    /*
     * VM 参数配置: -Xms20M
     *             -Xmx20M
     *             -Xmn10M
     *             -XX:+PrintGCDetails
     *             -XX:PretenureSizeThreshold=3145728(等于3M)
     */
        public static void main(String args[]){
            byte[] allocation;
            allocation = new byte[4 * _1MB];   // 直接会分配到老年代
        }
}

  运行后可以看到,内存会直接在老年代分配。[说明]:这里不给出运行结果,以免产生误导,因为在Parallel Scavenge收集器是不支持PretenureSizeThreshold这个参数的,得不到这样的结论。

2.3 长期存活对象将进入老年代

  Java虚拟机采用分代收集的思想来管理虚拟机内存。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并且经过第一次Minor GC后仍然存活,并且能被Survivor的话,将被移动到Survivor空间中,并且对象年龄增加到一定程度(默认15岁),就会被晋升到老年代。对晋升到老年代的对象的阈值可以通过-XX:MaxTenuringThreshold设置。

  下面给出测试demo:

public class LongTimeExistObj {
        private static final int _1MB = 1024 * 1024;

    /*
     * VM 参数配置: -Xms20M
     *              -Xmx20M
     *              -Xmn10M
     *              -XX:+PrintGCDetails
     *              -XX:MaxTenuringThreshold=1
     *              -XX:+PrintTenuringDistribution
     */

        public static void main(String args[]){
            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];
    }
}

  

  测试结果如下所示:

2.4 动态对象年龄判定

  虚拟并不是永远都要求对象年龄必须达到MaxTenuringThreshold才能晋升为老年代的,如果在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接静如老年代,无需要等到MaxTenuringThreshold中要求的年龄。

  下面做一个动态年龄测试demo:

public class LongTimeExistObj {
        private static final int _1MB = 1024 * 1024;

    /*
     * VM 参数配置: -Xms20M
     *              -Xmx20M
     *              -Xmn10M
     *              -XX:+PrintGCDetails
     *              -XX:MaxTenuringThreshold=15
     *              -XX:+PrintTenuringDistribution
     */
        @SuppressWarnings("unused")
        public static void main(String args[]){
            byte[] allocation1,allocation2,allocation3,allocation4;
            allocation1 = new byte[_1MB/4];

            // 使得allocation1 + allocation2 > survivor空间的一半(0.5M)
            allocation2 = new byte[_1MB/4];

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

  测试结果如下:

  执行代码结果中,可以看出:Survivor区占用空间仍然为0(from = 0,to = 0);而老年代的内存使用为5M,而其他对象都为4M,可以知道,alloccation1和allocation2都在没有达到15岁的时候就提前进入了老年代。验证了我们的结论---->在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接静如老年代

2.5 空间分配担保

  在发生Minor GC之前,虚拟机会先检查老年代可用的连续空间是否大于所有新生代的总空间,如果大于的话,那么这个GC就可以保证安全,如果不成立的,那么可能会造成晋升老年代的时候内存不足。在这样的情况下,虚拟机会先检查HandlePromotionFailure设置值是否允许担保失败,如果是允许的,那么说明虚拟机允许这样的风险存在并坚持运行,然后检查老年代的最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果大于的话,就执行Minor GC,如果小于,或者HandlePromotionFailure设置不允许冒险,那么就会先进行一次Full GC将老年代的内存清理出来,然后再判断。

  上面提到的风险,是由于新生代因为存活对象采用复制算法,但为了内存利用率,只使用其中的一个Survivor空间,将存活的对象备份到Survivor空间上,一旦出现大量对象在一次Minor GC以后依然存活(最坏的计划就是没有发现有对象死亡需要清理),那么就需要老年代来分担一部分内存,把在Survivor上分配不下的对象直接进入老年代,因为我们不知道实际上具体需要多大内存,我们只能估算一个合理值,这个值采用的方法就是计算出每次晋升老年代的平均内存大小作为参考,如果需要的话,那就提前进行一次Full GC.

  取平均值在大多数情况下是可行的,但是因为内存分配的不确定性太多,保不定哪次运行突然出现某些大对象或者Minor GC以后多数对象依然存活,导致内存远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。这样的情况下,担保失败是要付出代价的,大部分情况下都还是会将HandlePromotionFailure开关打开,毕竟失败的几率比较小,这样的担保可以避免Full GC过于频繁,垃圾收集器频繁的启动肯定是不好的。

  上面很繁琐(详细),实在看不下去就看图吧:

文中关于新生代、老年代的概念部分内容参考了博文:https://www.cnblogs.com/E-star/p/5556188.html

本文参考书籍:《深入理解java虚拟机》

时间: 2024-10-13 23:56:41

深入理解java虚拟机----->垃圾收集器与内存分配策略(下)的相关文章

深入理解JAVA虚拟机 垃圾收集器和内存分配策略

引用计数算法 很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1:当引用失效时,计数器值就减1:任何时刻计数器都为0的对象就是不可能再被使用的. 客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软的COM(Component Object Model)技术.使用ActionScript 3的FlashPlayer.Python语

[深入理解Java虚拟机]&lt;垃圾收集器与内存分配策略&gt;

Overview 垃圾收集考虑三件事: 哪些内存需要回收? 什么时候回收? 如何回收? 重点考虑Java堆中动态分配和回收的内存. Is Object alive? 引用计数法 给对象添加一个引用计数器. 该方法实现简单,判定效率高.但是它很难解决对象之间相互循环引用的问题,因此几乎很少有JVM选用该方法.eg: public class ReferenceCountingGC { public Object instance = null; // 占点内存,以便在GC日志中看清楚是否被回收过

Java虚拟机垃圾收集器与内存分配策略

Java虚拟机垃圾收集器与内存分配策略 概述 那些内存需要回收,什么时候回收,如何回收是GC需要完成的3件事情. 程序计数器,虚拟机栈与本地方法栈这三个区域都是线程私有的,内存的分配与回收都具有确定性,内存随着方法结束或者线程结束就回收了. java堆与方法区在运行期才知道创建那些对象,这部分内存分配是动态的,本章笔记中分配与回收的内存指的就是:java堆与方法区. 判断对象已经死了 引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它,计数器+1;引用失败,计数器-1.计数器为0则改判

深入理解JVM:垃圾收集器与内存分配策略

堆里面存放着Java世界几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还存活,哪些已经死去.判断对象的生命周期是否结束有以下几种方法 引用计数法 具体操作是给对象添加一个引用计数器,每当有一个地方引用时,计数器的值就加1,:当引用失效时,计数器就减1:任何时刻计数器为0的对象就 是不可能再被使用的.客观的说引用计数器算法实现简单,判定效率也很高,在大部分情况下他都是一个不错的算法.但是引用计数器有缺陷 举个简单的例子,对象A和对象B都有字段instance,

第三章 垃圾收集器和内存分配策略

第三章 垃圾收集器和内存分配策略 对象已死吗 引用计算方法 可达性分析算法 通过一些列的GC roots 对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC roots 没有任何引用链的则证明对象不可用的 虚拟机栈中的引用的对象 方法区中类静态属性引用的对象 方法去区中常量引用的对象 本地方法栈中JNI引用的对象 生存还是死亡 一次筛选,筛选是否有必要执行 finalize()方法 没有覆盖或者finalize()已经被调用过  视为没必要执行 放入一个F-Qu

垃圾收集器与内存分配策略(五)之垃圾日志与常见参数

垃圾收集器与内存分配策略(五)--垃圾日志与常见参数 理解GC日志 每个收集器的日志格式都可以不一样,但各个每个收集器的日志都维持一定的共性.如下面二段日志: 33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs] 100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->

垃圾收集器与内存分配策略(二)

垃圾收集算法简介 1.标记-清除算法       标记-清除算法主要分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一进行回收.对象的标记过程在垃圾收集器与内存分配策略(一)中已经介绍过. 存在的问题:一是效率问题,标记和清除的效率都不高:二是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的内存而不得不提前触发另一次垃圾收集动作. 2.复制算法       复制算法:它将内存按照容量划分为大小

垃圾收集器与内存分配策略(四)之垃圾收集器

垃圾收集器与内存分配策略(四)--垃圾收集器 收集算法是内存回收的方法论,垃圾收集器则是内存回收的具体实现. 垃圾收集器介绍 在垃圾收集器的层面上对并行与并发的解释: 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户现场仍处于等待状态. 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但并不一定是并行的,可能会交替执行),用户程序仍在继续执行,而垃圾收集程序运行于另一个CPU上. 对于不同的厂商,不同的版本的虚拟机都可能有很大的差别.此处讨论的是jdk1.7之后的

垃圾收集器与内存分配策略(三)之HotSpot的算法实现

垃圾收集器与内存分配策略(三)--HotSpot的算法实现 Java JVM 垃圾回收 在HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有着严格的考量,才能保证虚拟机高效地运行. 1. 枚举根节点 采用可达性分析从GC Roots节点中找引用链为例 存在的缺点: 1.在前面找出还存活对象时,采用可达性分析从GC Roots节点中找引用链时,可作为GC Roots的节点主要在全局性的引用(方法区的常量或类静态属性引用)与执行上下文(虚拟机栈栈帧中的本地变量表或本地方法栈中的Native