Java对象的创建
学了很久的java,是时候来一波深入思考了。比如:对象是如何在JVM中创建,并且被使用的。本文主要讲解下new对象的创建过程。要想更深入的了解建议去认认真真的看几遍《深入理解Java虚拟机》周志明写的。
首先自己想想:Person p = new Person() 这句话是干嘛的?废话,肯定是创建一个新对象的,那么JVM是怎么帮你创建的呢?在哪创建的?怎么在堆上分配内存的?你又是如何去根据变量找到对应的对象的?带着这些问题,继续往下看:
一、找到能生成对象的Class类
对象又不是孙猴子,石头里蹦出来的。你new一个对象,总要找到能生成这个对象的类吧。所以JVM是这样做的:
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用(这些都会在包含你这个new对象的对象的常量池中记录),并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,具体加载过程请看我下一篇博客。
二、堆上内存的分配
有能产生对象的类了,下面就是该在堆上创建你需要的对象了,首先,需要明确的是,对象的大小是已经确定好的,这是在类加载的时候就已经计算好的,包括方法在栈上栈帧的大小都是已经确定的,你只需要在堆上找到能存放这么大的一块区域就可以了。那么这个区域该怎么找呢?随便找一块能用的?还是说有其他的方法分配呢?
两种分配内存的方法:
- 指针碰撞:假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边(这就是GC中的复制算法),中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。
- 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错(这就是GC中的标记-清理算法),那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,有多大,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
那么上述两种分配算法实际怎么用的呢?
选择哪种分配方式由 Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决 定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
HotSpot采取G1垃圾回收器,其具有压缩整理功能,系统采用的分配算法是指针碰撞。
不知道你们发现没,这个两个内存分配方法好像只适用于单个线程,当有多个线程同时给多个对象分配内存,那该怎么办?毕竟多线程肯定会有竞争问题的存在。没毛病,下面讲解下两种解决多线程分配内存的方法:
- CAS同步处理:解决互斥的问题肯定是同步,而解决同步问题必然是加锁处理。所以,实际上虚拟机采用CAS(compareAndSet,乐观锁)配上失败重试的方式保证更新操作的原子性。
- 本地线程分配缓冲:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
三、对象的内存布局
房子(内存)有了,那如何把你的对象放进去呢?copy?肯定不是,你要放的有逻辑,条理,毕竟大家都是工科生,不能放的太随便,太艺术了。
首先,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作解释了对象的实例字段在Java代码中为什么可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。(我个人认为:内存空间都初始化为零值,也是为了清空原有内存上的数据,来存放现有的数据这一作用)。
对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
-
对象头:对象头包括两部分信息
- 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 另外一部分是类型指针。虚拟机通过这个指针来确定这个对象是哪个类的实例,类型指针指向的是方法区对应的类。
-
实例数据:对象真正存储的有效信息
这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary ObjectPointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。主要包括成员变量,方法是保存在方法区里的。
-
对齐填充
由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
四、对象的初始化
上面都是JVM干的事,后台的,默认的,本节主要讲从代码初始化流程。
目前,对象已经基本创建完成,但是成员变量还是只是默认初始化,如何显示的初始化值,构造器,成员变量,代码块都是如何初始化的饿,顺序是什么?
举例说明:
/**父类*/ public class Father { private static int b = 1; static { System.out.println("父类静态初始化块"); } { System.out.println("父类初始化块"); } public Father() { System.out.println("调用了父类无参构造器"); } public Father(int b) { this.b = b; System.out.println("调用父类的有参构造器"); } } /**子类*/ public class Son extends Father { private static int a =1; static { System.out.println("子类静态初始化块"); } { System.out.println("子类初始化块"); } public Son() { System.out.println("调用子类的构造器"); } public Son(int a){ this.a=a; System.out.println("调用子类的有参构造器"); } } public class Test { public static void main(String[] args) { Son son = new Son(); } }
打印语句:
父类静态初始化块
子类静态初始化块
父类初始化块
调用了父类无参构造器
子类初始化块
调用子类的构造器
五、对象的访问定位
对象访问方式取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
- 句柄访问:
Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 直接指针方式:
reference中存储的直接就是对象地址
两种方式的区别:
- 使用句柄来访问的最大好处就是引用变量中存储的是稳定的句柄地址,对象被移动(在垃圾收集时移动对象是很普通的行为)时就会改变句柄中实力数据指针,但是引用变量所指向的地址不用改变。
- 而使用直接指针访问方式最大的好处就是速度更快,节省了一次指针定位的时间开销,但是在对象被移动时,又需要改变引用变量的地址。
虚拟机HotSpot,它就是使用的第二种方式进行对象访问的,但从整个软件开发范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
此处有知识点:
类加载时 方法信息保存在一块称为方法区的内存中, 并不随你创建对象而随对象保存于堆中。如果instance method也随着instance增加而增加的话,那内存消耗也太大了,为了做到共用一小段内存,Java 是根据this关键字做到的,比如:instance1.instanceMethod(); instance2.instanceMethod(); 在传递给对象参数的时候,Java 编译器自动先加上了一个this参数,它表示传递的是这个对象引用,虽然他们两个对象共用一个方法,但是他们的方法中所产生的数据是私有的,这是因为参数被传进来变成call stack内的entry,而各个对象都有不同call stack,所以不会混淆。其实调用每个非static方法时,Java 编译器都会自动的先加上当前调用此方法对象的参数,有时候在一个方法调用另一个方法,这时可以不用在前面加上this的,因为要传递的对象参数就是当前执行这个方法的对象。
对象创建流程总结:
Person p = new Person()
1、由于是要创建Person类对象,java虚拟机(JVM)先去找Person.class文件,如果有的话,将其加载到内存。
2、没有的话进行类加载,将类型信息(包括静态变量,方法等)加载进方法区。
3、执行该类中static代码块。
4、到这时才进行堆内存空间的开辟,并为对象分配首地址。
5、在堆内存中建立对象的成员属性,并对其进行初始化(先进行默认初始化再进行显示初始化)。
6、进行构造代码块的初始化。
7、对象的构造函数进行初始化。
8、将堆内存中的地址(引用)赋给栈内存中的p变量。
参考文献: