《深入Java虚拟机》之对象内存布局及使用

Q1. 对象什么时候创建

当虚拟机遇到new关键字时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载。如果没有执行相应的加载过程,这部分内容可以看类的加载流程。

Q2. 对象在哪里创建

对象要创建时必须要为新对象分配内存空间,即在Java堆中划分出一块等大小的内存区域。然而并不是所有的对象都在堆上分配,存在一种叫做 栈上分配 的技术,先判断待创建对象是否发生逃逸,如果没有就将对象打散成不可分割的变量,分配在栈上。该技术通过 逃逸分析标量替换 得以实现。

逃逸分析、标量替换相关文章

Q3. 怎么分配空间

根据内存区域是否完整、连续,将Java堆划分为两种情况:

  • 指针碰撞:该情况是指Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,当分配内存时,就将指针向空闲内存空间移动一段和内存大小一样的距离即可
  • 空闲列表:如果Java堆中的内存是不规整的,已使用的内存和空闲内存相互交错,虚拟机就必须维护一个列表,记录上哪些内存是可用的,在分配时从列表中找到一块足够大的内存空间划分给对象实例

选择哪种方式存放内存是由 java堆是否规整 决定的,而Java堆是否规整又是由 所采用的垃圾收集器是否带有压缩整理功能 决定的。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

可能不同代的内存,分配方式可能不一样。

Q4. 对象创建时指针的同步问题

在并发情况中,可能正在为A分配内存空间,指针没来得及移动;对象B又同时使用了原来的指针来分配内存。虚拟机处理这种情况的方法也有两种:

  • 对分配内存空间的情况进行同步——采用CAS + 失败重试 的方式保证更新操作的原子性
  • 另一种是把内存分配的动作按照线程划分在不同的空间中,即每个线程在Java堆中预先分配一块内存,称为 本地线程分配缓存(TLAB) 。线程分配的对象放置在各自的TLAB中,等TLAB的空间用完后要分配新的内存空间时,才需要重新同步锁定。用 -XX:+/-UseTLAB 开启或关闭TLAB

Q5. 内存分配完毕后做什么

  1. 初始化内存空间
  2. 对象头的设置
  3. 调用初始化方法

内存分配完后的第一步就是先将分配到的内存空间初始化为默认值;如果使用TLAB,这一工作过程也可以 提前至TLAB分配时进行

对象头的设置包括元数据、对象的锁标志位、对象的分代年龄等等,而根据虚拟机的运行状态不同,还会有不同的设置

最后执行方法(构造函数),将所有字段按程序员的意愿进行初始化,此时,这个对象才算是真正可用

Q6.对象在内存里有什么

对象内存布局示意图

  • 对象头
    • 存储对象自身运行时的数据
    • 类型指针
    • 若对象为数组,还需保存数组的长度
  • 实例数据
  • 对齐填充

Q7. 对象头

对象头通常包含两个部分(特殊情况有三部分),第一个部分 用于保存对象运行时的数据,该部分又称 MarkWord,保存着如哈希码、GC分代年龄、锁状态标志、线程持有的锁等等信息,这部分在32位虚拟机里占32位,64位虚拟机里占64位(不开启指针压缩)。MarkWord被设计为不定长的数据结构,它会根据运行状态复用自己的存储空间(是未锁定、轻量级锁定等等)。

关于对象头锁标志位的过程可以看这里:《Java并发编程的艺术》之synchronized的底层实现原理

第二个部分 是类型指针,虚拟机通过这个指针确定 这个对象是哪个类的实例。在32位虚拟机里占32位,64位虚拟机里占64位(不开启指针压缩)。若开启指针压缩,该部分在x64位机子上只占32位,即4字节。

第三个部分 是当对象为数组时才存在的,主要用于记录数组的长度。占用一个int的大小,即32位

所以在x64机子上,普通对象头占用128位(16byte),数组对象头占用160位(20byte)。

Q8. 实例数据

实例数据就是对象里面存放的那些属性占用的大小。

基本类型占用字节数表格
类型 | 占用字节 |

  • | :-: |
    boolean | 1 |
    byte | 1 |
    short | 2 |
    char | 2 |
    int | 4 |
    float | 4 |
    double | 8 |
    long | 8 |

引用类型在64位机子上占用8个字节,在32位机子上占用4个字节

比如下面这个例子:

1
2
3
4
5
6
7
8
9
public static void main( String[] args ) throws IllegalAccessException {
A a = new A();
// SizeOfObject会在后面放出使用教程,主要涉及到java agent
System.out.println(SizeOfObject.fullSizeOf(a));
}
static class A{
int a;
double b;
}

当A对象被实例化后(未开启指针压缩),对象头占用16字节(8字节 + 8字节 + 0字节),实例数据占用12字节(int 4字节,double 8字节),内存补齐占用4字节(对象头和实例数据占用28字节,无法被8整出,所以需要补齐4字节),所以最终A对象会占用32个字节。

未开启指针压缩

当开启指针压缩时,对象头里的类型指针只会占用4字节,所以对象头和实例数据只会占用24个字节,由于能正好被8整出,所以内存补齐只占用0字节,所以最终A对象只会占用24个字节。

开启指针压缩

Q9. 对齐填充

因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍,对象头部分正好是8字节的整数倍,因此当实例数据没有对齐时,就需要对齐填充。对齐填充是提高效率的一种方法,并且有些CPU如果不内存对齐,程序会直接崩溃;

内存对齐的目的

Q10. 创建完一个对象后,如何访问对象实例

使用对象的方法是,通过栈上的reference数据来操作堆上的具体对象。由于Java虚拟机规范中只规定了Reference类型是一个指向对象的引用,并没有定义这个引用通过何种方式去定位。目前主流的方式有两种:

  • 使用句柄,该方案会在Java堆中划分一块区域作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体信息
    句柄引用示意图
  • 直接指针,reference存储的地址直接是对象地址,对象实例数据里会包含数据类型信息
    直接引用示意图

句柄引用的优势是移动对象实例后(比如GC情况下),reference变量不需要改变指向的地址。
直接引用的优势是速度快,节省了一次定位的开销。积少成多后,提高的速度也是很客观的

关于虚拟机选择何种引用方式的拓展