通过源码,实例详解java类加载机制

3,534 阅读10分钟

之前的文章中,介绍了class的字节码静态结构,这些类需要jvm加载到其在内存中分配的运行时数据区才会生效,这个过程包含:加载 -> 链接 -> 初始化 几个阶段,其中链接阶段又有验证 -> 准备 -> 解析三个部分,接下来我会用三篇文章分别详细介绍这三个阶段,本文先介绍jvm类的加载以及双亲委派模型的概念。

注意:类加载包含了从字节码流到jvm方法区java.lang.Class对象创建并初始化整个过程,而本文介绍的类的加载只是其中一个阶段

运行时数据区

本文内容基于hotspot jvm,运行时Class对象就存储在Method Area中

类加载时机

什么时候开始加载一个类的字节码呢?对此jvm规范并没有给出明确定义,但是jvm规范明确规定了类初始化的时机,根据类的加载发生在其初始化之前,可以反推出类其加载的触发条件。

注:本文的类是泛指,还包括接口等

jvm规范定义了有且仅有5种情况下如果类还没有初始化,会触发类的初始化:

  • 虚拟机启动所指定的主类(包含main方法的类)先被加载并初始化
  • 初始化一个字类的时候,递归初始化其父类
  • 执行new, getstatic, putstatic, invokestatic指令时
  • 通过反射调用使用类时
  • java.lang.invoke.MethodHandle实例解析结果为REF_getStatic, REF_putStatic, REF_invokeStatic方法句柄时

上面几种情况都很好理解,当前类引用了某个类并且使用了它,自然需要初始化,也自然要加载它,可以通过-XX:+TraceClassLoading查看加载的类。关于初始化,以后的文章会单独详细介绍

类加载器

类的加载就是把一个类的字节码静态结构通过jvm加载,并创建一个对应的java.lang.Class对象,存储在自己的运行时方法区内存空间,此后,这个类的数据便通过这个Class对象来访问,包括其类field,方法等。

字节码不仅是局限于本地文件系统中的文件,也可能是在内存中(动态生成),网络上,压缩包(jar, war)等,而类加载器的职责就是从这些地方加载字节码到jvm中。

类加载器按其实现可以分为两类:引导类加载器(Bootstrap Class Loader),用户类加载器(User-defined Class Loader)

  • 引导类加载器:加载$JAVA_HOME/jre/lib/下核心类库,如rt.jar,hotspot jvm中由C++实现

  • 用户类加载器:所有用户类加载器都继承了java.lang.ClassLoader抽象类,sun提供了两个用户类加载器,我们也可以定义自己的类加载器

    • 扩展类加载器(ExtClassLoader):sun.misc.Launcher$ExtClassLoader,负责加载$JAVA_HOME/jre/lib/ext下的一些扩展类

    • 应用类加载器(AppClassLoader):可由ClassLoader.getSystemClassLoader()方法获得,也称系统类加载器,负责加载用户(classpath中)定义的类。

    • 自定义类加载器(Custom ClassLoader):用户也可以定义自己的类加载器,实现一些定制的功能

关于类加载器补充几点:

  1. 用户类加载器都实现了 java.lang.ClassLoader抽象类,该类又个private final ClassLoader parent字段表示一个加载器的父加载器(设计模式中推荐使用这种组合的方式来代替继承),这是实现双亲委派模型的关键。
  2. 引导类加载器之加载jre lib目录(或-Xbootclasspath指定)下的类,并且只识别特定文件名如rt.jar,所以不会加载用户的类
  3. 对于数组,并不存在数组类型的字节码表示形式,它由jvm负责创建,一般在碰到newarray指令进行初始化时,如果数组的元素类型不是基本类型(如int[]),而是引用类型(如Integer[]),则会先加载基本类型,这可能由引导类加载器或用户类加载器加载,具体看引用类型是什么。
  4. jvm会缓存已加载过的类,并设置加载相应类的加载器,见下文
  5. 一个类和加载它的类加载器(定义类加载器)共同确定一个类的唯一性

下面通过实例来看一下:

/**
 * 自定义类加载器,重写loadClass,优先在当前目录加载
 */
public class MyClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        try {
            InputStream is = new FileInputStream("./" + name + ".class");
            byte[] data = new byte[is.available()];
            is.read(data);
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            return super.loadClass(name);
        }
    }
}
public class Callee {
    public Callee() {
        System.out.println("Callee class loaded by " + this.getClass().getClassLoader().getClass().getName());
    }
}
/**
 * Run: javac MyClassLoader.java Callee.java Test.java && java Test
 * /
public class Test {
    public static void main(String[] args) throws Exception {
        ClassLoader myClassLoader = new MyClassLoader();

        Class<?> calleeClass = myClassLoader.loadClass("Callee");

        //输出:calleeClass == Callee.class ? false
        System.out.println("calleeClass == Callee.class ? " + (calleeClass == Callee.class));

        //输出:Callee class loaded by sun.misc.Launcher$AppClassLoader
        Callee.class.newInstance();
        //输出:Callee class loaded by MyClassLoader
        Object calleeObj = calleeClass.newInstance();

    }
}

可以看出,虽然是同一个类Callee,但由于是不同类加载器加载,所以Class实例并不是同一个。

双亲委派模型

所谓双亲委派模型是指一个类加载器在加载某个类时,首先把委派给父加载器去加载,父加载器又委派给它的父加载器加载,如此顶层的引导类加载器为止,如果其父加载器在其搜索范围没有找到相应类,则尝试自己加载。

从双亲委派模型的定义可以看出,它要求每个加载器都有一个父加载器,如果某个类加载器的父加载器为null,则搜索引导类加载器是否加载过它要加载的类。

可以看出首先接收加载请求的类加载器并不一定真正加载类,可能由它的父加载器完成加载,接收加载请求的类加载器叫做初始类加载器(initiating loader),而完成加载的类加载器叫做定义类加载器(defining loader),初始类加载器和定义类加载器可能相同也可能不同。

如果两个类:D引用了C,L1作为D的定义类加载器,在解析D时会去加载C,这个加载请求由L1接收,假设C由另一个加载器L2加载,则L1最终将加载请求委托给L2,L1就称为C的初始加载器,L2是C的定义类加载器。

下面看看ClassLoader怎么实现双亲委派加载的:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
    	 // 一个类的加载是放在代码同步块里边的,所以不会有同一个类加载多次
        synchronized (getClassLoadingLock(name)) {
            // 首先检查该类是否已加载过
            Class<?> c = findLoadedClass(name);
            // 如果缓存中没有找到,则按双亲委派模型加载
            if (c == null) {
                try {
                    if (parent != null) {
                    	// 如果父加载器不为null,则代理给父加载器加载
                    	// 父加载器在自己搜索范围内找不到该类,则抛出ClassNotFoundException
                        c = parent.loadClass(name, false);
                    } else {
                    	// 如果父加载器为null,则从引导类加载器加载过的类中
                    	// 找是否加载过此类,找不到返回null
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 存在父加载器但父加载器没有找到要加载的类触发此异常
                    // 只捕获不处理,交给字加载器自身去加载
                }

                if (c == null) {
                    // 如果从父加载器到顶层加载器(引导类加载器)都找不到此类,则自己来加载
                    c = findClass(name);
                }
            }
            
            // 如果resolve指定为true,则立即进入链接阶段
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

通过源码可以看出,所有的类都优先委派给父加载器加载,如果父加载器无法加载,则自己来加载,逻辑很简单,这样做的好处是不用层次的类交给不同的加载器去加载,如java.lang.Integer最终都是由Bootstrap ClassLoader来加载的,这样只会有一个相同类被加载。

再来说说里边调用的几个方法:

  • getClassLoadingLock
protected Object getClassLoadingLock(String className) {
    Object lock = this;
    if (parallelLockMap != null) {
        Object newLock = new Object();
        lock = parallelLockMap.putIfAbsent(className, newLock);
        if (lock == null) {
            lock = newLock;
        }
    }
    return lock;
}

该方法很简单,parallelLockMap是一个ConcurrentHashMap<String, Object> map对象,如果当前classloader注册为可并行加载的,则为每一个类名维护一个锁对象供synchronized使用,可并行加载不同类,否则以当前classloader作为锁对象,只能串行加载。

  • findBootstrapClassOrNull
private Class<?> findBootstrapClassOrNull(String name)
{
   if (!checkName(name)) 
   		return null;
   
   return findBootstrapClass(name);
}
private native Class<?> findBootstrapClass(String name);

findBootstrapClass是jvm原生实现,查找Bootstrap ClassLoader已加载的类,没有则返回null

  • findClss
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

findClass交给子加载器实现,我们一般重写该方法来实现自己的类加载器,这样实现的类加载器也符合双亲委派模型。当然,双亲委派的逻辑都是在loadClass实现的,可以自己重写loadClass来打破双亲委派逻辑。

自定义类加载器:

/**
 * Run: javac MyClassLoader.java Callee.java Test.java && java Test
 */
public class MyClassLoader extends ClassLoader {

    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            InputStream is = new FileInputStream("./" + name + ".class");
            byte[] data = new byte[is.available()];
            is.read(data);
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            return super.loadClass(name);
        }
    }
}

public static void main(String[] args) throws Exception {
    ClassLoader myClassLoader = new MyClassLoader();
    Class<?> callerClass = myClassLoader.loadClass("Callee");
    // 输出:Callee class loaded by sun.misc.Launcher$AppClassLoader
    callerClass.newInstance();
}

可以看出,只需吧前面的示例方法名改为findClass就可以了,而且可以看到是由应用类加载器负责加载的(默认父加载器是AppClassLoader),符合双亲委派模型。

再来做个实验:

// 让自定义类加载器加载/tmp目录下的类
InputStream is = new FileInputStream("/tmp/" + name + ".class");

把刚编译的Callee.class移动至/tmp下(注意:当前目录不要也保留一份):

 mv Callee.class /tmp

再次编译运行:

javac MyClassLoader.java && java Test

结果:

Callee class loaded by MyClassLoader

Callee变成由自定义类加载器加载了,因为向上委托时都找不到该类,自定义加载器findClass方法起了作用。

再来做个有趣的实验:

定义一个类Caller里边调用了Callee

public class Caller {
    public Caller() {
        System.out.println("Caller class loaded by " + this.getClass().getClassLoader().getClass().getName());
        Callee callee = new Callee();
    }
}

修改Test.java,加载Caller

Class<?> callerClass = myClassLoader.loadClass("Caller");

再次编译运行:

javac MyClassLoader.java Caller.java Test.java
mv Callee.class /tmp # 保证当前目录下没有Callee.class,/tmp下有
java Test

为什么/tmp下有Callee.class但没有加载到呢?其实很好理解:输出第一句看出AppClassLoader加载了Caller.class,作为它的定义类加载器,当Caller中使用了Callee需要加载Callee.class的时候,AppClassLoader就会作为Callee.class的初始加载器去加载它,根据双亲委派模型,最后AppClassLoader调用自己的findClass尝试自己加载,classpath下没有这个类,肯定找不到~

这个例子还可以看出:真正去加载类的类加载器(调用findClass方法)找不到类抛出ClassNotFoundException,此异常被封装成NoClassDefFoundError抛出给使用的地方(初始类加载器),这种错误很常见。

反双亲委派模型

双亲委派模型很好的解决了加载类统一的问题,类加载都是由子加载器向上委派给父加载器加载,这样加载的类具有层次,但如果在父加载器加载的类中又要调用子加载器加载的类怎么办呢?

比如两个加载器L1,L2(L1 extends L2),L2加载了类A,类A中使用了类B(类B在L1搜索范围内,应由L1加载),则L2作为类B的初始加载器并向上委托父加载器加载,最终,父加载器加载失败,L2尝试自己加载。可以想象,L2在自己搜索范围也找不到类B,最终加载失败。

要解决这个问题就要适当打破双亲委派模型的限制:

  • Thread Context Class Loader

线程上下文加载器, 最典型的应用场景就是SPI技术,像JDBC,JNDI,JAXP等,接口规范都是由java核心类库来定义的,而规范的具体实现则是由不同厂商提供的,要在类库代码中调用用户代码时,就需通过线程上下文加载器来完成了。

可以通过Thread对象的setContextClassLoader方法设置当前线程上下文加载器,如果没有设置,则从父线程继承,如果父线程也没有设置过,那么就取应用类加载器(AppClassLoader)作为线程上下文加载器。

//ServiceLoader.java
public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
    service = Objects.requireNonNull(svc, "Service interface cannot be null");
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}
  • tomcat类加载机制

tomcat采用不同的类加载机制,主要为了解决两个问题:

  1. 公共的类库(如servlet-api.jar)需要共享
  2. 不同的应用可以依赖同一类的不同版本,不同应用相互隔离,互不影响

了解了目的,再来看看tomcat采取了那种措施:

本文不打算介绍源码,以后我会写一个tomcat源码系列

注:不同的jvm实现不太相同,这里的Bootstrap泛指hotspot中的Bootstrap和ExtClassloader

这是tomcat6之前的架构,common,Server(Catalina),Shared分别加载tomcat /common,/server, /shared 下的类,不过现在的版本(tomcat9)中如果配置了server.loader,shared.loader依然适用。

如果没有配置,tomcat依然创建commonLoader,catalinaLoader,sharedLoader三个类加载器(都是common类加载器实例,加载/lib目录下的类),所以一般架构如下:

现在再来看:

  1. common加载器遵循双亲加载模型,基本类库不重复
  2. WebappX加载器对应每个应用一个,加载/WEB-INF/classes, /WEB-INF/lib/*下的类,应用级别隔离

问题完美解决,WebappX加载顺序:

  1. 先交给Bootstrap loader加载
  2. 在应用/WEB-INF/classes下查找加载
  3. 在应用/WEB-INF/lib/*.jar下查找加载
  4. 转交给系统类加载器加载(classpath)
  5. 交给Common 类加载器加载(/lib)

可以看到/class, /lib目录下类加载优先级高于系统类加载器和common类加载器。

可以配置<Loader delegate="true"/>强行让其按双亲委派模型加载

原文地址:原文

往期内容:

详解字节码(class)文件

读取class文件

欢迎关注!