JVM — 类加载机制

前言:

通过这部分知识让我们对一些Java基础有更深刻的了解,也是为了精进Java的第一步。

为了有更好的阅读体验推荐类文件结构

一、为什么要使用类加载器

Java语言里,类加载都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会给java应用程序提供高度的灵活性。例如:
1.编写一个面向接口的应用程序,可能等到运行时再指定其实现的子类;
2.用户可以自定义一个类加载器,让程序在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分

二、类加载过程

2.1 概述

  使用Java编译器可以将Java代码编译为字节码Class文件,Java虚拟机并不关系Class来自何种语言。

        Class文件由类装载器装载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息对象可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能。

        虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

2.2  基本过程

类装载器就是寻找类的字节码文件,并构造出类在JVM内部表示的对象组件。在Java中,类装载器把一个类装入JVM中,要经过以下步骤:


扫描二维码关注公众号,回复: 878510 查看本文章
  • 装载:查找和导入Class文件
  • 链接:把类的二进制数据合并到JRE中
  1. 校验:检查载入Class文件数据的正确性;
  2. 准备:给类的静态变量分配存储空间
  3. 解析:将符号引用转成直接引用;

  • 初始化:对类的静态变量,静态代码块执行初始化操作

        加载(装载)、验证、准备、初始化和卸载这五个阶段顺序是固定的,类的加载过程必须按照这种顺序开始,而解析阶段不一定;它在某些情况下可以在初始化之后再开始,这是为了运行时动态绑定特性(JIT例如接口只在调用的时候才知道具体实现的是哪个子类)。值得注意的是:这些阶段通常都是互相交叉的混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。

2.3 详细过程

1)加载

主要完成:

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

       相对于类加载过程的其他阶段,加载阶段(准备地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

        加载完后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在内存中实例化一个Class对象,作为方法区中的这些类型数据的外部接口。

2)验证

    确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。主要分为:

  • 文件格式验证:class文件格式规范,如魔数是否对。主次版本是否在当前虚拟机处理范围。
  • 元数据验证:对字节码描述信息语义分析。如类是否有父类(除Object类之外)、类是否继承不能继承的(final修饰)。
  • 字节码验证:操作符合逻辑,合法的,进行校验分析。如操作栈放int,而用却按long类型来加载到本地变量表。
  • 符号引用验证:发生将符号引用转为直接引用,一般在解析中完成。如符号引用类中的类,字段,方法的访问性。
3)准备

        正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。仅仅是静态变量,实例变量是在对象实例化随对象一起分配在堆中。

        通常情况下,为默认值:public static int value  = 12,此时准备阶段过后初始值为0,而不是12,而将value赋12的指令是在程序被编译后,放在类构造器<clinit>()方法中,这一步在初始化阶段才会被执行。

        为什么是一般,如果加了final属性,则编译会把该字段添加ConstantValue属性,这样在准备阶段就会设置为12。

4)解析

        虚拟机常量池的符号引用替换为直接引用的过程。符号引用的目标不一定已经加载到内存中,而直接引用则必定是在内存中。

        虚拟机根据实际情况来看类被加载器加载时是对常量池中的符号引用进行解析,还是等到用到了符号引用使用前解析。

        解析主要是对类,接口,字段,类方法,接口方法等解析。

5)初始化

        在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

以下四种情况下初始化过程会被触发执行:

  • 使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候
  • 使用java.lang.reflect包的方法对类进行反射调用的时候
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化
  • JVM启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类

        类构造器<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的

        类构造器<clinit>()方法与类的构造函数(实例构造函数<init>()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中的第一个执行的<clinit>()方法的类肯定是java.lang.Object,这就是为什么父类静态语句块优于子类的。

        <clinit>()方法对于类或接口来说并不是必须的

        虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

结束生命周期:

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

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


三、类加载器

不同类加载器加载同一个class是不相同的。

双亲委派模型:

解决了各个类加载器的基础类统一问题,越基础的类由越上层的类加载器进行加载。

        比如:Java.lang.Object类,无论哪个类加载器去加载类,最终都是由启动类加载器进行加载,Object类在程序的各种类加载器环境中都是同一个类,否则,不适用双亲委派模型的话,系统将会出现多个Object类。

        不足:如果真的想调用下层用户代码时无法委派子类加载器进行类加载,解决这个问题,JDK引入了设置上下文类加载器

ClassLoader 隔离问题:

        对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由一个 ClassLoader 加载,是无法将一个类的示例强转为另外一个类的,这就是 ClassLoader 隔离。 

能不能自己写个类叫java.lang.System?

答案:通常不可以,但可以采取另类方法达到这个需求。 
解释:为了不让我们写System类,类加载采用委托机制,这样可以保证爸爸们优先,爸爸们能找到的类,儿子就没有机会加载。而System类是Bootstrap加载器加载的,就算自己重写,也总是使用Java系统提供的System,自己写的System类根本没有机会得到加载。

但是,我们可以自己定义一个类加载器来达到这个目的,为了避免双亲委托机制,这个类加载器也必须是特殊的。由于系统自带的三个类加载器都加载特定目录下的类,如果我们自己的类加载器放在一个特殊的目录,那么系统的加载器就无法加载,也就是最终还是由我们自己的加载器加载。


猜你喜欢

转载自blog.csdn.net/jae_wang/article/details/80328671