从覆盖 JDK 的类开始掌握类的加载机制

JVM 的类加载机制和 Java 的类加载机制相似,但 JVM 的类加载过程稍有些复杂。
JVM 经过加载 .class 文件,可以将其中的字节码解析成操做系统机器码。那这些文件是怎么加载进来的呢?又有哪些约定?接下来咱们就详细介绍 JVM 的类加载机制,同时介绍三个实际的应用场景。java

类加载过程

现实中并非说,我把一个文件修改为 .class 后缀,就可以被 JVM 识别。类的加载过程很是复杂,主要有这几个过程:加载、验证、准备、解析、初始化。
Cgq2xl4cQNeAO_j6AABZKdVbw1w802.png
如图所示。大多数状况下,类会按照图中给出的顺序进行加载。下面咱们就来分别介绍下这个过程。mysql

加载

加载的主要做用是将外部的 .class 文件,加载到 Java 的方法区内。加载阶段主要是找到并加载类的二进制数据,好比从 jar 包里或者 war 包里找到它们。程序员

验证

确定不能任何 .class 文件都能加载,那样太不安全了,容易受到恶意代码的攻击。验证阶段在虚拟机整个类加载过程当中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。像一些低版本的 JVM,是没法加载一些高版本的类库的,就是在这个阶段完成的。sql

准备

从这部分开始,将为一些类变量分配内存,并将其初始化为默认值。此时,实例对象尚未分配内存,因此这些动做是在方法区上进行的。数据库

下面两段代码,code-snippet 1 将会输出 0,而 code-snippet 2 将没法经过编译。
code-snippet 1:
     public class A {
         static int a ;
         public static void main(String[] args) {
             System.out.println(a);
         }
     }
 code-snippet 2:
 public class A {
     public static void main(String[] args) {
         int a ;
         System.out.println(a);
     }
 }
为何会有这种区别呢?编程

这是由于局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也能够是指定值);另一次在初始化阶段,赋予程序员定义的值。
所以,即便程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值。但局部变量就不同了,若是没有给它赋初始值,是不能使用的。tomcat

解析

解析在类加载中是很是很是重要的一环,是将符号引用替换为直接引用的过程。这句话很是的拗口,其实理解起来也很是的简单。安全

符号引用是一种定义,能够是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。架构

直接引用的对象都存在于内存中,你能够把通信录里的女朋友手机号码,类比为符号引用,把面对面和你吃饭的人,类比为直接引用。app

解析阶段负责把整个类激活,串成一个能够找到彼此的网,过程不可谓不重要。那这个阶段都作了哪些工做呢?大致能够分为:

  • 类或接口的解析
  • 类方法解析
  • 接口方法解析
  • 字段解析

咱们来看几个常常发生的异常,就与这个阶段有关。

  • java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
  • java.lang.IllegalAccessError 字段或者方法,访问权限不具有时的错误。
  • java.lang.NoSuchMethodError 找不到相关方法时的错误。

解析过程保证了相互引用的完整性,把继承与组合推动到运行时。

初始化

若是前面的流程一切顺利的话,接下来该初始化成员变量了,到了这一步,才真正开始执行一些字节码。

接下来是一道试题,你能够猜测一下,下面的代码,会输出什么?
public class A {
     static int a = 0 ;
     static {
         a = 1;
         b = 1;
     }
     static int b = 0;
 
     public static void main(String[] args) {
         System.out.println(a);
         System.out.println(b);
     }
 }
结果是 1 0。a 和 b 惟一的区别就是它们的 static 代码块的位置。

这就引出一个规则:static 语句块,只能访问到定义在 static 语句块以前的变量。因此下面的代码是没法经过编译的。
static {
         b = b + 1;
 }
 static int b = 0;
咱们再来看第二个规则:JVM 会保证在子类的初始化方法执行以前,父类的初始化方法已经执行完毕。

因此,JVM 第一个被执行的类初始化方法必定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。

<cinit>与<init>

说到这里,不得再也不说一个试题:<cinit> 方法和 <init> 方法有什么区别?

主要是为了让你弄明白类的初始化和对象的初始化之间的差异。
public class A {
     static {
         System.out.println("1");
     }
     public A(){
         System.out.println("2");
         }
     }
 
     public class B extends A {
         static{
         System.out.println("a");
     }
     public B(){
         System.out.println("b");
     }
 
     public static void main(String[] args){
         A ab = new B();
         ab = new B();
     }
 }
先公布下答案:
1
a
2
b
2
b
你能够看下这张图。其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,因此上面的 static 代码块只会执行一次,它对应的是 <cinit> 方法。

而对象初始化就不同了。一般,咱们在 new 一个新对象的时候,都会调用它的构造方法,就是 <init>,用来初始化对象的属性。每次新建对象的时候,都会执行。
CgpOIF4cQNeAYYhRAADbeRet_7k581.png
因此,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的前后原则,不难分析出正确结果。

类加载器

整个类加载过程任务很是繁重,虽然这活儿很累,但总得有人干。类加载器作的就是上面 5 个步骤的事。

若是你在项目代码里,写一个 java.lang 的包,而后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类固然不能轻易被覆盖,不然会被别有用心的人利用,这就太危险了。

那类加载器是如何保证这个过程的安全性呢?其实,它是有着严格的等级制度的。

几个类加载器

首先,咱们介绍几个不一样等级的类加载器。

Bootstrap ClassLoader

这是加载器中的大 Boss,任何类的加载行为,都要经它过问。它的做用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。固然这些 jar 包的路径是能够指定的,-Xbootclasspath 参数能够完成指定操做。

这个加载器是 C++ 编写的,随着 JVM 启动。

Extention ClassLoader

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。一样的,经过系统变量 java.ext.dirs 能够指定这个目录。

这个加载器是个 Java 类,继承自 URLClassLoader。

App ClassLoader

这是咱们写的 Java 类的默认加载器,有时候也叫做 System ClassLoader。通常用来加载 classpath 下的其余全部 jar 包和 .class 文件,咱们写的代码,会首先尝试使用这个类加载器进行加载。

Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。

双亲委派机制

双亲委派机制的意思是除了顶层的启动类加载器之外,其他的类加载器,在加载以前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都没法胜任,它才会真正的加载。

打个比方。有一个家族,都是一些听话的孩子。孙子想要买一块棒棒糖,最终都要通过爷爷过问,若是力所能及,爷爷就直接帮孙子买了。

但你有没有想过,“类加载的双亲委派机制,双亲在哪里?明明都是单亲?”
咱们仍是用一张图来说解。能够看到,除了启动类加载器,每个加载器都有一个parent,并无所谓的双亲。可是因为翻译的问题,这个叫法已经很是广泛了,必定要注意背后的差异。
Cgq2xl4cQNeAG0ECAAA_CbVCY1M014.png
咱们能够翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和咱们描述的同样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到本身。同时,咱们也注意到,这个方法是能够被覆盖的,也就是双亲委派机制并不必定生效。
CgpOIF4cQNeACEs8AACe317zgN8195.jpg
这个模型的好处在于 Java 类有了一种优先级的层次划分关系。好比 Object 类,这个毫无疑问应该交给最上层的加载器进行加载,即便是你覆盖了它,最终也是由系统默认的加载器进行加载的。

若是没有双亲委派模型,就会出现不少个不一样的 Object 类,应用程序会一片混乱。

一些自定义加载器

下面咱们就来聊一聊能够打破双亲委派机制的一些案例。为了支持一些自定义加载类多功能的需求,Java 设计者其实已经做出了一些妥协。

案例一:tomcat

tomcat 经过 war 包进行应用的发布,它实际上是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构。
Cgq2xl4cQNeAZ4FuAABzsqSozok762.png
对于一些须要加载的非基础类,会由一个叫做 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。这个加载器用来隔毫不同应用的 .class 文件,好比你的两个应用,可能会依赖同一个第三方的不一样版本,它们是相互没有影响的。

如何在同一个 JVM 里,运行着不兼容的两个版本,固然是须要自定义加载器才能完成的事。

那么 tomcat 是怎么打破双亲委派机制的呢?能够看图中的 WebAppClassLoader,它加载本身目录下的 .class 文件,并不会传递给父类的加载器。可是,它却可使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
可是你本身写一个 ArrayList,放在应用目录里,tomcat 依然不会加载。它只是自定义的加载器顺序不一样,但对于顶层来讲,仍是同样的。

案例二:SPI

Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它能够用来启用框架扩展和替换组件。

这个说法可能比较晦涩,可是拿咱们经常使用的数据库驱动加载来讲,就比较好理解了。在使用 JDBC 写程序以前,一般会调用下面这行代码,用于加载所须要的驱动类。
Class.forName("com.mysql.jdbc.Driver")
这只是一种初始化模式,经过 static 代码块显式地声明了驱动对象,而后把这些信息,保存到底层的一个 List 中。
可是你会发现,即便删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不须要作,很是的神奇,它是怎么作到的呢?
咱们翻开 MySQL 的驱动代码,发现了一个奇怪的文件。之因此可以发生这样神奇的事情,就是在这里实现的。
路径:
mysql-connector-java-8.0.15.jar!/META-INF/services/java.sql.Driver
里面的内容是:
com.mysql.cj.jdbc.Driver
经过在 META-INF/services 目录下,建立一个以接口全限定名为命名的文件(内容为实现类的全限定名),便可自动加载这一种实现,这就是 SPI。

SPI 其实是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。
CgpOIF4cQNeARP3IAAA2VH9MXoY723.jpg
这种方式,一样打破了双亲委派的机制。

DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。而具体的数据库驱动,却属于业务代码,这个启动类加载器是没法加载的。这就比较尴尬了,虽然凡事都要祖先过问,但祖先没有能力去作这件事情,怎么办?

咱们能够一步步跟踪代码,来看一下这个过程。
//part1:DriverManager::loadInitialDrivers
  //jdk1.8 以后,变成了lazy的ensureDriversInitialized
  ...
  ServiceLoader <Driver> loadedDrivers = ServiceLoader.load(Driver.class);
  Iterator<Driver> driversIterator = loadedDrivers.iterator();
  ...

  //part2:ServiceLoader::load
  public static <T> ServiceLoader<T> load(Class<T> service) {
      ClassLoader cl = Thread.currentThread().getContextClassLoader();
      return ServiceLoader.load(service, cl);
  }
经过代码你能够发现 Java 玩了个魔术,它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来讲,它当前的加载器是谁呢?也就是说,启动 main 方法的那个加载器,究竟是哪个?

因此咱们继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。咱们在 Launcher 中找到如下代码。
public Launcher() {
 Launcher.ExtClassLoader var1;
 try {
     var1 = Launcher.ExtClassLoader.getExtClassLoader();
 } catch (IOException var10) {
     throw new InternalError("Could not create extension class loader", var10);
 }
 
 try {
     this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
 } catch (IOException var9) {
     throw new InternalError("Could not create application class loader", var9);
 }
 Thread.currentThread().setContextClassLoader(this.loader);
 ...
 }
到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动,是没有什么问题的。

案例三:OSGi

OSGi 曾经很是流行,Eclipse 就使用 OSGi 做为插件系统的基础。OSGi 是服务平台的规范,旨在用于须要长运行时间、动态更新和对运行环境破坏最小的系统。

OSGi 规范定义了不少关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,经过使用特殊 Java 类加载器来强制执行,比较霸道。

好比,在通常 Java 应用程序中,classpath 中的全部类都对全部其余类可见,这是毋庸置疑的。可是,OSGi 类加载器基于 OSGi 规范和每一个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得很是的怪异。但咱们不难想象,这种与直觉相违背的加载方式,确定是由专用的类加载器来实现的。

随着 jigsaw 的发展(旨在为 Java SE 平台设计、实现一个标准的模块系统),我我的认为,如今的 OSGi,意义已经不是很大了。OSGi 是一个庞大的话题,你只须要知道,有这么一个复杂的东西,实现了模块化,每一个模块能够独立安装、启动、中止、卸载,就能够了。

如何替换 JDK 的类

如何替换 JDK 中的类?好比,咱们如今就拿 HashMap为例。

当 Java 的原生 API 不能知足需求时,好比咱们要修改 HashMap 类,就必需要使用到 Java 的 endorsed 技术。咱们须要将本身的 HashMap 类,打包成一个 jar 包,而后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是同样的。可是,java.lang 包下面的类除外,由于这些都是特殊保护的。

由于咱们上面提到的双亲委派机制,是没法直接在应用中替换 JDK 的原生类的。可是,有时候又不得不进行一下加强、替换,好比你想要调试一段代码,或者比 Java 团队早发现了一个 Bug。因此,Java 提供了 endorsed 技术,用于替换这些类。这个目录下的 jar 包,会比 rt.jar 中的文件,优先级更高,能够被最早加载到。

小结

一个 Java 类的加载,通过了加载、验证、准备、解析、初始化几个过程,每个过程都划清了各自负责的事情。

Java 自带的三个类加载器。main 方法的线程上下文加载器,实际上是 Application ClassLoader。

通常状况下,类加载是遵循双亲委派机制的。咱们也认识到,这个双亲,颇有问题。经过 3 个案例的学习和介绍,能够看到有不少打破这个规则的状况。类加载器经过开放的 API,让加载过程更加灵活。

不管是远程存储字节码,仍是将字节码进行加密,这都是业务需求。要作这些,咱们实现一个新的类加载器就能够了。

相关文章
相关标签/搜索