对象的实例化、内存布局与访问定位

对象的实例化

  • 对象创建的方式
    • new
    • Class的newInstance():通过反射的方式,只能调用空参构造器,权限必须是public
    • Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参构造器,权限没有要求
    • 使用clone()
    • 使用反序列化:从文件、网络中获取一个对象的二进制流
    • 第三方库Objenesis
  • 对象创建的步骤
    • 判断对象对应的类是否加载、链接、初始化:虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常;如果找到,则进行类加载,并生成对应的Class对象
    • 为对象分配内存:首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
      • 如果内存规整:指针碰撞
      • 如果内存不规整:虚拟机需要维护一个列表;空闲列表分配

如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法为对象分配内存。虚拟机维护了一个列表,记录上哪些内存块式可用的,再分配的时候从列表中找到一块足够大的空间分给对象实例,并更新列表上的内容。

  • 处理并发安全问题
    • 采用CAS配上失败重试、区域加锁保证更新的原子性
    • 每个线程预先分配一块TLAB
  • 初始化分配到的空间:所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
  • 设置对象的对象头:将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现
  • 执行init方法进行初始化:在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

对象的内存布局

  • 对象头(Header)
    • 运行时元数据(Mark Word)
      • 哈希值(HashCode)
      • GC分代年龄
      • 锁状态标志
      • 线程持有的锁
      • 偏向线程ID
      • 偏向时间戳
    • 类型指针:指向类元数据InstanceClass,确定该对象所属的类型

如果是数组,还需要记录数组的长度

  • 实例数据(Instance Data)
    • 说明:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
    • 规则:
      • 相同宽度的字段总是被分配在一起
      • 父类中定义的变量会出现在子类之前
      • 如果CompactFields参数为true(默认为true),子类的窄变量可能插入到父类变量的空隙
  • 对齐填充(Padding):非必须,无特别含义,仅仅起到占位符的作用

对象的访问定位

对象访问方式主要有两种:句柄访问直接指针(Hotspot采用)

  • 句柄访问

image.png
reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改

  • 直接指针

image.png

直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,直接内存是在Java堆外的、直接向系统申请的内存区间。来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。
通常,访问直接内存的速度会优于Java堆,即读写性能高。因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。

  • 非直接缓冲区

读写文件,需要与磁盘交互,需要由用户态切换到内核态。如下图,这里需要两份内存存储重复数据,效率低 image.png

  • 直接缓冲区

使用NIO时,如下图,操作系统划出的直接缓存区可以被Java代码直接访问,只有一份。NIO适合对大文件的读写操作 image.png

但是直接内存也可能导致OutOfMemoryError异常。由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
缺点:分配回收成本较高;不受JVM内存回收管理。
直接内存大小可以通过MaxDirectMemorySize设置,如果不指定默认与堆的最大值-Xmx参数值一致。

执行引擎

概述

执行引擎(Execution Engine)是Java虚拟机核心的组成部分之一。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。那么,如果想要让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

执行引擎的工作过程:

  • 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
  • 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
  • 当然,方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

从外观上看,所有的Java虚拟机执行引擎输入、输出都是一致的。输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果

Java代码编译和执行的过程

Java代码编译是由Java源码编译器来完成,流程图如下:
image.png
Java字节码的执行是由JVM执行引擎来完成,流程图如下:
image.png

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作
JIT编译器(Just In Time Compiler,即时编译器):虚拟机将源代码直接编译成和本地机器平台相关的机器语言

既然HotSpot VM中已经内置JIT编译器了,那为什么还需要再使用解释器来“拖累”程序的执行性能呢? 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要发挥作用把代码编译成本地代码需要一定的执行时间。但编译为本地代码后执行效率高。 当Java虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率