jvm中类和对象定义存储基础知识 | 京东云技术团队

1 类文件数据结构类型

Class文件结构主要有两种数据结构:无符号数和表

无符号数:用来表述数字,索引引用、数量值以及字符串等,比如 图1中类型为u1,u2,u4,u8分别代表1个字节,2个字节,4个字节,8个字节的无符号数

:表是有由多个无符号数以及其它的表组成的复合结构,比如图1中类型以_info结尾的项为表类型。

2 类结构定义

Class类文件是紧凑、顺序、无空隙的,魔数(MagicNumber)、Class文件版本(Version)、常量池(Constant_Pool)、访问标记(Access_flag)、本类(This_class)、父类(Super_class)、接口(Interfaces)、字段集合(Fields)、方法集合(Methods )、属性集合(Attributes)。其中因为java多继承所以interfaces接口类型为数组;attribute_info则是方法表中定义的code索引,指向具体的方法体字节码。如图1所示。

下面用一段程序做说明,此类有接口,有方法、类变量和实例变量,机器是如何识别字节码然后按照上面的规则来定义此class类呢?

package com.jd.crm.Logback;

public class TestClass implements Super{

    private static final int staticVar = 0;

    private int instanceVar=0;

    public int instanceMethod(int param) throws  Exception{
        return param ++;
    }
}
interface Super{ }

通过javap帮助解析class文件格式如下:

Classfile /D:/spm-workspace/test/target/classes/com/jd/crm/Logback/TestClass.class
  Last modified 2023-4-14; size 597 bytes
  MD5 checksum 9d5dd9fc2145ac17393fee7a707d3b9c
  Compiled from "TestClass.java"
public class com.jd.crm.Logback.TestClass implements com.jd.crm.Logback.Super
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#26         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#27         // com/jd/crm/Logback/TestClass.instanceVar:I
   #3 = Class              #28            // com/jd/crm/Logback/TestClass
   #4 = Class              #29            // java/lang/Object
   #5 = Class              #30            // com/jd/crm/Logback/Super
   #6 = Utf8               staticVar
   #7 = Utf8               I
   #8 = Utf8               ConstantValue
   #9 = Integer            0
  #10 = Utf8               instanceVar
  #11 = Utf8               <init>
  #12 = Utf8               ()V
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               LocalVariableTable
  #16 = Utf8               this
  #17 = Utf8               Lcom/jd/crm/Logback/TestClass;
  #18 = Utf8               instanceMethod
  #19 = Utf8               (I)I
  #20 = Utf8               param
  #21 = Utf8               Exceptions
  #22 = Class              #31            // java/lang/Exception
  #23 = Utf8               MethodParameters
  #24 = Utf8               SourceFile
  #25 = Utf8               TestClass.java
  #26 = NameAndType        #11:#12        // "<init>":()V
  #27 = NameAndType        #10:#7         // instanceVar:I
  #28 = Utf8               com/jd/crm/Logback/TestClass
  #29 = Utf8               java/lang/Object
  #30 = Utf8               com/jd/crm/Logback/Super
  #31 = Utf8               java/lang/Exception
{
  public com.jd.crm.Logback.TestClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #2                  // Field instanceVar:I
         9: return
      LineNumberTable:
        line 3: 0
        line 7: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   Lcom/jd/crm/Logback/TestClass;

  public int instanceMethod(int) throws java.lang.Exception;
    descriptor: (I)I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: iload_1
         1: iinc          1, 1
         4: ireturn
      LineNumberTable:
        line 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/jd/crm/Logback/TestClass;
            0       5     1 param   I
    Exceptions:
      throws java.lang.Exception
    MethodParameters:
      Name                           Flags
      param
}
SourceFile: "TestClass.java"

以上是javap帮助我们生成的class文件解析结果,只是给人看,而非机器。

通过编译后生成class文件格式如下,因为class文件是以8位作为一个字节的二进制流。为了方便计算,用16进制表示二进制(1个字节=2个十六进制的数,故下面每2个数就代表1个字节)

2.1 魔法数

前四个字节cafebabe是固定值,任何语言编译成jvm认识的二进制流,前四位必须是固定的cafebabe字节。

2.2 版本号

紧接着2个字节00表示次版本号为0 ;0034代表主版本为52(jdk版本号对应的jdk版本为1.8)参考jdk版本和class字节版本的对应关系

2.3 常量个数

常量个数const_pool_count字节码为00 20对应的说明常量个数为32,实际为31个,因为首位jvm作为保留位使用。

2.4 常量池

常量池存放两大常量:字面量和符号引,字面量如文本字符串,被生命的final常量值等,而符号引用则包含类、接口的全限名称、字段、方法名称和描述符号等等。参考javap生成的类文件信息。

这里只分析下其中一个常量,在上面常量个数2个字节后面紧接着一个字节0a十进制为10,参考常量池类型10代表类中方法的符号引用。继续参考方法类型MethodRef_info个格式定义:前两个字节0004代表方法所在类名称的索引,后两个字节0001a代表一个NameAndType类型的索引。

2.5 类访问标志

紧接常量池定义完后的u2标识访问标志,本例标识为0x0021和下图标志位按位或计算,如0x0001为真,0x0020也为真,其他为否 最终确认访问标志位ACC_PUBLIC、ACC_SUPER

2.6 本类、父类、接口索引集合

根据图1的规则,u2两个字节0003标识当前类名的引用到,引用常量池数组下标为#3,根据图3所示子项的类名为com/jd/crm/Logback/TestClass;0004代表父类类名的引用常量池数组下标为#4,根据图4所示引用的父类类名为java/lang/Object;紧接着0001标识接口个数,指明数量为1,0005标识第一个接口数组中接口的名称,指向常量池中下标为5的名称为com/jd/crm/Logback/Super;

比如查找当前类索引如下图

2.7 字段表集合

字段表以数组的形式定义存储在常量表中

以上图说明,0002标识域个数为2个域标识,在本类中有两个,一个类的域字段staticVar 一个是实例对象的域字段instanceVar,如字段结构定义(下图)定义,前2个字节001a为访问标识,和类访问标识一样,分别用001a的二进制和下图字段域访问标识类型做位或运算,得出访问类型为ACC_PRIVATE类型。name_index的占用两个字节0006,指向常量表下标为6的引用,descriptor_index=0007指向常量表下标为7的引用,此处为I标识为数据类型为int,attributes_count=0001为1个,值为0008指向常量表下标为#8的引用常量ConstantValue,标识为静态变量,最终依次类推第二个域标识引用

字段结构定义

字段域的访问标志请参考类访问标志,逻辑计算一致,只是规则不一样而已 如下图

2.8 方法表集合

和域字段集合表定义类似 也是数组方式定义在常量池中 ,其中方法的结构体第四个字段attributes_count代表方法的属性数量,attribute_info就是属性的集合参考属性表集合

方法表访问标识类型

通过上面方法的访问标志、名称索引和描述索引定义方法的基本信息,方法的代码块则存放于类型为Code的属性表中。

2.9 属性表集合

类、字段表、方法表本身可包含属性表,属性表格结构体如下,属性表结构类型较多,比如有Code类型、Exception类型、MethodParameters类型等等,具体参考属性表类型。所有的属性都是引用常量池中的属性类型名称。然后根据属性的长度指定该属性的内容,根据属性的不同类型解析不同的属性值。格式定义如下

以Code属性举例,Code属性结构如下所示

jvm按属性获取attribute_name_index指向常量池一个字符串常量Code,紧接着attribute_length标识Code类型Info信息长度,这个info内容包括:max_stack 最大栈深,max_locals局部变量槽数量,code_length标识机器字节码长度,往后查询字节码如下图所示,其实就是0/1/4/5/6/9的指令集。Code类型又嵌套异常属性表、行号表LineNumberTable、LocaVariableTable 局部变量表等等信息。如下图javap生成的类定义信息

1.Code1方法执行过程:

构造方法:descriptor ()V标识无参无返回值为Void的方法索引,flags可见性修饰符;

程序运行时,先将常量池、方法字节码、字符串常量池,静态变量加载到元数据区(1.8后字符串常量池,静态变量放入了堆);main线程开始运行,分配栈帧内存,其中操作数栈stack=2表示运行该方法所需要的最大操作数栈的深度是2;locals=1表示该运行方法所需要的最大局部方法表的最大slot数据是1;args_size是该方法的形参个数,如果是实例方法 第一个形参是this引用。此例正是this引用。所以args_size=1+实际的参数

aload_0: 加载 slot0的局部变量,即this,作为下面的invokespecial 构造方法调用的参数

invokespecial: 调用构造方法,常量池第#1项,即【Method java/lang/Object."<init>":()V】

aload_0 :再次加载 slot0的局部变量,即this

iconst0: 将int类型为0的数值压入栈顶(为什么要再放入栈顶,我个人人为可能是下面初始化实例会需要指定到当前的实例对象)

putfileld: 将常量池中#2 也就是com/jd/crm/Logback/TestClass.instanceVar 实例变量赋值为0,并弹出栈。

通过以上指令操作,对象已经初始化,可发现在实例变量初始化之前是先调用的构造器方法,后才初始化实例变量。

1.Code2方法instanceMethod执行过程:

descriptor标识为int类型入参、int类型出参

flags标识方法问public类型

statck=2代表栈深度为2,locals=2标识预留两个局部变量槽;args_size=2标识两个参数,分别为隐藏的this和方法的形式参数,下标[0]=this、 [1]=param 如下所示

LocalVariableTable:

Start Length Slot Name Signature

0 4 0 this Lcom/jd/crm/Logback/TestClass;

0 4 1 param I

0:iload_1 标识将上面局部变量槽LocalVariableTable下标为1的param参数压入栈

1:iconst_1 将int类型为1的常量数字压入栈

2: iadd 将当前栈顶的两个元素 param和1相加

3: ireturn 返回

LineNumberTable:

line 10: 0

标识实际java源代码的行数

2.10 字节码指令简介

•加载和存储指令:

•运算指令

•类型转换指令

•对象创建和访问指令

•操作数栈管理指令

•控制转移指令

•异常处理指令

•同步指令

•方法调用和返回执行

invokervirtual:调用对象的实例方法 invokerinterface 调用接口方法,自动运行期搜索一个实现接口的对象进行方法调用;invokerspeical:调用init、私有和父类调用的特殊方法调用;invokedynamic:运行时动态解析

3 类文件加载

3.1 加载

jvm通过classLoader(双亲委派)将class类文件二进制流加载到元数据区内存,

将字节流所标识的静态存储结构转换为元数据区的动态存储

在堆内存创建一个Class对象,堆中的Class并不存储静态变量、常量、方法等实际信息(实际存储元空间),可以看做只是一个句柄,通过对象头的类指针指向元空间类信息。这样在强制转换或者InstanceOf判断时,会根据对象中的类指针指向元空间的类常量池进行判断是否为同一个类。

3.2 验证

1、文件格式验证

2、元数据验证

3、字节码验证

4、符号引用验证

3.3 准备

准备阶段是为类变量(静态变量)分配内存并设置类变量初始值的阶段,分配这些内存是在元数据区里面进行的,但是类变量(无final修饰的静态变量)、字符串常量在1.8及以后都放入了堆区间。这个阶段有两点需要重点介绍以下的:

1、只有类变量(被static修饰的变量赋值初始值,static final修饰的赋值为程序指定值)会分配内存,不包括实例变量,实例变量是在对象实例化的时候在堆中分配内存的。

2、设置类变量的初始值是数量类型对应的默认值,而不是代码中设置的默认值。例如public static int number=111,这类变量number在准备阶段之后的初始值是0而不是111。而给number赋值为111是在类的初始化阶段。

3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

符号引用:常量池中类、字段的常量字符串表示方式

类和接口的解析举例:假如类A引用了类B,加载阶段是静态解析,这时候B还没有被放到JVM内存中,这时候A引用的只是代表B的符号,这是符号引用。

直接引用: 指向目标的指针或者相对偏移量

类和接口的解析举例:类A在解析阶段发现自己符号引用了B,如果这个时候B还没被加载。就是直接触发B的类加载,加载后会在运行常量池存储B的有效类信息地址,并且直接引用。

•类和接口的解析

•字段解析根据常量池字段filedrf_info中的符号进行解析,首先在符号引用的类中根据简单名称和字段描述符查找,如果查到则返回这个字段的直接引用并结束,否则从下往上地柜各个父类查找,如果还未查到则抛出NoSuckFieldError异常

•方法解析

•接口方法解析

4 类实例初始化

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化clinit方法。在Java中对类变量进行初始值设定有两种方式:定义静态变量并指定值、使用静态代码块

对象初始化

4.1 初始化对象前检查

jvm碰到一个new指令,首先判断改指令指向的常量池的类全名是否被加载、解析初始化过,如果没有则进行类加载,参考类文件加载

4.2 内存分配

通过jvm内存分配机制,此分配机制取决回收机制,通过指针碰撞方法或者空闲列表方式进行堆内存分配;

1.指针碰撞法 假设Java堆中内存是完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。这两种都是新生代垃圾收集器,因此都是使用复制算法,可以得到比较完整的内存区域。

2.空闲列表法 事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种“标记-清除”算法实现的,因此会得到很多碎片因此和空闲列表配合使用。

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

•CAS: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

•TLAB(本地现成缓冲区): 为每一个线程预先分配一块堆内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。

4.3 初始化0值

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

4.4 对象头设置

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

4.5 实例构造器初始化

4.6 对象的内存布局

对象在对中的存储布局主要分为三部分,对象头、实例数据、对齐填充

对象头:

主要两类:其主要包括两部分数据:Mark Word、Class对象指针。特别地对于数组对象而言,其还包括了数组长度数据。在64位的HotSpot虚拟机下,Mark Word占8个字节,其记录了Hash Code、GC信息、锁信息等相关信息;而Class对象指针则指向该实例的Class对象。

HotSpot对象头

实例数据:对象定义的实例变量,这部分数据存储受到虚拟机分配策略参数(-XX:FieldsAllocationStype)和字段定义的顺序影响。HotSpot默认分配的策略是将相同宽度字段一起存放,父类的变量会出现在子类变量之前。

对齐填充:jvm存储任何大小必须是8个字节的整数倍,不够补齐。这个和类二级制字节流一致。下面是个无锁状态的对象实例化后的数据结构,使用jol工具打印出的实例布局如下

5 对象的访问

5.1 句柄访问

Java堆中将会划分出一块内存来作为句柄池,reference中 存储的就是对象

的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信 息

5.2 直接访问

直接访问是reference中直接存储的实例对象的地址,实例对象中包含了类对象的访问指针,也就是如果访问类对象需要多一层引用

优缺点

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销, 由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的主要虚拟机Sun HotSpot而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见

6 虚拟机字节码执行引擎

6.1 运行时栈帧结构

1.局部变量表:在class文件被编译时,就已知某个方法的局部变量槽有几个,主要存放方法参数和方法内部定义的局部变量

2.操作数栈:和局部变量表相似,编译时就明确了操作数栈的深度

3.动态链接:大部分类在类加载解析过程中,会将符号引用转为直接引用,也就是在类加载阶段清楚调用哪个类的哪个方法(这些方法调用参考字节码指令简介中invoke*指令),但是有一部分必须在运行期间才能确定目标的方法的直接引用。

4.方法返回地址

6.2 方法调用

1.解析:在内解析阶段,会将符号引用转换为直接引用,这种在解析阶段就能确定的调用方法版本称为解析,比如invokesatic invokespecial invokevirtual等等指令指示的方法调用

2.静态分派:方法的重载,虚拟机需要根据方法的入参个数和类型方能定位到某个具体方法,发生在编译阶段,故也属于一种解析方式

3.重载方法匹配优先级:方法重载过程中,涉及方法的入参和个数,而入参存在自动类型转换,比如重载方法入参为char类型,如果不存在入参为char类型的方法匹配,则char进行自动类型转换为int类型,在最终匹配了Int入参类型的方法。方法重载的本质

4.动态分配:如下图所示,man和women和重新man引用指向women然后方法调用sayHello,此时字节码显示的符号引用都是Human#sayHello,但是实际执行结果和指令码不一致,这是因为invokevirtual指令,在指令调用之前都会aload_x来加载实际的数据类型,这就是方法重写的本质

5.invokedynamic指令:为了解决其他invok*指令方法分配规则完全固化在虚拟机中的问题,jvm支持设计者更高的灵活度,将动态调用可以以api的方式直接使用。参考java.lang.invoke包的使用方式。

6.3 基于栈的字节码解释执行引擎

jvm是基于栈的指令集合,这种指令自身不带参数,使用操作数栈的输入输出作为指令本身的参数。物理机一般是基于寄存器的指令集,指令本身携带参数并存放在寄存器。

下面是一个基于栈来展示在虚拟机中字节码是如何执行的。

以上字节码执行过程如下

7 容易混淆点

7.1 文件常量池

类加载后,类的域字段、方法和类描述信息会加载到元数据区,既属于类的静态常量池

7.2 运行时常量池

我们上面说的class文件中的常量池,它会在类加载后进入方法区中的运行时常量池。并非只有Class定义的文件常量合并处理后放入运行时常量池,在运行期间也可以将新的常量放入池中,比如String类的intern方法

7.3 字符串常量池

字符串常量池存放在堆内存(>=1.8)中,堆里边的字符串常量池存放的是字符串的引用或者字符串(两者都有),如下图描述字符串创建的堆分布

上图说明:

引用初始化初始化s、s2是先看常量池,有就返回对象引用,否则创建abc对象,然后创建s1/s2Ref常量引用返回

字符串相加:先创建StringBuilder对象,然后apend字符串a、apend字符串b 然后toString(new方法)生成字符串ab对象并在字符串常量池生成引用返回,为什么不要字符串相加,就是因为会生成大量StringBuilder对象

String s = "a"+"b";//返回的是常量池的ab字符串的引用
String s1 ="ab";
System.out.println(s == s1);//因两个最终都指向字符串常量池,所以为true

new 字符串相当于堆创建两个对象,一个String对象,然后创建字符串堆存储,然后String对象引用到字符串的堆存储,

String s1 ="a";
String s = new String ("a").intern();//强制生成字符串常量池引用
System.out.println(s == s1);//返回true
String s1 ="a";
String s = new String ("a");
System.out.println(s == s1);//返回false

8 附件

jvm常量池类型和结构体定义

常量池类型

常量池类型结构定义

常见的属性类型

jdk版本好class字节版本号对应关系

属性表类型

作者:京东物流 王北永

来源:京东云开发者社区

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章