学习Tomcat(六)之类加载器

1,044 阅读11分钟

通过前面的文章我们知道,Tomcat的请求最终都会交给用户配置的servlet实例来处理。Servlet类是配置在配置文件中的,这就需要类加载器对Servlet类进行加载。Tomcat容器自定义了类加载器,有以下特殊功能:1. 在载入类中指定某些规则;2.缓存已经载入的类;3.实现类的预加载。 本文会对Tomcat的类加载器进行详细介绍。

Java类加载双亲委派模型

Java类加载器是用户程序和JVM虚拟机之间的桥梁,在Java程序中起了至关重要的作用,关于其详细实现可以参考了java官方文档关于虚拟机加载的教程,点此直达官方参考文档。java中的类加载默认是采用双亲委派模型,即加载一个类时,首先判断自身define加载器有没有加载过此类,如果加载了直接获取class对象,如果没有查到,则交给加载器的父类加载器去重复上面过程。我在另外一篇文章中详细介绍了Java的类加载机制,此处不做详细介绍。

java-class-load-2021-10-05-18-35-03

Loader接口

在载入Web应用程序中需要的servlet类及其相关类时要遵守一些明确的规则,例如应用程序中的servlet只能引用部署在WEB-INF/classes目录及其子目录下的类。但是,servlet类不能访问其它路径中的类,即使这些累包含在运行当前Tomcat的JVM的CLASSPATH环境变量中。此外,servlet类只能访问WEB-INF/LIB目录下的库,其它目录的类库均不能访问。Tomcat中的载入器值得是Web应用程序载入器,而不仅仅是类载入器,载入器必须实现Loader接口。Loader接口的定义如下所示:

public interface Loader {

    public void backgroundProcess();
    public ClassLoader getClassLoader();
    public Context getContext();
    public void setContext(Context context);
    public boolean getDelegate();
    public void setDelegate(boolean delegate);
    public void addPropertyChangeListener(PropertyChangeListener listener);
    public boolean modified();
    public void removePropertyChangeListener(PropertyChangeListener listener);
}

后台任务:Loader接口需要进行在servlet类变更的时候实现类的重新加载,这个任务就是在backgroundProcess()中实现的,WebApploader中backgroundProcess()的实现如下所示。可以看到,当Context容器开启了Reload功能并且仓库变更的情况下,Loaders会先把类加载器设置为Web类加载器,重启Context容器。重启Context容器会重启所有的子Wrapper容器,会销毁并重新创建servlet类的实例,从而达到动态加载servlet类的目的。

    @Override
    public void backgroundProcess() {
        Context context = getContext();
        if (context != null) {
            if (context.getReloadable() && modified()) {
                ClassLoader originalTccl = Thread.currentThread().getContextClassLoader();
                try {
                    Thread.currentThread().setContextClassLoader(WebappLoader.class.getClassLoader());
                    context.reload();
                } finally {
                    Thread.currentThread().setContextClassLoader(originalTccl);
                }
            }
        }
    }

类加载器:Loader的实现中,会使用一个自定义类载入器,它是WebappClassLoader类的一个实例。可以使用Loader接口的getClassLoader()方法来获取Web载入器中的ClassLoader的实例。默认的类加载器的实现有两种种:ParallelWebappClassLoader和WebappClassLoader

Context容器:Tomcat的载入器通常会与一个Context级别的servelt容器相关联,Loader接口的getContainer()方法和setContainer()方法用来将载入器和某个servlet容器关联。如果Context容器中的一个或者多个类被修改了,载入器也可以支持对类的重载。这样,servlet程序员就可以重新编译servlet类及其相关类,并将其重新载入而不需要重新启动Tomcat。Loader接口使用modified()方法来支持类的自动重载。

类修改检测:在载入器的具体实现中,如果仓库中的一个或者多个类被修改了,那么modified()方法必须放回true,才能提供自动重载的支持

父载入器:载入器的实现会指明是否要委托给父类的载入器,可以通过setDelegate()和getDelegate方法配置。

WebappLoader类

Tomcat中唯一实现Loader接口的类就是WebappLoader类,其实例会用作Web应用容器的载入器,负责载入Web应用程序中所使用的类。在容器启动的时候,WebApploader会执行以下工作:

  • 创建类加载器
  • 设置仓库
  • 设置类的路径
  • 设置访问权限
  • 启动新线程来支持自动重载

创建类加载器

为了完成类加载功能,WebappLoader会按照配置创建类加载器的实例,Tomcat默认有两种类加载器:WebappClassLoader和ParallelWebappClassLoader,默认情况下使用ParallelWebappClassLoader作为类加载器。用户可以通过setLoaderClass()设置类加载器的名称。WebappLoader创建类加载器的源码如下所示,我们可以看到类加载器的实例必须是WebappClassLoaderBase的子类。

    private WebappClassLoaderBase createClassLoader()
        throws Exception {

        if (classLoader != null) {
            return classLoader;
        }

        if (ParallelWebappClassLoader.class.getName().equals(loaderClass)) {
            return new ParallelWebappClassLoader(context.getParentClassLoader());
        }

        Class<?> clazz = Class.forName(loaderClass);
        WebappClassLoaderBase classLoader = null;

        ClassLoader parentClassLoader = context.getParentClassLoader();

        Class<?>[] argTypes = { ClassLoader.class };
        Object[] args = { parentClassLoader };
        Constructor<?> constr = clazz.getConstructor(argTypes);
        classLoader = (WebappClassLoaderBase) constr.newInstance(args);

        return classLoader;
    }

设置仓库

WebappLoader会在启动的时候调用类加载器的初始化方法,类加载器在初始化的时候会设置类加载的仓库地址。默认的仓库地址为"/WEB-INF/classes"和"/WEB-INF/lib"。类加载器初始化源码如下所示:

    @Override
    public void start() throws LifecycleException {

        state = LifecycleState.STARTING_PREP;

        WebResource[] classesResources = resources.getResources("/WEB-INF/classes");
        for (WebResource classes : classesResources) {
            if (classes.isDirectory() && classes.canRead()) {
                localRepositories.add(classes.getURL());
            }
        }
        WebResource[] jars = resources.listResources("/WEB-INF/lib");
        for (WebResource jar : jars) {
            if (jar.getName().endsWith(".jar") && jar.isFile() && jar.canRead()) {
                localRepositories.add(jar.getURL());
                jarModificationTimes.put(
                        jar.getName(), Long.valueOf(jar.getLastModified()));
            }
        }

        state = LifecycleState.STARTED;
    }

设置类路径

设置类路径是在初始化的时候调用setClassPath()方法完成的(源码如下)。setClassPath()方法会在servlet上下文中为Jasper JSP编译器设置一个字符串类型的属性来指明类路径信息。此处不详细介绍JSP相关内容。

  private void setClassPath() {

        // Validate our current state information
        if (context == null)
            return;
        ServletContext servletContext = context.getServletContext();
        if (servletContext == null)
            return;

        StringBuilder classpath = new StringBuilder();

        // Assemble the class path information from our class loader chain
        ClassLoader loader = getClassLoader();

        if (delegate && loader != null) {
            // Skip the webapp loader for now as delegation is enabled
            loader = loader.getParent();
        }

        while (loader != null) {
            if (!buildClassPath(classpath, loader)) {
                break;
            }
            loader = loader.getParent();
        }

        if (delegate) {
            // Delegation was enabled, go back and add the webapp paths
            loader = getClassLoader();
            if (loader != null) {
                buildClassPath(classpath, loader);
            }
        }

        this.classpath = classpath.toString();

        // Store the assembled class path as a servlet context attribute
        servletContext.setAttribute(Globals.CLASS_PATH_ATTR, this.classpath);
    }

设置访问权限

若是运行Tomcat的时候,使用了安全管理器,则setPermissions()方法会为类载入器设置访问相关目录的权限,比如只能访问WEB-INF/classes和WEB-INF/lib的目录。若是没有使用安全管理器,则setPermissions()方法只是简单地返回,什么也不做。其源码如下:


    /**
     * Configure associated class loader permissions.
     */
    private void setPermissions() {

        if (!Globals.IS_SECURITY_ENABLED)
            return;
        if (context == null)
            return;

        // Tell the class loader the root of the context
        ServletContext servletContext = context.getServletContext();

        // Assigning permissions for the work directory
        File workDir =
            (File) servletContext.getAttribute(ServletContext.TEMPDIR);
        if (workDir != null) {
            try {
                String workDirPath = workDir.getCanonicalPath();
                classLoader.addPermission
                    (new FilePermission(workDirPath, "read,write"));
                classLoader.addPermission
                    (new FilePermission(workDirPath + File.separator + "-",
                                        "read,write,delete"));
            } catch (IOException e) {
                // Ignore
            }
        }

        for (URL url : context.getResources().getBaseUrls()) {
           classLoader.addPermission(url);
        }
    }

开启新线程执行类的重新载入

WebappLoader类支持自动重载功能。如果WEB-INF/classes目录或者WEB-INF/lib目录下的某些类被重新编译了,那么这个类会自动重新载入,而无需重启Tomcat。为了实现此目的,WebappLoader类使用一个线程周期性的检查每个资源的时间戳。间隔时间由变量checkInterval指定,单位为s,默认情况下,checkInterval的值为15s,每隔15s会检查依次是否有文件需要自动重新载入。顶层容器在启动的时候,会启动定时线程池循环调用backgroundProcess任务。

    protected void threadStart() {
        if (backgroundProcessorDelay > 0
                && (getState().isAvailable() || LifecycleState.STARTING_PREP.equals(getState()))
                && (backgroundProcessorFuture == null || backgroundProcessorFuture.isDone())) {
            if (backgroundProcessorFuture != null && backgroundProcessorFuture.isDone()) {
                // There was an error executing the scheduled task, get it and log it
                try {
                    backgroundProcessorFuture.get();
                } catch (InterruptedException | ExecutionException e) {
                    log.error(sm.getString("containerBase.backgroundProcess.error"), e);
                }
            }
            backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
                    .scheduleWithFixedDelay(new ContainerBackgroundProcessor(),
                            backgroundProcessorDelay, backgroundProcessorDelay,
                            TimeUnit.SECONDS);
        }
    }

    @Override
    public void backgroundProcess() {
        Context context = getContext();
        if (context != null) {
            if (context.getReloadable() && modified()) {
                ClassLoader originalTccl = Thread.currentThread().getContextClassLoader();
                try {
                    Thread.currentThread().setContextClassLoader(WebappLoader.class.getClassLoader());
                    context.reload();
                } finally {
                    Thread.currentThread().setContextClassLoader(originalTccl);
                }
            }
        }
    } 

WebappClassLoader类加载器

Web应用程序中负责载入类的类载入器有两种:ParallelWebappClassLoader和WebappClassLoaderBase,二者实现大同小异,本节以WebappClassLoader类加载器为例,介绍Tomcat的类加载器。

WebappClassLoader的设计方案考虑了优化和安全两方面。例如,它会缓存之前已经载入的类来提升性能,还会缓存加载失败的类的名字,这样,当再次请求加载同一个类的时候,类加载器就会直接抛出ClassNotFindException异常,而不是再次去查找这个类。WebappClassLoader会在仓库列表和指定的JAR文件中搜索需要在载入的类。

类缓存

为了达到更好的性能,WebappClassLoader会缓存已经载入的类,这样下次再使用该类的时候,会直接从缓存中获取。由WebappClassLoader载入的类都会被视为资源进行缓存,对应的类为“ResourceEntry”类的实例。ResourceEndty保存了其所代表的class文件的字节流、最后一次修改日期,Manifest信息等。如下为类加载过程中读取缓存的部分代码和ResourceEntry的定义源码。

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

    // 省略部分逻辑
    // (0) Check our previously loaded local class cache
    clazz = findLoadedClass0(name);
    if (clazz != null) {
        if (log.isDebugEnabled())
            log.debug("  Returning class from cache");
            if (resolve)
                resolveClass(clazz);
        return clazz;
    }
     // 省略部分逻辑
}

protected Class<?> findLoadedClass0(String name) {

    String path = binaryNameToPath(name, true);

    ResourceEntry entry = resourceEntries.get(path);
    if (entry != null) {
        return entry.loadedClass;
    }
     return null;
}


public class ResourceEntry {
    /**
     * The "last modified" time of the origin file at the time this resource
     * was loaded, in milliseconds since the epoch.
     */
    public long lastModified = -1;

    /**
     * Loaded class.
     */
    public volatile Class<?> loadedClass = null;
}

载入类

载入类的时候,WebappClassLoader要遵循如下规则:

  1. 因为所有已经载入的类都会缓存起来,所以载入类的时候要先检查本地缓存。
  2. 若本地缓存没有,则检查父类加载器的缓存,调用ClassLoader接口的findLoadedClass()方法。
  3. 若两个缓存总都没有,则使用系统类加载器进行加载,防止Web应用程序中的类覆盖J2EE中的类。
  4. 若启用了SecurityManager,则检查是否允许载入该类。若该类是禁止载入的类,抛出ClassNotFoundException异常。
  5. 若打开了标志位delegate,或者待载入的在类不能用web类加载器加载的类,则使用父类加载器来加载器来加载相关类。如果父类加载器为null,则使用系统类加载器。
  6. 从当前仓库载入类。
  7. 当前仓库没有需要载入的类,而且delegate关闭,则是用父类载入器来载入相关的类。
  8. 若没有找到需要加载的类,则抛出ClassNotFindException。

Tomcat类加载结构

Tomcat容器在启动的时候会初始化类加载器,Tomcat的类加载器分为四种类型:Common类加载器,Cataline类加载器和Shared类加载器,此外每个应用都会有自己的Webapp类加载器,也就是我们上文介绍的WebappClassLoader,四者之间的关系如下所示。

tomcat-class-loader-2021-10-05-18-38-17

Common类加载器,Cataline类加载器和Shared类加载器会在Tomcat容器启动的时候就初始化完成,初始化代码如下所示:

    private void initClassLoaders() {
        try {
            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) {
            handleThrowable(t);
            log.error("Class loader creation threw exception", t);
            System.exit(1);
        }
    }


    private ClassLoader createClassLoader(String name, ClassLoader parent)
        throws Exception {

        String value = CatalinaProperties.getProperty(name + ".loader");
        if ((value == null) || (value.equals("")))
            return parent;

        value = replace(value);

        List<Repository> repositories = new ArrayList<>();

        String[] repositoryPaths = getPaths(value);

        for (String repository : repositoryPaths) {
            // Check for a JAR URL repository
            try {
                @SuppressWarnings("unused")
                URL url = new URL(repository);
                repositories.add(new Repository(repository, RepositoryType.URL));
                continue;
            } catch (MalformedURLException e) {
                // Ignore
            }

            // Local repository
            if (repository.endsWith("*.jar")) {
                repository = repository.substring
                    (0, repository.length() - "*.jar".length());
                repositories.add(new Repository(repository, RepositoryType.GLOB));
            } else if (repository.endsWith(".jar")) {
                repositories.add(new Repository(repository, RepositoryType.JAR));
            } else {
                repositories.add(new Repository(repository, RepositoryType.DIR));
            }
        }

        return ClassLoaderFactory.createClassLoader(repositories, parent);
    }

而Webapp类加载器则是在Context容器启动时候有WebappLoader初始化,Webapp类加载器的父类加载器是Tomcat容器在初始化阶段通过反射设置的,反射设置父类加载器的源码如下所示:

    public void init() throws Exception {

        initClassLoaders();

        Thread.currentThread().setContextClassLoader(catalinaLoader);

        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();

        // Set the shared extensions class loader
        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;
    }

Tomcat类加载结构的目的

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

还有最后一个类的共享的问题,如果十个web应用都引入了spring的类,由于web类加载器的隔离,那么对内存的开销是很大的。此时我们可以想到shared类加载器,我们肯定都会选择将spring的jar放于shared目录底下,但是此时又会存在一个问题,shared类加载器是webapp类加载器的parent,若spring中的getBean方法需要加载web应用底下的类,这种过程是违反双亲委托机制的。

打破双亲委托机制的桎梏:线程上下文类加载器线程上下文类加载器是指的当前线程所用的类加载器,可以通过Thread.currentThread().getContextClassLoader()获得或者设置。在spring中,他会选择线程上下文类加载器去加载web应用底下的类,如此就打破了双亲委托机制。

参考文档列表

我是御狐神,欢迎大家关注我的微信公众号:wzm2zsd

qrcode_for_gh_83670e17bbd7_344-2021-09-04-10-55-16

本文最先发布至微信公众号,版权所有,禁止转载!