Java 虚拟机系列文章目录导读:

深入理解 Java 虚拟机(一)~ class 字节码文件剖析
深入理解 Java 虚拟机(二)~ 类的加载过程剖析
深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析
深入理解 Java 虚拟机(四)~ 各种容易混淆的常量池
深入理解 Java 虚拟机(五)~ 对象的创建过程
深入理解 Java 虚拟机(六)~ Garbage Collection 剖析

1. 前言

《深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析》中我们介绍了方法的调用方法的执行

Java 是面向对象的一门语言,绝大部分的方法都是实例方法,调用方法前,需要创建对象,然后才能调用它的方法。

那么今天我们就来谈谈 Hotspot 虚拟机在 Java 堆中对象的创建、对象的内存布局、对象的访问相关的知识。

2. 对象的创建

2.1 类型的加载

虚拟机遇到 new 指令时,首先将去检查该指令的参数是否能够在常量池定位到一个类的符号引用,如 java/lang/Object

然后判断该类是否已经被加载、解析和初始化过。如果没有,则执行执行相应的类加载过程,完整的类加载过程如下图所示:

类加载过程

更多关于类加载的细节,可以查看: 深入理解 Java 虚拟机(二)~ 类的加载过程剖析

2.2 内存的分配和初始化

对象的所需要的内存大小在类加载完成后便完全确定了。那么就可以为开辟对象所需要的内存了。

如果堆内存是绝对规整的,也就是用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,这种分配方式称之为 指针碰撞(Bump-the-Pointer)。

如果堆内存并不是规整的,也就是说可用内存不是连续的,已用内存和空闲空内存是相互交错的。这时候虚拟机需要维护一个列表,用于记录哪些内存是可以用的,在分配内存的时候从列表找出一个足够大内存空间划分给对象实例。这种分配方式称之为 空闲列表(Free List)

到底是采用指针碰撞还是空闲列表来分配内存是由 Java 堆是否规整决定的,Java 堆是否规整又是由所采用的垃圾回收器是否带有压缩(Compact)整理功能决定的。

在使用 Serial、ParNew 等带有 Compact 过程的收集器,通常采用指针碰撞。

而使用 CMS 这种基于 Mark-Sweep 算法的收集器,通常采用空闲列表

关于垃圾回收器相关的技术,将在介绍 JVM 垃圾回收的时候统一介绍。

指针碰撞和空闲列表解决了如何划分可用空间。但是如果在并发的情况下会有线程安全问题。解决这个问题有两个方案:

  • 分配内存空间动作进行同步

    实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性

  • 本地线程分配缓冲

    本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)意思是把内存分配的动作按照线程划分在不同的空间之间进行,即每个线程在 Java 堆中预先分配一块小内存,称之为本地线程分配缓冲
    对象在那个线程创建,那么这个对象的内存就在所在线程的 TLAB 中分配。如果线程的 TLAB 用完了需要分配新的 TLAB 时才需要同步锁。

内存分配完毕后,需要将分配到的内存空间都初始化为零值(不包括对象头部分)。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

2.3 对象的初始化

上面的工作都完成之后,对于虚拟机来说,这个对象已经创建出来了,但是从 Java 程序的角度来说,对象创建完毕后,还需要执行对象的初始化操作,虚拟机对象创建完毕后会执行 <init> 也就是构造方法。

至此,一个对象的创建就宣告结束了。整个过程可以用下图来表示:

对象创建过程

3 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的存储布局主要分为 3 个区域:

  • 对象头(Header)

    对象头包括两部分信息:

    • mark word

      如哈希码(HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程ID,偏向时间戳等

    • klass pointer(类型指针)

      类型指针就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定该对象是属于哪个类的。
      如果对象是一个数组,对象头中还需要保存数组长度的数据。

  • 实例数据(Instance Data)

    实例数据就是程序中定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的都需要记录。这部分的存储顺序会受到虚拟机分配策略参数和字段在代码中定义的顺序的影响。
    Hotspot 默认的分配策略为 longs/double、ints、short/chars、bytes/boolean、oops(ordinary object points),也就是说相同宽度的字段总是分配到一起的。

  • 对齐填充(Padding)

    由于 Hotspot 虚拟机的自动内存管理要求对象的其实地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的整数倍(1 倍或 2 倍),因此对象的实例数据部分没有对齐时,就需要通过对齐填充来补全。
    需要注意的是,对齐填充不是必然存在的,它仅仅其中占位符的作用。

4. 对象的访问定位

Java 程序需要通过栈上的引用(reference)来操作堆上的具体对象。reference 类型在 Java 虚拟机规范种只规定了一个指向对象的引用。对于如何通过引用定位到对象没有做出具体的规定。

对象的访问方式取决于虚拟机的实现。目前主流的访问方式有:

  • 句柄

    如果使用句柄访问的话,Java 堆将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自的具体地址信息,如下图所示:

    通过句柄访问对象

  • 直接指针

    如果使用直接指针访问,那么 Java 堆中的对象布局中就必须考虑如何访问类型数据的相关信息,而 reference 中存储的直接就是对象地址,如下图所示:

    通过直接指针访问

使用句柄来访问对象的最大好处就是 reference 中存储的是稳定的句柄地址,对象被移动时(垃圾回收时移动对象是非常普遍的行为)也只会改变句柄的实例数据指针,reference 本身不需要修改。

使用直接指针来访问对象的最大好处是速度更快,它相比句柄的方式节省了一次指针定位的时间开销,由于对象的访问是非常频繁的。Hotspot 虚拟机使用的直接指针的方式来进行访问对象的。

Reference

  • 《深入理解Java虚拟机 - JVM 高级特性与最佳时间(第二版)》

如果你觉得本文帮助到你,给我个关注和赞呗!

另外本文涉及到的代码都在我的 AndroidAll GitHub 仓库中。该仓库除了 Java虚拟机 技术,还有 Android 程序员需要掌握的技术栈,如:程序架构、设计模式、性能优化、数据结构算法、Kotlin、Flutter、NDK,以及常用开源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持续更新,欢迎 star。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐