Java对象的内存布局、访问定位和创建

784 阅读8分钟

本来这篇文章已经发表了,无奈新改版的csdn点击写新文章跳转到了该文章的编辑状态,以为是缓存,清空保存,结果成功删除本文章。无奈CSDN没有回滚功能,只好又重新写了一番……顿悟文件备份的重要性。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3个区域: 对象头,实例数据和对齐填充。

对象头

对象头包括两部分信息: 运行时数据和类型指针。

  • 运行时数据 : 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。
存储内容 标志位 状态
对象哈希码、分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空(不需要记录信息) 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

HotSpot虚拟机对象头Mark Word

并且对象头结构在64位JVM与32位JVM中的实现细节是不同的 摘自链接


这里写图片描述
这里写图片描述
  • 类型指针 : 对象头的另一部分是类型指针,即对象指向他元数据的指针,虚拟机可以通过这个指针来确定
    这个对象是哪个类的实例。
    但是如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以用过普通的Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

对象的实例数据

接着数据头的是对象的实例数据,这部分是真正存储的有效信息。

无论是从父类中继承下来的,还是在子类中定义的,都需要记录下来。

对齐填充

最后一部分对齐填充并不是必然存在的,也没有特别的含义…它仅仅起着占位符的作用。 由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的引用数据来操作堆上的具体对象。 对象的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种。

句柄引用和直接引用不同就在于:使用句柄引用的话,那么Java堆中将会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,但是直接引用引用中存储的直接就是对象地址。

Java通过直接指针访问对象


这里写图片描述

Java使用的是直接指针访问对象的方式,因为它最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

对象的创建

  • 1.类加载检查 : 虚拟机遇到一条new指令时,首先将会去检查这个指令的参数是否能在常量池中(.Class文件的静态常量池)定位到这个类的符号引用。并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  • 2.分配内存 : 对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。但是不同的垃圾回收器的算法会导致堆内存存在两种情况:绝对规整和相互交错。
    指针碰撞 : 假设Java堆中内存是绝对规整的,所有用过的内存都存放在一起,空闲的内存存放在另一边,中间放着一个指示器作为分界点的指示器,那所分配的内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
    空闲列表 : 但是如果Java堆中的内存并不是规整的,已使用的内存和空闲内存相互交错,那么虚拟机会维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划给对象实例,并更新列表上的记录。这种分配方式称为“空闲列表”。

  • 3.分配内存的并发问题 : 即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。针对这个问题有两种解决方案。
    失败重试 : 对分配内存空间的动作进行同步处理,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
    本地线程分配缓冲 : (Thread Local Allocation Buffer,TLAB)哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

  • 4.内存空间初始化零值 : 内存分配完成后,虚拟机需要将分配到的内存空间都初始化零值,这一步操作保证了对象的实例字段( 成员变量)在Java代码中可以不赋值就直接使,程序能够访问到这些字段的数据类型所对应的零值。

  • 5.对象设置 : 接下来虚拟机便会对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在上面提到的对象头之中。至此一个新的对象产生了。

  • 6.实例构造器的init方法 : 虽然对象产生了,但是<init>方法并没有执行,所有的字段还需要赋值。(包括成员变量赋值,普通语句块执行,构造函数的执行等。)





Clinit和init

  • Clinit : 类构造器的<Clinit>方法(个人认为是Class init的结合名称),与类的初始化有关,例如静态变量(类变量)和静态对象赋值,静态语句块执行。如果一个类中没有静态语句块,也没有对类变量或静态对象的赋值,那么编辑器可以不为这个类生成<Clinit>方法。

  • init : 实例构造器(即成员变量,成员对象等),例如成员变量和成员对象的赋值,普通语句块的执行,构造函数的执行。

public class Child extends Parent {

    static int s = 2;// clinit

    static Bean beans = new Bean("hello_static");// clinit

    static {
        System.out.println("Child 静态方法块" + "-->s=" + s);// clinit
    }

    int m = 1;// init

    Bean bean = new Bean("hello_normal");// init

    Child() {
        System.out.println("Child 构造方法" + "-->m=" + m);// init
    }

    {
        System.out.println("Child 普通方法块" + "-->m=" + m);// init
    }

}



下面结合父类,我们来看一下这些字段的输出顺序

public class Parent {

    int m = 1;
    static int s2 = 2;

    Parent() {
        System.out.println("Parent 构造方法" + "-->m=" + m);
    }

    {
        System.out.println("Parent 普通方法块" + "-->m=" + m);
    }

    static {
        System.out.println("Parent 静态方法块" + "-->s2=" + s2);
    }
}
public class Test2 {

    public static void main(String[] args) {

        Child c=new Child();
    }

}

//Parent 静态方法块-->s2=2  (静态变量赋值会优先静态方法块的执行)
//Child 静态方法块-->s=2
//Parent 普通方法块-->m=1
//Parent 构造方法-->m=1
//Child 普通方法块-->m=1
//Child 构造方法-->m=1




被动引用

在上面的代码中,如果我们执行的是以下代码,将会有什么样的输出呢。

public class Test2 {

    public static void main(String[] args) {

        System.out.println("输出"+Child.s2);//s2为父类类变量
    }

}
//Parent 静态方法块-->s2=2
//输出2

上面这段代码运行之后,只会执行父类的类变量赋值和静态方法块。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的类加载过程和初始化(Clinit方法)。因为没有使用new指令,所以并不会走对象的创建过程和类的init方法。