JVM虚拟机——Class文件与类加载机制

Class文件结构

JVM的无关性

平台的无关性是建立在操作系统上,虚拟机厂商提供了许多可以运行在不同平台的虚拟机,他们都可以执行和载入字节码,保证了程序的“一次编写,到处运行”。Java虚拟机不和任何语言绑定,只与“Class文件”这种二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及一些其他的辅助信息。

Class类文件

任何一个Class文件都对应着唯一一个类或者接口的定义信息,Class文件是一组以8位字节为基础单位的二进制流。

Class文件

格式

各个数据项目按照顺序紧凑的排列在Class文件中,中间没有任何的分隔符,这样整个Class文件存储的内容几乎全是程序运行的必须数据。
Class文件采取一种类似C语言结构体的伪结构来存储数据,只有两种数据结构:无符号数和表

  • 无符号数属于基本的数据类型,u1,u2,u4,u8分别代表1 2 4 8个字节的无符号数,无符号数可以用来描述数字,索引引用、数量值或者按照UTF-8编码的字符串值。
  • 是有多个无符号数或者其他表作为数据项构成的复杂数据类型,所有表都习惯性的以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。
内容

Class文件不像XML等描述语言,由于没有任何分隔符,所以其中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表的含义是什么,长度是多少,顺序如何,都不允许改变。

  • 魔数与Class文件的版本:Class文件的头4个字节称为魔数,他的作用是确定这个文件是否是一个可以被虚拟机接受的Class文件。第5和第6个字节是次版本号,第7和第8个字节是主版本号。虚拟机拒绝执行超过其版本号的Class文件。
  • 常量池:常量池中的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(这个容量计数是从1开始的)。
    常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近Java中的常量概念:如文本字符串、声明为final的常量值等;符号引用属于编译原理方面的概念,包括: 类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
  • 访问标志:用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;如果是类的话,是否定义为final等
  • 类索引、父类索引和接口索引集合:这三项来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,其他的Java类都有父类,父类索引也自然都不为0。接口索引用来描述这个类实现了哪些接口,按照implements语句后的接口顺序从左到右排列在接口索引集合中。
  • 字段表集合:描述接口或者类中声明的变量。字段表中不会列出从父接口或者超类中继承来的字段。但有可能列出原本不存在的字段:比如内部类为了保持对外部类的访问,会自动添加外部类实例的字段。
  • 方法表集合:描述方法的定义。方法里的Java代码,经过编译后存放在属性表集合中的方法属性表集合中的一个名为“Code”的属性里。与字段表类似,如果方法在子类没有被重写,就不会出现在方法表集合中。
  • 属性表集合:存储Class文件、字段表、方法表,都有自己的属性表集合。

字节码指令

java虚拟机的指令由一个字节长度的操作码和操作数构成。以下是一些常见的字节码指令:

加载和存储指令

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容:
将一个局部变量加载到操作栈:

iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>

将一个数值从操作数栈存储到局部变量表:

istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>

将一个常量加载到操作数栈:

bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>

扩充局部变量表的访问索引的指令:

wide

运算或算数指令

用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

加法指令:iadd、ladd、fadd、dadd。 减法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul等等

类型转换指令

可以将两种不同的数值类型进行相互转换,Java虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):
int类型到long、float或者double类型。
long类型到float、double类型。
float类型到double类型。
处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:

i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f

创建类实例的指令

new

创建数组的指令

newarray、anewarray、multianewarray

访问字段指令

getfield、putfield、getstatic、putstatic

数组存取相关指令

把一个数组元素加载到操作数栈的指令:

baload、caload、saload、iaload、laload、faload、daload、aaload

将一个操作数栈的值存储到数组元素中的指令:

bastore、castore、sastore、iastore、fastore、dastore、aastore

取数组长度的指令:

arraylength

检查类实例类型的指令

instanceof、checkcast

操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:

pop、pop2

复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:

dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2

将栈最顶端的两个数值互换:

swap

控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下:
条件分支:

ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne

复合条件分支:

tableswitch、lookupswitch

无条件分支:

goto、goto_w、jsr、jsr_w、ret

方法调用指令

invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic指令用于调用类方法(static方法)。
invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关。

方法返回指令

是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现。

同步指令

有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构。而我们现在的PC机使用的是依赖寄存器的指令集进行工作。

方法调用

调用目标在编译器进行编译时就必须确定,这类方法的调用称为解析。在Java中符合”编译期可知,运行期不可变“这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可见,所以他们不可重写其他版本,适合在类加载阶段进行解析。

  • 静态分派:静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
  • 动态分派:最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

类加载机制

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

初始化

初始化阶段,虚拟机规范规定了有且只有5中情况必须立即对类进行初始化:

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的java场景是:使用new关键字实例化对象的时候、读取或者设置一个类的静态字段的时候以及调用一个类的静态方法的时候;
    1.对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类引用父类中定义的静态字段,只会触发父类的初始化。
    2.数组形式的new不会触发初始化。
    3.直接打印类的常量不会触发类的初始化(有可能项目中的常量改了,关联使用的类不重新编译就会还是原来的值)。常量在编译阶段会通过常量传播优化,将常量的值存储到使用此常量类的常量池中,以后对常量的引用都变为了类对自身常量池的引用了;如果一个常量去引用另一个常量,这个时候编译阶段无法进行优化,才会触发类的初始化。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化;
  • 当初始化一个类时,如果其父类还没有进行初始化,则需要先触发其父类的初始化;
  • 虚拟机启动时,用户需要指定一个主类(包含main()方法的那个类),虚拟机会先初始化这个主类;
  • 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

加载阶段

加载阶段需要完成3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流;
  • 将这个字节流所代表的静态存储结构转为方法区的运行时数据结构;
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各个数据的访问入口

验证

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

准备阶段

正式为类变量(被static修饰的变量,实例变量会在对象实例化时随着对象一起分配在堆中)分配内存并设置类变量的初始值(设置为数据类型的零值,真正赋值是在编译时)的阶段,这些变量所使用的内存都将在方法区中进行分配。

解析

虚拟机将常量池内的符号引用替换为直接引用的过程。主要解析静态方法和私有方法两大类。

类初始化阶段

是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的Java程序代码,根据程序员通过程序制定的主观计划去初始化类变量和其他资源。
或者说:初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
初始化是线程安全的:虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。所以如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。

双亲委派模型

对于任何一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性。从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(这个类加载器使用C++实现,是虚拟机自身的一部分),另一种就是所有其他的类加载器(这些类加载器都由Java语言实现,独立于虚拟机外部,全都继承自抽象类java.lang.ClassLoader)。

  • 启动类加载器(Bootstrap ClassLoader):这个加载器负责将存放在lib目录中的类库加载到虚拟机内存中。开发者无法直接使用此加载器。
  • 扩展类加载器(Extension ClassLoader):这个加载器负责加载lib\ext目录中的所有类库,开发者可直接使用此加载器。
  • 应用程序类加载器(Application ClassLoader):也成为系统类加载器,负责加载用户类路径(classpath)上所指定的类库,开发者可直接使用,如果应用程序没有定义过自己的类加载器,一般默认使用这个。
    PS:ClassLoad中的loadClass方法中的逻辑代码就是双亲委派模型。在自定义ClassLoad的子类的时候,常见的有两种做法:一种重写loadClass方法,另一种重写findClass方法。其实本质差别不大,毕竟loadClass也会调用findClass,但从逻辑上讲我们最好不要直接修改loadClass的内部逻辑,因为loadClass这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。同时,为了避免在重写loadClass方法时必须写双亲委派的重复代码,降低了代码的复用性,所以我建议只在findClass里重写自定义类的加载方法。

我们的程序都是由这三种类加载器相互配合进行加载的。
双亲委派模型要求除了顶层的启动类加载器以外,其余的类加载器都应有自己的父类加载器,父子关系不是继承,而是组合关系,使用双亲委派模型最大的好处是Java类随着他的加载器一起具备了一种带有优先级的层次关系。如果没有使用双亲委派模型,由各个类加载器自行去加载,应用程序会变得一片混乱。

tomcat类加载机制

tomcat本身也是一个Java项目,因此也必须被JDK的类加载器加载,也就必然存在引导类加载器、扩展类加载器、应用程序类加载器。
Common ClassLoader作为Catalina ClassLoader和Shared ClassLoader的parent,而Shared ClassLoader又可能存在多个children类加载器WebApp ClassLoader,一个WebApp ClassLoader实际上就对应一个Web应用,那Web应用就有可能存在Jsp页面,这些Jsp页面最终会转成class类被加载,因此也需要一个Jsp的类加载器。
需要注意的是,在代码层面Catalina ClassLoader、Shared ClassLoader、Common ClassLoader对应的实体类实际上都是URLClassLoader或者SecureClassLoader,一般我们只是根据加载内容的不同和加载父子顺序的关系,在逻辑上划分为这三个类加载器;而WebApp ClassLoader和JasperLoader都是存在对应的类加载器类的。

  • Bootstrap 引导类加载器 :加载JVM启动所需的类,以及标准扩展类(位于jre/lib/ext下);
  • System 系统类加载器 :加载tomcat启动的类,比如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。位于CATALINA_HOME/bin下;
  • Common 通用类加载器: 加载tomcat使用以及应用通用的一些类,位于CATALINA_HOME/lib下,比如servlet-api.jar;
  • webapp 应用类加载器:每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值