从源码理解双亲委派机制,原来如此简单

15 篇文章 2 订阅
10 篇文章 1 订阅

本文,我们介绍类加载机制的几个硬核问题:

  • 1 从JDK源码级别剖析双亲委派机制原理

  • 2自定义类加载器研究如何打破双亲委派机制

  • 3.理解Tomcat的沙箱安全机制

目录

1 双亲委派机制介绍

2 双亲委派机制实现原理

3. 打破双亲委派机制

4 Tomcat的沙箱机制

1 双亲委派机制介绍

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式。

简单来说,双亲委派机制,就是app加载器先向上交由父类加载器进行加载,父类中找不到,再由子类加载器自行加载。具体来说:

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;

  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;

  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

  4. 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常。

 那为什么要设计成双亲委派机制呢?注意是两个方面的考虑:

  • 1.安全机制,防止核心API库被随意篡改。

  • 2.避免重复加载:当父类已经加载过该类时,子类加载器不再加载。保证被加载类的唯一性。

2 双亲委派机制实现原理

双亲委派机制到底怎么进行的呢?比如说,我们要加载自己写的Math类,具体是如何加载的呢?

在Launcher类里可以看到下面这个方法:

public ClassLoader getClassLoader() {
    return this.loader;
}

这里的loader是什么呢?就是我们上面提到的this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);而后面的getAppClassLoader()就是Math的加载器。

也就说一个类在被装载之前已经定好该用哪个加载器了。这就好比某些地方的干部选举,真正谁干啥早已经定好了,选举的时候只是为了公布而已。

我们看一下ClassLoader类中具体是如何载入类的:

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            //第一步,在已加载的类中查找是否存在
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                  //第二步,通过父加载器去加载
                    if (parent != null) {
                      //扩展类加载器没有重写loadClass方法,会再次进入本方法
                        c = parent.loadClass(name, false);
                    } else {
                      //启动类加载器加载某个类
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
​
                if (c == null) {
                    //第三步,如果还是没有找到类,那就要去磁盘查找文件,将类加载进来
                    long t1 = System.nanoTime();
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
在上面的代码中第二步是实现双亲委派的核心代码 :
  1. 如果当前是app加载器,则去ext扩展类加载器中查找

  2. 如果是扩展类加载器,则去查启动类加载器

  3. 如果没有父加载器了才启动类加载器来加载

如果都没有找到,再执行第三步,去磁盘查找文件,将类加载进来,该代码在URLClassLoader类中实现,代码如下:

//具体实施查找的方法
protected Class<?> findClass(final String name) throws ClassNotFoundException {
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                  //第四步,将类的全限定名,改写成目录的形式 (将.替换成/)
                    String path = name.replace('.', '/').concat(".class");
                    //第五步,去磁盘目录中查找文件
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                          ///第六步,将文件转化为内存中的类
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
      ......
    return result;
}

可以看到这里执行的时候会先修改文件的路径格式并加上后缀,例如本来字节码文件地址是”com.lqc.Math“,那修改之后就是com/lqc/Math.class。然后就到该路径下去加载,其实就是一个普通的文件读写操作。

可以看到接下来的重要方法是defineClass()用于将文件装载到内存中,进去之后我们能看到的仍然是文件读写的操作,代码如下:

private Class<?> defineClass(String name, Resource res) throws IOException {
    long t0 = System.nanoTime();
    int i = name.lastIndexOf('.');
    URL url = res.getCodeSourceURL();
    if (i != -1) {
        String pkgname = name.substring(0, i);
        // Check if package already loaded.
        Manifest man = res.getManifest();
        definePackageInternal(pkgname, man, url);
    }
    // Now read the class bytes and define the class
    java.nio.ByteBuffer bb = res.getByteBuffer();
    if (bb != null) {
        // Use (direct) ByteBuffer:
        CodeSigner[] signers = res.getCodeSigners();
        CodeSource cs = new CodeSource(url, signers);
        sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
        return defineClass(name, bb, cs);
    } else {
        byte[] b = res.getBytes();
        // must read certificates AFTER reading bytes.
        CodeSigner[] signers = res.getCodeSigners();
        CodeSource cs = new CodeSource(url, signers);
        sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
        return defineClass(name, b, 0, b.length, cs);
    }
}

读入之后,很明显接下来就是调用defineClass()进一步处理了,我们进入看一下:

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
    throws ClassFormatError
{
    protectionDomain = preDefineClass(name, protectionDomain);
    String source = defineClassSourceLocation(protectionDomain);
    Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
    postDefineClass(c, protectionDomain);
    return c;
}

而这里最关键的方法很明显是defineClass1(),,然后次方法也是native的,也就是由C++来实现的,我们明白即可。

private native Class<?> defineClass1(String name, byte[] b, int off, int len,
                                     ProtectionDomain pd, String source);

上面的中我们最需要关注的是如何进行双亲委派的,也就是在loadClass()方法的功能。

3. 打破双亲委派机制

为什么要打破双亲委派机制呢?因为有时候我们需要使用自己定义的加载器,而不是全都用JVM的。例如在Tomcat中,假如同时管理物料和会员两个项目:erp.war和member.war,而此时前者是基于Spring4构建的,后者是基于Spring5,两者很多实现是不一样的。我们平时开发时,如果遇到包冲突就必须调整好,否则就无法启动,那Tomcat如何保证能同时运行两个服务呢?此时我们需要将Spring4和Spring5两个环境隔离开。

打破双亲委派就是自己设计一个加载器,如果要我们自己实现一个类加载器该怎么做呢?通过上面的代码可以看到,我们需要在加载类的时候,从自定义的路径中去查找文件。所以在实现自定义加载器时,只需要把原加载器中定义加载文件的路径替换为我们自己的路径即可。 因此,我们从findClass(name)入手,而扩展类加载器和APP类加载器,实际都没有去实现,而是在父类URLClassLoader中实现的 findClass(name)方法,所以在自定义加载器中,只要重写下方path的路径即可。

protected Class<?> findClass(final String name) throws ClassNotFoundException {
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                  //查找磁盘文件路径的核心代码
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                        }
                    }
                }
            }, acc);
    }
    return result;
}

我们先写一个要加载的类:

public class HelloApp {
    private static int a = 1;
    public static void main(String[] args) {
        System.out.println(a);
    }
}

在我电脑中,该类编译好的路径是:/Users/liuqingchao/Desktop/study_space/jvm/target/classes/,然后包路径是ch2_class_loader.java.HelloApp

所以我们访问该类的完整路径就是/Users/liuqingchao/Desktop/study_space/jvm/target/classes/ch2_class_loader/java,所以我们的自定义加载器的完整代码如下:

package ch2_class_loader.java;
import java.io.FileInputStream;
/**
 * 自定义类加载器
 */
public class MyClassLoader extends ClassLoader {
    //需要加载的自定义加载路径
    private String classPath;
​
    //指定路径的构造方法
    public MyClassLoader(String path){
        this.classPath = path;
    }
​
    //修改路径的自定义加载器
    private byte[] loadByte(String name) throws Exception {
        //保持和原代码大体一致
        String path = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(classPath + "/" + path + ".class");
        int len = fis.available();
        byte[] data = new byte[len];
        fis.read(data);
        fis.close();
        return data;
    }
​
    @Override
    protected Class<?> findClass(final String name) throws ClassNotFoundException{
        try {
            byte[] data = loadByte(name);
            //找到文件后,继续沿用原有的加载类的方法
            return defineClass(name, data, 0, data.length);
        } catch (Exception e) {
            e.printStackTrace();
            throw new ClassNotFoundException();
        }
    }
​
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if(name.startsWith("ch2_class_loader.java")){
                        c = findClass(name);
                    }else {
                        c = this.getParent().loadClass(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
​
    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("/Users/liuqingchao/Desktop/study_space/jvm/target/classes/");
        Class clazz = classLoader.loadClass("ch2_class_loader.java.HelloApp");
        Object obj = clazz.newInstance();
        System.out.println(clazz.getClassLoader());
    }
}

运行之后结果如下:

ch2_class_loader.java.MyClassLoader@610455d6
进程已结束,退出代码 0

在上面的loadClass()中有个问题我们要重点强调一下:

if(name.startsWith("ch2_class_loader.java")){
    c = findClass(name);
}else {
    c = this.getParent().loadClass(name);
}

这里为什么要加一个判断呢?我们可以尝试将其去掉,此时会报错:

找不到Object.class,该类是Java所有类的基类,找不到说明几乎所有的方法我们都不能用了。这里之所以出错,是因为我们只是将HelloApp类自己加载,其他的都还要通过正常的双亲委派机制进行,因此这里需要加一个if判断一下。

4 Tomcat的沙箱机制

  1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的 不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是 独立的,保证相互隔离。

  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程 序,那么要有10份相同的类库加载进虚拟机。

  3. web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的 类库和程序的类库隔离开来。

  4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中 运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。

再看看我们的问题:Tomcat 如果使用默认的双亲委派类加载机制行不行?

答案是不行的。为什么?

  • 第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认 的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。

  • 第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。

  • 第三个问题和第一个问题一样。

  • 我们再看第四个问题,我们想我们要怎么实现jsp文件的热加载,jsp 文件其实也就是class文 件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp 是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想 到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载 器。重新创建类加载器,重新加载jsp文件。

tomcat的类加载结构如下:

 tomcat的几个主要类加载器:

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;

  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;

  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;

  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本, 这样实现就能加载各自的spring版本;

从图中的委派关系中可以看出: CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用, 从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。 WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader 实例之间相互隔离。 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的 就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例, 并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。

tomcat 这种类加载机制违背了java 推荐的双亲委派模型了吗?答案是:违背了。 很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个 webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委 派机制。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纵横千里,捭阖四方

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值