目录
前言
对象的创建
对象的内存布局
对象的访问定位
前言
了解JVM的内存区域划分之后, 也大致了解了java程序的内存分布模型, 也了解它里面的内存区域里面的类型和各个类型的作用, 接下来我们进一步从对象创建到访问的角度, 来看看这些内存区域之间是怎么关联起来的.
对象的创建
对象的创建在java语言中最直接的体现就是new这个关键字, 虚拟机在遇到一个new指令(这个指令是JVM内部指令集的一部分, 直到JVM如何对对象分配内存并初始化 )的时候, 首先会去检查这个指令的参数(例如类名, 你要实例化的是哪一个类的)是否能在常量池中定位到一个符号引用.
这些类的信息一般都存储在常量池中, 常量池是方法区的一部分, 用于存储各种字面量和符号引用, 常量池在编译的时候就已经确定了. 在运行时,JVM会将这些符号引用解析为直接引用,即指向内存中实际的类或字段等的指针或偏移量.
什么符号引用, 什么是直接引用?
符号引用用于表示一个类、接口、字段或方法的引用
在java语言层面, 一个类被编译之后就会生成字节码文件, 也就是Class文件, class文件中有着类文件的常量池, 在这个类文件被加载的时候, 对应的常量池也会被加载到虚拟机运行时常量池中去.
简而言之就是JVM会去检查new指令对应的类的Class文件是否被加载, 如果被加载, 那么就一定可以在运行时常量池中找到对应的类符号引用, 如果找不到就进行类的加载过程.
例如当执行到new MyClass()时,JVM会检查MyClass是否已经被加载到内存中。如果没有,JVM会触发类加载过程,将MyClass的字节码加载到内存中,并创建一个java.lang.Class对象来表示这个类。在这个过程中,MyClass的符号引用(如类的全限定名)会被解析为直接引用(如指向内存中Class对象的引用)
在类加载校验通过之后, 就是为对象分配内存的时候, 创建对象所需的内存其实是在类加载之后就可以完全确定的.
接下来的问题就是如何分配内存, 有两种方式:
- 指针碰撞
- 内存块列表
指针碰撞其实就是假设java内存是绝对规整的, 例如将一个内存划分为两块, 一块是已经被使用的内存区域, 一块是空闲的内存区域 他们之间有一个指针来标记当前已被使用的内存的地址, 如下:
这个指针也被称为分界点指针
另外一种就是内存块列表:
java虚拟机的堆内存, 一般不是规整的, 那么空闲的内存和已使用内存就会交错在一起, 这个时候使用分界点指针就没什么意义, 此时虚拟机就必须维护一个表, 这个表将java的堆内存划分为很多个内存块, 表里面的指针分别指向对应的内存块, 并且记录了那些内存是可用的, 分配的时候, 从内存块中找出一个拥有足够大的内存空间的给实例对象即可.
具体采用那种方式, 需要看虚拟机的具体实现. 比如有的虚拟机是内存规整的, 就可以使用指针碰撞的方法. 而是否规整又取决于内存的回收机制, 如果一个垃圾回收器可以将带有内存碎片的空间给整理成上述的规整形态, 就可以使用指针碰撞的形式.
上述只是对象的内存分配, 还有一个问题就是在分配完成内存之后, 修改指针引用的时候会出现并发问题, 例如, 给引用A创建好一个对象之后, 需要将对象的地址付给A的指针, 但是在还没赋值好的时候, 另外一个对象使用了这个A的指针, 就会出现问题.
针对这个问题, JVM采用了CAS + 失败重试的方法保证 分配和更新引用 这两个操作的原子性, 另外一种做法就是使用线程分配缓冲, 一个线程只能访问自己本地缓存, 只有本地缓冲区用完了, 才可以使用锁区同步申请缓存空间.
内存分配完之后, 为分配好的内存的对象进行初始化零值操作, 使程序能访问到这些字段的数据类型所对应的零值
接下来就是对象头的设置. 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息
这些东西都完成之后, 剩下的就是根据程序员自己的代码逻辑进行初始化操作, 也就是调用构造方法, 即Class文件中的<init>()方法. 这个方法只会执行一次, 按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来
对象的内存布局
在HotSpot中, 对象在内存中的存储布局可以划分为3个:
- 对象头
- 实例数据
- 对齐填充
对象的对象头部分包括两类信息, 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”.
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例, 如果对象是一个Java数组,那在对象头中还必须有一块用于
记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小.
接下来就是实例数据, 是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来
第三部分就是内存对齐: 这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补
对象的访问定位
接下来你应该需要了解 一个对象存储在堆区该怎么访问? 我们之前提到过, 一个对象的实例创建并调用init初始化之后, 就是一个完全体的对象了, 然后这个对象会被赋值给java虚拟机栈中的一个引用变量, 然后你可以通过这个变量去访问他
这个引用变量也被称为reference数据, 通过它来操作堆中的数据, 但是由于它只是规定了它指向一个内存对象, 但是没有明确的指明该如何指定, 所以说不同的场景下的定位方法, 有着不同的性能区别.
一般有下面这两种:
- 句柄
- 直接引用
如下图:
句柄引用其实就是利用了java内存布局中对象头和实例数据的存储可以在不同的运行时区域, 例如, java的对象头可以存储在方法区, 实例数据则存放在堆区. 然后在堆区中生成一个句柄池子, 池子中有很多句柄对象, 一个句柄对象可以被一个reference类型数据给引用, 然后一个句柄对象就是表示一个完整的对象信息, 里面包括对实例数据和对象头数据的引用.
下面这一种就是最直接的做法, reference指向的就是java内存中对象的地址. 然后对象的类型数据则是存放的指针, 真正的类型数据存放在方法区中.
区别?
- 使用句柄池, 就需要单独在内存中开辟句柄池空间 , reference存储的使句柄池, 访问句柄池中的句柄对象之后, 还需要通过句柄对象中的指针, 再寻找一次真正的实例对象数据和对象类型数据. 相当于多了一次寻找的开销, 最大的优势就是句柄池是稳定的, 对象无论怎么修改, 只要保持和句柄池中的数据指向一致即可, 而对于reference来说, 对象内存地址的修改是透明的(例如GC的情况下 , 对象的地址可能就会发生变化.), reference不需要改变句柄对象的指向.
- 使用指针访问就直接很多, reference指向的既是对象, 可以直接访问其实例数据. 免去了一次寻找对象实例数据的开销, 由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本