Java虚拟机中的对象探秘

前言: Java是一门面向对象的语言,在Java程序运行的过程中,无时无刻都有对象被创建出来;在了解了Java虚拟机在运行时管理的内存区域的结构划分后,就可以进一步去理解在虚拟机中是如何为对象分配内存、对象的内存布局以及如何访问对象;下面将基于主流的Java虚拟机Sun HotSpot以及常用的内存区域Java堆,对上述的三个方面进行深入理解;

虚拟机中对象的创建过程

    在语言层面上,创建对象仅仅是一个new关键字而已,但是,在虚拟机中,创建一个对象的过程远不止这么简单;
    虚拟机中创建一个对象要经过以下的步骤:
    a. 当虚拟机在执行字节码指令的过程中,遇到了一条new指令,虚拟机将会首先去检查能否根据该指令的参数在常量池中找到对应的一个类的符号引用;如果每找到对应的类的符号引用,那么将会报错,反之,如果找到的话,还会检查此符号引用代表的类是否已被虚拟机加载、解析以及初始化过,如果没有被加载、解析以及初始化,则先进行类的加载、解析以及初始化,反之,则进入步骤b;
    b. 类加载检查通过后,虚拟机就会为新生的对象分配内存;由于类加载完成后,对象所需的内存大小也被确定下来,因此,此时虚拟机为对象分配内存就仅仅是在Java堆中划分一块大小已知的内存;这里需要注意的是:根据Java堆所占的内存是否绝对规整,决定采用指针碰撞还是采用空闲列表来为对象分配内存;而Java堆中的内存是否绝对规整又取决于虚拟机的垃圾收集器是否具有压缩整理的功能;
    c. 内存分配完成后,虚拟机就会为对象分配到的内存进行初始化,将其初始化为对应的零值(不包含对象头);这一步保证了在Java代码中可以不用显式地为对象的实例字段赋初始值,就可以使用其实例字段,程序能够访问到这些字段的数据类型对应的零值;
    d. 将对象的实例字段初始化为零值后,就需要为对象的对象头中的信息进行设置;对象头中存放了对象是哪一个类的实例、如何能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息;
    在上述四个步骤全部完成后,从虚拟机的视角来看,一个新的Java对象才真正地产生;但是,从Java程序的角度来看,此时对象的创建才刚刚开始,因为Java程序中使用的对象,必须是按照程序员意愿进行设置后的对象,因此,仅仅是将实例字段初始化为零值,并没有完成程序员对新生对象的设置;所以,一般在执行完new指令后,会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样才算将一个真正可用的对象完全产生出来(从Java程序的角度);
    下面对上述四个步骤中,没有详细分析的一些地方进行单独叙述:
    对象内存的分配方式有两种:指针碰撞法和空闲列表法;
        指针碰撞适用于Java堆的内存区域是绝对规整的情况,指针碰撞采用一个位于已用内存区域和空闲内存区域之间的指针作为分界点的指示器,对象的内存分配就对应着将该指针向空闲内存区域方向移动一个与对象占用内存大小相同的距离;如虚拟机中的垃圾收集器为带有Compact过程的Serial、ParNew垃圾收集器等,系统采用的分配算法就是指针碰撞;
        空闲列表适用于Java堆的内存区域不是绝对规整的情况;由于Java堆中内存不是规整的,所以空闲内存与已用内存互相交错,导致指针碰撞无法解决内存分配问题,所以就提出了空闲列表的方式来解决内存分配问题;虚拟机维护一个记录空闲的内存块的列表,从而在为对象分配内存时,从空闲列表中查找足够大的内存块分配给新生的对象,在分配完之后,更新空闲列表中的记录;如使用基于Mark-Sweep算法的CMS垃圾收集器的虚拟机将会采用空闲列表的分配算法;
        由于对象所分配得到的内存来自于Java堆,Java堆又是所有线程所共享的内存区域,所以在考虑内存分配方式的同时,还需要考虑多个线程并发进行内存分配时的线程安全,即不同线程在为各自的对象分配内存时,要注意共享的Java堆是否能够避免线程安全问题;
        解决内存分配时可能产生的线程安全问题的方法有:一、在内存分配时进行分配操作的同步处理,实际上虚拟机采用CAS配上失败重试的方式保证指针指示器或空闲列表的更新操作的原子性;二、在Java堆中划分出本地线程分配缓冲区(TLAB),使得不同线程的内存分配操作,在自己专有的TLAB中进行,这样就避免了原来的线程安全问题,只有当线程已有的TLAB用完时需要再分配新的TLAB时,才对分配TLAB操作进行同步锁定,虚拟机是否采用TLAB可以通过参数-XX:+/-UseTLAB来设置;       

对象的内存布局

    在HotSpot虚拟机中,对象在内存中存储的布局可以分为对象头、实例数据以及对齐填充三个部分;
    对象头:
        HotSpot虚拟机中的对象头中包含两部分信息:一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等,这些数据在Java程序的执行过程中,可能会发生变化,同时这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,因此,官方称之为Mark Word;另外一部分用于存放类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪一个类的实例;并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息不一定要通过对象本身;
    实例数据:
        实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容;无论是从父类中继承得到的,还是在子类中定义的,都需要在这部分内存中记录下来;
    对齐填充:
        这部分并不是必然存在的,其是否存在取决于对象的实例数据部分所占内存的大小是否为8字节的整数倍,为什么这么说呢?这是因为HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,由于对象头不是32bit就是64bit,都是8字节的整数倍,因此对齐填充存在的唯一目的是为了使得对象的大小满足虚拟机的要求;

对象的访问定位

    创建出来的对象就是用来使用的,那么虚拟机使用对象就要知道对象所在的位置,因此,如何确定对象的位置是使用对象的前提;
    在Java程序中,是通过虚拟机栈上的局部变量表中的reference数据来操作Java对上的具体对象的;由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义该引用通过何种方式去定位和访问Java堆上的具体对象,因此定位和访问Java堆上的对象的方式取决于具体的Java虚拟机实现;
    目前主流的访问方式有使用句柄和直接指针两种;
    使用句柄:
        使用句柄这种方式,将会在Java堆中划分出一块内存作为句柄池,用于存放对象所对应的句柄,reference数据中存储的就是句柄的地址;句柄中包含两部分数据:一个到对象实例数据的指针和一个到对象类型数据的指针;其中对象实例数据存放在Java堆上的实例池中,对象数据类型数据存放在方法区中;
    使用直接指针:
        使用直接指针这种方式,那么reference数据中存放的就是Java堆上的对象地址;而和对象实例数据一起的还有指向对象类型数据的指针;
    两种方式各有优点:使用句柄的方式可以带来reference数据中存放的是固定的句柄地址,由垃圾收集所导致的移动对象所带来的对象地址的改变,将不会影响reference中的数据;使用直接指针的方式可以带来访问对象的速度变快,因为直接指针方式减少了一次指针定位的时间开销;由于访问Java对象非常频繁,因此直接指针将会带来非常可观的执行成本;就HotSpot虚拟机来说,采用的就是直接指针的访问方式;

猜你喜欢

转载自blog.csdn.net/boker_han/article/details/79373472