关于后端:详解类加载流程类加载机制及自定义类加载器

51次阅读

共计 9793 个字符,预计需要花费 25 分钟才能阅读完成。

迷失的时候,抉择更艰苦的那条路。

一、引言

当程序应用某个类时,如果该类还未被加载到内存中 ,则JVM 会通过加载、链接、初始化 三个步骤 对该类进行类加载。

二、类的加载、链接、初始化

1、加载

类加载指的是将类的 class 文件读入内存,并为之创立一个 java.lang.Class 对象。类的加载过程是由类加载器来实现,类加载器由 JVM 提供。咱们开发人员也能够通过继承 ClassLoader 来实现本人的类加载器。

1.1、加载的 class 起源
  • 从本地文件系统内加载 class 文件
  • 从 JAR 包加载 class 文件
  • 通过网络加载 class 文件
  • 把一个 java 源文件动静编译,并执行加载。

2、类的链接

通过类的加载,内存中曾经创立了一个 Class 对象。链接负责将二进制数据合并到 JRE 中。链接须要通过验证、筹备、解析三个阶段。

2.1、验证

验证阶段用于查看被加载的类是否有正确的内部结构,并和其余类协调一致。即是否满足 java 虚拟机的束缚。

2.2、筹备

类筹备阶段负责为类的类变量分配内存,并设置默认初始值。

2.3、解析

咱们晓得,援用其实对应于内存地址。思考这样一个问题,在编写代码时,应用援用,办法时,类晓得这些援用办法的内存地址吗?显然是不晓得的,因为类还未被加载到虚拟机中,你无奈取得这些地址。

举例来说,对于一个办法的调用,编译器会生成一个蕴含指标办法所在的类、指标办法名、接管参数类型以及返回值类型的符号援用,来指代要调用的办法。

解析阶段的目标,就是将这些符号援用解析为理论援用。如果符号援用指向一个未被加载的类,或者未被加载类的字段或办法,那么解析将触发这个类的加载(但未必会触发解析与初始化)。

3、类的初始化

类的初始化阶段,虚拟机次要对类变量进行初始化。虚拟机调用 < clinit> 办法,进行类变量的初始化。

java 类中对类变量进行初始化的两种形式:

  1. 在定义时初始化
  2. 在动态初始化块内初始化
3.1、< clinit> 办法相干

虚构机会收集类及父类中的类变量及类办法组合为 < clinit> 办法,依据定义的程序进行初始化。虚构机会保障子类的 < clinit> 执行之前,父类的 < clinit> 办法先执行结束。

因而,虚拟机中第一个被执行结束的 < clinit> 办法必定是 java.lang.Object 办法。

public class Test {
    static int A = 10;
    static {A = 20;}
}
class Test1 extends Test {
    private static int B = A;
    public static void main(String[] args) {System.out.println(Test1.B);
    }
}
// 输入后果
//20

从输入中看出,父类的动态初始化块在子类动态变量初始化之前初始化结束,所以输入后果是 20,不是 10。

如果类或者父类中都没有动态变量及办法,虚拟机不会为其生成 < clinit> 办法。

接口与类不同的是,执行接口的办法不须要先执行父接口的办法。只有当父接口中定义的变量应用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的办法。

public interface InterfaceInitTest {long A = CurrentTime.getTime();
}
interface InterfaceInitTest1 extends InterfaceInitTest {int B = 100;}
class InterfaceInitTestImpl implements InterfaceInitTest1 {public static void main(String[] args) {System.out.println(InterfaceInitTestImpl.B);
        System.out.println("---------------------------");
        System.out.println("以后工夫:"+InterfaceInitTestImpl.A);
    }
}
class CurrentTime {static long getTime() {System.out.println("加载了 InterfaceInitTest 接口");
        return System.currentTimeMillis();}
}
// 输入后果
//100
//---------------------------
// 加载了 InterfaceInitTest 接口
// 以后工夫:1560158880660

从输入验证了:对于接口,只有真正应用父接口的类变量才会真正的加载父接口。这跟一般类加载不一样。

虚构机会保障一个类的 < clinit> 办法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的 < clinit> 办法,其余线程都须要阻塞期待,直到流动线程执行 < clinit> 办法结束。

public class MultiThreadInitTest {
    static int A = 10;
    static {System.out.println(Thread.currentThread()+"init MultiThreadInitTest");
        try {TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {e.printStackTrace();
        }
    }
    public static void main(String[] args) {Runnable runnable = () -> {System.out.println(Thread.currentThread() + "start");
            System.out.println(MultiThreadInitTest.A);
            System.out.println(Thread.currentThread() + "run over");
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();}
}
// 输入后果
//Thread[main,5,main]init MultiThreadInitTest
//Thread[Thread-0,5,main]start
//10
//Thread[Thread-0,5,main]run over
//Thread[Thread-1,5,main]start
//10
//Thread[Thread-1,5,main]run over

从输入中看出验证了:只有第一个线程对 MultiThreadInitTest 进行了一次初始化,第二个线程始终阻塞期待等第一个线程初始化结束。

3.2、类初始化机会
  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建指标类实例的 new 指令时,初始化 new 指令的指标类;
  3. 当遇到调用静态方法或者应用动态变量,初始化动态变量或办法所在的类;
  4. 子类初始化过程会触发父类初始化;
  5. 如果一个接口定义了 default 办法,那么间接实现或者间接实现该接口的类的初始化,会触发该接口初始化;
  6. 应用反射 API 对某个类进行反射调用时,初始化这个类;
  7. Class.forName()会触发类的初始化
3.3、final 定义的初始化

留神:对于一个应用 final 定义的常量,如果在编译时就曾经确定了值,在援用时不会触发初始化,因为在编译的时候就曾经确定下来,就是“宏变量”。如果在编译时无奈确定,在首次应用才会导致初始化。

public class StaticInnerSingleton {
    /**
     * 应用动态外部类实现单例:* 1:线程平安
     * 2:懒加载
     * 3:非反序列化平安,即反序列化失去的对象与序列化时的单例对象不是同一个,违反单例准则
     */
    private static class LazyHolder {private static final StaticInnerSingleton INNER_SINGLETON = new StaticInnerSingleton();
    }

    private StaticInnerSingleton() {}

    public static StaticInnerSingleton getInstance() {return LazyHolder.INNER_SINGLETON;}
}

看这个例子,单例模式动态外部类实现形式。咱们能够看到单例实例应用 final 定义,但在编译时无奈确定下来,所以在第一次应用 StaticInnerSingleton.getInstance()办法时,才会触发动态外部类的加载,也就是提早加载。

这里想指出,如果 final 定义的变量在编译时无奈确定,则在应用时还是会进行类的初始化。

3.4、ClassLoader 只会对类进行加载,不会进行初始化
public class Tester {
    static {System.out.println("Tester 类的动态初始化块");
    }
}
class ClassLoaderTest {public static void main(String[] args) throws ClassNotFoundException {ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        // 上面语句仅仅是加载 Tester 类
        classLoader.loadClass("loader.Tester");
        System.out.println("零碎加载 Tester 类");
        // 上面语句才会初始化 Tester 类
        Class.forName("loader.Tester");
    }
}
// 输入后果
// 零碎加载 Tester 类
//Tester 类的动态初始化块

从输入证实:ClassLoader 只会对类进行加载,不会进行初始化;应用 Class.forName()会强制导致类的初始化。

三、类加载器

类加载器负责将.class 文件(不论是 jar, 还是本地磁盘,还是网络获取等等)加载到内存中,并为之生成对应的 java.lang.Class 对象。一个类被加载到 JVM 中,就不会第二次加载了。

那怎么判断是同一个类呢?

每个类在 JVM 中应用全限定类名(包名 + 类名)与类加载器联结为惟一的 ID,所以如果同一个类应用不同的类加载器,能够被加载到虚拟机,但彼此不兼容。

1、JVM 类加载器分类

1.1、Bootstrap ClassLoader

Bootstrap ClassLoader 为根类加载器,负责加载 java 的外围类库。根加载器不是 ClassLoader 的子类,是有 C ++ 实现的。

public class BootstrapTest {public static void main(String[] args) {
        // 获取根类加载器所加载的全副 URL 数组
        URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        Arrays.stream(urLs).forEach(System.out::println);
    }
}
// 输入后果
//file:/C:/SorftwareInstall/java/jdk/jre/lib/resources.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/rt.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/sunrsasign.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jsse.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jce.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/charsets.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jfr.jar
//file:/C:/SorftwareInstall/java/jdk/jre/classes

根类加载器负责加载 %JAVA_HOME%/jre/lib 下的 jar 包(以及由虚拟机参数 -Xbootclasspath 指定的类)。

咱们将 rt.jar 解压,能够看到咱们常常应用的类库就在这个 jar 包中。

1.2、Extension ClassLoader

Extension ClassLoader 为扩大类加载器,负责加载 %JAVA_HOME%/jre/ext 或者 java.ext.dirs 零碎相熟指定的目录的 jar 包。大家能够将本人写的工具包放到这个目录下,能够不便本人应用。

1.3、System ClassLoader

System ClassLoader 为零碎(利用)类加载器,负责加载加载来自 java 命令的 -classpath 选项、java.class.path 零碎属性,或者 CLASSPATH 环境变量所指定的 JAR 包和类门路。程序能够通过 ClassLoader.getSystemClassLoader()来获取零碎类加载器。如果没有特地指定,则用户自定义的类加载器默认都以零碎类加载器作为父加载器。

四、类加载机制

1.1、JVM 次要的类加载机制。

  1. 全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖和援用的其余 Class 也由该类加载器负责载入,除非显示应用另一个类加载器来载入。
  2. 父类委托(双亲委派):先让父加载器试图加载该 Class,只有在父加载器无奈加载时该类加载器才会尝试从本人的类门路中加载该类。
  3. 缓存机制 :缓存机制会将曾经加载的 class 缓存起来,当程序中须要应用某个 Class 时,类加载器先从缓存区中搜查该 Class,只有当缓存中不存在该 Class 时,零碎才会读取该类的二进制数据,并将其转换为 Class 对象,存入缓存中。 这就是为什么更改了 class 后,须要重启 JVM 才失效的起因。

留神:类加载器之间的父子关系并不是类继承上的父子关系,而是实例之间的父子关系。

public class ClassloaderPropTest {public static void main(String[] args) throws IOException {
        // 获取零碎类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("零碎类加载器:" + systemClassLoader);
        /*
        获取零碎类加载器的加载门路——通常由 CLASSPATH 环境变量指定,如果操作系统没有指定
        CLASSPATH 环境变量,则默认以以后门路作为零碎类加载器的加载门路
         */
        Enumeration<URL> eml = systemClassLoader.getResources("");
        while (eml.hasMoreElements()) {System.out.println(eml.nextElement());
        }
        // 获取零碎类加载器的父类加载器,失去扩大类加载器
        ClassLoader extensionLoader = systemClassLoader.getParent();
        System.out.println("零碎类的父加载器是扩大类加载器:" + extensionLoader);
        System.out.println("扩大类加载器的加载门路:" + System.getProperty("java.ext.dirs"));
        System.out.println("扩大类加载器的 parant:" + extensionLoader.getParent());
    }
}
// 输入后果
// 零碎类加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
//file:/C:/ProjectTest/FengKuang/out/production/FengKuang/
// 零碎类的父加载器是扩大类加载器:sun.misc.Launcher$ExtClassLoader@1540e19d
// 扩大类加载器的加载门路:C:\SorftwareInstall\java\jdk\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
// 扩大类加载器的 parant:null

从输入中验证了:零碎类加载器的父加载器是扩大类加载器。但输入中扩大类加载器的父加载器是 null,这是因为父加载器不是 java 实现的,是 C ++ 实现的,所以获取不到。但扩大类加载器的父加载器是根加载器。

1.2、类加载流程图


图中红色局部,能够是咱们自定义实现的类加载器来进行加载。

五、创立并应用自定义类加载器

1、自定义类加载剖析

除了根类加载器,所有类加载器都是 ClassLoader 的子类。所以咱们能够通过继承 ClassLoader 来实现本人的类加载器。

ClassLoader 类有两个要害的办法:

  1. protected Class loadClass(String name, boolean resolve):name 为类名,resove 如果为 true,在加载时解析该类。
  2. protected Class findClass(String name):依据指定类名来查找类。

所以,如果要实现自定义类,能够重写这两个办法来实现。但举荐重写 findClass 办法,而不是重写 loadClass 办法,因为 loadClass 办法外部会调用 findClass 办法。

咱们来看一下 loadClass 的源码

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {
        // 第一步,先从缓存里查看是否曾经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {long t0 = System.nanoTime();
            try {
                // 第二步,判断父加载器是否为 null
                if (parent != null) {c = parent.loadClass(name, false);
                } else {c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            if (c == null) {
                // 第三步,如果后面都没有找到,就会调用 findClass 办法
                long t1 = System.nanoTime();
                c = findClass(name);
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {resolveClass(c);
        }
        return c;
    }
}

loadClass 加载办法流程:

  1. 判断此类是否曾经加载;
  2. 如果父加载器不为 null,则应用父加载器进行加载;反之,应用根加载器进行加载;
  3. 如果后面都没加载胜利,则应用 findClass 办法进行加载。

所以,为了不影响类的加载过程,咱们重写 findClass 办法即可简略不便的实现自定义类加载。

2、实现自定义类加载器

基于以上剖析,咱们简略重写 findClass 办法进行自定义类加载。

public class Hello {public void test(String str){System.out.println(str);
    }
}
public class MyClassloader extends ClassLoader {
    /**
     * 读取文件内容
     * @param fileName 文件名
     * @return
     */
    private byte[] getBytes(String fileName) throws IOException {File file = new File(fileName);
        long len = file.length();
        byte[] raw = new byte[(int) len];
        try (FileInputStream fin = new FileInputStream(file)) {
            // 一次性读取 Class 文件的全副二进制数据
            int read = fin.read(raw);
            if (read != len) {throw new IOException("无奈读取全副文件");
            }
            return raw;
        }
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        // 将包门路的 (.) 替换为斜线(/)
        String fileStub = name.replace(".", "/");
        String classFileName = fileStub + ".class";
        File classFile = new File(classFileName);
        // 如果 Class 文件存在,零碎负责将该文件转换为 Class 对象
        if (classFile.exists()) {
            try {
                // 将 Class 文件的二进制数据读入数组
                byte[] raw = getBytes(classFileName);
                // 调用 ClassLoader 的 defineClass 办法将二进制数据转换为 Class 对象
                clazz = defineClass(name, raw, 0, raw.length);
            } catch (IOException e) {e.printStackTrace();
            }
        }
        // 如果 clazz 为 null, 表明加载失败,抛出异样
        if (null == clazz) {throw new ClassNotFoundException(name);
        }
        return clazz;
    }
    public static void main(String[] args) throws Exception {
        String classPath = "loader.Hello";
        MyClassloader myClassloader = new MyClassloader();
        Class<?> aClass = myClassloader.loadClass(classPath);
        Method main = aClass.getMethod("test", String.class);
        System.out.println(main);
        main.invoke(aClass.newInstance(), "Hello World");
    }
}
// 输入后果
//Hello World

ClassLoader 还有一个重要的办法 defineClass(String name, byte[] b, int off, int len)。 此办法的作用是将 class 的二进制数组转换为 Calss 对象。

此例子很简略,我写了一个 Hello 测试类,并且编译过后放在了以后门路下(大家能够在 findClass 中退出判断,如果没有此文件,能够尝试查找.java 文件,并进行编译失去.class 文件;或者判断.java 文件的最初更新工夫大于.class 文件最初更新工夫,再进行从新编译等逻辑)。

六、总结

本篇从类加载的三大阶段:加载、链接、初始化开始细说每个阶段的过程;具体解说了 JVM 罕用的类加载器的区别与分割,以及类加载机制流程,最初通过自定义的类加载器例子完结本篇。

正文完
 0