Java new 背着我们干了什么

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Insert_day/article/details/70849094
new 是 Java 最常用的关键字之一,我们使用 new 创建对象。
我们经常像下面代码一样创建一个对象:

Dog dog = new Dog();  

只需一行代码,我们就能创建出一只狗,是不是很简单!为什么这么简单?
答:不简单就没人用。
Java 作为面向对象语言,提倡封装,如果自己的语法设计都做不到,那还怎么要求程序员们做到。
创建对象这么常用而且模板化的操作,如果还要写上三四行代码,我想大家都不会乐意。

程序员们作为一个没有安全感的群体,经常会为各种事担忧,我们怕技术发展太快会把自己淘汰,我们怕被产品坑,我们更怕新来的领导居然是个产品经理,我们怕面对新人的问题支支吾吾,我们怕PHP居然成了最好的语言,我们怕......

好吧,以上都是我自己的担忧。但我要说的是,面对我们唯一可以完全信任的代码,决不允许它背着我干我不知道的事。要知道被自己最信任的人两肋插了两刀,感觉好像PHP真的...... 

这个 new 我早就看得不顺眼了,以前还真有人用这个东西把我问得哑口无言。那它在背后到底瞒着我们干了些什么呢?

用new这个关键字的话,是调用new指令创建一个对象,然后调用构造方法来初始化这个对象。反编译class的时,你会看到一个 Object obj=new Object(); 这种语句,会先调用new指令生成一个对象,然后调用dup来复制对象的引用,最后调用Object的构造方法。

这个过程可以细分为一下几个步骤:

1、找它爸,对象和类的一个实例,先有类后有对象,我们通俗把类看作是对象它爸,所以要先找它爸。这个过程就是类加载过程,虚拟机遇到一条new指令时,首先将去检查这个指令的参数(类)是否能在常量池中定位到一类的符号引用(符号引用是一个字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置,并且检查这个符号引用代表的类是否已被加载、 解析和初始化过。 如果没有,那必须先执行相应的类加载过程

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、 转换解析化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。完成了这个过程,就是找到了对象它爸。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)、 初始化(Initialization)、 使用(Using)和卸载(Unloading)7个阶段。 其中验证、 准备、 解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图所示。
 
加载阶段,虚拟机需要完成以下3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、 字节码验证、 符号引用验证。

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。 其次,这里所说的初始值“通常情况”下是数据类型的零值。

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 
直接引用(Direct References):直接引用可以是直接指向目标的指针、 相对偏移量或是一个能间接定位到目标的句柄。 

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。 到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

把它爸找到后,理想化环境下,我们认为它爸DNA完全遗传给它。所以,在类加载过程中所做的一切都可以认为是为创建对象做准备。对它爸做所的初始化,在创建它时就不用重复进行了。

2、选择出生地,我们知道很多国内准妈妈都想尽办法去美国生孩子,这样孩子就是美国公民了,看来出生地也是至关重要啊!我们对象的出生地虽然没有本质上的优劣,但是还是有一个选择的过程。这个选择的过程就是为对象分配内存。在什么地方分配内存也是必须考虑的问题。

类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配的方式有两种:

假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称“指针碰撞”(Bump the Pointer)

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。 

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 因此,在使用Serial、 ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

3、安全分娩,选好了出生地,它妈就安心养胎了,但时它能不能生下来还要经历最后一关 —— 分娩。这一步至关重要,要是不能确保安全,那就功亏一篑了。这里的安全问题就是线程安全问题。
 
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。 

解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理 —— 实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

4、开出生证明,医院会给新生宝宝开出生证明,写上重量、身长、出生时间、性别等信息。

这就相当于给对象的实例变量赋初始值,宝宝的重量和身长会在初始值得基础上不断变化,相当于是可变变量。这个初始值可能在以后的生活中不断变化,但是最开始是有一个值的。

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

5、上户口,出生以后会给孩子上户口,确定所属的籍贯、名字、家庭等信息。

在创建对象时也是一样,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、 如何才能找到类的元数据信息、 对象的哈希码、 对象的GC分代年龄等信息。 

这些信息存放在对象的象头(Object Header)之中。 根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。 关于对象头的具体内容。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。

所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

所以,new 背着我们生了个孩子 !!!

参考:《深入理解Java虚拟机 JVM高级特性与最佳实践》
阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页