Java虚拟机 之 内存分配

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/yichen97/article/details/95913654

概述

JVM的内存分配的原则有对象优先在Eden分配、大对象直接进入老年代、长期存活的对象进入老年代、空间分配担保、逃逸分析与栈上分配等。

下面就一一详细介绍。

对象优先在Eden分配

关于这项策略,我们首先要先清楚的:

Eden是属于新生代内存的一块区域。

JVM打印GC详细信息的

参数:

-verbose:gc -XX:+PrintGCDetails

选用特定的垃圾收集器

参数:

-XX:+UseSerialGC

为什么默认的垃圾回收器不是Serial呢?垃圾回收器选择的依据是什么?

原来,垃圾回收器是根据我们所处的环境来指定的。

如果jdk所处的环境作为一个Server服务来运行的话,那他默认指定的就是Parallel收集器;

如果所处的环境作为一个Client客户端,他所收集的内存比较小,所以一般使用Serial收集器。

如何查看我们当前是Server还是Client环境呢?

在命令行种输入命令:

java -version

我这里如下显示:

这就说明,我这的环境是64位的Server端。

为什么我这的会被默认成Server端?

原来它会检测本机,只要是大于2G,且CPU是多核环境,他就被默认位Server端。因为目前极少有电脑小于2G,或者CPU是单核的,所以目前所看的jdk版本都是Server版本。

现在我创建一个byte,让他分配4M的内存:

public class Main {

     public static void main(String[] args) {
        byte[] mByte = new byte[4 * 1024 *1024];
     }
}

虚拟机参数:

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC

打印如下:

从打印来看,目前eden区域现在有26240K,代码中使之分配的4M内存让该区域有23%的内存被使用。

这就证明了对象优先在eden区域分配。

再举一个例子:

先用参数将堆内存设置为20M不可扩容:

-Xms20M -Xmx20M

再用参数指定新生代内存10M:

-Xmn10M

此时,整个堆内存的区域有20M,堆内存中划分了新生代和老年代,其中,新生代有10M、老年代也有10M。新生代内存中有三块区域,分别是一块Eden区域和两块Survivor区域,

那么,如何制定Eden区域的内存大小呢?

可以通过一个参数:

-XX:SurvivorRatio=8

它的值就是eden区域所占的比例。现在总的新生代为10M,假如说SurvivorRatio的值为8,那么eden区域就是8M,两个Survivor区域都是1M。

现在我的参数是这样子:

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8

我要先分配三个2M的内存,然后再分配一个4M的内存。

public class Main {

     public static void main(String[] args) {
           byte[] b1 = new byte[2 * 1024 * 1024];
           byte[] b2 = new byte[2 * 1024 * 1024];
           byte[] b3 = new byte[2 * 1024 * 1024];
           byte[] b4 = new byte[4 * 1024 * 1024];

           System.gc();
     }
}

在我的打印中发现:

在eden区域中有8000k,占了一半多,也就是占了4M,恰好b4为4M;tenured区域有10M占了60%,也就是占了有6M,恰好b1+b2+b3为6M。

该分配方式似乎违背了内存优先分配到Eden区域。其实并没有违背,它只是内存放不开了,然后采取了空间分配担保策略。

在该问题中,Eden区域一共有8M,陆陆续续向里面占了3个2M的区域。

目前Eden区域还剩2M(8M - 2M -2M -2M),现在又来了一个4M的,这时候这块区域已经装不开了,需要垃圾回收,所以发生了一次Minor GC。这种GC是发生在新生代的垃圾收集,而新生代的对象具有“朝生夕死”的特点,所以这块区域的回收频率是很高的,也就经常发生GC。

这里的第一次GC新生代内存确实被回收掉了,但是对内存基本上是没有被回收的,因为这三个对象还被认为成存活的对象,所以没有被回收。

因为内存不够,所以需要向有内存的地方去借。先借Survivor区域,但是Survivor区域每个区域只有1M,这显然是不够的,但是tenured区域是足够的,于是向tenured区域去借,这就成了内存分配担保。

然后把三块2M的对象移动到tenured区域里来了,所以tenured区域就占了60%。

最后再把新的对象(4M)放到Eden区域里。

第二次Full GC是可以通过手动调用或者系统自动发起的,执行的频率较低,因为老年代的内存大多存活时间都比较长,所以Full GC发生的概率要小很多。

大对象直接进入老年代

对于大对象,多大算大对象?

它的默认值可能是根据制定的内存区域所计算出来的值。根据内存区域的不同,对大对象的判定也是不同的。

指定大对象的参数:

-XX:PretenureSizeThreshold=6M

该参数指定了大对象的默认值是6M,如果大于6M,对象则直接进入老年代中。

为什么大对象要直接分配到老年代中?

首先,大对象一般是大的字符串或者数组,它的存活时间比较长。假如说将它分配到Eden区域中,因为在新生代内存中,垃圾回收算法一般采用复制算法,再加上Eden区域执行GC的频率是比较高的,那么每次执行GC,都需要移动这个大对象,所以效率是很低的。

所以将大对象直接放到老年代中,老年代的垃圾回收次数是非常低的,所以在性能方面是有很大提升的。

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

因为JVM为分代思想,什么时候应该在新生代,什么时候在老年代,JVM是怎么做到的呢?

虚拟机给每个对象都定义了一个年龄Age计数起器,当一个对象从Eden区域出生到第一次GC后,仍然存活且被移动到Survivor区域,则对象年龄设为1,然后该对象在Survivor中每度过一次GC,年龄就加一岁,当它的年龄加到一定程度,就会被晋升到老年代中。

参数:设置对象晋升老年代的值(设置对象晋升老年代的年龄为15)

-XX:MaxTenuringThreshold=15

但并不是要求对象必须达到 MaxTenuringThreshold 参数所设定的值才能晋升老年代,如果再Survivor区域中的相同年龄对象的总和大于Survivor空间的一半,则年龄大于或者等于该年龄的对象就可以直接进入老年代,就不用等到参数所设定的值了。

空间分配担保

空间分配担保就是在进行内存分配的过程中,如果内存不足,可能向老年代去借用内存。

步骤:

1. JVM先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。若满足,则minor GC确保是安全的。

2. 若 1 不成立,则JVM会查看参数 

HandlePromotionFailure

设置的值是否允许担保失败。

3. 若在 2 中设置为允许担保失败,JVM则会去检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。

若大于,则会去尝试进行一次Minor GC。

4. 若 3 小于历次晋升到老年代对象的平均大小、或者 2 参数设置为不允许担保失败,则进行一次 Full GC。

担保时的风险

因为新生代使用的是复制算法,为了提高利用率,只能使用其中一个Survivor来作为轮换备份,因此出现大量对象在Minor GC后仍存活,需要把Survivor无法容纳的对象直接进入老年代。

在实际的垃圾回收之前,是无法知道本次垃圾回收中有多少对象会存活下来的。所以,只好取之前每一次回收晋升到老年代对象容量的平均值,与老年代的剩余空间做对比。

若某次的Minor GC存活后对象远大于平均值,则会导致担保失败,JVM只好在失败后再重新发起一次Full GC。

逃逸分析与栈上分配

在Java的堆上分配对象已经不是唯一的选择,它就会寻求额外的存储空间,在栈上分配内存就是其中之一。

栈上分配的好处

当一个方法执行的时候它需要创建一个栈帧,方法调用,栈帧进栈;方法执行完毕,栈帧出栈。这一块存储区域根据方法的执行与释放,从而不用使用垃圾回收器。从性能上来讲,是非常高的。

如何将对象分配到栈上?

需要逃逸分析手段。使用逃逸分析,筛选出未发生逃逸的对象,就可以把没有发生逃逸的对象在栈上进行分配。

什么是逃逸分析?

逃逸分析就是JVM在执行性能优化前的一种分析技术。

它的目标就是分析出对象的作用域,当一个对象被定义在方法体内部之后,它的受访问权限就只限于方法体内,一旦其外部成员引用后,这个对象就发生了逃逸。

如果这个对象只在该方法体内部有效,就可以认为:这个对象没有发生逃逸。如果没有发生逃逸,就可以把这个对象分配到栈上去。

public class StackAllocation {

    public StackAllocation obj;

    //方法返回StackAllocation对象,发生逃逸
    public StackAllocation getInstance() {
       return obj == null ? new StackAllocation():obj;
    }

    //为成员属性赋值,发生逃逸
    public void setObj() {
       this.obj = new StackAllocation();
    }

    //对象作用域仅在当前方法中有效,没有发生逃逸
    public void useStackAllocation() {
       StackAllocation stackAllocation = new StackAllocation();
    }

    //引用成员变量的值,发生逃逸
    public void useStackAllocation2() {
       StackAllocation stackAllocation = getInstance();
    }
}

在定义对象的时候,如果定义在方法体中,对象的作用域只在方法中,那么它不发生逃逸。只要不发生逃逸,对象的内存分配直接放到栈内存中去,随着方法的结束,栈内存就会被移出,内存也就被回收,那么性能和效率也是比较高的。

猜你喜欢

转载自blog.csdn.net/yichen97/article/details/95913654
今日推荐