类加载问题

Posted 刘小虾~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了类加载问题相关的知识,希望对你有一定的参考价值。

https://zhuanlan.zhihu.com/p/51374915https://zhuanlan.zhihu.com/p/51374915
《深入理解java虚拟机》

简述一下类加载过程

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的。一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
《规范》中没有规定加载的时机,但是规定了必须立即对类进行初始化的情况(而加载、验证、准备自然需要在此之前开始):

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时(·使用new关键字实例化对象的时候。·读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。·调用一个类型的静态方法的时候。)
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  • 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化
  • 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
    注意:
    通过子类引用父类的静态字段,不会导致子类初始化
    通过数组定义来引用类,不会触发此类的初始化
    常量(final static)在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

加载

“加载”(Loading)阶段是整个“类加载”(ClassLoading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:1)通过一个类的全限定名来获取定义此类的二进制字节流。2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。3)在内存(堆)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

准备

正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。final static修饰的变量在此阶段会分配空间并赋初始值,如果没有final修饰则只会分配空间并赋零值(例如boolean的零值位false),在初始化阶段再赋初始值。

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,
· 符号引用(SymbolicReferences):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
· 直接引用(DirectReferences):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

初始化

变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器()方法的过程。
· ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
· ()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。
· 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

双亲委派模型及使用原因

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(BootstrapClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
站在Java开发人员的角度来看,类加载器就应当划分得更细致一些,自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载:
· 启动类加载器(BootstrapClassLoader):前面已经介绍过,这个类加载器负责加载存放在<JAVA_HOME>\\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,
· 扩展类加载器(ExtensionClassLoader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\\lib\\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展JavaSE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
· 应用程序类加载器(ApplicationClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
JDK9之前的Java应用都是由这三种类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能。这些类加载器之间的协作关系“通常”会如下图

以上各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(ParentsDelegationModel)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。
类加载器的双亲委派模型在JDK1.2时期被引入,并被广泛应用于此后几乎所有的Java程序中,但它并不是一个具有强制性约束力的模型,而是Java设计者们推荐给开发者的一种类加载器实现的最佳实践。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。每个Class对象的内部都有一个classLoader字段来标识自己是由哪个ClassLoader加载的。ClassLoader就像一个容器,里面装了很多已经加载的Class对象。
双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中,

先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。
那些位于网络上静态文件服务器提供的jar包和class文件,jdk内置了一个URLClassLoader,用户只需要传递规范的网络路径给构造器,就可以使用URLClassLoader来加载远程类库了。URLClassLoader不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。ExtensionClassLoader和AppClassLoader都是URLClassLoader的子类,它们都是从本地文件系统里加载类库。
AppClassLoader可以由ClassLoader类提供的静态方法getSystemClassLoader()得到,它就是我们所说的「系统类加载器」,我们用户平时编写的类代码通常都是由它加载的。当我们的main方法执行的时候,这第一个用户类的加载器就是AppClassLoader。

  • ClassLoader传递性
    程序在运行过程中,遇到了一个未知的类,它会选择哪个ClassLoader来加载它呢?虚拟机的策略是使用调用者Class对象的ClassLoader来加载当前未知的类。何为调用者Class对象?就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法挂在哪个类上面,那这个类就是调用者Class对象。前面我们提到每个Class对象里面都有一个classLoader属性记录了当前的类是由谁来加载的。因为ClassLoader的传递性,所有延迟加载的类都会由初始调用main方法的这个ClassLoader全全负责,它就是AppClassLoader。
    前面我们提到AppClassLoader只负责加载Classpath下面的类库,如果遇到没有加载的系统类库怎么办,AppClassLoader必须将系统类库的加载工作交给BootstrapClassLoader和ExtensionClassLoader来做,这就是我们常说的「双亲委派」。
    AppClassLoader在加载一个未知的类名时,它并不是立即去搜寻Classpath,它会首先将这个类名称交给ExtensionClassLoader来加载,如果ExtensionClassLoader可以加载,那么AppClassLoader就不用麻烦了。否则它就会搜索Classpath。
    而ExtensionClassLoader在加载一个未知的类名时,它也并不是立即搜寻ext路径,它会首先将类名称交给BootstrapClassLoader来加载,如果BootstrapClassLoader可以加载,那么ExtensionClassLoader也就不用麻烦了。否则它就会搜索ext路径下的jar包。
    这三个ClassLoader之间形成了级联的父子关系,每个ClassLoader都很懒,尽量把工作交给父亲做,父亲干不了了自己才会干。每个ClassLoader对象内部都会有一个parent属性指向它的父加载器。
    重新理解一下 ClassLoader 的意义,它相当于类的命名空间,起到了类隔离的作用。位于同一个 ClassLoader 里面的类名是唯一的,不同的 ClassLoader 可以持有同名的类。ClassLoader 是类名称的容器,是类的沙箱。

如何自定义自己的类加载器,自己的类加载器和Java自带的类加载器关系如何处理

ClassLoader里面有三个重要的方法loadClass()、findClass()和defineClass()。
loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。

class ClassLoader 

  // 加载入口,定义了双亲委派规则
  Class loadClass(String name) 
    // 是否已经加载了
    Class t = this.findFromLoaded(name);
    if(t == null) 
      // 交给双亲
      t = this.parent.loadClass(name)
    
    if(t == null) 
      // 双亲都不行,只能靠自己了
      t = this.findClass(name);
    
    return t;
  

  // 交给子类自己去实现
  Class findClass(String name) 
    throw ClassNotFoundException();
  

  // 组装Class对象
  Class defineClass(byte[] code, String name) 
    return buildClassFromCode(code, name);
  


class CustomClassLoader extends ClassLoader 

  Class findClass(String name) 
    // 寻找字节码
    byte[] code = findCodeFromSomewhere(name);
    // 组装Class对象
    return this.defineClass(code, name);
  

自定义类加载器不易破坏双亲委派规则,不要轻易覆盖 loadClass 方法。否则可能会导致自定义加载器无法加载内置的核心类库。在使用自定义加载器时,要明确好它的父加载器是谁,将父加载器通过子类的构造器传入。如果父类加载器是 null,那就表示父加载器是「根加载器」。
双亲委派规则可能会变成三亲委派,四亲委派,取决于你使用的父加载器是谁,它会一直递归委派到根加载器。

Thread.contextClassLoader

如果你稍微阅读过 Thread 的源代码,你会在它的实例字段中发现有一个字段非常特别

class Thread 
  ...
  private ClassLoader contextClassLoader;

  public ClassLoader getContextClassLoader() 
    return contextClassLoader;
  

  public void setContextClassLoader(ClassLoader cl) 
    this.contextClassLoader = cl;
  
  ...

contextClassLoader「线程上下文类加载器」,这究竟是什么东西?
首先 contextClassLoader 是那种需要显示使用的类加载器,如果你没有显示使用它,也就永远不会在任何地方用到它。你可以使用下面这种方式来显示使用它
Thread.currentThread().getContextClassLoader().loadClass(name);
这意味着如果你使用 forName(string name) 方法加载目标类,它不会自动使用 contextClassLoader。那些因为代码上的依赖关系而懒惰加载的类也不会自动使用 contextClassLoader来加载。
其次线程的 contextClassLoader 是从父线程那里继承过来的,所谓父线程就是创建了当前线程的线程。程序启动时的 main 线程的 contextClassLoader 就是 AppClassLoader。这意味着如果没有人工去设置,那么所有的线程的 contextClassLoader 都是 AppClassLoader。
那这个 contextClassLoader 究竟是做什么用的?我们要使用前面提到了类加载器分工与合作的原理来解释它的用途。
它可以做到跨线程共享类,只要它们共享同一个 contextClassLoader。父子线程之间会自动传递 contextClassLoader,所以共享起来将是自动化的。
如果不同的线程使用不同的 contextClassLoader,那么不同的线程使用的类就可以隔离开来。
如果我们对业务进行划分,不同的业务使用不同的线程池,线程池内部共享同一个 contextClassLoader,线程池之间使用不同的 contextClassLoader,就可以很好的起到隔离保护的作用,避免类版本冲突。
如果我们不去定制 contextClassLoader,那么所有的线程将会默认使用 AppClassLoader,所有的类都将会是共享的。
线程的 contextClassLoader 使用场合比较罕见,如果上面的逻辑晦涩难懂也不必过于计较。

怎么破坏双亲委派模型

重写loadClass方法即可。

  • tomcat重写ClassLoader的两个方法
    loadClass工作流程:
    先在本地Cache查找该类是否已加载过,即Tomcat的类加载器是否已经加载过这个类。若Tomcat类加载器尚未加载过该类,再看看系统类加载器是否加载过。若都没有,就让ExtClassLoader加载,为防止Web应用自己的类覆盖JRE的核心类。
    因为Tomcat需打破双亲委托,假如Web应用里自定义了一个叫Object的类,若先加载该Object类,就会覆盖JRE的Object类,所以Tomcat类加载器优先尝试用ExtClassLoader去加载,因为ExtClassLoader会委托给BootstrapClassLoader去加载,BootstrapClassLoader发现自己已经加载了Object类,直接返回给Tomcat的类加载器,这样Tomcat的类加载器就不会去加载Web应用下的Object类了,避免覆盖JRE核心类。
    若ExtClassLoader加载失败,即JRE无此类,则在本地Web应用目录下查找并加载,若本地目录下无此类,说明不是Web应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
    若上述加载过程都失败,抛ClassNotFound。
    findClass工作流程:
    先在Web应用本地目录下查找要加载的类
    若未找到,交给父加载器查找,即AppClassLoader
    若父加载器也没找到这个类,抛ClassNotFound

可见 Tomcat 类加载器打破了双亲委托,没有一上来就直接委托给父加载器,而是先在本地目录下加载。
但为避免本地目录类覆盖JRE核心类,会先尝试用ExtClassLoader加载。
那为何不先用AppClassLoader加载?
若这样,就又变成双亲委托,这就是Tomcat类加载器的奥妙。

如何确定一个类

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(ClassLoader)。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

以上是关于类加载问题的主要内容,如果未能解决你的问题,请参考以下文章

JVM类加载器

类加载问题

JVM类加载器

JVM类加载器

据说 99.99% 的人都会答错的类加载的问题

JVM系列二(类加载)