new一个对象的背后,竟然有这么多可以说的

x33g5p2x  于2021-09-20 转载在 其他  
字(8.0k)|赞(0)|评价(0)|浏览(321)

一、前言

作为一名java开发工程师,每天要处理上千个对象,你居然说我没对象?

就算没有对象,那就new一个呗。

GirlFriend gf = new GirlFriend();

不会就这么容易吧?当然不会!

那么GirlFriend对象到底是怎么产生的呢?


二、类加载

当遇到new指令时,jvm首先去常量池寻找该类的符号引用,找不到,则执行类加载

以下是类加载各个阶段的主要任务,现在记不住也没有什么关系。

 

1. 装载

我觉得这里使用装载更好一点。第一,可以避免与类加载过程中的“加载”混淆;第二,装载体现的就是一个“装”字,仅仅是把货物从一个地方搬到另外一个地方而已,而这里的加载,却包含搬运货物、处理货物等一系列流程。

装载阶段,将.class字节码文件的二进制数据读入内存中,然后将这些数据翻译成类的元数据,元数据包括方法代码,变量名,方法名,访问权限与返回值,接着将元数据存入方法区。最后会在堆中创建一个Class对象,用来封装类在方法区中的数据结构,因此我们可以通过访问此Class对象,来间接访问方法区中的元数据。

在Java7与Java8之后,方法区有不同的实现,这部分详细内容可以参考我的另外一篇博客灵性一问——为什么用元空间替换永久代?

总结来讲,装载的子流程为:

.class文件读入内存——>元数据放进方法区——>Class对象放进堆中

最后我们访问此Class对象,即可获取该类在方法区中的结构。

2. 连接

连接又包括验证、准备、初始化

2.1 验证

验证被加载类的正确性与安全性,看class文件是否正确,是否对会对虚拟机造成安全问题等,主要去验证文件格式与符号引用等。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么就没有必要去验证,毕竟验证需要花费一定的的时间,可以使用**-Xverfity:none**来关闭大部分的验证。

2.2 准备

在这个阶段中,主要是为类变量(静态变量)分配内存以及初始化默认值,因为静态变量全局只有一份,是跟着类走的,因此分配内存其实是在方法区上分配。

这里有3个注意点:

(1)在准备阶段,虚拟机只为静态变量分配内存,实例变量要等到初始化阶段才开始分配内存。这个时候还没有实例化该类,因此还不存在实例变量。

(2)为静态变量初始化默认值,注意,是初始化对应数据类型的默认值,不是自定义的值

(3)被final修饰的静态变量,如果值比较小,则在编译后直接内嵌到字节码中。如果值比较大,也是在编译后直接放入常量池中。因此,准备阶段结束后,final类型的静态变量已经有了用户自定义的值,而不是默认值。

2.3 解析

解析阶段,主要是将class文件中常量池中的符号引用转化为直接引用

符号引用的含义:

可以直接理解为是一个字符串,用这个字符串来表示一个目标。就像博主的名字是SunAlwaysOnline,这个SunAlwaysOnline字符串就是一个符号引用,代表博主,但是现在不能通过名字直接找到我本人。

直接引用的含义:

直接引用是一个指向目标的指针,能够通过直接引用定位到目标

将符号引用转化为直接引用,就能将平淡无奇的字符串转化为指向对象的指针。

3. 初始化

执行初始化,就是虚拟机执行类构造器<clinit>方法的过程,<clinit>方法是由编译器自动去搜集类中的所有类变量与静态语句块合并产生的。可能存在多个线程同时执行某个类的<clinit>()方法,虚拟机此时会对该方法进行加锁,保证只有一个线程能执行。

到了这个阶段,类变量与类成员变量才会被赋予用户自定义的值

更详细的类加载机制与常问的类初始化顺序,可以移步我的另外一篇文章类的奇幻漂流——类加载机制探秘


其中值得注意的一点是,类加载中的装载阶段,是利用双亲委派机制进行装载字节码的。

双亲委派机制

当一个类加载器收到了一个类加载请求时,它自己不会先去尝试加载这个类,而是把这个请求转交给父类加载器,每一个层的类加载器都是如此,因此所有的类加载请求都应该传递到最顶层的启动类加载器中。只有当父类加载器在自己的加载范围内没有搜寻到该类时,并向子类反馈自己无法加载后,子类加载器才会尝试自己去加载。

加载标准类库与用户代码,会有不同的方式:

因此,GirlFriend的字节码最终会由Application ClassLoader进行装载,返回Class对象。

这里有一个进阶的知识点,那就是对双亲委派的几次破坏,例如jdbc、tomcat与java9的模块系统,感兴趣的可以参考我的另外一篇文章深度思考:老生常谈的双亲委派机制,JDBC、Tomcat是怎么反其道而行之的?

有了Class对象,如果要创建实例对象,还需要在堆上开辟出一块空间来。


三、在堆上分配内存

在堆上分配内存前,首先得知道到底要分配多大的空间。

其实在类加载完成后,jvm就已经能计算好对象所占的字节数。

对象在内存中的布局,包括对象头、实例数据与对齐填充。

对象的内存布局

对象头

对象头中又包括Mark Word与Klass Word。当该对象是一个数组时,对象头还会增加一块区域,用来保存数组的长度。以64位系统为例,对象头存储内容如下图所示:

|---------------------------------------------------------------------------------------------------------------|
|                                              Object Header (128 bits)                                         |
|---------------------------------------------------------------------------------------------------------------|
|                        Mark Word (64 bits)                                     |      Klass Word (64 bits)    |       
|---------------------------------------------------------------------------------------------------------------|
|  unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:0 | lock:01 |     OOP to metadata object   |  无锁
|----------------------------------------------------------------------|---------|------------------------------|
|  thread:54 |         epoch:2      | unused:1 | age:4 | biased_lock:1 | lock:01 |     OOP to metadata object   |  偏向锁
|----------------------------------------------------------------------|---------|------------------------------|
|                     ptr_to_lock_record:62                            | lock:00 |     OOP to metadata object   |  轻量锁
|----------------------------------------------------------------------|---------|------------------------------|
|                     ptr_to_heavyweight_monitor:62                    | lock:10 |     OOP to metadata object   |  重量锁
|----------------------------------------------------------------------|---------|------------------------------|
|                                                                      | lock:11 |     OOP to metadata object   |    GC
|---------------------------------------------------------------------------------------------------------------|

Mark Word

主要存储hashcode、gc年龄、锁标志等。在32位系统上,Mark Word为32位,在64位系统上,为64位,即8个字节。

Klass Word

存储对象的类型指针,指向对象类元数据。虚拟机能够通过这个指针,来确定该对象到底是哪个类的实例。在32位系统上,该区域占用32位,在64位系统上,占用64位,但是!当64位机器设置最大堆内存为32G以下时,将会默认开启指针压缩,将8字节的指针压缩为4字节。当然也可以使用**+UseCompressedOops**直接开启指针压缩。

Array Length

前面说过,如果对象是一个数组,那么对象头会增加一个额外的区域,用来记录数组的长度。在32位系统上,该区域占用32位,在64位系统上,占用64位,同样的,如果开启指针压缩,则会压缩到32位。

可以看得出来,一个非数组的对象的对象头占用12个字节,即Mark Word(8)+Klass Word(4)

实例数据

基本数据类型占用的长度就不用多说了,对于引用类型占用的长度,同样视系统位数而定。32位系统占用4字节,64位系统8字节,开启指针压缩那就占用4字节。

实例数据部分只会存放对象的实例数据,并不会存放静态数据。

对齐填充

HotSpot虚拟机规定对象的起始地址必须是8的整数倍,也就是要求对象的大小必须是8的整数倍。因此如果一个对象的对象头+实例数据占用的总内存没有达到8的倍数时,会进行对齐填充,将总大小填充到最近的8的倍数上。

在一个64位的机器上,开启指针压缩。那么new Object()占用的字节数为16字节(Mark Word占用8字节+Klass Word被压缩后占用4个字节+对齐填充占用4个字节)

怎么去判断具有父类的子类对象大小,可以参考我的另外一篇文章对象的内存布局,怎样确定对象的大小

因此,下一步需要在堆上找到一块大小为16字节的空闲内存区域。

分配内存区域的机制

指针碰撞

前提是需要堆是规整的,已使用的内存占据堆的左侧,未使用的内存在右侧,中间使用一个指针作为分界线。需要分配内存空间时,就把指针往空闲的一侧移动即可。一般来说,标记复制和标记整理算法,可以使得堆是规整的。

空闲列表

如果当前堆不是规整的,例如垃圾收集器使用标记清除算法。此时jvm需要维护一个空闲区域的列表,分配内存时,直接去列表中寻找足够大的空闲区域即可。

内存分配并发安全

然而,对象的创建工作是很频繁的,为了保证效率,JVM可以并发地给对象分配内存空间。

由于分配内存的时候不是原子性的操作,至少需要以下几步:查找空闲列表、分配内存、修改空闲列表等等,这是不安全的。

解决并发时的安全问题也有两种策略:

CAS

CAS属于乐观锁,假设数据一般不会造成冲突,所以在拿数据的时候不会去加锁,但是会在更新的时候判断此期间内有没有别的线程修改过数据

虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。

了解更多关于CAS的知识,可以先移步我的另外一篇文章浅探CAS实现原理

TLAB

如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:

每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。 虚拟机是否使用TLAB,可以通过**-XX:+/-UseTLAB**参数来进行配置(jdk5及以后的版本默认是启用TLAB的)。
 

GC机制

当需要分配的内存大小没有超过**-XX:+PretenuerSizeThreshold**的值时,意味着会先在Eden区分配。

如果Eden区满了,会先判断老年代最大的可用连续空间是否大于新生代的所有对象总空间。

如果大于(意味着就算没有任何对象死亡的话,老年代也可以直接放得下),则直接进行一次Minor GC(只回收年轻代)。虚拟机会采用复制算法先进行释放内存,回收死亡对象,然后将存活的对象一次性复制进from区域中。

如果小于,并且没有开启担保的话,即老年代不愿意为Minor GC失败而担保,则进行FullGC(一般来说,只回收老年代)。

如果开启了担保,JVM会判断老年代的最大连续内存空间是否大于历次晋升平均值的大小。

如果大于,说明由以前的经验得来,老年代基本放得下,因此进行Minor GC。

如果小于,说明如果此时进行Minor GC,年轻代也不一定放得下,晋升到老年代时,老年代也不一定放得下,因此进行FullGC。

这里贴一张堆的图,可以更好地了解堆的内部区域及参数。关于更多堆区的知识,可以参考我的另外一篇文章说说java中的堆区

初始化实例变量默认值

内存分配完毕后,会将内存空间中的实例变量都初始化为默认值。

下一步就是对对象头进行必要的设置 ,例如该对象属于哪一个类,hashcode与GC年龄等信息。

赋予实例变量指定值

接着执行对象的init()方法,即先执行实例的代码块,再执行构造方法,根据传入的属性值给实例变量赋值。

最后一步,则是将堆中刚刚新建的对象实例的首地址赋值给栈中的对象引用。

到了这个阶段,我们才可以使用引用变量访问到对象实例。


四、new一个单例对象需要注意的

如下是一个双重检验锁(DCL)版本的单例模式

public class SingletonDCL {
    private volatile static SingletonDCL instance;
 
    private SingletonDCL() {
    }
 
    public static SingletonDCL getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
 
}

有几个疑问:

为什么要检验两次是否为null?

最初的想法,是直接利用synchronized将整个getInstance方法锁起来,但这样效率太低,考虑到实际代码更为复杂,我们应当缩小锁的范围

在单例模式下,要的就是一个单例,new SingletonDCL()只能被执行一次。因此,现在初步考虑成以下的这种方式:

    public static SingletonDCL getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                    //一些耗时的操作
                    instance = new SingletonDCL();
            }
        }
        return instance;
    }

但这样,存在一个问题。线程1与线程2同时判断instance为null,接着线程1拿到锁之后,创建了单例对象并释放锁。线程2拿到锁之后,又创建了单例对象。

此时线程1和线程2拿到了两个不同的对象,违背了单例的原则。

因此,在获取锁之后,需要再进行一次null检验。

为什么使用volatile 修饰单例变量?

这段代码,instance = new SingletonDCL(),在虚拟机层面,其实分为了3个指令:

  • 为instance分配内存空间,相当于堆中开辟出来一段空间
  • 实例化instance,相当于在上一步开辟出来的空间上,放置实例化好的SingletonDCL对象,各项实例变量已经初始化好并且被赋予指定值
  • 将instance变量引用指向第一步开辟出来的空间的首地址

但由于虚拟机做出的某些优化,可能会导致指令重排序,由1->2->3变成1->3->2。这种重新排序在单线程下不会有任何问题,但出于多线程的情况下,可能会出现以下的问题:

线程1获取锁之后,执行到了instance = new SingletonDCL()阶段,此时,刚好由于虚拟机进行了指令重排序,先进行了第1步开辟内存空间,然后执行了第3步,instance指向空间首地址,第2步还没来得及执行,此时恰好有线程2执行getInstance方法,最外层判断instance不为null(instance已经指向了某一段地址,因此不为null),直接返回了单例对象,接着线程2在获取单例对象属性的时候,可能就会产生空指针错误!

因此这里需要用volatile 修饰单例变量,来避免指令重排序


五、对象一定是创建在堆上吗?

一般来说,基本数据类型与引用变量都是存放在虚拟机栈中的,确切一点地讲,是位于栈中的局部变量表里。关于虚拟机栈的知识,大家可以参考我的另外一篇文章虚拟机栈的五脏六腑

但是,为了jvm的运行效率,在编译期间,JIT会做很多优化,其中一个优化的技术叫做“逃逸分析”。

逃逸分析

简答来说,它会分析对象的作用域,用来决定是否将对象分配在堆区**。如果某个对象在a方法中创建,且其他任何方法都访问不到该对象,那么称该对象没有逃逸出a方法。**jdk1.7时,默认就开启了逃逸分析。

例如:

private void a() {
        GirlFriend gf = new GirlFriend();
        System.out.println(gf.age);
    }

无法在a方法的外部访问到gf对象(果然爱情都是自私的),说明gf对象是没有发生逃逸的。

如果某个对象没有发生逃逸,那么就没有必要分配到堆区,可以直接分配在栈上。现在需要解决的问题就是,如何去拆解这个对象?

标量替换

所谓标量替换,就是将聚合量拆解为标量。java实例对象是聚合量,基本数据类型是标量。

经过逃逸分析后,发现gf对象并没有发生逃逸。因此这里可以对gf对象进行标量替换。

如下,可以将上述代码优化为:

private void a() {
        int age = 18;
        System.out.println(age);
    }

拆解过后,就可以直接在栈上分配了

栈上分配

一般的实例对象被分配在堆区,当对象死亡后,需要使用GC机制进行垃圾回收。如果在栈上分配,就可以随着栈帧出栈而被销毁,在一定程度上减轻了GC压力。

另外值得一提的是,逃逸分析后,还可以实现对锁的消除

同步消除

当某对象没有逃逸出方法,对其同步的操作就可以消除掉。因为虚拟机栈本来就是线程私有的,没有逃逸出方法,就是没有逃逸出线程。

例如下面的代码:

优化前:

private void a(){
        GirlFriend gf = new GirlFriend();
        synchronized (gf){
            //不方便写的某些业务
        }
    }

优化后:

private void a() {
        GirlFriend gf = new GirlFriend();
        //不方便写的某些业务
    }

只有先开启了逃逸分析,才可以接着做标量替换、栈上分配与同步消除


六、总结

new一个对象的背后:

  1. 首先去常量池寻找该类的符号引用,找不到,则执行类加载
  2. 在堆上分配内存,分配机制有指针碰撞与空闲列表多线程下进行分配内存时,有cas失败重试与TLAB
  3. 将分配到的内存空间中的数据类型都 初始化为零值
  4. 对对象头进行设置 ,例如类元数据指针、hashcode与GC分代年龄等信息
  5. 调用对象的init()方法 ,即实例代码块与构造方法,根据传入的属性值给实例变量赋值
  6. 栈中新建对象引用 ,并指向堆中刚刚新建的对象实例

注意点:

  • new一个单例对象时,DCL的模式需要加上volatie,禁止指令重排序。
  • 对象不一定非得在堆区分配,经过JIT逃逸分析后,如果能进行标量替换,就有可能在栈上分配。

相关文章