jvm的类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存结束,真个生命周期包括了几个阶段:

加载-》验证-》准备-》解析-》初始化-》使用-》卸载

 

虚拟机规范严格规定了有且只有四种情况必须立即对类进行初始化操作

  1. 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行初始化,必须先初始化,生成这4条指令最常见的java代码场景是:new一个对象、读取或者设置一个类的静态字段(被final修饰,在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法
  2. java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,必须触发初始化
  3. 当初始化一个类时,如果其父类还没有初始化,则先初始化其父类
  4. 当虚拟机启动时,需要先初始化一个主类(包含了main方法的类)

 

一、加载

虚拟机规范了加载阶段的三件事

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

虚拟机的规范并没有非常明确的规定如何来获取类的二进制字节流,因此加载阶段出现了各种花样

  1. 从zip包读取,最终成为了JAR,WAR格式的基础
  2. 从网络中获取
  3. 运行时计算,即动态代理动态生成代理的二进制字节流
  4. 由其他文件生成例如JSP

人们可以通过使用系统提供的类加载器来加载,也可以自定义类加载器来加载

 

二、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区进行分配,这时候进行内存分配的仅包括类变量,也就是static修饰的变量,初始值通常情况下指的是零值,比如

public static int value = 123;准备阶段过后value值为0,而不是123;特殊情况下,静态变量被final修饰,那么在编译期javac将会为value生成ConstantValue属性,在准备阶段value就会被初始化为ConstantValue指定的值

 

三、初始化

初始化阶段就是执行类构造器<cinit>()方法的过程,

  1. <cinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并而成,静态语句块中只能访问定义在静态语句块之前的静态变量,定义在其后的变量,静态语句块中可以对其赋值,但不能对其进行访问,也就是说不能调用其成员变量和方法
  2. <cinit>()方法与类的构造器不同,类的构造器是实例初始化方法,<cinit>()不需要显式调用父类的<cinit>()方法,虚拟机会在调用子类的<cinit>()之前,首先保证父类<cinit>()已经执行完毕,因此,虚拟机中第一个执行<cinit>()的类就是java.lang.Object
  3. 由于父类的<cinit>()方法首先被调用,因此变量赋值操作也会优先于子类
  4. <cinit>()方法对于类和接口来说不是必须的,如果一个类中没有静态代码块,也没有静态变量赋值操作,那么编译器可以不为该类生成<cinit>()方法
  5. 接口不能使用静态语句块,但仍然有变量赋值操作,与类不同的是,接口在执行<cinit>()方法之前不会去执行其父接口的<cinit>()方法,只有当父接口的变量被使用时,父接口才会初始化,同理,接口的实现类在执行<cinit>()之前也不会调用接口的<cinit>()
  6. <cinit>()方法的执行是线程安全的,多线程在执行类初始化时,只会有一个线程会执行<cinit>()方法,其他线程阻塞

 

四、类加载器

对于任意一个类,需要加载这个类的类加载和其本身一同确定类在虚拟机中的唯一性。比较两个类是否相等,只有在两个类是被同一类加载器加载的情况下才有意义,否则,即使两个类是由同一个class文件加载的,只要加载他们的类加载器不同,那么两个类必定不相等

双亲委派模型

站在java虚拟机的角度来讲,只存在两种不同的类加载器,一种是启动类加载器Boostrap,这个类加载器是c++编写的,隶属于虚拟机本身,另外一种就是所有其他的类加载器,这些类加载器都是由java编写,属于虚拟机之外的加载器,并且全部继承自抽象类java.lang.ClassLoader

从Java程序员的角度来看,一般会用到三种系统提供的类加载器

  1. Boostrap 前面讲了
  2. 扩展类加载器Extension ClassLoader负责加载JAVA_HOME/lib/ext目录中的,或者被java.ext.dirs系统变量指定的路径下的所有类库,开发者可以直接使用扩展类加载器
  3. 应用程序类加载器 Application 由于这个类加载器是由ClassLoader.getSystemClassLoader()方法返回的,所以也称作是系统类加载器,它负责加载classpath上所指定的类库,是程序中默认的类加载器

Bootstrap -> Extension -> Application -> userClassLoader

这样的一种层次关系成为类的双亲委派模型,双亲委派模型要求除了顶层的Boostrap加载器之外,其余的类加载器都应当有自己的父加载器,这里类加载器之间的父子关系一般不会使用继承来实现,而是使用组成的方式来复用父加载器的代码

双亲委派模型的工作方式:如果一个类加载器收到了一个加载类的请求,它首先不会自己去加载这个类,而是把这个请求委派给父加载器去加载,每一个层次的类加载器都是如此,因此所有的请求最终都会传送到顶层的Boostrap加载器中,只有当父加载器反馈自己无法加载该类时,子加载器才会自己尝试加载

使用双亲委派模型组织类加载器的好处是,java类随着加载它的类加载器也有了层次优先级,例如Object类,他存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都会委派给Bootstrap进行加载,保证了Object类在各种类加载器的环境中都是同一个类

 

五、执行引擎

栈帧:是java虚拟机数据区中的虚拟机栈的栈元素

栈帧中包含了局部变量表,操作数栈,动态链接,方法返回地址

局部变量表中存储了方法的参数,以及方法中定义的局部变量,在java程序被编译为class文件时,就已经确定了最大局部变量表的容量

 

方法调用,不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(比如我调用一个接口方法,这里就需要确定执行哪个实现类的方法),class文件编译的过程,不包含传统编译中的连接操作,所有方法在class文件中存储的都是符号引用,而不是方法在运行时内存布局的真正入口地址,也就是直接引用,需要在类加载期间或者运行时动态获取目标方法的直接引用,什么样的方法是在加载期间获取直接引用,什么样的方法在运行期间获取直接引用呢?

当一个方法在编译期就已经确定运行时不可变,那么它就会在类加载的时候符合引用转化为直接引用,这类方法一般都是静态方法或者私有方法,别的类无法通过继承或者别的方式重写出其他的版本,这类方法的调用成为解析

与之对应的有四条虚拟机字节码指令

  1. invokestatic:调用静态方法
  2. invokespecial:调用实例构造器,私有方法,父类方法
  3. invokevirtual:调用所有的虚方法
  4. invokeinterface:调用接口方法

只要能被1,2两条指令调用的方法都可以在类加载期间将符号引用转化为直接引用,被final修饰的方法不可变编译期可知,运行期不可变,因此也是在类加载期间转化直接引用的,解析调用一定是一个静态的过程,在编译期就会完全确定,在类加载阶段就会将涉及到的符号引用转化为直接引用,

 

分派:不同于解析,分派包括静态分派和动态分派

  • 静态分派,介绍静态分派之前我们首先看一下重载,Human Man Woman  三个类,Man和Woman都继承自Human,Human man = new Man();对于这段代码的理解,Human我们称为变量的静态类型static type,后面的Man称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅是在使用时发生,变量的本身的静态类型是不会发生改变的,并且编译期可知,而实际类型只能在运行期才确定,编译器在编译程序的时候并不知道变量的实际类型是什么

            //实际类型的变化比较好理解

            Human man = new Man();

            man = new Woman();

            //静态类型的变化

            sr.sayHello((Man) man);

            sr.sayHello((Woman) man);

            由于重载方法只有入参不同,所以使用哪个重载版本就取决于参数的类型和个数,编译期在重载时是通过参数的静态类型而不是实际类型作为判断依据的,因为实际类型在编译期是不可知的,而编译期需要确定使用哪个重载版本就只能根据静态类型,所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派,最典型的应用就是重载,而对于一些没有显式静态类型的字面量,很多情况下重载版本不是唯一的,往往只能确定一个更合适的版本,例如

char->int->long->float->double->Character->Character实现的接口->Object,这样的一个重载顺序

  • 动态分派

静态分派与重载由密切的联系,动态分派和多态性的另外一个重要体现重写同样有密切联系,因为编译期是无法确定变量的实际类型的,所以动态分派显然是运行期的操作,编译期只会在方法调用的地方加一个invokevirtual指令,等到运行的时候,做出相应的操作

  1. 找出操作数栈顶的第一个元素指向的对象的实际类型,记作C
  2. 如果C中找到了和常量池中符号引用(描述符和方法的简单名称)相同的方法,则进行访问权限检验,如果通过返回方法的直接引用,查找结束,不通过则抛非法访问异常
  3. C中没有找到和常量池中符号引用相同的方法,则按照继承关系自下往上依次搜索
  4. 如果最终都没有找到,则抛出java.lang.AbstractMethodError

由于invokevirtual指令第一步确定的就是变量的实际类型,因此两次调用Invokevirtual指令的时候都会把常量池中的符号引用转化到不同的直接引用上,这个过程就是重写的本质.

 

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

java虚拟机的执行引擎在执行java代码的时候有两种方式,一种是解释执行,通过解释器执行,另一种是即时编译,通过即时编译器产生本地代码执行

无论是解释还是编译,无论是虚拟机还是物理机,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需

要经过几个步骤

即时编译器会将频繁执行的代码直接编译为本地代码缓存起来,下次调用时就可以直接执行而不用逐条解释,加快执行速度

 

总结:

jvm虚拟机可以分为三大部分,

  1. 类加载器
  2. 字节码执行引擎
  3. 运行时数据区

jvm是基于栈的指令集,而不是基于寄存器的指令集,举个例子,1+1,基于栈的指令集 iconst 1, iconst 1 iadd, istore_0,意思是两次将1压入栈中,iadd弹出栈进行加法计算后重新入栈,然后将结果保留到局部变量表0的slot位置,而基于寄存器的指令集是  mov eax ,1  add  eax, 1,mov指令将1放入寄存器eax中,然后将1和eax的值进行加法计算,结果放入eax中,很明显寄存器的指令少,运行速度快,少了很多入栈出栈的操作。但是基于寄存器的指令集与硬件强关联无法做到可移植,不符合java的理念,而基于栈的指令集就不用考虑底层对于寄存器的操作,可以做到可移植性,并且指令紧凑,一个字节对应一条指令,不需要考虑空间分配,但是由于入栈出栈频繁,意味着内存的交互很多,因此速度会比寄存器指令集执行慢很多

 

java的逃逸分析:一个方法返回了一个方法中产生的新对象时,外部如果调用这个方法就有可能对该对象做出修改,所以我们就说这个对象逃逸了,如果这个方法中新产生的对象不会当作返回值返回出去,那么这个对象就没有逃逸,这样就可以使用标量替换在栈中分配内存,而不会在堆中为其分配内存。

public static StringBuffer craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb; //sb逃逸出去了
}
 
public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();  //sb在方法内部消化,没有逃逸
}

具体的逃逸分析可以https://blog.csdn.net/w372426096/article/details/80938788

注:加入一些我个人的思考,众所周知,程序的执行是由CPU控制器取指执行,机器指令是CPU硬件决定的一些列0 1代码,这种代码晦涩难记因此,开发人员不可能直接编写0 1代码来完成程序,所以汇编语言就出现了,他使用了一些容易记忆和理解的指令来代替机器指令,并且一一对应,所以开发人员可以使用汇编语言对计算机进行一些操作,但是由于汇编指令和机器指令一一对应所以也会导致汇编程序非常庞大冗余,所以我们需要将指令进行抽象,创造出更加容易编写的语言,所以c语言也就诞生了,C语言规定了语法词法等等规则,通过编译器可以将源代码转化为汇编指令,然后再通过汇编器将汇编指令转为机器指令,通过链接库函数以及启动函数(也就是和操作系统的接口)从而变为可执行的指令集,java语言是运行在jvm虚拟机上的,jvm虚拟机与操作系统之间通过一些列的接口进行通信,java程序不用考虑这一层的原理,面向对象设计更加简单易懂,java程序要运行,首先需要编译器javac将java程序编译为class文件,再经过jvm虚拟机中的解释器逐条翻译为机器指令进行执行(JIT即时编译器不在这里做描述),因此运行速度会比c语言慢很多,但是却易于编写。

 

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