【Java虚拟机】《深入理解Java虚拟机》| 虚拟机类加载机制(二)

《深入理解Java虚拟机》| 虚拟机类加载机制(二)


  • 前提概念
    • 什么是类加载器?
    • 显式加载和隐式加载
    • 类加载器对判断类是否相等的影响
  • Java的类加载器机制
    • Java中类加载器的类型
    • 类加载器的详细介绍
    • 双亲委派模型
    • 双亲委派模型的破坏者 | 线程上下文类加载器
  • Tomcat的类加载器机制
    • Tomcat是个web容器, 那么它要解决什么问题
    • Tomcat类加载机制的改进
    • Tomcat的类加载器
    • 部分违背了双亲委派模型
  • 类加载机制的相关问题
    • 一个Tomcat容器会启动多少个JVM实例?
    • 为什么需要多个类加载器?一个不行吗?
    • 一个项目引用了两个Jar包,这两个Jar内部都定义了一个包名和类名一样的类,那么会发生什么?
    • 自己写的java.lang.String类能否被加载?
    • ClassLoader.loadClass和Class.forName之间的区别

前提概念


什么是类加载器?

书上的话:

虚拟机设计团队把类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”的这个动作放在Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码模块称为 “类加载器”

我的理解

简而言之,类加载器就是在类加载过程的加载阶段中完成获取某类的二进制字节流的一个东西(代码模块),我们通过这个东西去获取类的字节码信息,并存放到方法区和生成该类的Class对象。


显示加载和隐式加载的概念

显式加载:
Class.forNameClassLoader.loadClass去主动加载某个类

隐式加载:
new了某个类,会隐式的触发该类的加载,在Java的双亲委派模型机制中,通常是由应用委托到扩展委托到根类加载器,然后不行再回调回来
显示加载:

一般显式加载是为了指定什么类加载去加载什么类;或者想在使用之前先加载类


类加载器对判断类是否相等的影响

对于任意一个类,都需要由加载它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性。

什么意思呢?就是说,如果你要在Java程序中判断类是否相等,既是否是同一个类。那必须满足一个前提条件

  • 这两个类必须是同一个类加载器加载的

意思就是说,如果现在有一个Test.class字节码文件,被两个类加载器所加载,他们所得到的类是不同的类。所以如果你要比较两个类是否相等,或两个对象的类是否相同,前提条件是必须是类由同一个类加载器锁加载,否则比较没有意义。


类加载器的命名空间

每个类加载器有自己的命名空间,类似的概念还有初始类加载器定义类加载器运行时包等,但是为了不让概念复杂化,所以这里我们只要了解,相互之间访问关系就好了
JAVA类装载器classloader和命名空间namespace - @作者:飞天金刚

不同类加载器的命名空间关系:

  • 同一个命名空间内的类是相互可见的,即可以互相访问。
  • 父加载器的命名空间对子加载器可见。
  • 子加载器的命名空间对父加载器不可见。
  • 如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见。

Java类加载器基于的三项原则

  • 委托性
    委托性说简单点就是一个类加载器接到类加载任务,可以选择自己加载或是委托给其他类加载器加载
  • 可见性
    所谓可见性原则,就是子级类加载器可以看到父级类加载器加载的类文件,但是反过来则行不通。这意味着,假如应用类加载器加载了Java.class 文件,那么尝试使用扩展类加载器去加载Java.class 文件,则会抛出异常
  • 唯一性
    根据这个原则,一个类文件被父级类加载器加载后,子级类加载器则不能加载它。尽管完全可以编写一个类加载器,不走双亲委派流程,它自己加载类文件。但这样的行为其实违反了委托和唯一性原则,这样做没有什么好处。在编写自己的类加载器时,我们应当遵守所有的类加载器原则。

Java的类加载器机制


Java中类加载器的类型

从Java虚拟机的角度分类

只存在两种不同的类加载器

  • 启动类加载器
  • 其他所有类加载器

启动类加载器是使用C++实现的,是虚拟机自身的一部分;除了启动类加载器,其他所有类加载器都是由Java语言实现的,独立于虚拟机外部,并且全部都继承于抽象类java.lang.ClassLoader

从Java开发人员的角度分类

从Java开发人员的角度分类,类加载器还可以划分的更细致一些,大致分为3类

  • 启动类加载器(BootStrap class loader)
  • 扩展类加载器(Extensions class loader)
  • 应用程序类加载器(Application class loader)

中文翻译名称是有很多的

启动类加载器又叫根类加载器,引导类加载器


Java类加载器的详细介绍

启动类加载器
  • 它用来加载Java核心库(JAVA_HOME/jre/lib/rt.jarsun.boot.class.path路径下)的内容,是由C++语言实现的,是Java虚拟机本身的部分,并不继承自java.lang.ClassLoader类

  • 加载扩展类加载器,应用程序类加载器,并指定他们的父类加载器

扩展类加载器
  • 用来加载Java扩展库(JAVA_HOME/jre/ext/*.jarjava.ext.dirs路径下)的所有类库
  • sun.misc.Laucher$ExtClassLoader实现,继承自java.lang.ClassLoader
  • 开发者可以直接使用扩展类加载器
应用程序类加载器
  • 它加载Java应用的类路径(classpath ,java.class.path)下的类,一般情况下,我们实现的类都是由它来完成加载的
  • sun.misc.Laucher$AppClassLoader实现,继承自java.lang.ClassLoader
自定义类加载器
  • 开发人员可以通过继承java.lang.ClassLoader类的方式实现自己的类加载器以满足一下特殊的需求

双亲委派模型

【Java虚拟机】《深入理解Java虚拟机》| 虚拟机类加载机制(二)_第1张图片
什么是双亲委派模型?

如上图展现的类加载器之间的的这种层次的关系,就称为类加载器的双亲委派模型

  • 既双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器
    (总之就是一句话,除了顶层类加载器外,其他类加载器都必须有一个爸爸,包括自己实现的自定义加载器,它的爸爸就是应用程序类加载器)

这里的类加载器之间的父子关系一般不会以继承的关系去实现,而是使用组合的方式来复用父加载器的代码。

双亲委派模型机制的工作流程:
  • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给自己的父类加载器去完成
  • 每一层的类加载器都是如此,因此所有的加载请求最终都应该传递到顶层的启动类加载器中
  • 只有父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到相应的类),子加载器才会尝试自己去加载

总结起来就是,所有类加载器收到加载请求,第一时间是把请求转移给父类,让父类去加载,只有父类不能加载该类时,子类才会尝试自己去加载。

双亲委派模型的好处

好处就是保证Java程序的稳定性和安全性。

  • 比如我们的java.lang.Object类, 它是存放在rt.jar包中的,所以无论哪个类加载器要加载这个类,都会把请求转移顶层的启动类加载器去执行,因此Object类在程序的各种类加载器环境中都是同一个类。

  • 相反如果没有实现双亲委派模型,我们用户自己编写了一个java.lang包下的Object类,那么系统将会出现多个不同的Object类,Java类体系中最基础的行为也就无法得到保证,应用程序就将会变的一片混乱。

其他补充

比一定所有的类加载器都需要实现双亲委派模型,因为这不是一个强制性的约束模型,只是Java设计者推荐的。

有些程序,比如Tomcat服务器的类加载器就不完全是双亲委派模型,而是某些部分与双亲委派模型是一个相反的模型。比如类载器收到类加载请求,第一反应就是自己尝试加载,如果不行再委派给自己的父类加载器


双亲委派模型的破坏者 | 线程上下文类加载器

什么是线程上下文类加载器?

线程上下文类加载器(ThreadContextClassLoader),并不是一个指定的类加载器,也不特指某个类加载器,但它通常默认是应用类加载器(AppClassLoader), 如果是Tomcat的机制,有可能是(WebApp类加载器)。

在Java中的体现就是,我们可以通过Thread.currentThread.getContextClassLoader去获取当前线程的上下文类加载器,这个上下文类加载器默认是应用类加载器,也可以通过Thread.currentThread.setContextClassLoader去修改这个线程的上下文类加载器。通常情况下,在线程创建的时候就会通过setContextClassLoader方法将应用类加载器植入

它主要的作用就是让根类加载器无法加载到的类,可以通过线程去获取应用类加载器(通常)去人为加载。 比如Java的底层提供了很多接口,比如数据库接口,和调用接口的类,这样类是由根类加载器加载的,但是具体的驱动实现是在第三方Jar中实现的,当在根类加载器加载的类中调用第三方Jar包提供的实现类时,会发现根类加载器无法加载到这些实现类,所以我们需要在调用类中通过获取线程上下文类加载器,将第三方Jar包的实现类加载到虚拟机中。

简而言之就是要解决双亲委派中的逆向问题,即父加载器要加载本应子加载器加载的类。

为什么会出现父类加载器去加载子类加载器才能加载的类?

这是双亲委派这个模型自身的缺陷导致的。我们说,双亲委派模型很好的解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API, 但没有绝对,如果基础类调用会用户的代码怎么办呢?
为何违背双亲委派模型

也许你就会问,我们通常在写项目的时候,也通常会有一个common Jar包,那为什么它们的加载不需要线程上下文类加载器去加载呢?他们也算是某个意义上的第三方Jar包呀

这是因为common包的代码通常是在我们自己写的Java类中使用的,很少有机会被启动类加载器所加载的类所调用;既然是在自己写的Java类中使用,另一层的意思就是基本都是被应用类加载器所加载的类使用,所以应用类加载器所加载的类调用了某个未被加载的类,从而触发该类的隐式加载,而该类能只被应用类加载,那自然也是被应用类加载器所加载,根本就用不着通过线程上下文的方式

但在常用线程下上文类加载器处理的SPI机制中,通常, 接口和调用接口的类是由启动类加载器所加载的, 而实现类是由第三方Jar包定义的,且这个Jar不能被启动类加载器和扩展类加载器所处理,不在它们的处理范围,所以只能有应用类加载器加载;但调用实现类的类(实现类可以向上转型成接口)的类加载器无法加载这个实现类,所以只能委托应用类加载器去加载,怎么委托,就是通过打破双亲委派模型的线程上下文的方式

public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取当前调用线程的类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

比如ServiceLoader的load方法,ServiceLoader是启动类加载器所加载,而传入的实现类对象ServiceLoader类中被调用,则会触发ServiceLoader的类加载器 |启动类加载器去加载实现类,但启动类加载器无法加载它;那怎么办,那只能叫能加载他们的类加载器来加载罗,那怎么叫?线程上下文类加载器出场的机会就到了,在该线程被创建的时候,应用类加载器就被植入到线程中,当到ServiceLoader要使用的时候,我就从线程中取出应用类加载器去加载启动类加载器不能加载的类

为什么需要线程上下文类加载器?当根类加载器无法加载的时候,不是会回调到子类加载器去帮忙加载吗?

这个问题我自己也纠结了很久,也查了很多资料,猜测可能是知识深度不够。但是后来想通了一些, 虽然不是非常的确定,但是大概也能说的通,如果有更好的答案,请告诉我!!

我的观点是:

  • 如果是隐式加载,其实是不需要线程上下文类加载器的帮助的,毕竟委托到根类加载器时加载时,即使加载不了还是会回调回应用类加载器的,所以根本不需要通过线程上下文的方式去获得应用类加载器
  • 需要线程上下文类加载的帮助去加载类的时候,基本都是在代码上在显式加载指定类;一般都是调用类使用加载自己的类加载器去加载某类,发现不成功,便通过线程上下文类加载器去加载

所以只要出现一个需求,需要父类加载器去加载子类加载器才能加载的类时,不用思考,这肯定需要线程上下文类加载去帮忙!!

下面这篇文章讲的很好,可以看这里
Java线程上下文加载器与SPI - @作者:ideabuffer
理解TCCL:线程上下文类加载器
jvm原理(24)通过JDBC驱动加载深刻理解线程上下文类加载器机制 - @作者:魔鬼_


Tomcat的类加载机制


也许,你会感觉到困惑,为什么Java已经有一个套类加载机制了,Tomcat又有一套类加载机制呢?这不会冲突吗?这两套加载机制是独立的?还是共存的呢?

  • 首先这两套机制并不冲突,但也不是共存的状态,而是独立的;当我们仅仅是跑一个Java项目,没有用到什么Web容器时,默认的类加载机制就是Java的那套;但是当我们的Java工程被打成war/jar包放在Tomcat容器时,启动的就是Tomcat的一套类加载机制了
  • 同样,我们现在流行的SpringBoot会使用嵌入式的Tomcat容器,Tomcat作为一个子组成嵌入Spring容器中,那么我们现在说的Tomcat类加载机制也就不适用了,那时就会有SpringBoot自己的一套类加载机制改进来处理

虽然在SpringBoot嵌入式容器开发中,我们现在所说的这一套类加载机制也将不适用,但是我们这里也要讲究一个历史,熟悉过去,才能更好的发展未来嘛。所以我们还是要来讲讲Tomcat容器的类加载机制到底是怎么样的?相比Java默认的类加载机制又有什么好处?


Tomcat是个web容器, 那么它要解决什么问题

  • 一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  • 部署在同一个web容器中相同的类库相同的版本应该要可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机?
  • web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  • web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。

Tomcat类加载机制的改进

下图是Tomcat在Java的基础上改进后的类加载机制

【Java虚拟机】《深入理解Java虚拟机》| 虚拟机类加载机制(二)_第2张图片
我们知道,Tomcat的类加载机制是在Java的基础上改进而来的,所以它也有启动类加载器,扩展类加载器和应用类加载器,也保留了双亲委派模型。

但它不仅限于此,它也在应用类加载器的基础上扩展了一些子类加载器, 比如Common类加载器

  • Common类加载器

而 Common类加载器又在自己的基础上扩展了Catalina类加载器和Shared类加载器

  • Catalina类加载器
  • Shared类加载器

当然还有 Shared类加载器的子类加载器:WebApp类加载器


Tomcat的类加载器

我们常见的就是 Common类加载器Catalina类加载器Shared类加载器以及WebApp类加载器等几个类加载器,他们对于加载的目录就是 /common/*/server/*/shared/* (不过在这三个目录在tomcat 6之后已经合并到根目录下的lib目录下)和 /WebApp/WEB-INF/* 中的Java类库;其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

【Java虚拟机】《深入理解Java虚拟机》| 虚拟机类加载机制(二)_第3张图片

  • Common类加载器
    最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及Webapp下的所有Java应用访问,一般加载可以被Tomcat本身和Java应用共享的Jar包
  • Catalina类加载器
    Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见,一般用于加载Tomcat自身的Jar包
  • Shared类加载器
    各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见,用于加载多个Java应用都会用到的Jar包,比如Spring的Jar包
  • WebApp类加载器
    各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,既单个Java应用的类加载器,地位就类似Java默认类加载机制的应用类加载机制

另外通常情况下,Catalina类加载器和Shared类加载器不一定需要,所以Tomcat可能只会创建Common类加载器和WebApp类加载器

所以从上面的设计中,我们就可以知道,这样的设计是为了做隔离和避免重复加载操作,因为一个Tomcat容器只有一个进程,可能只启动一个JVM实例,但是却可以部署多个Java Web项目;这么多个Java独立项目共享一个虚拟机实例,那默认的Java类加载机制就无法满足了,所以Tomcat设计了比较复杂的类加载机制层级去保证各个Java应用共享虚拟机却能保证一定的隔离性,互补影响,互补干扰;也为了让多个实例有共有引用时,不用重复加载,只加载一次即可


部分违背了双亲委派模型

我们知道双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。

但是Tomcat为了实现Java应用共享同一虚拟机实例的隔离性,并没有完全遵守双亲委派模型的原则,而让每个WebApp类加载器加载自己的目录下的class文件,并不会传递给父类加载器。只有自己无法加载的时候,才会委派给父类加载

但是其余的部分还是按照双亲委派模型来走了的~


类加载机制的相关问题


一个Tomcat容器会启动多少个JVM实例?

通常情况下,我们启动了多少个main()方法就会有多少个JVM实例,但在同一个Tomcat容器中,通常会在webapp下有多个应用;即一个Tomcat容器会启动多个Java应用,或者说有多个JavaWeb工程,那么这样的一种情况,这个Tomcat容器会启动多少个JVM实例呢?

答案就是:一个

  • 一个Tomcat容器会启动一个JVM,但可能有多个JavaWeb在这同一个JVM实例中运行
  • 一个Tomcat容器部署了多个应用,虽然处于同一个JVM环境,但是他们之间是无法相互调用的,这依赖于Tomcat的类加载机制的不同

为什么需要多个类加载器?一个不行吗?

Java 类加载器(ClassLoader)的实际使用场景有哪些? - @作者:知乎

一般是为了隔离性


自己写的java.lang.String类能否被加载?

当我们写了一个包和类名 都跟Java库一模一样的String类时(java.lang.String);而我们的虚拟机会加载我们自定义的String吗?

实际上,并不会,它实际调用的还是Jdk的String,但是代码层面并不会报错,这是因为双亲委派模型的作用在生效。

  • 首先,JDK包下的java.lang.String必然会被启动类加载器首先加载。所以一开始java.lang.String一开始就存储虚拟机中了。
  • 然后我们写的代码触发了java.lang.String的类加载,首先会被应用类加载器获取,但会传递给自己的父类 | 扩展类加载器,而扩展类加载器收到后,又会传递给自己的父类 | 启动类加载器 ,这已经是一个顶级的根类加载器了,所以它没有父亲
  • 而此时被委托的启动类加载器就会判断自己能否加载java.lang.String这个类,而它发现自己能加载,但是却已经加载过了,所以就不会再理会此次Java.lang.String类加载的请求了

如果没有双亲委派模型

  • 首先启动类加载器会加载JDK包下的java.lang.String
  • 然后收到触发,应用类加载器会接到加载自定义java.lang.String类的任务,于是将其类加载
  • 然后我们的系统就会存在两个版本的java.lang.String
  • 那么问题来了,我们实例化的java.lang.String到底是哪个?

一个项目引用了两个Jar包,这两个Jar内部都定义了一个包名和类名一样的类,那么会发生什么?

就比如,我这里有一个项目A,引用了Jar包BJar包C包B包C都有一个com.snailmann.Hello

Jar包B的Hello代码:

public class Hello {

    public Hello() {
        System.out.println(name);
    }
    
    private String name = "HelloB";
    
}

Jar包C的Hello代码:

public class Hello {

    public Hello() {
        System.out.println(name);
    }
    
    private String name = "HelloC";
    
}

项目A的测试代码:

public class Test {

    public static void main(String[] args) {
        Hello hello = new Hello();
    }
}

我初步的猜想是,可能会发生编译错误,出现import包时,不知道import哪一个;但事实上,并不会报错,因为两个类的包名都是一样的

结果:

helloB

为什么会是这样一个不报错还能输出HelloB的情况呢?不报错应用是包名没有导错,的确是这样;为什么输出HelloB呢?
这就关系到虚拟机是先触发加载JarB还是JarCcom.snailmann.Hello类,这是什么因素导致的呢?

我自己简单的测试了一下,可能就是项目Apom.xml文件中的导入的Jar依赖顺序

如果JarB的依赖在JarC的前面,那么虚拟机加载的就是JarB的类;相反则是JarC的类


ClassLoader.loadClass和Class.forName之间的区别

  • Class.forName(className)加载class的同时会初始化静态域
  • ClassLoader.loadClass(className)只干一件事情,就是将.class文件加载到JVM中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName借助当前调用者的class的ClassLoader完成class的加载。

参考资料


  • 《深入理解Java虚拟机》
  • Java类装载体系中的隔离性 - @作者:lovingprince

你可能感兴趣的:(Java虚拟机)