Java-对象的创建和分配

一、虚拟机中对象的创建过程

虚拟机中对象的创建过程.png

在这个图中,蓝色部分是在JVM遇到一个字节码new指令时进行的。
类加载步骤,就是将对应的.class文件加载到内存中。
而分配内存的步骤,包含了为对象分配内存和并发安全问题。

而对象的创建过程,一般有以下六个步骤:

(1)判断对象对应的类是否加载、连接和初始化
首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被类加载器加载、链接和初始化

(2)为对象分配内存
类加载完成后,会在java堆中划分一块内存分配给对象。内存分配根据java堆是否规整,有两种方法:
(1)指针碰撞:即在分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离。指针碰撞只能在内存空间规整的使用
(2)空闲列表:不规整,则由虚拟机维护一个空闲列表来记录可用内存,然后查找足够大的内存分配给对象,更新空闲列表
使用什么方式进行分配内存,是由内存的规整度决定的,而内存的规整度是由垃圾回收器决定的,是否带整理。比如,带整理的,可以使用指针碰撞的方式,这样的方式速度比较快,不需要另外维护一张表。

在JVM中,一个对象占据的内存一定是连续的。

(3)处理并发安全问题
有两种方式:
(1)对分配内存空间的动作进行通不处理,不如在虚拟机采用CAS算法并配上失败重试的方式保证更新操作的原子性
(2)每个线程在java堆中预先分配一小块内存(一般在eden区),这块内存称为本地线程分配缓存,线程需要分配内存时,就在对应线程的TLAB(本地线程分配缓冲)上分配内存,当线程中的TLAB用完并且被分配到了新的TLAB时,这时候才需要同步锁定。通过-XX:+/-UserTLAB参数来设置虚拟机是否使用TLAB。就是实现划分好一块内存区域给对象,一般是一小块内存占百分之一,如果这块内存太小,则从新划一块更大的给对应的对象。默认情况下TLAB是开启的

(4)初始化分配到的内存空间
除了对象头以外的都初始化为零值,不是构造方法。int类型的就是0,boolean就是false

(5)设置对象的对象头
将对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。

(6)执行init方法进行初始化
初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建出来。调用构造方法init。
这里的初始化与类加载过程中的第五步初始化是有点区别的,类加载过程中的第五步的初始化,是初始化比如静态代码块、静态属性和其他属性的赋值动作等等,会放在一个方法中,这个方法是在这个构造器之前执行的。

二、对象的内存布局

一个对象一般在内存中分为三块:对象头、实例数据、对齐填充(非必须)

1.对象头

对象头分为三个部分:Mark World,类型指针,array length(用于记录数组长度,非数组则没有这部分内容)

(1)Mark World模型
image.png

这里对对象头的长度进行说明:如果是32位的情况下,那么对象头的长度一般是两字宽,一字宽是4字节32位,两字宽就是64位,这里的两字宽组成,其实是Mark World长度为一字宽,类型指针的长度为一字宽,如果是数组的话,其对象头的长度则还需要再加一字宽,即对象头变成三字宽12字节96位;如果是64位的虚拟机,则Mark World的长度就是两字宽8字节64位,那么对象头的最大长度就是192位。

(2)类型指针

即指向该对象所属类元数据的指针,虚拟机通常通过这个指针来确定该对象所属的类型

(3)array length

如果对象是一个数组,在对象头中还应该有一个记录数组长度的数据,因为JVM可以通过对象的元数据确定对象的大小,但是不能通过对象的元数据确定对象的长度

2.实例数据

实例数据存储的是真正的有效数据,即各个字段的值。无论是子类中定义的,还是从父类中继承下来的都需要记录。这部分数据的存储顺序受到虚拟机的分配策略以及字段在类中定义顺序的影响。

3.对齐补充

这部分数据不是必然存在的,因为对象的大小总是8字节的整数倍,该数据仅用于补齐实例数据部分不足整数倍的部分,充当占位符的作用。
hotspot管理的对象,对对象的大小是有要求的,所以hotspot中对象对应的大小必须是8字节的一个整数倍,如果实例数据刚好是8字节的整数倍,则不需要对齐补充。

三、对象的访问定位

1.通过句柄的方式访问

通过句柄的方式访问.png

通过句柄访问的实现方式中,JVM堆中会专门有一块区域用来作为句柄池,存储相关句柄所执行的实例数据地址(包括在堆中的地址和在方法区中的地址)。这种实现方式由于用句柄表示地址,因此十分稳定。这样的方式,需要 通过二次查找,会多一次指针定位的开销,一次开销虽然小,但是JVM中会创建大量的对象,所以累积开销就比较大。但是这样做的好处就是当对象实例数据发生改变时,并不需要改变栈中的指针指向,只需要改变句柄的指针指向即可。

2.直接访问方式访问

直接访问方式定位.png

通过直接访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息包含了到方法区中的响应的对象类型数据的指针。这种方式的最大优势就是查询速度快,在hotspot中使用的就是这种方式。
直接访问的方式,其实就是栈中有一个指针指向Java堆中的对象实例数据,而Java堆中的对象实例数据中又有一个指针指向了方法区中的对象类型数据。
现在我们一般理解的JVM堆栈其实就是这样的。

对象类型数据,其实就是对象对应的类的Class信息。

四、对象的存活判断

1.引用计数法

每个对象都有一个引用计数器,被引用则加1,引用失效则减1。当引用计数器的值变为0时,说明该对象没有被引用,被定义为垃圾对象。因为引用计数法没有解决对象之间相互循环引用的问题,在这样的情况下,会出现两个对象相互引用,但是外部其他对象并没有引用这两个对象,会让虚拟机认为这两个对象仍然是存活的,而不会被回收

2.可达性算法

可达性分析:如果一些对象通过等号等方式与GC的对象是可达的,那么这些对象就不会被回收。而如果与GC ROOTS对象是不可达的,那么就是可以被回收。
方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。

可达性分析算法.png

可以作为GC Roots的对象一般包括以下几种:

(1)虚拟机栈(栈帧中的本地变量表)中引用的对象(线程栈变量即局部变量表中的变量)
(2)方法区中类静态属性引用的对象(静态变量)
(3)方法区中常量引用的对象(常量池:final常量)
(4)本地方法中JNI(一般说的native方法)引用的对象(JNI指针)

五、对象分配策略

对象内存分配.png

几乎所有的对象都是在内存中分配的,但是也有例外。有些对象也可以在栈上分配。虚拟机经过逃逸分析之后,如果一个对象永远也不会发生逃逸,且不是大对象,那么该对象也是可以在栈上分配的,比如方法中创建的局部变量,该对象的定义和初始化都在方法内,并且该对象不会外部线程和其他线程都无法知道,所以是无法发生逃逸的,则可以分配在栈上。逃逸分析,一般就是不会方法逃逸,也不会线程逃逸,这样的可以分配在栈上。
比如:

/**
 * 满足逃逸分析,创建的MyObject对象并没有方法逃逸,也没有线程逃逸,所以在栈上分配对象
 * 这样不会进行垃圾回收,所以逃逸分析可以提高性能,减少GC
 */
public class EscapeAnalysisTest {

    public static void main(String[] args) {
        for (int i = 0; i<5000000;i++) {
            allocate();
        }
    }

    public static void allocate() {
        MyObject object = new MyObject();
    }
}

class MyObject{}
栈中分配(需要经过逃逸分析之后)

在栈中分配对象,其实就是经过逃逸分析之后,如果一个对象没有发生逃逸,即在对象在子线程中被分配,并且指向该对象的指针永远不会发生逃逸(即只有创建线程可以看见该对象),那么经过逃逸分析之后,可以将对象分配在栈中,而不是分配在堆中,减少了垃圾回收。
但是如果在方法中直接声明了基本数据类型的对象,那么就会在栈上直接分配。而其他引用对象在栈上分配,则需要经过逃逸分析之后才可以。

1.对象分配策略

(1)优先在Eden区分配
(2)空间分配担保

垃圾回收回收新生代,采用minor GC,而回收老年代一般采用full GC。悲观安全的做法:每一次晋级到老年代都进程一次的major GC或者full GC。JVM提出了一个空间分配担保,找JVM做担保,如果担保失败,再进行一次major或者full GC。
即:在GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果小于则担保失败,如果大于则担保成功,则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,比如之前晋升了5次,平均大小为5M,则这个时候老年代的最大可用连续空间就需要大于5M,在小于的时候,就会通过full GC来让老年代腾出更多的空间,如果大于的话,则通minor GC。通过老年代的担保,是为了避免在大量对象存活的情况下,交换空间无法容纳的对象可以直接进入老年代。

(3)大对象直接进入老年代

老年代与新生代的空间大小比例为2:1
为什么分成Eden、from、to,比例为8:1:1
大数据分析:90%的对象在第一次GC回收时就会被回收掉。而10%会进入交换区,所以一般来说,浪费的空间其实就是10%,利用率有90%

(4)长期存活的对象进入老年代

Eden区的对象,其实是没有年龄的,而From和To区中的对象才有年龄,而年龄则会保存在对象头中的MarkWorld中。当对象进入老年代之后,也没有年龄了。
长期存活的对象,即GC之后年龄达到15的,就会进入老年代。这里的年龄其实就是指的经过GC的次数,坚持15次GC之后依然存活的默认是长期存活的。

(5)动态对象年龄判定

From区和To区合称为survivor。如果交换空间中相同年龄的所有对象所占空间的大小总和大于交换空间的一半,则年龄大于或者等于该年龄的对象就可以直接进入老年代。不需要等到年龄为15的时候

2.每个区域采用的算法

Eden区是采用标记清除复制算法,复制是将存活对象从Eden区复制到Survivor的To空间。交换区也是采用标记清除复制算法,而老年代一般也是采用标记清除算法。除非是Full gc的时候,偶尔会整理。

逃逸分析

你可能感兴趣的:(Java-对象的创建和分配)