创建对象的五种方式

JVM内存分配机制 - 图4

对象的创建

  1. ![image.png](https://cdn.nlark.com/yuque/0/2022/png/25761560/1641906690776-5bd2d9d6-ffc6-44d5-b5d3-58a1285107c9.png#clientId=u0a5d4cd6-e718-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=321&id=u09d8fd7d&margin=%5Bobject%20Object%5D&name=image.png&originHeight=642&originWidth=393&originalType=binary&ratio=1&rotation=0&showTitle=false&size=30101&status=done&style=none&taskId=ueee71f8e-aa4a-40de-8c51-64e159f129b&title=&width=196.5)

类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个 符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
new指令对应到语言层面上讲是,new关键字、对象克隆、对象序列化等。

分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来
划分内存的方法:
指针碰撞(Bump the Pointer,默认): 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
image.png
空闲列表(Free List): 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
image.png
注:在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况,解决办法:
CAS(compare and swap) 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB) 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过­XX:+/­ UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+UseTLAB),­XX:TLABSize指定TLAB大小。

初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

image.png
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头ObjectHeader之中。在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(InstanceData)和对齐填充(Padding)。HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,数组还有数组长度。
image.png
企业微信截图_16419545914958(1).png
由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
填充了可以每次直接取8字节,而不用去计算每次该取多少字节,这样效率会比较高。

执行方法

执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

对象内存分配 image.png

栈上分配

JVM通过逃逸分析,发现对象的作用域就在某个方法内,不会被外部访问,就把对象通过标量替换之后存储在栈上。
逃逸分析:就是分析对象的作用域,是否能够在一个方法范围内;
标量替换:通过分析确定对象不会逃逸之后,JVM不会创建该对象,而是用该对象的成员变量等价替代该对象。 image.png
注:栈上分配依赖逃逸分析和标量替换。

对象在Eden区分配

大多数情况下,对象在新生代中Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活 的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认Eden与Survivor区8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在Serial和ParNew两个收集器下有效
为什么要这样呢?
为了避免为大对象分配内存时的复制操作而降低效率。

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

对象第一次进入survivor区域,年龄是1岁,然后每经过一次minorGC,年龄增加1岁。当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。
对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。
对象动态年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制 image.png

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小
如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生”OOM” 。
当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”。

对象内存回收

当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡(引用计数法,可达性分析)

标记阶段的算法-引用计数法

对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器加1;当引用失效时,引用计数器减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:需要单独的字段存储计数器 ——-增加了存储空间的开销
每次赋值都要更新计数器,伴随着加法和减法操作 ——-增加了时间开销
无法处理循环引用的情况 ——导致java没有使用此算法
image.png

标记阶段的算法-可达性分析法(java,C#)

解决了在引用计数算法中循环引用的问题,防止内存泄漏的发生。
GC Roots对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的 对象都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

image.png

常见的引用类型

java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用,四种引用强度依次逐渐减弱。

强引用

无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(不回收)

User user = new User();

软引用

在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没足够的内存,才会抛出OOM。(内存不足即回收)

SoftReference<User> user = new SoftReference<User>(new User());

弱引用

被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收,很少用)

WeakReference<User> user = new WeakReference<User>(new User());

虚引用

能在这个对象被收集器回收时收到一个系统通知,也叫幽灵引用或者幻影引用。(跟踪,几乎不用)

对象的finalization机制(对象终止机制)

允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。(允许被重写)
判定一个对象obj是否可回收,至少要经历两次标记过程:
①如果对象obj到GC Roots没有引用链,则进行第一次标记;进行筛选,判断此对象是否有必要执行finalize()方法(即当前对象是否重写了finalize()方法)。
②如果这个对象重写了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态:
一个无法触及(可达)的对象有可能在某一个条件下“复活”自己。
可触及的:从根节点开始,可以到达这个对象。
可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及对象不可能被复活,因为finalize()只会被调用一次。

注:永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。

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

1.该类所有的对象实例都已经被回收,也就是java堆中不存在该类的任何实例
2.加载该类的ClassLoader已经被回收
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

问题一:讲下对象创建的流程

  1. 类加载检查
    检查new指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
    2. 分配内存
    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
    3. 初始化零值
    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
    4. 设置对象头
    初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
    5. 执行init方法
    为属性赋程序员设置的值,并执行构造方法

    问题二:解释下对象栈上分配

    我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。通过分析确定对象不会逃逸之后,JVM不会创建该对象,而是在栈上分配内存,用该对象的成员变量等价替代该对象(标量替换)。这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
    对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
    标量替换:通过分析确定对象不会逃逸之后,JVM不会创建该对象,而是用该对象的成员变量等价替代该对象。