JVM学习(二)类的加载,对象的创建,内存分配及访问定位

参考资料:

  《深入理解java虚拟机》

   https://www.cnblogs.com/chenyangyao/p/5245669.html

   https://blog.csdn.net/qq_41907991/article/details/79832585

   https://www.cnblogs.com/duanxz/p/4967042.html

一.类加载机制

1.加载

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(可控性最强,如可自定义类加载器等)。
  2.  将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3.  在内存中生成一个代表这个类的java.lang.Cass对象, 作为方法区这个类的各种数据的访问入口。

注:数组类本身不是由加载器加载,而是由java虚拟机直接创建的(直接继承自Object,这个类负责管理所代表的数组)。但是数组的元素类型最终要靠类加载器去创建。

2.验证

  1. 文件移式验证:是否以魔数CAFBABE开头主次版本号是否在虚抢机的处理范重内,常量池中是否有被不支持的常量类型、指向常量池中的各种索引是否有指向不存在或不符合类型的常量。
  2. 元数据验证:对字节码描述的信息进行语义分析以保证其描述的信息符合JAVA语言规范,如这个类是否有父类这个类的父类是否继承了不允许被继承的类、如果这个类不是抽象类是否实现了其父类或接口中要求实现的方法类中的字段和方法是否与父类产生矛盾。。....
  3. 字节码验证:对类的方法体进行校验,保证方法在运行时不会做出危害虚拟机的事情,如保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,保证跳转指令不会跳转到方法体意外的字节码指令上、保证方法体中的类型转换是有效的。注意: JDK1.6之后,java虚拟机进行了一项优化,给方法体的Code属性的属性表中增加了一个名为StackMapTable的属性,描述了方法体中所有的基本块(按照控制流拆分的代码块)开始时本地变量表和操作数栈应有的状态。
  4. 符号引用验证:该校验发生在虚拟机将符号引用转化为直接引用的时候(在解析阶段发生),校验的主要内容有符号引用中通过字符串描述的全限定名能否找到对应的类、在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段、符号引用的类、字段是否可被当前类访问(public、protected)。。

3.准备(默认初始化)

正式为类变量分配内存并设F类变量的初始值(0初始值),这些变量所使用的内存都在方法区中进行分配。也就是如static int value- 123准备阶段后,value的值为0,不是123,因为这个时候尚未开始执行任何java方法,而把value的值赋值为123是程序被编译之后,存放于类构造器cclinit>0方法之中的,该代码将在初始化阶段执行。

4.解析

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

4.1、类或接口的解析:
           假设当前代码所处的类为D, 如果要把一个未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下三步:

  1. 如果C不是一个数组类型,那么虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载的过程中,由于元数据验证,字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现任何异常解析过程就宣告失败。
  2. 如果是数组类型并且数组的元家类型为对象, 也就是N的描述符会是类似"Java/lang/Integer"的形式, 那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,则需要加载的元素类型就是"Java.lang. integer",接着由虚报机生成一个代表此数组维度和元素的数组对象。
  3. 如果上面的解析没有任何异常,那么在虚拟机中C实际上已经成为一个有效的类或者接口了,但在解析完成之前还要进行符号引用验证,确认D具有C的访问权限

4.2、字段解析(类变量、实例变量) :

      要解析一个未被解析过的字段符号引用.首先将会对字段的符号引用CONSTANT Fieldref info内的class index项 中索引的CONSTANT Class info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口的时候出现异常都会导致字段符号引用解析的失败,如果解析成功,将这个字段所属的类或接口用C表示,虚拟机规范要求按照以下步骤对C进行后续字段的搜索

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。查找结束。
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果在接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4. 否则,查找失败,拋出java.lang.NoSuchFieldError异常。

      注意:如果有一个同名字段同时出现在接口或父类,或者同时在自己或者父类的多个接口中时,编译器可能拒绝编译。

4.3、类方法解析:

      与字段解析一样,首先也需要根据方法引用CONSTANT Method info中的class index项中索引的方法所属的类或者接口的符号引用。如果解析成功,用C表示这个类,然后会按照下述的步骤进行后续的类方法搜索:

  1. 类方法和接口方法的符号引用的常量定义是分开的,分别为CONSTANT Method info和CONSTANT Interface-Methodref info,如果在类方法表中发现class_ index中 索引的C是一一个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常,
  2. 如果通过了第一步,在类C中查找是否有简单名和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在类C的父类中递归查找是否有简单名和描述符都与目标相匹配的方法,如果有就返回这个方法的直接引用,查找结束。
  4. 否则,在类C实现的接口列表及他们的父接口中递归查找是否有简单名和描述符都和目标相匹配的方法,如果有,这说明C是一个抽象类,查找结束,抛出java.lang.AbstractMethodError
  5. 否则,查找失败,抛出java.lang.NoSuchMethodError

注意:,如果查找过程成功的返回了直接引用,将会对这个类方法进行权限验证,如果发现不具备对此方法的访问权限,就拋出java.lang. lilalAccessError异常。

4.4、接口方法解析:

   与类方法解析类似

5.初始化

初始化阶段才真正开始执行类中定义的java代码(字节码)。在前面的准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序代码对变量进行初始化

  1. (clinit)方法是由编译器自动收集类中的所有类变量的赋值动作和静态初始化块中的语句合并而成的编译器收集的顺序是按照源文件中出现的顺序决定的,静态代码块中只能访问定义在静态代码块之前的类变量,定义在之后的,只能在静态代码块中对其进行赋值,但不能访问。
  2. (clinit)方法与类的构造函数(或者说是实例构造器())不同它不需要显示的调用父类构造器,虚拟机会保证在子类的(clinit)方法执行之前,父类的(clinit)方法已经执行完毕·(clinit)对于类或者接口来说并不是必须的如果一个类中没有任何静态语句块也没有对变量的赋值操作那么编译器就可以不为这个类生成(clinit)。
  3. 接口中不能使用静态代码块,但是仍然有变量的初始化赋值操作,因此和类一样也会生成(clinit)。但是与类不同的是,执行接口的()方法不需要先执行父接口的()只有当父接口中定义的变量在本接口中被使用时父接口才会执行初始化另外,接口的实现类在初始化时也一样不会执行接口的clinit)
  4. 虚拟机可以保证一个类的()方法在多线程环境下是安全的。

二.对象的创建

1.遇到new指令时,先检查指令参数是否能在常量池中定位到一个类的符号引用:

       (A)、如果能定位到,检查这个符号引用代表的类是否已被加载、解析和初始化过;

       (B)、如果不能定位到,或没有检查到,就先执行相应的类加载过程;

2. 对象所需内存的大小在类加载完成后便完全确定(JVM可以通过普通Java对象的类元数据信息确定对象大小);

      为对象分配内存相当于把一块确定大小的内存从Java堆里划分出来;

(A)分配方式:

如果Java堆是绝对规整的:一边是用过的内存,一边是空闲的内存,中间一个指针作为边界指示器,分配内存的方式就是将指针向空闲空间

那边挪动与对象相等大小的距离,这种分配方式称为"指针碰撞"(Bump the Pointer);

假设Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,这样就无法进行简单地指针碰撞了,这时候虚拟机会维护一张列

表,列表中记录哪些内存块是可用的,在分配内存的时候就从列表中找到一块足够大的空间划分给对象实例,同时更新列表上的记录,这

种内存分配的方式叫做“空闲列表”。

(B)线程安全问题

     对象的创建是在堆上的,而堆是线程共享的,上面两种方式分配内存的操作都不是线程安全的,有两种解决方案:

(I)、同步处理

      对分配内存的动作进行同步处理:

      JVM采用CAS(Compare and Swap)机制加上失败重试的方式,保证更新操作的原子性;

      CAS:有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做;

(II)、本地线程分配缓冲区

      把分配内存的动作按照线程划分在不同的空间中进行:

      在每个线程在Java堆预先分配一小块内存,称为本地线程分配缓冲区(Thread Local Allocation Buffer,TLAB);

      哪个线程需要分配内存就从哪个线程的TLAB上分配;

      只有TLAB用完需要分配新的TLAB时,才需要同步处理;

JVM通过"-XX:+/-UseTLAB"指定是否使用TLAB;
 

(C)对象的组成

讲完了Java堆对象的内存分配策略,那存储的对象到底存储的是什么呢?对象中都包含哪些内容呢?

对象在内存中主要存储这三个信息:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头,存储对象自身的运行时数据:哈希码,GC分代年龄,锁状态标志等。

第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等

对象头另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。 

实例数据,存储对象真正有效的信息。是我们在程序代码里面所定义的各种类型的字段内容

对齐填充,起到占位符的作用,当实例数据部分没有对齐时,就需要对齐填充来补全。

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

 

三、对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄和直接指针两种。 
  如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。如图1所示。 
 

 
图1 通过句柄访问对象
  如果使用直接指针访问的话,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如图2所示。 

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

扩展

在Hotspot JVM中,32位机器下,Integer对象的大小是int的几倍?

我们都知道在Java语言规范已经规定了int的大小是4个字节,那么Integer对象的大小是多少呢?要知道一个对象的大小,那么必须需要知道对象在虚拟机中的结构是怎样的,根据上面的图,那么我们可以得出Integer的对象的结构如下:

Integer只有一个int类型的成员变量value,所以其对象实际数据部分的大小是4个字节,然后再在后面填充4个字节达到8字节的对齐,所以可以得出Integer对象的大小是16个字节。

因此,我们可以得出Integer对象的大小是原生的int类型的4倍

关于对象的内存结构,需要注意数组的内存结构和普通对象的内存结构稍微不同,因为数据有一个长度length字段,所以在对象头后面还多了一个int类型的length字段,占4个字节,接下来才是数组中的数据,如下图:

猜你喜欢

转载自blog.csdn.net/qq_41907991/article/details/84310145
今日推荐