一篇文章带你真正领略java虚拟机的类加载机制

前言:本篇博文结合了《深入理解java虚拟机》(第二版),以及张龙的 “深入理解JVM虚拟机”(B站视频版)以及本人所看的各种其他书籍,及一些java面试题目之中介绍到的类加载机制部分,从底层全面讲起来,真正的能够理解这些过程,当然写出来也是对学习情况的一种输出的过程。

虚拟机的类加载机制

首先既然讲到了虚拟机的类加载机制,我们当然就是想知道的第一点就是——什么是类加载?

什么是?

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

怎么做到?

过程
在这里插入图片描述
分为五个大的步骤:加载 连接 初始化 使用 卸载
但是其中 连接又分为三个步骤: 验证 准备 解析

如何具体实现?

加载

加载是Java虚拟机中类加载中的第一个过程:
注意: 加载是类加载五个大步骤中的第一个小的步骤,不要弄混淆
关于加载 虚拟机会做以下的三件事情

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

注意: 但是关于第一点的获取二进制字节流就有多种的方法:

  1. 在本地系统中直接加载
  2. 过网络下载 .class 文件
  3. 通过 压缩 zip jar,进行加载

加载完成之后: 虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区之中的数据存储格式由虚拟机自行定义。

连接

连接分为三个小的步骤来分步执行,这里一一进行讲解

验证(连接阶段的第一步)

这一步所做的工作很少:目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机自身的安全
分为一下的四个步骤:

  • 类文件的结构检查
  • 语义检查 (例如一个类不能是abstract 和final)
  • 字节码验证
  • 二进制兼容性的验证

准备(连接阶段的第二步)

这个阶段相对之下还是比较重要的:

正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的的内存都将在方法区中进行分配
但是这个时候 要注意的是 这个时候进行内存分配仅仅包括类变量(被static修饰的变量,而不包括实例变量)赋初始值: 并不是我们认为赋予的初始值,是根据类型所指定的初始零值。这个时候分配的都是给定变量的零值。
下图是一些类型的变量所给定的零值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M201HzcV-1582691215600)(en-resource://database/1214:1)]
举个栗子

public static int value=123;

在准备阶段 会为其赋予的是 0,而不是123 ,想要获取到123 我们认为给定的值,必须是在程序被编译以后才会有,存放在类构造器中才会执行。

解析(连接阶段的第三步)

解析阶段就是虚拟机将常量池内的符号引用替换成直接引用的过程
此时我们不禁会有疑问,什么是符号引用,什么又是直接引用呢。

  • 符号引用
    符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中
  • 直接引用
    直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化

初始化算是类加载的最后一步,(为什么是类加载的最后一步:因为对于后面的使用就是我们的调用的各种过程,已经不需要再做过多的介绍内容
在前面的各个步骤中除了加载阶段用户可以自定义的使用自己编写的类加载器,其余的阶段都是自发进行性的。到了初始化阶段 才开始执行用户所定义的java代码。

前面讲到了在准备阶段中,系统会为变量赋予了最开始的零值,在初始化阶段就会根据程序员通过程序制定的主观计划去初始化类变量和其他资源。其实初始化阶段就是执行类构造器的阶段<clinit>()
<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作,和静态语句块(static{}})中的语句合并产生的。

什么时候初始化?

我们在上面已经完成了对基础信息的理解与掌握。下面开始学习什么时候一个类会初始化成功?这里就要提及到主动使用被动使用
并且所有的虚拟机的实现 必须是每个类或接口 被java程序 首次主动使用 时候才是会初始化。
主动使用 :

  • 创建类的实例 new
  • 访问某个类或接口的静态变量,或者对该静态变量赋值。
  • 调用类的静态方法
  • 反射
  • 初始化一个类的子类 在初始化一个类的子类 表示对父类的主动使用
  • 被标记为启动类 (包含main方法。)
被动使用

注意的是除了以上的情况下其他的方法,都被列为被动使用 都不会初始化 ,即使已经执行完了初始化之前的步骤但是也不会初始化。

初始化步骤
  • 假如说这个类还没有被加载和连接,那就先进行加载和连接。因为任何类的三个步骤 都是 加载 连接 初始化。
  • 假如说这个类还有一个父类 那就先对这个类的父类进行初始化 (对于接口的类型 不进行初始化处理)
  • 假如说类存在初始化语句,那就依次执行这个初始化语句。
  • 一个父接口 不会因为他的子接口 或者实现类的初始化而初始化 只用当程序首次使用特定接口的静态变量时,才会导致接口的初始化。
  • 在这里插入图片描述
    如下图表示了类与接口的不同,在下面也会进行一一讲解。

既然已经讲到了初始化步骤时候,这里就要讲到final关键字:

final

表示常量在编译的阶段 这个常量就会被存入到 调用这个常量的方法所在的类的常量池中 所以就相当于会被放入到一个类的常量池中
可能这句话听起来有点拗口,在下面的栗子中我们可以得知的是对于parent_3的 str1来说,其就会被存放到main方法的常量池中。
但是在本质上 调用类并没有直接引用到定义常量的类 就是 说 在一个类中 引用另外一个类中的 final 时候 并不会对其类中的静态代码块进行对应的初始化 (这个时候 两则之前就没有任何的关系 所以 就可以将class文件就算删除 也是可以的)
栗子

  public static void main(String[] args) {
// 1        System.out.println(Parent_3.str1);
//  2       System.out.println(Parent_3.str);
    }
}
class  Parent_3{

    public static final String  str1="dd";
    public static  final  String  str=UUID.randomUUID().toString();
    static {
        System.out.println("parent static code ");
    }

此时对于上面的输出:
在使用第一条打印语句的时候 只会打印出: dd。
在使用第二条打印语句的时候,会出现:
parent static code e39877fd-0bce-472c-a70d-320c9707f8bf
以上例子说明的是 在我们调用(因为被final修饰时候 被称为 常量的) str 与 str1时候 由于 str1是一个编译器就可以知道的常量,所以在调用时候 ,编译期就知道其值,就会把它放到调用类的常量池中,这个时候 就不会对类Parent_3进行初始化。此时静态代码块也就不会执行。但是在调用 str时候 由于 在编译期间无法知道其值,是一个运行期常量,所以要对被调用类进行初始化才能够知道其值,所以可以对静态代码块进行打印输出。

关于接口的基本特点

在前面讲到的:一个父接口 不会因为他的子接口 或者实现类的初始化而初始化 只用当程序首次使用特定接口的静态变量时,才会导致接口的初始化
在调用一个接口的时候,若是一个接口继承自一个父类的接口。此时,若是删除父类的接口,并不会产生问题,说明在接口类型中,调用子类的时候并不会对父类进行一个初始化。
这是为什么呢:
是因为:对于一个接口来说时候 其中的值都是 public static final 类型的常量。前面我们有对final类型进行一个讲解,就是说,会存放到调用类的常量池中。所以此时并不会执行初始化,也是原由。
此时若是把子类中的类型改成UUID类型的时候,删除class 文件就会出现问题.
这说明了,静态类型的时候 会在main函数中进行调用时候 加载到 常量池中。若是 UUID类型时候 就需要在运行期才能够 知道其值,运行期时候就需要有其原class文件 所以 在使用到UUID,并且删除掉class 时候 就会出现编译的异常。但是在只有真正的使用到父类的时候 (例如应用父类中的常量时候) 才会真正的初始化。


前面讲解了final关键字和接口的相关问题,下面举一个栗子来真实类在加载和初始化时候的特性。
栗子

public class Test4 {
    public static void main(String[] args) {
      Single single= Single.getSingle();
        System.out.println("A"+Single.a);
        System.out.println(Single.b);
    }
}
class  Single{
  public   static  int a=1;

    private static  Single single=new Single();
  private  Single(){
      a++;
      b++;
      System.out.println(a);
      System.out.println(b);
    }
    public   static  int b=0;
  public  static  Single getSingle(){
      return  single;
  }
}

最后打印的结果是
2 1 A2 0
解释:由于前面提到的 第一个步骤是:连接+ 加载 + 初始化
在加载里面还是会有 三个步骤其中有一个就是准备的过程 其目的
为类的静态变量赋初值,分配空间等
所以在开始时候 的准备中 会先进行一轮的初始化 int类型会变成0, string类型是 null 。所以第一轮的时候 a是系统初始化 0 new类型是null b也是 0 这个是准备阶段。在执行阶段时候 a赋值 1 调用过程中会调用到构造函数 此时 会对 a++ 和b++ 。执行到此时候 并没有 我们人为的对b 进行赋值 所以 此时的打印是 2,1 然后 执行到下面 时候 我们人为赋值重新赋初值时候 ,又重新变成了 0 所以最后的打印是 0。
这个过程就深刻演示了在准备阶段和初始化是什么样的过程。

初始化时机

类的卸载

栗子
例如说 一个类被加载 ,连接 初始化后,她的生命周期就开始了。当代码这个类的Class文件不再被引用,即不可以触及时候,Class对象就会结束生命周期,这个类在方法区内的数据也会被卸载,从而结束这个类的生命周期,就是说一个类什么时候结束生命周期,取决于它所代表的Class对象什么时候结束生命周期。
由java虚拟机自带的类加载器所加载的类在虚拟机的生命周期中,始终不会被卸载。在后面我们介绍的: 根类加载器,扩展类加载器,和系统类加载器。java虚拟机本身会始终引用这个类加载器,而这些类加载器则会始终引用他们所加载的类的class对象,因此这些class对象始终是可触及的。用户自己定义的类加载器是可以卸载的


在完成了上面的讲解以后,我们对类的记载的过程有了基础的认知,关于 类和接口的问题是重总之中,两者之前的不同也是在面试时候常常问到的问题。下面我们开始更加细致的讲解。

类加载器

什么是类加载器:前面讲到加载时候的第一件事:“通过一个类的全限定名来获取描述此类的二进制字节流”但是这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何取获取所需要的类。这个动作代码模块称为类加载器

类加载器:并不需要等到某一个类首次主动使用时候才会加载它
(此时就想到了一个例子: 是说一个子类和一个父类,在其中都有静态代码块 但是对于静态代码块而言 只用对类进行初始化使用到时候 才会被使用到 main函数中 使用子类调用父类的变量(这里因为 子类是继承自父类的 所以可以使用其中的变量)但是打印出来的是 父类的静态代码块 此时 对子类并没有初始化 但是不代表没有进行加载 关于类的加载而是 是会被加载的)所以有了以下的定义:
对于 jvm来说 运行类加载器在预料某个类将要被使用之前就会先对其进行一个预先加载,但是如果遇到了什么错误,此时也并不一定会报错,必须在程序首次使用该类的时候 才会报错误。
对于类加载器而言:
从虚拟机的角度来说有两种类加载器:

  1. 启动类加载器是虚拟机的自身一部分;
  2. 所有其他的类加载器(都有java语言进行编写)。
    除了根类加载器之外,其余的类加载器都有且只有一个父加载器。
    但是在开发角度来说 就会有以下的三种类加载器:
  • 启动类加载器:
  • 扩展类加载器
  • 应用程序类加载器
  • 自己定义的类加载器(属于用户自定义的加载器)
类加载器各个用途

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u7SMamOb-1582691215604)(en-resource://database/1216:1)]

类加载器的种类

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nZLvPCtS-1582691215605)(en-resource://database/1230:1)]

类与类加载器之间的关系

前面讲到了类加载器,但是类加载器并不只是对类加载这个功能,还有更多的功能。对于每一个类,都需要由加载她的类加载器和这个类本身来一起确定其在java虚拟机中的唯一性。对于每一个类加载器 都拥有自己独立的命名空间。这个时候结合给的例子说,只有两个类是由同一个类加载的前提下才能说其是否相等,进行比较。否则尽管说这两个类来源于同一个class文件,但是也必定不相等。


在讲述完类加载器以后,我们可能还需要了解一下命名空间的作用:在自定义类记载器时候会出现的一个问题,也是在面试时候比较容易考到的地方。

命名空间

什么是?

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成
  • 在同一个命名空间中,不会出现类的完整名字(包括类的包名) 相同的像个类。
  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名) 相同的两个类
    理解
    前面已经介绍过了关于命名空间的问题,若是使用父类的加载器进行加载 会从类路径的class文件中加载,此时若是new了两个对象 但是对于使用了父类加载器的时候,前一次加载以后 后一次的就会直接调用 就属于在同一个命名空间之下。
    但是若是使用自己所定义的类加载器 由于new出来了两个对象,就会产生两个不同的命名空间,也会产生不同的类。 这但是若是新的对象指定了前一个对象作为其父类加载器时候,产生的就是相同的hashcode,因为父类加载器在前面加载过,在后面就不会重复加载,而是在其的基础上再进行一遍调用。

以上是对类加载器有了基础的认知,其实类加载器之中的知识要在具体的实战中才会得以显示,下面我们来介绍一下一个重要的机制,来具体也更加深刻认知类加载器。

双亲委派机制

什么是?

什么是双亲委派机制:如果一个类加载器收到了类加载的请求,她首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层序的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只用当父加载器反馈自己无法完成这个加载请求(在搜索范围汇总没有找到所需要的类)时候,子加载器才会尝试着自己去加载。(这里的类加载器之间的父子关系一般不会以继承的关系,而是使用组合关系来复用父加载器的代码)
为什么要使用?
使用这个模式来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如我们的java.lang.Object,存在方 rt.jar中无论是哪个类加载器要加载这个类,最后实现的结果是都会委托给模型最顶端的启动类加载器进行加载,因此Object类在程序的各个类记载器环境中都是同一个类,否则自行加载时候 最基础的体系结构就不能够得到保证。
好处

  • 可以确保java核心库的类型安全
    前面提到了会引用 java.lang.Object 时候,也是就说 在运行期若是使用自己所定义的类进行加载,就会存在多个版本的java.lang.Object,而且是不兼容,不可见的。
  • 提供了java核心类库不会被自定义的类所替代
  • 不同的类加载器可以为相同(binary name) 的类创建额外的命名空间,相同名称的类可以并存在java虚拟机中,只需要不同的类加载器来加载他们即可。不同类加载器所加载的类之间是不兼容的。这就相当于在java虚拟机内部创建了一个又一个相互隔离的Java类空间。
    如何实现双亲委派模型的呢
    实现在 java.lang.ClassLoader中的loaderclass()方法中:首先检查是否已经被加载,若没有加载则调用父加载器的loadClass() 方法,若父加载器为空,则默认使用启动类加载器作为父加载器。如果父类加载失败,,抛出 ClassNotFoundException异常后,再调用自己的findClass() 方法进行加载。
    优点(面试)
    能够提高软件系统的安全性。因为在此机制下,用户之定义的类加载器不可能加载应该由父加载器加载的可靠类。从而防止不可靠甚至恶意的diamante代替由父加载器加载的可靠代码。例如java.lang.Object 类总是由根类加载器加载,其他任何用户定义的类加载器都不可能加载含有恶意代码的java.lang.Object类。

前面我们介绍到了双亲委派机制的好处所在,但是其不是一个强制性的约束模型而是说java设计者推荐给我们使用的类加载的实现方式,在Java的世界中大部分的类加载器都遵循这个模型。但是有时候也会出现不适用的情况,这个时候推出了线程上下文加载器。

双亲委派机制(不适用情况)

在我们的双亲委托模型中,类加载器是由下至上的,即下层的类加载器会委托上层加载器进行加载,但是对于 SPI来说,有些接口却是Java核心库所提供的的,而Java核心库是由启动类记载器进行加载的,而这些接口的实现却是由不容的jar(包,厂商提供的),Java的启动了加载器是不会加载来源于其他的jar包中的信息,这样双亲委托模型就无法满足SPI的要求。

为了解决的是在有得时候 不适用的情况下:线程上下文加载器(ThreadContestClassLoader)这个类加载器可以通过java.lang.Thread类的setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程汇总继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

为什么会出现线程上下文加载器

是因为举个例子“对于jDBC我们都有学习和了解,JDBC是一个标准,对于很多的数据库厂商,例如MySQLDB2等都会根据这个标准来进行自己的实现,既然是一个标准,那么这些原生的标准和类库都是存在于JDK中的,很多的接口都是有厂商来实现”这个时候,这些接口都肯定是由我们根类加载器进行加载实现的。
我们要是想来直接使用厂商提供给我们的实现,就需要把厂商给的实现放置在类的应用的ClassPath下面,此时就不会由启动类来进行加载,这也是加载器的加载范围限制,这个时候 这些实现,就需要用到系统类加载器(关于这一点为什么会使用到系统类加载器可以去看类加载器的各个不同的使用场景),或是应用类加载器来进行加载,因为这些加载器才回去扫描当前的classPath
。这个时候 就会出现一个问题“例如有一个Connection 接口 这个接口由启动类进行加载,但是具体实现由系统或是应用加载。父加载加载的接口看不到子加载器实现的类或是接口(关于这一点是需要记忆的),这个时候 例如一个接口想要去调用自己的实现,但是由于加载自己(接口)的是父亲加载器,加载实现的是儿子加载器,所以根本就不可能读到相关的信息。 这个时候 对于就算是将实现放入到ClassPath下也不能够应用”所以因运而生了(不得以为止)产生了线程上下文加载器

但是对于线程上下文加载器:父ClassLoader可以使用当前线程Thread.currentthread().getContextClassLoader()所指定的ClassLoader加载的类

结语

以上就是对jvm虚拟机学习过程中类加载器的具体学习,涵盖了目前来说具体的知识点,也带有本人自己的理解色彩,有什么错误地方,不合适的地方,还希望大家能指出。

发布了26 篇原创文章 · 获赞 5 · 访问量 717

猜你喜欢

转载自blog.csdn.net/weixin_44015043/article/details/104514594