引入
本文主要以最常用的虚拟机HotSpot
和最常用的内存区域Java
堆为例,深入探讨一下HotSpot
虚拟机在Java
堆中对象分配、布局和访问的全过程。
1. 对象的内存布局
在HotSpot
虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
2. 对象的创建
2.1 对象的创建流程
JVM
遇到一条字节码new
指令时:
检查指令的参数是否能在常量池中定位到一个类的符号引用;
检查这个符号引用代表的类是否已经被加载、解析和初始化。没有则必须执行相应的类加载过程;
类加载检查通过后,JVM为新生对象分配内存;
内存分配后,JVM将分配到的内存空间(但不包括对象头)都初始化为零值;
设置对象头(
Object Header
),如:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
从虚拟机的视角来看,完成上述流程后一个新的对象已经产生了。但是从Java
程序的视角看来,对象创建才刚刚开始——构造函数,即Class
文件中的<init>()
,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。
new
指令之后会接着执行<init> ()
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
2.2 内存分配
对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java
堆中划分出来。
2.2.1 指针碰撞(Bump The Pointer
)
假设Java
堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞(Bump The Pointer
)。
2.2.2 空闲列表(Free List
)
假设Java
堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表(Free List
)
选择哪种分配方式由Java
堆是否规整决定,而Java
堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact
)的能力决定。
2.3 堆抢占的情况下,JVM如何保证线程安全
对象创建在JVM
中发生很频繁,在并发情况下也并非线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
JVM
提供了两种方案来来解决上述问题:
CAS
: 对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS
配上失败重试的方式保证更新操作的原子性;TLAB
: 是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java
堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer
,TLAB
),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
虚拟机是否使用 TLAB
,可以通过-XX:+/-UseTLAB
参数来设定。
3. 对象的访问定位
创建好了对象自然是为了后续使用这个对象,而对象的访问方式实际上由JVM
实现而决定,主流的访问方式主要有两种:
句柄
直接指针
句柄(
Handle
):用于标识某个资源或对象的唯一标识符。本质上是一个整数值或指针类型,它与资源实体之间的映射关系由操作系统维护。句柄只能通过系统函数来访问和操作。
3.1 使用句柄访问
3.1.1 访问方式
Java
堆中划分出一块内存作为句柄池,reference
中存储的是对象的句柄地址,句柄中包含对象实例数据以及类型数据各自具体的地址信息。
3.1.2 优点
reference
中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference
本身不需要被修改。
3.2 使用直接指针访问
3.2.1 访问方式
Java
堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference
中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
HotSpot采用这种方式进行对象访问。
3.2.2 优点
速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java
中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
参考资料
《深入理解Java虚拟机(第3版)》