Java虚拟机基础——3类加载机制

96
隔壁老李头
4.3 2018.10.03 10:35* 字数 6494

Java虚拟机整体篇幅如下:

本片文章内容如下:

  • 1、类加载器
  • 2、"类"的生命周期
  • 3、一个类载入过程
  • 4、"类"结束生命周期
  • 5、new 一个对象过程
  • 6、总结

一、类加载器

(一)、概述

首先来看一下java程序的执行过程。如下图:


类加载器1.png

在这个框架图很容易大体上了解Java程序工作原理。首先当程序员写好.java文件后,需要先运行(假设该文件为demo.java)

javac demo.java  

此时,你的Java代码就被编译成字节码(.class),如果你是在IDE开发工具中,你保存代码的时候,开发工具已经帮你完成了上述的编译工作,因此你可以在对应的目录下看到class文件。此时class文件依然保存在硬盘中,因此,当你在命令行中运行

java demo

就完成了上面红色框中的工作,JRE的加载器从硬盘中读取class文件,载入到系统分配给JVM的内存区域——运行时数据区(Runtime Data Areas),然后执行引擎解释或者编译类文件,转化为特定的CPU机器码,CPU执行机器码,至此完成整个过程。

下面就来研究一下类加载器是什么东西?又是如何工作的?

(二)、层级关系

类加载器被组织成一种层级结构关系,也就是父子关系。其中Bootstrap是所有类加载器的父亲,如下图:


类加载器2.png

我们先来简单介绍下上面涉及到的几个class Loader

  • Bootstrap class loader
    当运行Java虚拟机时,这个类加载器被创建,它加载了一些基本的Java API,包括Object这个类。需要注意的是,这个类加载器不是用Java语言写的,而是用C/C++写的。
  • Extension class loader:
    这个类加载器出了基本API之外的一些拓展类,包括一些与安全性能相关的类。
  • System Class Loader
    它加载应用程序中的类,也就是在你的classpath中配置的类。
  • User-Defined Class Loader
    这是开发人员通过拓展ClassLoader类定义的自定义加载类,加载程序员定义的一些类。

(三)、委派模式

请参考Android插件化基础1-----加载SD上APK中的"双亲委托"

二、一个类生命周期

类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载7个阶段,其中验证、准备和解析这是三个部分统称为连接(linking)。如下图:


类加载3.png

其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的"开始"(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是相互交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始),这是为了支持Java语言的运行时绑定

三、一个类载入过程

通过上面的内容我们知道,一个类的加载过程被分为5个阶段:加载、验证、准备、解析、初始化。如下图

image.png

下面我们就详细讲解下

(一)、"加载"阶段

类的装载指的是将类的.class文件中的二进制数据读到内存中,将其放在运行时数据区的方法区内,让后在Java堆创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于Java堆中的Class对象,Class对象封装了类在方法区内的数据结构,并向Java程序员提供了访问方法区内的数据结构的接口。

image.png.png

"加载(loading)"阶段是"类加载(Class Loading)"过程的第一个阶段,在此阶段,虚拟机需要完成以下三件事:

  • 1、加载二进制字节流:
    通过一个类的全限定名(包名+类名)吗,来获取此类的二进制字节流,虚拟机规范没有指定二进制字节流从哪里读取,可以是class文件,可以是jar,也可以是由动态代理在运行时生成,等等,只要符合规范的字节流即可,由类加载器来决定字节流的来源、
  • 2、生成方法区的数据结构:
    将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 3、创建Class实例
    在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

加载阶段即可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码格式验证动作)是交叉进行的,加载阶段尚未完成,连接可能已经开始。

加载.class文件的方式有:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip、jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

(二)、"验证"阶段

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

Java语言本身是相对安全的语言,使用Java编码是无法做到如访问数组边界意外的数据、将一个对象转型为它并且未实现的类型等,如果这样做了,编译器将拒绝编译。但是,Class文件并不一定是由Java源码编译而来,可以使用任何途径,包括16进制编译器(如 UItraEdit)直接编写。如果直接编写了有害的"代码"(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或者程序的安全。

不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面的四个阶段的验证:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

那么下面我们就来一一介绍

1、文件格式验证

是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

  • 验证魔数是否是0xCAFEBABE;
  • 主、次版本号是否正在当前虚拟机处理范围内;
  • 常量池的常量是否有不被支持的常量类型等,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储方法区中
  • 常量池里的项是否执行不存在的常量或者不符合类型的常量
    经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。

更详细的关于.class文件的格式的内容,请参考Java字节码(.class文件)格式详解(一)

2、元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或者接口中要求的所有方法等。

3、字节码验证

主要工作是进行数据流和控制流分析,宝贝被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。

4、符号引用验证

发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在"解析阶段"中发生。验证符号引用通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

如果无法通过符号引用验证,将抛出一个java.lang.IncompatibleClassChangeError的子类

验证阶段对于虚拟机的类加载机制来说,不一定是必须要的阶段。如果所运行的全部代码确认是安全的。

可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。

(三)、"准备"阶段

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配到Java堆中。

比如:

public static int value=100

在"准备"阶段的时候value初始值为0,"初始化"阶段才会变为100

但是,对于static final field,此阶段是直接赋值的。
比如:

private static final int value=100;  

在"准备"阶段的时候value初始值为100

Java虚拟机中各种类型的默认初始值。

数据类型 默认初始值
int 0
long 0L
short (short)0
byte (byte)0
short '\u0000'
boolean false
float 0.0f
double 0.0d
reference null

(四)、"解析"阶段

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

1、符号引用(Symbolic References)与直接引用(Direct Reference)

可能有同学不了解符号引用和直接引用,我们就在这里简单介绍下:

  • 符号引用
    符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_CLASS_INFO、CONSTANT_FIELDREF_INFO、CONSTANT_METHODREF_INFO等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不是一定加载到内存中。在Java中,一个java类会编译.class文件。在编译时,java类并不知道所引用类的实际地址,因此只能使用符号引用来代替。比如com.test.Human类引用了com.test.Car类,在编译时Human类并不知道Car的实际内存地址,因此只能使用符号com.test.Car(假设是这个,当然实际中由类似于CONSTANT_CLASS_INFO的常量来标识的)来标识Car类的地址。各种虚拟机实现的内存布局可能不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用:直接引用可以是
    • 直接指向目标的指针(比如,指向"类型(Class对象)"、类变量、类方法的直接引用可能是指向方法区的指针)
    • 相对偏移量(比如,指向实体变量、实例方法的直接引用都是偏移量)
    • 一个能间接定位到目标的句柄。
      直接引用是和虚拟机布局相关的,同一个符号在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载到内存中了。

上面说的东西有点"空",不好理解,那我们举例说明:
在java中,一个java类将会编译成一个class文件。在编译时,java类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。比如com.demo.People类引用com.demo.Tool类,在编译时People类并不知道Tool类的实际内存地址,因此只能使用符号com.demo.Tool(假设)来表示Tool类的地址。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类 的实际内存地址,因此便可以既将符号com.demo.Tool替换为Tool类的实际内存地址,及直接引用地址。

2、解析类或者接口
解析类或者接口.png
解析类或者接口2.png
3、解析字段
解析字段.png
4、解析类方法
解析类方法.png
5、解析接口方法
解析接口方法.png

(五)、"初始化"阶段

类初始化时类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

1、<clinit>与<init>

Java在编译生成.class文件时,会自动产生两个方法,一个是的初始化方法<clinit>。另一个是实例的初始化方法<init>。

<clinit>与<init>的区别

  • 这两个方法一个是虚拟机在装载一个类初始化的时候调用——<clinit>。另一个是在类实例化的时候调用的——<init>。
  • 所有的类变量初始化语句和类静态初始化语句都被Java编译器收集到了一起,放在一个特殊的方法中。这个方法就是——<clinit>
  • <init>方法时在一个类进行对象实例化时调用的。实例化一个类有四种途径:
    • 调用new操作符;
    • 调用Class或java.lang.reflect.Constructor对象的newInstance()方法;
    • 调用任何现有对象的clone()方法;
    • 通过java.io.ObjectInputStream类的getObject()进行反序列化

所以Java编译器为它的每一个类都至少生成一个实例初始化方法。一个用于初始化静态的类变量,一个是初始化实例变量。

2、<clinit>()方法的执行规则:
  • 1、<clinit>()方法时由编译器自动收集类中的所有变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面静态语句中可以赋值,但不能访问。
  • 2、<clinit>()方法与实例构造器<init>()方法(类的构造器)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
  • 3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 4、接口中不能使用静态语句块,但仍然有类变量(final static) 初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际引用中这种阻塞往往是很隐蔽的。
3、主动引用和被动引用:

在准备阶段,变量已经赋过一次系统要去的初始值,在初始化阶段,则是根据程序员通过程序的主管计划区初始化类变量和其他资源。Java虚拟机规范了4种情况必须立即对类进行初始化(加载、验证、准备必须在此之前完成)

  • 1、当使用new关键字实例化对象时,当读取或者设置一个类的静态字段(被final修饰的除外)时,以及当调用一个类的静态方法时(比如构造方法就是静态方法),如果类未初始化,则需要先初始化。
  • 2、当通过反射机制对类进行调用时,如果类未初始化,则需要先初始化。
  • 3、当初始化一个类时,如果其父类未初始化,先初始化父类。
  • 4、用户指定的执行主类(含main方法那个类)在虚拟机启动时会仙贝初始化。

除了上面这4种方式,所有引用类的方法都不会触动初始化,称为被动引用。上面这4种方式是主动引用。如:通过子类引用父类的静态字段,不会导致子类初始化;通过数组定义来引用类,不会触发此类的初始化;引用类的静态扁郎不会触发定义常量类的初始化,因为常量在编译阶段已经被放入到常量池中了。

(五)、小结:

上面就是一个类加载兵可以使用的整个过程,Java的类加载这种只有需要的时候才加载进来的做法为内存节省了很大的空间。
总体流程如下:


类加载5.png

四、"类"结束生命周期

在以下情况的时候,Java虚拟会结束生命周期

  • 1、执行了System.exit()方法
  • 2、程序正常执行结束
  • 3、程序在执行过程中遇到了异常或错误而异常终止
  • 4、由于操作系统出现错误而导致Java虚拟机进程终止

五、new 一个对象过程详解

上面的基本上都是理论知识,但是怎么把理论知识转化为实战经验,是需要大家平时工作中日益积累的。所以很多招聘者会在面试中增加一个问题——“请描述Java中 new一个对象的过程”类似的面试题,这道题其实说深也深,说浅也浅。那我们就结合我们上面的理论知识,来是描述一下"Java中 new一个对象的过程"

我们将整个过程划分为两个部分:

  • 1 类加载过程
  • 2 对象创建过程

下面我们就详细跟踪下:

  • 1、类加载过程
    • 1.1、JVM会先去方法区中找到没有相应类的.class存在。如果有,就直接使用;如果没有就把相关的.class加载到方法区。
    • 1.2、在.class加载到方法区时,会分为两个部分加载:先加载非静态内容,再加载静态内容。
    • 1.3、加载非静态内容:把.class中的所有非静态内容加载到方法区下的非静态区域内
    • 1.4、加载静态内容:
      • 1.4.1、把.class中的静态内容加载到方法区下的静态区域内
      • 1.4.2、静态内容加载完成之后,对所有静态变量进行默认初始化
      • 1.4.3、所有静态变量默认初始化完成之后,再进行显示初始化
      • 1.4.4、当静态区域下的所有静态变量显示初始化后,执行静态代码块
    • 1.5、当静态区域下的静态代码块,执行完之后,整个类的加载就完成了。
  • 2、对象创建过程
    • 2.1、在堆内存中开辟一块空间
    • 2.2、给开辟空间分配一个地址
    • 2.3、把对象的所有非静态成员加载到所开辟的空间下
    • 2.4、所有非静态成员变量默认初始化完成之后,调用构造函数
    • 2.5、所有非静态变量默认初始化完成之后,调用构造函数
    • 2.6、在构造函数入栈时,分为两部分:先执行构造函数中的隐式三式,再执行构造函数中书写的代码
      • 2.6.1、隐式三步
        • 2.6.1、执行super语句
        • 2.6.2、对开辟空间下的所有非静态成员变量进行显式初始化
        • 2.6.3、执行构造代码块
      • 2.6.2、在隐式三步执行完之后,执行构造函数中书写的代码
    • 2.7、在整个构造函数执行完并弹栈后,把空间分配的地址赋值给一个引用对象

五、总结

我们先来看下Java程序的执行流程图:


执行流程图.png

再来看下JVM的大致物理结构图


物理结构图.png

Java虚拟机把描述类的数据从Class文件加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制。Class文件由该类装载器装载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息对象可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借用这个Class相关的元信息对象简介调用Class对象的功能,这里就是我们经常能见到的Class类。

类从被加载到虚拟机存在开始,到卸载出内存位置,它的整个生命周期包括了:"加载(Loading)"、"验证(Verification)"、"准备(Preparation)"、"解析(Resolution)"、"初始化(Initialization)"、"使用(using)"和"卸载(Unloading)"七个阶段。其中验证、准备和解析三个部分统称为"连接(Linking)",这七个阶段的发生顺序如下:

生命周期.png

大家喜欢就点赞,您的每一次点赞,都是我努力和进步的动力!您可能想不到:您的小小一按,可能就会对另外一个人产生翻天覆地的影响。!最后谢谢您的支持与厚爱

虚拟机(JVM、DVM、ART)
Web note ad 1