通俗易懂 启动类加载器、扩展类加载器、应用类加载器

通俗易懂 启动类加载器、扩展类加载器、应用类加载器

本文贴出的类加载器相关源码全部做了精简,只要会CRUD就能看懂。并不是所有JDK源码都是开源的,如果你想自己看完整的源码,却发现在Eclipse里看不到sun包源码,可以直接拉到最后一小节。但是个人强烈不建议毫无目的地通读源码。

〇、类加载器是干什么的?

初学Java的时候,你应该用命令行编译过Java文件。Java代码通过javac编译成class文件,而类加载器的作用,就是把class文件装进虚拟机。

面试请回答:将“通过类的全限定名获取描述类的二进制字节流”这件事放在虚拟机外部,由应用程序自己决定如何实现。

宏观来看,只有两种类加载器:启动类加载器、其他类加载器。

启动类加载器属于虚拟机的一部分,它是用C++写的,看不到源码;其他类加载器是用Java写的,说白了就是一些Java类,一会儿就可以看到了,比如扩展类加载器、应用类加载器。

  • 启动类加载器:BootstrapClassLoader
  • 扩展类加载器:ExtentionClassLoader
  • 应用类加载器:AppClassLoader (也叫做“系统类加载器”)

既然只是把class文件装进虚拟机,为什么要用多种加载器呢?因为Java虚拟机启动的时候,并不会一次性加载所有的class文件(内存会爆),而是根据需要去动态加载。

一、它们分别加载了什么?

类加载器是通过类的全限定名(或者说绝对路径)来找到一个class文件的。可以直接打印启动类加载器BootstrapClassLoader的加载路径看看:

这一小节里,你只关心输出结果就可以了,反正这些API我也是现查的。
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
    System.out.println(url);
}

输出结果(%20是空格):

file:/C:/Program%20Files/Java/jre1.8.0_131/lib/resources.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/rt.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jsse.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jce.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/charsets.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jfr.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/classes

可以看到,启动类加载器加载的是jre和jre/lib目录下的核心库,具体路径要看你的jre安装在哪里。再打印一下扩展类加载器ExtentionClassLoader的加载路径看看:

URL[] urls = ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs();
for (URL url : urls) {
    System.out.println(url);
}

输出结果:

file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/access-bridge-64.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/cldrdata.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/dnsns.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/dns_sd.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/jaccess.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/jfxrt.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/localedata.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/nashorn.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/sunec.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/sunjce_provider.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/sunmscapi.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/sunpkcs11.jar
file:/C:/Program%20Files/Java/jre1.8.0_131/lib/ext/zipfs.jar

很明显,扩展类加载器加载的是jre/lib/ext目录下的扩展包。这些类库具体是什么不重要,只需要知道不同的类库可能是被不同的类加载器加载的。

JVM是怎么知道我们把JRE安装到哪里了呢?因为你安装完JDK之后配置了环境变量啊!那些 JAVA_HOME、CLASSPATH 之类的就是干这个用的。

最后是AppClassLoader:

URL[] urls = ((URLClassLoader) ClassLoader.getSystemClassLoader()).getURLs();
for (URL url : urls) {
    System.out.println(url);
}

输出结果:

file:/D:/JavaWorkSpace/PicklePee/bin/

这是当前java工程的bin目录,也就是我们自己的Java代码编译成的class文件所在。

二、Java虚拟机的入口

当我们运行一个Java程序时,首先是JDK安装目录下的jvm.dll启动虚拟机,而sun.misc.Launcher类就是虚拟机执行的第一段Java代码。之前提到,除BootstrapClassLoader以外,其他的类加载器都是用Java实现的——在Launcher里你就可以看到它们。

以下是sun.misc.Launcher的精简版源码,阅读起来应该毫无难度:

public class Launcher {
    private static Launcher launcher = new Launcher();
    private ClassLoader appClassLoader;

    // 启动类加载器不是Java类,我们这里拿到的是其加载路径字符串
    private static String bootClassPath = System.getProperty("sun.boot.class.path");

    public Launcher() {
        ClassLoader extentionClassLoader; // 扩展类加载器在这里
        try {
            extentionClassLoader = ExtClassLoader.getExtClassLoader(); 
        } catch (Exception e) {...}

        try {
	    // 应用类加载器在这里,get时把扩展类加载器作为参数,后面我们会回到这里。
            appClassLoader = AppClassLoader.getAppClassLoader(extentionClassLoader); 
        } catch (Exception e) {...}
    }	
	
    // 静态内部类:扩展类加载器,父类是URLClassLoader
    static class ExtClassLoader extends URLClassLoader {}

    // 静态内部类:应用类加载器,父类是URLClassLoader
    static class AppClassLoader extends URLClassLoader {}

    public static Launcher getLauncher() {
        return launcher;
    }
    public ClassLoader getClassLoader() {
        return appClassLoader;
    }

    private static URLStreamHandlerFactory factory = new Factory();
    private static class Factory implements URLStreamHandlerFactory {
        public URLStreamHandler createURLStreamHandler(String protocol) {
            /* 创建一个文件句柄(File Handler)
               我们硬盘上的class文件就是通过这个句柄进入内存 */
        }
    }
}

可以看到,扩展类加载器和应用类加载器都是Launcher里的静态内部类。它们都是调用了自己的静态方法getExtClassLoader返回自己的实例,看一下发生了什么:

/* 扩展类加载器是Launcher的静态内部类,这里只是把它单独拎出来了 */
static class ExtClassLoader extends URLClassLoader {

    public ExtClassLoader(File[] dirs) throws IOException {
        // 交给了父类URLClassLoader处理。中间的参数是null,它是什么呢?
        super(getExtURLs(dirs), null, factory);
    }

    public static ExtClassLoader getExtClassLoader() throws IOException {
        final File[] dirs = getExtDirs();
        ExtClassLoader extentionClassLoader = new ExtClassLoader(dirs);
        return extentionClassLoader
    }

    private static File[] getExtDirs() {
	// 就这样拿到了扩展类加载器的加载路径,这跟第一小节我们打印的路径是一样的。
	String s = System.getProperty("java.ext.dirs");
	File[] dirs;
	... 按照;分割字符串s转化为一个File数组 ...
	return dirs;
    }
}

刚刚传了三个参数给父类URLClassLoader的构造器,继续深入:

public class URLClassLoader extends SecureClassLoader implements Closeable {
    private ArrayList<URL> path = new ArrayList<URL>();

    public URLClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {
        super(parent); // 第二个参数null是这里的parent,然后又再次扔给了父类
        
        /* 保存这些类库的路径并创建一个获取jar文件的句柄,想干什么已经很明显了 */
        for (int i = 0; i < urls.length; i++) {
            path.add(urls[i]);
        }
        if (factory != null) {
            jarHandler = factory.createURLStreamHandler("jar");
        }
    }
}

URLClassLoader继续把这个null扔给父类SecureClassLoader?看看它要做什么:

public class SecureClassLoader extends ClassLoader {
    protected SecureClassLoader(ClassLoader parent) {
        super(parent);
        ...
    }
    ...
}

什么也没干,直接扔给了父类:

public abstract class ClassLoader {    
    private final ClassLoader parent;

    private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent; // parent最终是ClassLoader里的全局变量
        ...
    }
    
    // 我们从子类到达这里
    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }

    protected ClassLoader() {
	// 默认parent是系统(应用)类加载器!
        this(checkCreateClassLoader(), getSystemClassLoader());
    }

    public final ClassLoader getParent() {
        if (parent == null) {
            return null; 
        }
        return parent;
    }
	
    /* 防止恶意代码对系统产生影响,有兴趣可以搜索Java安全管理器 */
    private static Void checkCreateClassLoader() {
        SecurityManager security = System.getSecurityManager();
        if (security != null) {
            security.checkCreateClassLoader();
        }
        return null;
    }
}

终于到头了,从扩展类加载器的getExtClassLoader()一路走来,发现参数null传给了最顶层ClassLoader的全局变量parent,看一下关系图:

你可能注意到,JDK总是通过一个类似System.getProperty("xxx")的方法来获取class文件路径。这个字符串参数到底是哪来的呢?其实它可以在虚拟机启动时手动赋值。比如:

java -D java.ext.dirs=路径 MyClass  //这样自定义的路径将覆盖Java本身的拓展类路径
还有一个命令是 -Xbootclasspath 可以改变核心类库的加载路径,知道有这回事儿就行了,最好别用。

回到源码,还记得一开始Launcher类里,得到应用类加载器的这行代码吗?

appClassLoader = AppClassLoader.getAppClassLoader(extentionClassLoader); 

它把创建的扩展类加载器作为参数传给了应用类加载器,进去看一下:

static class AppClassLoader extends URLClassLoader {
    public static ClassLoader getAppClassLoader(final ClassLoader extcl) throws IOException {
        final String s = System.getProperty("java.class.path"); // 得到应用类加载器的加载路径
	final File[] path = getClassPath(s);
        URL[] urls = pathToURLs(path);

        // 传入的扩展类加载器extcl在这里
        return new AppClassLoader(urls, extcl);
    }

    AppClassLoader(URL[] urls, ClassLoader parent) {
        // AppClassLoader的父类也是URLClassLoader,只不过第二个参数由null变为扩展类加载器
	super(urls, parent, factory);
    }
}

至此应该一切都清晰了,后面的过程与扩展类加载器一样!只不过最终的parent参数会被赋值为扩展类加载器(extcl)而不是null。扯了这么多,这个parent到底是干什么的?

三、父加载器

ClassLoader里的parent是父加载器。刚刚看了类加载器的继承关系图,但是父加载器不是父类,这是两个不同的概念。看一下前面ClassLoader的getParent()方法,任何一个类加载器调用此方法得到的对象就是它的父加载器。

AppClassLoader的父类是URLClassLoader,但是它的父加载器是ExtentionClassLoader。

除了启动类加载器(BootstrapClassLoader),每个类加载器都有一个父加载器。比如刚才的应用类加载器,它的父加载器是扩展类加载器。你可能会说扩展类加载器的parent是null,所以它没有父加载器?

有,它的父加载器就是BootstrapClassLoader。任何parent为null的加载器,其父加载器为BootstrapClassLoader,先记住这个结论,很快你会看到原因。

最后一个问题,如果你直接继承ClassLoader自己实现一个类加载器,且不指定父加载器,那么这个自定义类加载器的父加载器是什么?

是应用类加载器AppClassLoader。可以拉回去看看ClassLoader的无参构造器。

父加载器关系

四、双亲委派模型

有一个描述类加载器加载类过程的术语:双亲委派模型。然而这是一个很有误导性的术语,它应该叫做单亲委派模型(Parent-Delegation Model)。但是没有办法,大家都已经这样叫了。所谓双亲委派,这个就是指ClassLoader里的全局变量parent,也就是父加载器

双亲委派的具体过程如下:

  1. 当一个类加载器接收到类加载任务时,先查缓存里有没有,如果没有,将任务委托给它的父加载器去执行。
  2. 父加载器也做同样的事情,一层一层往上委托,直到最顶层的启动类加载器为止。
  3. 如果启动类加载器没有找到所需加载的类,便将此加载任务退回给下一级类加载器去执行,而下一级的类加载器也做同样的事情。
  4. 如果最底层类加载器仍然没有找到所需要的class文件,则抛出异常。

所以是一条线传上再传下,并没有什么“双亲”。整个过程的Java实现也没有什么神秘的:

public abstract class ClassLoader {
    // name: Class文件的绝对路径
    // resolve: 找到后是否立即解析(什么是解析?)
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (lock) {
            // 尝试从缓存获取,这也是为什么修改了Class后需重启JVM才能生效
            Class<?> target = findLoadedClass(name); // native方法
            if (target == null) {
                try {
                    if (parent != null) {
                        // 委托给父加载器, 只查找不解析
                        target = parent.loadClass(name, false);
                    } else {
                        // 父加载器为null,则委托给启动类加载器BootstrapClassloader
                        target = findBootstrapClassOrNull(name); // native方法
                    }
                } catch (ClassNotFoundException e) {...}
				
				
                if (target == null) {
                    // 父加载器没有找到,才调用自己的findClass()方法
                    target = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(target); // native方法
            }
            return target;
        }
    }
    
    // findClass是模板方法,需要重写
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }    
}
什么是解析?把符号引用变为直接引用。比如com.test.Car里面有一个com.test.Wheel类,在编译时Car类并不知道Wheel类的实际内存地址,此时com.test.Wheel只是一个符号。“解析”的意思就是把被引用的类加载入内存,然后将com.test.Wheel这个符号变成一个指针,能够定位到内存中目标。

到现在就剩下findClass这个模板方法了,URLClassLoader继承了ClassLoader以后,重写了此方法,做了三件事:

protected Class<?> findClass(final String name) throws ClassNotFoundException {
    // 1、安全检查
    // 2、根据绝对路径把硬盘上class文件读入内存
    byte[] raw = getBytes(name); 
    // 3、将二进制数据转换成class对象
    return defineClass(raw);
}

如果我们自己去实现一个类加载器,基本上就是继承ClassLoader之后重写findClass方法,且在此方法的最后调包defineClass。

五、为什么要双亲委派?

确保类的全局唯一性

如果你自己写的一个类与核心类库中的类重名,会发现这个类可以被正常编译,但永远无法被加载运行。因为你写的这个类不会被应用类加载器加载,而是被委托到顶层,被启动类加载器在核心类库中找到了。如果没有双亲委托机制来确保类的全局唯一性,谁都可以编写一个java.lang.Object类放在classpath下,那应用程序就乱套了。

从安全的角度讲,通过双亲委托机制,Java虚拟机总是先从最可信的Java核心API查找类型,可以防止不可信的类假扮被信任的类对系统造成危害。

六、所以知道这些到底有什么用?

  1. 面试。
  2. 研究Tomcat、JBoss等Servlet容器原理,可能得另开一篇了。
  3. 如果你不想自己的代码被反编译,可以将编译后的代码加密,用自己的类加载器解密。
  4. 我编不下去了。

★、用Eclipse查看sun包源码

sun包源码正常情况下是看不到的。

想看这部分源码,先确定一下你当前的Java版本(cmd 输入 java -version)

然后下载对应的 OpenJDK。从外网下载速度可能比较慢,我搞了一套6-12版本放到了百度网盘。

链接:https://pan.baidu.com/s/1ZwllHyhxSDihAhi8ZqustA 
提取码:cx2c 

下载之后解压到任意目录,然后Eclispe里 Window → Preferences:

弹出External Folder后,打开刚刚解压的OpenJDK文件夹,找到src路径:

不同版本OpenJDK目录结构可能与此图不一样,但是肯定都能找到src目录,选它就没错!

然后重启Eclipse。完成!

编辑于 2019-07-27 15:29