阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

 ̄綄美尐妖づ 提交于 2020-07-24 00:39:58

内存分配机制

阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

逐步分析

类加载检查:

虚拟机遇到一条new指令(new关键字、对象的克隆、对象的序列化等)时,会先去检查这个指令的参数在常量池中定位到一个类的符号引用,并且这个符号引用代表的类是否应被加载过,如果没有那么就去加载该类

分配内存

类加载完毕后会给对象分配内存空间。对象的所需的内存大小在类加载完毕后就便可完全确认,为对象分配内存大小的空间等同于把一块确定大小的内存从java堆中划分出来。

 如何划分内存?

指针碰撞(默认使用指针碰撞):如果java堆内存是绝对规整的,那么会把所有用过的内存放在一边,空闲的内存放在另外一边,中间用一个指针来作为分界点的指示器,那所分配的内存仅仅把那个指针空闲空间的挪动一段与对象大小相同的距离。
空闲列表:如果java堆内存不是绝对规整的,已使用的空间和未使用的空间互相交错,那么虚拟机维护一份列表,记录哪些内存块是可用的,在划分内存空间的时候从列表中找到一块足够大的内存空间分配给对象实例,并更新列表上的记录。
分配内存遇到高并发的问题?现在有多个线程同时并发需要进行内存分配 

CAS  :虚拟机采用失败重试的机制方式保证操作的原子性对分配内存空间的动作进行同步处理,第一个线程抢占到了分配空间,第二个线程没有抢占到就重试抢占后面一块内存空间
本地线程分配缓冲:把内存分配的动作按照线程分配在不同的空间之中完成,也就是每个线程在java堆中预先分配出一块小的内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用(JVM默认开启-XX:+UseTLAB) ,-XX:TLABSize指定TLAB大小,默认是Eden区的百分之1,放不下就走CAS
 

初始化

内存分配完毕后,给变量赋默认值,如果使用了TLAB,那么这个过程也可以提前至TLAB分配时进行

4.设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息都存放在对象的对象头Object Header中。

在HosSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头,实例数据,对齐填充。

1.HotSpot虚拟机的对象头包括三部分信息:Mark Word、Klass Pointer类型指针、数组长度

Mark Word标记字段(32位 4字节 ,64位占8字节)用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
对象头的另外一部分是类型指针(Klass Point 开启压缩占4字节,关闭压缩占8字节),并不是Class ,我们使用的对象的getClass方法的那个Class对象是在堆内存而这个是类的元数据信息 。即对象指向它类的元数据的指针,元数据信息是放在方法区之中,虚拟机通过这个指针来确定这个对象是那个类的实例,类的元数据信息是放在C++的对象来承载的
 
阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧


实例数据

对象的实例数据就是该对象的引用大小

对齐填充

保证对象是8个字节的整数倍。64位的机器每一行都是64位,如果现在8个字节直接取一行,那如果不是对齐,还要评估这个对象的大小,还要从这个对象大小的起始位置开始偏移,这样非常的麻烦,8个字节对齐是最优的寻址方式.

什么是java对象的 指针压缩?

jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
jvm配置参数:启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­UseCompressedOops
为什么要进行指针压缩?

1.在64位平台的HotSpot中使用32位指针,内存使用会出多1.5倍左右,同时GC也会承受较大压力

2.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对象指针存入堆内存时压缩的编码然后在取出到CPU寄存器后解码进行优化(对象指针在堆内存中是32位,在寄存器是35位,2的35次是32G),使得JVM使用32位地址就可以支持更大的内存配置

如果压缩了用4个字节没有压缩用8个字节,节约内存空间。多一个Object header,实际没开指针压缩是通过两块一起来存储Klass Point,成员对象String类型也用8个字节来存储 ,成员对象Object也需要8个字节 。那么我们每个对象都有对象头,指针压缩可以减少我们每个对象的大小,同样的内存大小可以放更多的对象才会触发GC
阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧
阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

执行<init>方法 

成员变量的赋值以及构造方法的调用

 

对象内存分配

对象内存分配流程图

阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

对象栈上分配

JVM内存分配可以知道JAVA中的对象都是堆上进行分配,当对象没有被引用的时候,需要一开GC进行回收内存,如果对象数量较多的时候,会给GC带来较大的压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析来确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样对象所占用的内存空间就可以随着栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中

public class AllotOnStack {
    /**
     * Description : 栈上分配,标量替换
     * 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
     * 使用如下参数不会发生GC
     * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
     * 使用如下参数都会发生大量GC
     * -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
     * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
     **/
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        System.out.println(start);
        for (int i = 0; i < 100000000; i++) {
            allot();
        }
        long end = System.currentTimeMillis();
        System.out.println(end);
        System.out.println(end - start);
    }
    private static void allot() {
        AllotOnBO allotOnBO = new AllotOnBO();
        allotOnBO.setA("123");
    }
}

class AllotOnBO {
    public void setA(String a) {
        this.a = a;
    }
    private String a;
}

 很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结 束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内 存一起被回收掉。

JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优 先分配在栈上(栈帧上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis) 

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该 对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就 不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认 开启。

标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(比如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,也就是聚合量。而JAVA中的对象就是可以被进一步分解的聚合量。

结论:栈上分配的依赖于逃逸分析和标量替换,如果不开变量替换意义不大

 

Minor GC 和Full GC 有什么区别?

MinorGC / Young GC:指的是新生代的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。
MajorGC / Full GC:一般指老年代,年轻代,方法区的垃圾回收,Major GC 的速度一遍比 Minor GC的慢10倍以上
Eden与Survivor区默认8:1

大量的对象被分配在Eden区,Eden区满了之后出发minor GC ,可能百分之99的对象都被当成垃圾回收掉,存活(标记)对象会被移动到Survivor,下一次当Eden区又满了之后会触发Minor GC把Eden区和Survivor区的存活对象移动到另一块Survivor区.每移动一次年龄加1,一直达到年龄15的时候会把移动到老年代。新生代的对象都是朝生夕死的,所以为了减少Minor GC的频率 尽量让Eden区尽量大 ,Survivor区够用即可。JVM默认比例已经很合适了

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

public class MinorGc {
    // -XX:+PrintGCDetails
    public static void main(String[] args) {
        byte[] allocation1, allocation2;
        allocation1 = new byte[60000 * 1024];
        allocation2 = new byte[20000 * 1024];
    }
}

[GC (Allocation Failure) [PSYoungGen: 65245K->776K(76288K)] 65245K->60784K(251392K), 0.0333426 secs] [Times: user=0.05 sys=0.03, real=0.05 secs] 
Heap
 PSYoungGen      total 76288K, used 21431K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)
  eden space 65536K, 31% used [0x000000076b400000,0x000000076c82bef8,0x000000076f400000)
  from space 10752K, 7% used [0x000000076f400000,0x000000076f4c2020,0x000000076fe80000)
  to   space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)
 ParOldGen       total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)
 Metaspace       used 3487K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

分配了allocation1对象 这个时候Eden区几乎已经满了,下一步指令又要分配20M对象 ,Eden区已经不够给allocation2 分配内存空间了虚拟机触发Minor GC ,GC期间虚拟机发现结果From区只有10M放不下 ,所以只好把新生代对象提前存放到老年代,老年代的空间足够存放allocation1,剩下的对象JVM自身的一些类 比如:Object,加载器被移动到了From区。Minor GC完之后Eden区给 allocation2 对象分配内存

大对象直接进入老年代

大对象就是需要大量连续空间内存空间的对象比如:字符串,数组。JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在Serial和ParNew两个收集器下有效

比如设置JVM参数-XX:+PrintGCDetails -XX:PretenureSizeThreshold=1000000(字节) -XX:+UseSerialGC ,在执行刚刚的代码这个时候发现大对象直接进入老年代

为什么这样设计?

为了避免为大对象分配内存时的复制操作而降低效率

Heap
 def new generation   total 78656K, used 6995K [0x00000006c1c00000, 0x00000006c7150000, 0x0000000716800000)
  eden space 69952K,  10% used [0x00000006c1c00000, 0x00000006c22d4ed0, 0x00000006c6050000)
  from space 8704K,   0% used [0x00000006c6050000, 0x00000006c6050000, 0x00000006c68d0000)
  to   space 8704K,   0% used [0x00000006c68d0000, 0x00000006c68d0000, 0x00000006c7150000)
 tenured generation   total 174784K, used 80000K [0x0000000716800000, 0x00000007212b0000, 0x00000007c0000000)
   the space 174784K,  45% used [0x0000000716800000, 0x000000071b620020, 0x000000071b620200, 0x00000007212b0000)
 Metaspace       used 3485K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

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

JVM采用了分代年龄收集的思想,那么回收这个对象的时候就需要考虑放在Survivor还是老年代,考虑的依据虚拟机会为每个对象分配一个年龄计算器,如果对象在Eden区经过一次Monir GC后存活下的对象,移动到Survivor后年龄+1,之后的Minor GC每存活一次年龄再次+1 一直加到15(CMS默认是6,不同的垃圾收集器略不同),就会被移动到老年代。可以通过参数-XX:MaxTenuringThreshold来设置

对象动态年龄判断

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。那么年龄大于或等于这批的对象将直接挪到老年代,例如:年龄1+年龄2+年龄3+年龄4+年龄N的对象 其中年龄1,2,3的对象总和超过了Survivor区域的百分之50(-XX:TargetSurvivorRatio可以指定),那么就会将3和3以上的对象都进入老年代。

老年代分配担保机制(触发Full GC)

年轻代每次Minor GC之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了

如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。

如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC ,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生OOM

当然,如果Minor GC之后剩余存活的需要挪到老年代的对象还是大于老年代可用空间,那么也会触发Full GC ,Full GC完之后如果还是没有空间放Minor GC之后的存活对象,则会发生OOM

老年代分配担保机制担保的就是存在Full GC的情况下 减少一次Minor GC ,如果没有担保那么就是Minor GC->Full GC

阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

对象内存回收

引用计数器

给对象中添加一个引用计数器,每当一个地方引用它,计数器就加;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最重要原因它很难解决对象之间互相循环引用的问题。所谓对象之间的互相引用问题,如下面代码所示:除了对象ObjA和ObjB互相引用着对方之外,这两个对象之间再无任何引用。但是因为他们互相引用对方,导致他们的引用计数器都不为0,于是引用计数算法无法通知GC回收他们

public class ReferenceCountingGc {
    Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}

阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

可达性分析算法

将GCRoots对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots根节点: 线程栈的本地变量,静态变量,本地方法栈的变量等等

阿里面试官:小伙子,你给我说一下JVM对象创建与内存分配机制吧

finalize()方法最终判断对象是否存活

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这个时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程

标记的前提是对象在进行可达性分析法后发现没有与GC Roots相连接的引用链

1.第一次标记并进行一次筛选

筛选的条件就是该对象是否覆盖了finalize()方法,没有覆盖直接回收

2.第二次标记

如果这个对象覆盖了finalize()方法,只要重新与引用链上任何一个对象关联即可,比如把自己赋值给某个类的变量或对象的成员变量,那么第二次标记的时候它将移除“即将回收”的集合。

注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次

如何判断一个类是无用的类?

方法区主要回收的是无用的类,如何判断一个类是无用的类

类需要满足下面三个条件才能算是无用的类

该类的所有实例都被回收,Java堆中没有存在该类是任何实例
加载该类的ClassLoader被回收(只有自定义的类加载器才能被回收)
该类的对应的Java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!