重新解读classloader

148 阅读12分钟

目标

通过对比其他语言的类加载机制,深入理解Java类加载的本质和发展现状,澄清一下对Java类加载机制存在的普遍误解。

类加载本质是代码复用

软件工程从诞生伊始, 代码复用就一直是提高工作效率和工程质量的最好方法之一。无论是c语言的include xlib,c++的静态/动态连接,以及各种现代高级语言的require xmodule,都是代码复用的不同表现方式,而Java的类加载也是其中之一。

在Java 9之前,Java的代码复用在编译期由classpath配合Class和Method的access modifier控制,最终的类加载则由jvm在运行期完成。而Jvm规范并没有规定类加载的具体时机,只需要在某些指令执行前完成对应类加载即可。

Screenshot from 2021-06-29 14-51-38.png 由此可见,Jvm规范并没有限制类加载的实现方式,所谓的“双亲委派”类加载机制仅仅是Hotspot的类加载实现而已,不应该将其与Java的类加载混为一谈。因为随着Java的发展,模块化将成为Java代码复用的主要方式,随之而来的类加载的实现方式也需要发生改变。实际上,大多数现代编程语言都是采用模块化的方式来复用代码,如Python、Go、NodeJs等。

Jvm类加载规范

todo launcher

前面说过,Jvm对于类的具体加载时机不做限制,但是也给出了一些需要遵循的规范,旨在帮助开发者更方便灵活地自定义类加载过程。

The Java Virtual Machine uses one of three procedures to create class or interface C denoted by N:

If N denotes a nonarray class or an interface, one of the two following methods is used to load and thereby create C:

  • If D was defined by the bootstrap class loader, then the bootstrap class loader initiates loading of C (§5.3.1).
  • If D was defined by a user-defined class loader, then that same user-defined class loader initiates loading of C (§5.3.2).

Otherwise N denotes an array class. An array class is created directly by the Java Virtual Machine (§5.3.3), not by a class loader. However, the defining class loader of D is used in the process of creating array class C.

摘自Jvm规范5.3节

简单翻译一下

Jvm可以使用三种方式之一来创建类或者接口对象C(名称记为N)

如果N表示一个非数组的类或者接口,那么可以用下面两种方式之一来加载并创建C

  • 如果D(D就是触发C加载的那个类,有可能是正常的类引用,也有可能是反射等)是由bootstrap cl 定义(define)的,那么用bootstrap cl去加载C
  • 如果D是由用户自定义的加载器定义的,那么就用这个用户自定义加载器去加载C

如果N表示一个数组类,那么该数组类将由Jvm直接创建,但是在创建C的过程中Jvm会用到D的classloader。

这段规范赋予了用户介入Jvm类加载过程的能力,由于用户自定义加载器加载的类触发的类加载默认继续使用用户自定义类加载器,用户可以在Jvm启动的某一时刻接管类加载(见后文Tomcat类加载实现),用自定义类加载器去加载类。

endorsed directory & upgradable modules

stackoverflow.com/questions/4…

hotspot类加载

hotspot类加载机制即所谓的“双亲委派”机制:

16a8d30870f9a8e5.jpeg

详细的实现逻辑这里就不细说了,随便搜下,一堆文章。这里是为了知识的完整性简单贴个示意图。

再强调一次,“双亲委派”不是JVM规范。实际上,使用“双亲委派”的很大原因是为了安全,为了使Applet运行在一个沙盒环境中,通过SecurityManager管理代码和资源的访问和操作。而“双亲委派”可以防止用户恶意代码破坏平台的运行(比如自定义一个恶意String类替换掉系统String类,将会破坏运行在JVM上的所有Applet)。但现在的JVM应用场景主要是后端服务,不需要SecurityManager,也没有不受信任的其他应用,所以完全可以只用一个类加载器,这个类加载器可以按照一定的约定(比如像Maven那样jar包仲裁的方案)去加载类。所有类都可以是平等的。

当然,“双亲委派”是一种优秀的实现,但是我们要分清规范和实现:规范通常是宽松的,持久的,而实现是具体的,易变的。如果把实现当做规范,就会限制我们的视野和想象力,无法认识到问题的本质。比如servlet被很多人看成是web规范,而实际上servlet只是JavaEE用来处理web请求的一种实现而已(可能快要被淘汰了,正如JavaEE的其他“重量级”实现一样)。而任何能正确解析处理web协议的程序都能成为web server(甚至tomcat对于现在的微服务来说可能已经过重了,webflux了解一下)。

tomcat类加载

tomcat为了解决运行在其上的应用之间的代码共享和隔离问题,模仿hotspot的方案将不同作用的类库放在不同的路径下,并用不同的加载器加载。

在Tomcat目录结构中,有三组目录(/common(可以被Tomcat和所有web应用使用)、/server(可以被Tomcat使用)、/shared(可以被所有web应用使用))可以存放java类库,另外还有个/WEB-INF(这个目录在所属应用目录内,只被所属应用使用)存放web应用自身的源码和库。

实际上,从tomcat 6.x之后,这三个目录就不是默认显式存在的了。现状是/common的默认目录是/lib,另外两个的默认目录为空。可以在/conf/catalina.properties中修改这三项配置。

现在考虑一下,如何设计类加载器支持这些目录的功能。前面已经提过,用户自定义加载器加载的类触发的类加载默认继续使用用户自定义类加载器。那么,我们可以在启动类(系统类加载器加载的)的main方法里,直接使用自定义类加载器去加载自定义启动类,然后用这个自定义启动类去启动应用。

注意,避免在一个应用中,出现同一类不同的类对象(由不同的类加载器加载),这会产生不可预知的错误。比如,它们的实例永远不会equals,可能会造成内存泄漏。这要求我们对类加载流程有个整体的把控,安全的处理方式是,对于同一目录下的类库,有且仅有一个类加载器可以实际加载,其他类加载器要么不使用此目录类库(隔离)要么将请求delegate给实际加载的类加载器。

所以我们至少需要四个类加载器,一个加载common,一个加载server,一个加载share,一个加载web应用。为了共享类库,我们先将需要加载的类delegate给共享类加载器,只有在共享库里找不到才去应用自有库里找(思考一下,有没有可能我想先在应用自有库里找,没有的话再去共享库找。也就是说,对于共享库和应用库都有的类,优先使用应用库的,应用库没有的类才使用共享库,共享库与应用库的关系类似Java中的继承关系。实际上,tomcat默认的就是这种查找顺序。但是它也提供了“双亲委派”查找顺序的可选项)。

而由于SecurityManager的作用,用户自定义加载器不能访问一些系统类库路径,所以必须将某些类的加载代理给系统类加载器。好在java.lang.Classloader类给了我们一个获得系统类加载器的方法,对于hotspot,这个方法返回的是sun.misc.Launcher$AppClassloader.

所以,我们的tomcat类加载器架构设计大概是这样:

Screenshot from 2021-06-30 14-45-39.png

下面,我们深入tomcat源码看下具体实现。

先简单介绍一下tomcat架构:

Tomcat 有两个核心组件:连接器和容器,其中连接器负责外部交流,容器负责内部处理。具体来说就是,连接器处理 Socket 通信和应用层协议的解析,得到 Servlet 请求;而容器则负责处理 Servlet 请求。

Tomcat 设计了 4 种容器,分别是 Engine、Host、Context 和 Wrapper。这 4 种容器不是平行关系,而是父子关系。下面我画了一张图帮你理解它们的关系。我们应用程序对应的容器级别是Context。

image.png

现在我们已经对tomcat架构有个简单的了解,根据前面对类加载器的设计,大概猜想一下具体代码的实现逻辑。首先,我们应该要用/server的类加载器去加载tomcat的启动类,再用web应用的类加载器去加载Context容器相关类,并启动Context容器。当然,并不是只有这俩类加载器在工作,它们会将合适的类库交给它们的parent/delegate去加载。我们只需要设置好相应的类加载器负责的目录,再配置好它们的关系,就可以自动按照规则加载。

看下tomcat源码: tomcat的启动类是Bootstrap类,先看下Bootstrap的main方法


    public static void main(String args[]) {

        synchronized (daemonLock) {
            if (daemon == null) {
                // Don't set daemon until init() has completed
                Bootstrap bootstrap = new Bootstrap();
                try {
                    //先初始化
                    bootstrap.init();
                } catch (Throwable t) {
                    handleThrowable(t);
                    t.printStackTrace();
                    return;
                }
                daemon = bootstrap;
            } else {
                // When running as a service the call to stop will be on a new
                // thread so make sure the correct class loader is used to
                // prevent a range of class not found exceptions.
                Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
            }
        }

        try {
            //根据命令行参数,执行不同的功能
            String command = "start";
            if (args.length > 0) {
                command = args[args.length - 1];
            }

            if (command.equals("startd")) {
                args[args.length - 1] = "start";
                daemon.load(args);
                daemon.start();
            } else {
                // 省略若干代码
            }
        } catch (Throwable t) {
                // 省略若干代码
        }

    }

再看下init方法

    public void init() throws Exception {

        //初始化loaders
        initClassLoaders();
        //这是tomcat主线程,它的上下文loader自然是server loader,也就是catalinaLoader
        Thread.currentThread().setContextClassLoader(catalinaLoader);
        //load需要授权的类,tomcat的loader实现是比较规范的。如果不考虑授权的话,我们只需要load一个启动入口的类就可以了
        SecurityClassLoad.securityClassLoad(catalinaLoader);

        // Load our startup class and call its process() method
        if (log.isDebugEnabled())
            log.debug("Loading startup class");
        Class<?> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");
        Object startupInstance = startupClass.getConstructor().newInstance();

        // 把shared loader传递下去,以便后面启动Context的时候作为parent
        if (log.isDebugEnabled())
            log.debug("Setting startup class properties");
        String methodName = "setParentClassLoader";
        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object paramValues[] = new Object[1];
        paramValues[0] = sharedLoader;

        Method method =
            startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);

        catalinaDaemon = startupInstance;

    }

initClassloaders方法

    private void initClassLoaders() {
        try {
            //设置好各loader之间的关系
            commonLoader = createClassLoader("common", null);
            if( commonLoader == null ) {
                // no config file, default to this loader - we might be in a 'single' env.
                commonLoader=this.getClass().getClassLoader();
            }
            catalinaLoader = createClassLoader("server", commonLoader);
            sharedLoader = createClassLoader("shared", commonLoader);
        } catch (Throwable t) {
            //省略若干代码
        }
    }

Bootstrap初始化之后,开始start,最终调用的是Catalina的start方法

    public void start() {

        if (getServer() == null) {
            //读取配置文件并初始化server
            load();
        }

        if (getServer() == null) {
            log.fatal(sm.getString("catalina.noServer"));
            return;
        }

        long t1 = System.nanoTime();

        // Start the new server
        try {
            //tomcat的容器是组合模式,每个容器都拥有一个子容器列表,当容器启动时,也会依次启动其子容器。所以Context在这个过程中也会被启动
            getServer().start();
        } catch (LifecycleException e) {
            log.fatal(sm.getString("catalina.serverStartFail"), e);
            try {
                getServer().destroy();
            } catch (LifecycleException e1) {
                log.debug("destroy() failed for failed Server ", e1);
            }
            return;
        }
        // 省略若干代码

    }

Context的启动

dll,虚拟环境

spring类加载

TODO:spring 也有自定义的类加载器

Java的模块化

模块化的好处

  1. 模块能带来清晰的边界:package和modifier的访问控制有缺陷,比如我想在package内部public的类也会被其他用户访问到;而模块必须显式声明依赖和接口,这使得组件的功能和边界变得非常清晰。

  2. 一定程度上避免 classpath 冲突(a.b/a.b.c 和 x.y/a.b.c 不冲突)

  3. 应用程序瘦身:对于需要打包jre的应用,再也不用引用庞大的jre环境,只需要引用自己需要的模块即可

  4. 应用依赖的减少会带来启动速度的提升,也会减少应用程序内存占用量。

模块化与安卓

模块化类加载

关于热部署

模块化与热部署

debugger

Why Classloader

转载自知乎

类似的动态加载代码机制:例如 POSIX C 有 dlopen, dlclose

Ruby 里的 require 就是加载, 也有 reload 用于重新加载不过还是 Java 的 ClassLoader 更复杂. 它的特殊性和 Java 的营销和设计决定有关系:

Applet

Java Applet 过去很被重视, 考虑到很多代码都是从网上下载来的不知道会干出什么事来, 所以 class loader 得考虑沙箱机制. 但合作伙伴们能力被限制就不高兴了, 所以还得有信任政策机制... 而 C 语言的主要应用场景里, 一般没这些需求.

wire format

Java 的 wire format 是 class 而不是源文件, 所以黑客们可以造出一些神奇的 class (例如在字节码里不断 pop stack 把运行栈击穿, 进而突破沙箱), 所以得有校验机制去排除这类威胁. 而以源代码为 wire format 的语言例如 Javascript 就没这个问题.

未竟之意

本想深入剖析一番java类加载原理,奈何见识浅薄,无法站在当时设计者角度去思考问题,所以写得有些隔靴搔痒。其实写这篇文章的初衷是想吐槽一下java面试八股文之一的“双亲委派”机制,这仅仅是一种实现,不是规范,更不是真理。从历史来看,可能当时有这么设计的原因,但是在现在的应用中,这种设计已经没有多大意义。面试官视野应该开阔一些,别都用上模块了还在问这个问题。类加载器的核心功能就是将二进制转成class对象,至于class对象加载后该如何管理,不是只有将其与加载器绑定这一种方法。实际上,java的classloader就是class的命名空间,但在很多其他语言中,我们可以通过指定类的命名空间的方式来实现一份源码,多份类对象的功能。而后者这种方式显得更加简洁明了,贴切易懂。classlaoder作为命名空间的功能已经被模块替代,除非需要一个class拥有多个实例,否则class实例似乎没有必要与classloader关联起来,classloader仅仅作为一个loader。

批判是最好的学习方式之一

reference

Dynamic Class Loading in the Java Virtual Machine, by Sheng Liang and Gilad Bracha, published in the proceedings of the ACM Conference on Object Oriented Programming Systems, Languages, and Applications (OOPSLA), 1998