(九)Java创建一个对象 内存究竟是如何分配的?

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:

  1. 自动给对象分配内存以
  2. 自动回收分配给对象的内存。

new一个对象的时候,都知道是存到堆里面,而堆细分了很多区域,那他究竟是存在堆的什么地方,什么时候才会发生Minor GC。下面通过代码真实的去感受一下,本篇文章来为您揭晓答案。

一、概要

在经典分代的设计下,新生对象通常会分配在新生代中,少数 情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。对象分配的规则并不是固定的,新对象的创建和存储细节取决于虚拟机当前使用的是哪一种垃圾收 集器以及虚拟机中与内存相关的参数的设定

JDK1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
有些参数Parallel收集器并没有,所以部分案例会采用Serial收集器。尽管实际开发不会用Serial收集器,但是我们的目的是通过代码示例了解对象分配。

-XX:+UseSerialGC:老年代和年轻代都使用Serial收集器

文章当中会多次提到GC,GC分为以下三种:

  1. 新生代(Minor GC)
  2. 老年代(Major GC)
  3. Java堆(Full GC)

二、对象优先在Eden分配

对象优先在Eden分配?意思就是创建对象的时候,对象会优先放在Eden区域,这可不是瞎说的,我们可以通过代码演示,很直观的就能了解到对象分配的实际过程。

下图示例创建一个对象,观察堆的占用情况。
在这里插入图片描述

2.1、什么是Eden区?

详细了解:https://blog.csdn.net/weixin_43888891/article/details/123959030

这还得谈及到Appel式回收,HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设 计新生代的内存布局。

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

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

2.2、启动参数讲解

用到了以下启动参数:

  • -Xms20M:代表的是给堆分配20MB
  • -Xmx20M:代表的是堆最大占用20MB,也就是不可扩展
  • -Xmn10M:其中 10MB分配给新生代,剩下的10MB分配给老年代
  • -XX:+PrintGCDetails:告诉虚拟机在发生垃圾收集行 为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
  • -XX:Survivor-Ratio=8:决定了新生代中Eden区与一 个Survivor区的空间比例是8∶1。
  • -verbose:gc:只输出GC前后实际内存情况,而不是年轻代

运行命令:

-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

2.3、代码示例

testAllocation()方法中,尝试分配三个2MB大小和一个4MB大小的对象。

public class MemoryTest {
    
    

    private static final int _1MB = 1024 * 1024;

    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
    }

    public static void main(String[] args) {
    
    
        testAllocation();
    }
}

2.4、运行结果详解

从下图输出的结果也清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。其中from space和to space就是我们一直所说的两块Survivor区,其中to space在日志当中永远是0,因为两块Survivor是轮流复制使用的。
在这里插入图片描述

GC日志详解:

[PSYoungGen: 6656K->911K(9216K)]意思是年轻代GC前后情况
6656KB表示GC前存活对象使用的内存容量
911KB表示垃圾收集GC后所有存活对象使用的内存容量
9216KB表示的是年轻代总容量

6656K->5015K(19456K)意思是GC前后内存实际占用情况(一定是以实际内存为准的)

因为allocation1、2对象都是存活 的,虚拟机几乎没有找到可回收的对象,导致实际内存根本没释放。

执行流程:

下图是我故意在创建allocation4对象的地方打了个断点。会发现,在第四行没开始执行的时候,已经触发GC。那么就证明是在创建allocation3的时候就已经Minor GC。

在这里插入图片描述
按正常来说应该创建allocation4的时候才GC,为什么在创建allocation3就GC了呢?

因为项目在启动的时候堆内存已经占用了2MB左右。然后再加上allocation1和allocation2已经6MB左右了,这时候已经不足以给allocation3分配对象。

而Survivor空间只有 1MB大小,不足以存放allocation1和allocation2,所以只好通过分配担保机制提前转移到老年代去

运行空方法,会发现eden区已经占用了27%
在这里插入图片描述

通过下面的GC日志会发现eden占用了6MB左右,而老年代占用了4MB。

这次收集结束后,4MB的allocation4和2MB的allocation3放到了eden区当中,而application1、application2晋升到了老年代。

在这里插入图片描述

2.5、堆当中初始化占用的内存

在启动的时候eden就会占用一定的内存,并不是说只有创建对象的时候才会占用内存。
跑了一个空方法,自动就占用了2MB左右内存,这还只是我将新生代设置为了10MB。
在这里插入图片描述
新生代设置为100Mb的时候占用了6MB左右。
在这里插入图片描述

2.6、总结

新生代分为一块较大的Eden空间和两块较小的 Survivor空间,其中有一块Survivor空间是专门存放GC过后的对象。

  1. 创建对象的时候,会将对象放到年轻代的eden区域和一块Survivor空间。
  2. eden区域和Survivor区域满了,或者是不足以给新创建的对象分配区域的时候,会进行触发GC。
  3. GC过后将没清理掉的对象放到另一块Survivor空间。
  4. 假如GC过后,剩余的对象太多,而专门存放GC后对象的Survivor空间又不足以存放,这时候会将适量的对象移送到老年代(这里的适量指的是,他会计算将新生代给老年代多少对象的时候就足够分配新对象了,并不会全部把新生代移过去)。
  5. 假如老年代对象也不足以存放,直接java.lang.OutOfMemoryError: Java heap space内存溢出。

三、大对象直接进入老年代

3.1、什么是大对象?

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者 元素数量很庞大的数组,本节例子中的byte[]数组就是典型的大对象。大对象对虚拟机的内存分配来说 就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。

3.2、大对象带来的坏处

  1. 大对象在分配空间时,它容易 导致内存明明还有不少空间时就提前触发垃圾收集。
  2. 1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代),Parallel Scavenge使用的是标记复制法。假如这个对象没达到清理的要求,那就意味着GC的时候要进行复制,而当复 制对象时,大对象就意味着高额的内存复制开销。

为了解决这个问题,jvm提供了以下参数:

-XX:PretenureSizeThreshold:指定大于该设置值的对象直接在老年代分配(单位是字节),这样做的目的就是避免在Eden区及两个Survivor区 之间来回复制,产生大量的内存复制操作。
注意:参数只对Serial和ParNew两款新生代收集器有效,HotSpot 的其他新生代收集器,如Parallel Scavenge并不支持这个参数

3.3、代码示例

设置启动参数: 使用Serial收集器

-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:PretenureSizeThreshold=3145728

-XX:PretenureSizeThreshold=3145728:超过3MB直接放入老年代

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

    public static void testPretenureSizeThreshold() {
    
    
        byte[] allocation;
        allocation = new byte[4 * _1MB]; //直接分配在老年代中
    }

    public static void main(String[] args) {
    
    
        testPretenureSizeThreshold();
    }
}

运行结果:
在这里插入图片描述
执行代码testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而 老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中。

四、长期存活的对象将进入老年代

4.1、长期存活的对象

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存 活对象应当放在新生代,哪些存活对象放在老年代中。

为做到这点,虚拟机给每个对象定义了一个对 象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

4.2、启动参数

这是之前没用过的参数:
-XX:TargetSurvivorRatio=89 Survivor区对象使用率90%,默认是50%

为什么要设置这个XX:TargetSurvivorRatio?

首先第一点他的作用是,代表的使用率,所谓的使用率就是,假如 Survivor区只有1MB内存,使用率50%,意味着只能用1MB*50%。当Survivor区空间太小的时候就会导致跳过年龄判断,直接担保机制进入老年代,这并不是我们本次试验想要看到的,我们想要看到的是Survivor区当中存活的对象超过指定年龄移到老年代。

当我们第一次使用from区的时候,同样也是会占用一定的内存,刚刚我们提到了eden区在使用的时候会占用了一定的内存,而并不是我们创建对象所占用的,这是为什么呢?就好比手机说的是128G内存,但是总会有一些软件也好,系统也罢占用掉一部分内存。

from区(from区就是Survivor区)同样也是,因为我们设置的新生代是10MB,from区也就是1MB,如果使用默认值50%的话,可能256KB+系统自动占用的就超过了50%,超过50%就会根据年龄放到老年代,达不到我们想要看的结果,所以这里我们设置成90%。

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代,所以我们需要调大一点,避免出现这个情况。

本次测试还是用了Serial收集器。

-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:TargetSurvivorRatio=90

4.3、代码示例

执行代码testTenuringThreshold()方法,此方法中allocation1对象需要256KB内存,Survivor 空间可以容纳。
当-XX:MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代, 新生代已使用的内存在垃圾收集以后非常干净地变成0KB。
-XX:MaxTenuringThreshold=15时, 第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时候新生代仍然有404KB被占用。

如下代码我给堆分配10MB内存,eden区也就是8MB,那也就意味着,创建application3的时候会进行GC,GC并没有要清理的对象,只好将application1移动到Survivor空间中,然后创建application4的时候再次进行GC,这时候假如MaxTenuringThreshold=1,那么allocation1和allocation2都会升级到老年代。

代码怎么测呢?

我们要想测年龄,首先写的代码当中要制造两次年轻代GC,然后观察MaxTenuringThreshold=1的时候,然后再创建一个from区能够存放的对象(能够存放指的是内存放的下),并且保证第一次GC不会被清理掉,如果两次GC过后,这个对象不在from里面存放着,而到了老年代,那说明年龄参数是真实有效的。如果设置MaxTenuringThreshold=15,两次GC过后,还在from当中,那说明也没什么问题。

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

    public static void testTenuringThreshold() {
    
    
        byte[] allocation1, allocation2, allocation3;
        // 什么时候进入老年代决定于XX:MaxTenuring- Threshold设置
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        // 第一次触发GC
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        // 第二次触发GC
        allocation3 = new byte[4 * _1MB];
    }

    public static void main(String[] args) {
    
    
        testTenuringThreshold();
    }
}

MaxTenuringThreshold=1运行结果:

按正常allocation1 和allocation2经过1次GC是不会被清理掉的,因为他们还处于被引用状态。

通过下面运行结果可以观察出上面的代码,是进行了两次GC。两次GC过后,Survivor区并没有对象。因为=1的时候就代表的是,GC1次过后,存活的对象再次进行GC的时候对象直接移动到老年代。
在这里插入图片描述

MaxTenuringThreshold=15的运行结果:
这一次会发现Survivor区存在对象,但是老年代也存在对象,原因是设置的15,也就意味着必须15次GC,才会升级到老年代,而Survivor区内存有限,只能放1MB的对象,所以他会将allocation1放到Survivor区,而allocation2属于是被逼无奈被放到了老年代。
在这里插入图片描述
这里可以清晰的看到原本占用了9175k的内存,经历了一次GC成了占用4983k,原因就是我们将allocation3设置为null,所以他把第一次new的对象给清理掉了。
在这里插入图片描述

在这里插入图片描述

4.4、jdk8下是什么样的

设置启动参数,去除了-XX:+UseSerialGC,这样我们就可以采用JDK8默认的收集器了。

-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:TargetSurvivorRatio=90

运行结果:

惊讶的发现竟然连GC都没有触发,并且eden区占用了6MB,老年代占用了8MB。也就证明老年代存放了两个4MB的对象。
在这里插入图片描述
为什么没有进行GC?

通过试验我猜测是jdk1.8改了年轻代GC机制,当新创建对象的时候,假如对象是大对象(超过3MB),而且年轻代也不够存放,这时候会直接放到老年代,不会触发年轻代GC。假如小于等于3MB,会进行触发年轻代GC,进行正常的流程判断。

通过代码证明了我所说的,并且可以得到验证,当对象大于3MB的时候,会直接存放到老年代。

运行参数:

-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:TargetSurvivorRatio=90

在这里插入图片描述
将allocation3改为3MB,直接触发年轻代GC。
在这里插入图片描述

那jdk1.8默认的收集器到底对象有年龄这一说吗?

当然有,对于这个,我们就得首先制造两次年轻代GC。
启动参数:

-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:TargetSurvivorRatio=90

代码示例:

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

    public static void testTenuringThreshold() {
    
    
        byte[] allocation1, allocation2, allocation3, allocation4;
        // 1.将eden区放满对象,5374K对象,eden区直接92%
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[3 * _1MB];
        // 第一次GC
        allocation3 = new byte[3 * _1MB];
        allocation3 = null;
        allocation3 = new byte[3 * _1MB];
        // 第二次GC
        allocation4 = new byte[3 * _1MB];
    }

    public static void main(String[] args) {
    
    
        testTenuringThreshold();
    }
}

MaxTenuringThreshold=1 运行结果:

在这里插入图片描述
MaxTenuringThreshold=15 运行结果:

在这里插入图片描述

4.5、总结

简单梳理一下流程

  1. 创建对象第一次会被放到eden区
  2. 当eden区满了的时候会触发GC,存活的对象会放到from区,也就是Survivor区。
  3. 当两个区域都不足以分配新的对象的时候,会进行担保将eden区的放到老年代,这个所谓的担保也就是Survivor区空间不足的情况下走的机制。
  4. 当然还有特殊情况会不进行担保机制,在JDK1.8默认收集器下,当新对象大于3MB的时候,新生代空间又不足,这时候不会触发新生代GC,会直接将新对象存放到老年代。

晋升老年代的条件:

  1. 在jdk1.8下对象超过3MB,并且新生代空间不足。
  2. 设置了XX:MaxTenuringThreshold年龄参数,经过几次新生代GC仍然存活的对象,并且存活次数大于XX:MaxTenuringThreshold设置的参数,再下次GC的时候会存放到老年代。

五、什么时候会Minor GC?

在发生Minor GC之前

  1. 第一步虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。
  2. 如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
    1. 如果允 许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大 于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
    2. 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

说白了-XX:HandlePromotionFailure开启的话,就是不仅仅让老年代现有空间和新生代占有的空间对比,还要检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,假如这两点都不满足就Full GC,满足一点的话就Minor GC。

注意:JDK7及以后这个参数就失效了。成为了默认开启。
只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行MinorGC,否则FullGC。

为什么要取历史平均大小?

因为一共有多少对象会在这次回收中活下来在实际完成内存回收之 前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与 老年代的剩余空间进行比较。

取历史平均大小有什么坏处?

取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC存活后的对 象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实 地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下 都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。

六、本章小结

虚拟机之所以提供 多种不同的收集器以及大量的调节参数,就是因为只有根据实际应用需求、实现方式选择最优的收集 方式才能获取最好的性能。
如果要到实践调优阶段,必须了解每个具体收集器 的行为、优势劣势、调节参数。

6.1、必须要懂的

新生代分为一块较大的Eden空间和两块较小的 Survivor空间,其中两块小的 Survivor空间是用到了标记复制法,来回替换用的,也就是只有一块Survivor空间是专门存放GC过后的对象。

6.2、内存分配运行流程

在这里插入图片描述

晋升老年代的条件:

  1. 在jdk1.8下对象超过3MB,并且新生代空间不足。
  2. 设置了XX:MaxTenuringThreshold年龄参数,经过几次新生代GC仍然存活的对象,并且存活次数大于XX:MaxTenuringThreshold设置的参数,再下次GC的时候会存放到老年代。

猜你喜欢

转载自blog.csdn.net/weixin_43888891/article/details/124309197