关注微信公众号:CodingTechWork,一起学习进步。
引言
Java程序员都知道如何创建对象,不就是一个Person person = new Person()
的语句就解决了么?然而,我们只知道new,却对于底层如何实现对象的创建、如何存储到内存中去、又如何被访问的知之甚少。
对象的创建
流程图
创建流程
- Java程序new一个对象。
- 虚拟机遇到一条
new指令
时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用
,且检查该符号引用代表的类是否已被加载、解析和初始化过
。若没有,需先进行相应的类加载过程。 - 在类加载检查通过后,虚拟机将为新生对象
分配内存
。(对象在内存中所需要的大小在类加载完成后就确定了) - 内存分配完之后,虚拟机需要将分配到的内存空间
初始化为零值
(不包括对象头)。保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,可以访问对应的零值。(对应准备阶段) - 虚拟机对对象进行必要的设置(
对象头的设置
)。如这个对象是哪个类的实例、如何找到类的元数据信息、对象哈希码、对象的GC分代年龄等信息。 - 以上虚拟机中新对象产生,对应到Java程序还需要继续执行
<init>
方法,将对象在程序中进行初始化。
内存空间分配方式
为对象分配空间就是从Java堆
中划分出一块确定大小的内存给新生对象,考虑符合划分可用空间的两种方式:“指针碰撞”和“空闲列表”
- 指针碰撞:若Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个
指针作为分界点的指示器
,所分配内存仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
。在使用Serial、ParNew收集器时
等带有Compact过程
时,系统分配算法是指针碰撞。 - 空闲列表:Java堆中内存不是规整的,已使用的内存和空闲的内存相互交错,VM需维护一个列表,记录上哪些内存是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。使用
CMS收集器
时,就是采用的空闲里列表,CMS是基于Mark-Sweep算法(标记-清除)
的收集器。
并发安全问题
Java对象创建在程序中是非常常见的,所以在VM中对象创建是非常频繁,容易出现多线程并发安全问题
:如程序中创建对象A和对象B,底层VM给A对象分配内存,指针没来及修改,对象B同时使用原来的指针分配内存。
解决方案有两种:同步处理和本地线程分配缓冲
- 同步处理:分配内存空间的动作进行同步处理(CAS操作),VM采用
CAS配上失败重试
的方式保证更新操作的原子性
; - 本地线程分配缓冲:Thread Local Allocation Buffer, TLAB,把内存分配的动作
按照线程划分
在不同的空间
之中进行,即每个线程在Java堆中预先分配一小块内存
,即为TLAB,哪个线程要分配内存,就在哪个线程的TLAB上分配,只有用完后并分配新的TLAB,才需要同步锁定。通过-XX:+/-UseTLAB
参数设定是否需要使用TLAB。
对象的内存布局
概述
Java对象在内存存储的布局分为3块:对象头、实例数据和对齐填充
。
对象头
对象头(Header)分为两部分:用于存储对象自身的运行时数据和类型指针
。
运行时数据
Mark Word
,用于存储对象自身的运行时数据包括:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、GC分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标志 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
Mark Word是一个非固定
的数据结构,在极小的空间内存储尽量多的数据,会根据对象的状态复用
自己的存储空间,如在32位HotSpot VM中,若对象处于未锁定状态,Mark Word的32bit空间中25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,即32(存储空间)=25(哈希码)+4(分代年龄)+2(锁标志位)+1(固定0)
类型指针
即对象指向它的类元数据的指针
,虚拟机通过这个指针来确定对象是哪个类的实例,但是并非查找对象的元数据就一定要通过对象本身,也只是适用于普通对象,普通Java对象可以通过元数据信息
可以确定Java对象的大小。不适用的Java对象,如Java数组对象
的对象头中必须有一块能保持记录数组长度的数据,因为从数组元数据中无法确定数组的大小。
实例数据
实例数据(Instance Data)是对象真正存储的有效信息
,也是程序代码中定义的各种类型的字段内容
。这部分存储顺序会受到VM分配策略参数
和字段在Java源码中定义顺序
的影响。
VM默认分配策略
HotSpot默认分配策略为longs/doubles、ints、shorts/chars、bytes/nooleans、oops,相同宽度的字段会被分配到一起,在父类中定义的变量会出现在子类之前。
对齐填充
对齐填充(Padding)是非必要
的,只是起着占位符的作用
。VM自动内存管理系统要求对象起始地址(对象大小)必须是8字节的整数倍
,对象头都是8字节的整数倍,而实例数据部分若没有8字节的整数倍,可以通过对齐填充进行补全。
对象的访问方式
概述
Java程序通过栈上的reference数据类操作堆上的具体对象(栈中的局部变量表存储了对象名的变量,堆中存储了对象的具体地址)。主流的对象访问定位方式有两种:使用句柄和直接指针
。
使用句柄
使用句柄访问对象,Java堆中会划分出一块内存作为句柄池
,reference中存储
的就是对象的句柄地址
,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针
使用直接指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储
的直接就是对象地址
。(Sun HotSport VM的使用方式)
访问方式对比
使用句柄访问优势是reference中存储的是稳定的句柄地址,对象被移动时,只会改变句柄中实例数据指针,reference本身不会变;
使用直接指针访问优势是速度快,节省一次指针定位时间开销。(JVM默认使用)
参考
《深入理解Java虚拟机》