在上一篇文章中,我们花了较大的篇幅去介绍了JVM的运行时数据区,并且重点介绍了栈区的结构及作用,在本文中,我们将主要介绍对象的创建过程及在堆中的分配方式。
对象的创建
在上文我们提过一些问题,你的对象是怎么new出来的?new出来又放在哪里?怎么引用的? 老规矩,我们还是通过字节码来了解一下。
public static void main (String[] args){People p = new People();
}
这样的代码大家一点也不会陌生,我们都知道使用new关键字可以创建一个对象,咦!一看字节码才知道,我们的一行new的代码,对应的字节码原来要做这么多操作!我们逐一来分析一下。
一、new指令
1、检查、加载相应类
JVM遇到new指令时,先检查指令参数(上面字节码中的#2)是否能在常量池中定位到一个类的符号引用(上面最终定位到常量池中的com/test/entity/People):
- 如果能定位到,检查这个符号引用代表的类是否已被加载、解析和初始化过;
- 如果不能定位到,或没有检查到,就先执行相应的类加载过程;
具体类的加载、解析、初始化的过程大家可以去查找JVM类加载机制相关资料,这里就不展开啦!我们需要知道的是这一步保证了在方法区中,存在要创建实例对象的类对象!
2、空间分配
咱们到了适婚年龄,也就该找个对象了吧!你看上了一个姑娘,长得楚楚动人,就跑去跟他妈说:“我要一个对象,把你女儿嫁给我吧!”。她妈妈倒是十分爽快:“好啊,我女儿总得有个地方住吧,小伙子你有房吗?”。这时候场面一度十分尴尬,心里嘀咕着“要是国家能分配房子就好了!”。这在当前社会显然不现实,毕竟咱们还没进入共产主义社会!然而在JVM王国里,对象住的“房子”却是“国家”统一分配的。国家集中圈了一大块“地”,谁家要娶“媳妇”,就给他家分配一块“地”,“媳妇”胖点呢,地就大一点,“媳妇”瘦一点呢,“地”就小一点。在这里,你一个人可以同时拥有多个对象,在这里,多个人可以拥有同一个对象。所以这里的老百姓安居乐业、这里一片祥和……当然,由于这块“地”大小有限,而你又同时拥有很多对象,还有其他人也要娶对象,所以那些不用了的对象的“地”国家就会进行统一征收(当然这里不会给补贴,毕竟是免费分配的~)以继续分给其他人用。
上面扯了这么多,相信你已经知道“你”就代表着一个线程,“国家”指的是JVM,“国家”圈的一块“地”就是堆空间,你娶的“对象”就是实例对象,“国家”分配地的动作就是内存分配,而国家征收的动作就是垃圾回收。
由于要找对象的人太多了,所以分配的操作也很频繁,那么摆在“国家”的问题就来了:怎么合理分配?怎么最大限度的提高空间利用率?怎么提高分配效率?不用了的空间怎么回收?怎么知道哪些空间不用了?上面很多问题都需要结合后面的垃圾回收相关的内容来讨论,这里只讨论分配内存的方式。
一个对象需要占用多大的内存?这个问题其实在类加载完成后就已经确定啦!JVM可以通过普通java对象的类元信息确定对象大小。为对象分配内存相当与把一块确定大小的内存从java堆中划分出来。那么问题来了,这么大的一块堆空间摆在JVM的面前,JVM该划哪一块空间来分配内存呢?随机找一块空间分配算了?or紧挨着之前分配的空间后面进行分配?这里需要说到的是两种分配方式:
1)、 指针碰撞
如果Java堆是绝对规整的:一边是用过的内存,一边是空闲的内存,中间一个指针作为边界指示器,分配内存只需向空闲那边移动指针,这种分配方式称为"指针碰撞"(Bump the Pointer)。这里有个条件就是“绝对规整”,类似下图,左边全是被绿过了的,右边则全是等着被绿的。新分配对象时候就是多绿了一块,边界指示器向后移动!
2)、 空闲列表
如果Java堆不是规整的:用过的和空闲的内存相互交错。需要维护一个列表,记录哪些内存可用。分配内存时查表找到一个足够大的内存,并更新列表,这种分配方式称为"空闲列表"(Free List)。类似下图,好好的一块内存被绿得乱七八糟,用上面指针碰撞的方式是碰不动了!所以就用一个小本本记着哪里有多大的空闲空间可以绿!当然下图的地址编号是虚拟的,空闲列表的样子也是我意淫出来的,表达的意思你懂就行!
我们能看到,导致这两种方式的差异主要取决于java堆是否规整,而java堆是否规整又是由jvm采用的垃圾收集器是否带有压缩功能决定的。使用Serial、ParNew等带Compact过程的收集器时,JVM采用指针碰撞方式分配内存。而使用CMS这种基于标记-清除(Mark-Sweep)算法的收集器时,采用空闲列表方式。(下篇文章会具体介绍不同的垃圾收集器)
不管是指针碰撞还是空闲列表,都会存在同一个问题,那就是在多线程的场景下的线程安全问题。多个线程同时在new的时候把对象分配到同一块内存了咋办,不得干起来么!于是jvm采用了两种方案来解决:
1)、 同步处理:JVM采用CAS(Compare and Swap)机制加上失败重试的方式,保证更新操作的原子性。CAS机制是一种轻量级锁机制,后续在聊多线程的时候再讲!
2)、 本地线程分配缓冲区:把分配的内存按照不同的线程划分在不同的空间进行,每个线程在java堆区预先分配一小块内存,称为本地线程分配缓冲区(Thread Local Allocation Buffer)。哪个线程需要分配就从哪个线程的TLAB上分配,只有在TLAB用完需要分配新的TLAB的时候才需要做同步处理(通过上一点中的CAS机制)。
3、对象初始化
内存分配完后,就需要初始化实例对象了,虚拟机需要将分配到的内存空间中的数据类型都初始化为零值(不包括对象头,如果是使用TLAB,初始化0值的操作提前至分配TLAB时)。接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息都存放在对象的对象头中。做完以上以后,从虚拟机视角来看,一个新的对象已经产生了!
4、返回地址
JVM完成对象内存的分配及对象初始化之后,会返回对象的地址,并且压入操作数的栈顶,供后续操作!
二、dup指令
dup命令没猜错的话是duplicate的简写。在讨论dup命令前,我们先看一个简单的例子
public static void main (String[] args){int a;int b = a = 88;}
我们看看对应的字节码
public static void main(java.lang.String[]); Code: 0: bipush 88 2: dup 3: istore_1 4: istore_2 5: return
由于88这个值在一条语句中需要重复赋给两个变量,所以使用dup指令对栈顶的值进行了复制,且压入栈顶。我们在new对象的时候,new指令后面都会紧跟dup指令!然后是invokespecial和astore指令,相信聪明的你应该想到invokespecial和astore指令都会需要从栈顶弹出值来执行!在执行完dup指令后,操作数栈栈顶就有两个指向该对象实例内存的reference数据,如果<init>方法有参数,还需要把参数加载到操作栈。
三、invokespecial指令
invokespecial指令调用对象实例方法<init>,通过符号引用#3定位到的是People对象的实例方法<init>。这时候操作数栈栈顶值(指向对象实例的内存reference)会被弹出(如果<init>方法有参数,参数也会出栈)。执行<init>方法会在java虚拟机栈中创建<init>方法的栈帧,并且把出栈的数据放入栈帧的局部变量表中。变量表中指向对象实例的内存reference就是我们经常用到的this,表示对该对象实例进行操作!执行完该指令后,一个完整的对象就创建完成啦!
四、astore指令
astore依然需要弹出栈顶值,然后存储到编号为1的变量中供后续使用。至此一个完整的对象已经创建且返回对象内存引用给本地变量存储了。
对象的访问定位
我们上面已经把对象创建的问题解决了,同时我们也都知道,引用类型的变量存储的是**对象的引用**!那这个引用类型数据怎么定位到堆中的对象呢?目前主流的对象访问方式有两种:
1、使用句柄
JVM在堆区划分一块内存作为句柄池,引用类型变量中存储就是对象的句柄地址。对象句柄包含两个地址(如下图):
- 在堆中分配的对象实例数据的地址。
- 这个对象类型数据地址。
2、使用直接指针
引用类型变量中存储就是在堆中分配的对象实例数据的地址。
句柄池的方式会在句柄池中存放类型对象的相关信息,而直接访问的方式会把类型对象的信息放入实例对象的对象头中(我们知道对象头包含“指向对象类型数据的指针”,其实这并不是必须的,我们常用的HotSpot虚拟机采用的是直接指针的方式,所以对象头中会包含“指向对象类型数据的指针”,如果某类虚拟机采用的是句柄的方式访问对象,那可能就不需要在头部存储这个指针了)。这两种方式都互有优缺点:
- 句柄方式访问对象时,多一次指针定位的时间开销。但是对象移动时(垃圾回收时常见的动作),栈上的变量的引用不需要修改,只需改变句柄中实例数据指针。
- 直接指针对象相对句柄方式访问节省了一次指针定位的时间开销,性能更好。如果对象访问非常频繁,提升会更明显!但是在对象移动时,栈上的变量的引用也需要变化。