深入理解Java中的对象

对象的创建

Java是一门面向对象的编程语言,在Java应用程序中,每时每刻都有新的对象被创建出来。在语言层面上,创建一个对象仅仅是一个new关键字而已(clone、反序列化不在讨论范围内)。但是在虚拟机层面,创建对象的过程是一幕宏图伟业。大概分为以下几部分:

检查类是否已经加载

当Java遇到一个new字节码指令时,首先会去常量池中定位这个类的符号引用,然后检查这个符号应用所代表的类是否已经被加载、解析、初始化过。如果没有,那么必须先执行这个类的初始化过程。

类的初始化过程大概分为类的加载、校验、准备、解析、初始化。以后会进行详细的解析。

为新生对象分配内存

《深入解析Java内存模型》一文中,我们介绍过,虚拟机为对象分配内存的方式有两种:指针碰撞、空闲列表。
但是虚拟机具体采用哪一种方式取决于虚拟机采用的垃圾收集器的设置,如果采用基于压缩整理算法的垃圾收集器(Serial、PerNew),则采用指针碰撞的方式,反之,则理论上只能采用空闲列表的方式(如CMS垃圾收集器)。
除了分配内存之外,虚拟机还有一个不得不考虑的问题:在Java应用程序中,创建对象是一个非常频繁的行为,在并发的情况下,如何保证安全地为对象分配内存呢?
虚拟机层面有两种方案:

1.采用CAS机制和失败重试的方式

对象分配在Eden区时,通过CAS机制和失败重试的方式保证原子性操作。

CAS之所以支持原子性操作,是因为从硬件层面的支持。例如X86架构下,通过cmpxchg这个汇编指令在硬件层面原子性地完成“compare and swap”。在不同的操作系统下具体实现有所差异,如linux和windows,但效果是一样的。如果硬件层面不支持,那么Java程序中就只能通过同步锁来保证原子性操作了(synchronized关键字)。
在JDK5版本中加入了J.U.C并发工具包,它的基石就是这个CAS机制,如Atomic系列,AQS系列等。在JDK5以前,则只能采用同步锁…

2.本地线程分配缓冲

JVM虚拟机支持把内存的分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,这块内存就称为:本地线程分配缓冲(Thread Local Allocation Buffer TLAB)。哪个线程需要分配内存,就先在本地线程分配缓冲中分配,直到缓冲区分配完,才需要使用同步锁申请新的缓冲区。

虚拟机是否使用TLAB,可以通过XX:+/-UseTLAB参数来设定。
并不是开启了TLAB,对象的就一定会分配在缓冲区当中,实践证明,当对象大小超过一定阀值后,将直接分配在堆内存中。
开启TLAB的情况下,并且基于经典的分代理论上,虚拟机为对象分配内存的逻辑大致如下:
1.如果tlab_top + obj_size <= tlab_end,则直接在TLAB上分配;
2.如果talb_top + obj_size > tlab_end,则加锁申请一块新的TLAB,继续尝试分配;
3.如果此时还是放不下,则eden_top + obj_size <=eden_end,则将分配在EDEN区域;
4.如果EDEN区不够放,则触发一次Young GC;
5.如果Young GC之后,还是放不下,则基于老年代内存担保机制,分配在老年代;
6.如果老年代还是放不下,则触发Full GC。
… 关于GC相关知识,我们后面会专门介绍。

对象在内存中的布局

虚拟机完成对象的内存分配之后,还必须将分配的内存空间(不包括对象头)都初始化为零值(如果采用了TLAB,则这个动作可以在分配到TLAB时顺便完成)。这样就保证了在Java代码中,对象的字段可以不赋初始值就可以被程序使用(当然,访问到的是这些字段对应的类型的初始值)。
接下来,虚拟机还要进行一些其他的必要设置,这部分设置基本上就是对对象头的设置了。例如这个对象属于哪个类的实例;哈希码(只有在执行了Object::hashCode时才会真正进行计算);对象的GC分代年龄、锁标志等。

从虚拟机的角度来讲,以上动作执行完成之后,一个新的对象就已经产生了,但是从Java程序的角度来看,一个对象的创建才刚刚开始。那就是执行构造函数。只有构造函数执行完成之后,一个对象才算真正的创建完成。

在Java虚拟机中,一个对象在内存中的布局如下:
对象在内存中的布局

对象在堆内存中的存储布局可以分为3个部分:对象头、实例数据、对齐填充。

对象头

对象头包含的数据如下:

系统位数对象头内容解释
32/64bitMark Word(标记字段)存储对象的分代年龄/hashCode/锁标记等
32/64bitClass Pointer(类型指针)JVM通过这个指针确定这个类属于哪个类的实例
32/64bitArray Length(数组类型时才有)用于确定数组的大小

对象头存储的第一部分数据是对象自身的运行时数据,如:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据官方成为“Mark Word”。

考虑到空间利用效率,Mark Word被设计成有着动态定义的数据结构,以便在极小的空间中尽可能的存储更多的数据,根据对象的状态,复用自己的空间。

它的具体存储内容如下:
对象头
对象头中的标志位代表着不同的锁标志,实际上Java 的同步锁就是基于此。
普通对象的对象头占用2个机器码,如果是数组对象,则需要3个机器码,多一个机器码用于记录数组的大小。

实例数据

实例数据部分是对象真正存储的有效信息,即我们程序代码了定义的字段,无论是父类中继承下来的,还是子类中自己定义的,全部要记录下来。这部分的存储顺序受到虚拟机分配策略参数(-XX:FiledAllocationStyle)和字段在Java源码中的顺序影响。

HotSpot虚拟机默认的分配顺序为:long/doubles/ints/shorts/chars/bytes/booleans/oops(普通对象的引用)。

对齐填充

这部分的数据不是必须的,并且也没有特别的含义。它仅仅起到占位符的作用。因为虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,换句话就是一个对象的大小必须是8字节的整数倍,因此,不足8字节的部分,采用对齐填充的方式补足。

对象的访问定位

创建完对象自然是为了使用(屁话)。对象的使用方式是通过栈上的reference数据来操作堆上的具体对象。但是《Java虚拟机规范》并没有规定具体采用什么方式来操作。主流的操作方式有两种:句柄、直接指针。

句柄

如果使用句柄的方式访问的话,那么虚拟机将在堆中划分一部分内存作为句柄池。
句柄访问对象

直接访问

采用直接指针的方式,如果只是访问对象本身的话,则不要多一次间接访问的开销。
直接访问

很多博客都说直接访问的方式比句柄间接访问的方式要好,(实际上HotSpot虚拟机也是采用直接访问的方式),这个结论不是很严谨。假设对象在内存中的位置分配之后就不会在变化,那么直接访问的方式确实会非常快捷高效;但实际上,Java虚拟机在GC后,对象位置通常都会发生变化(垃圾收集时移动对象是非常普遍的行为),这时,仅需要改变句柄池中的对象地址即可,而reference本身不要做任何变化。
在其他语言、框架中采用句柄的方式也是比较常见的。

感谢阅读,希望对你有所帮助​。
参考文献:​《深入理解Java虚拟机:JVM高级特性及最佳实践》、CSDN博客文档
​申明:本文为原创文章,没有任何商业目的,仅作为学习记录、交流探讨。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值