类加载器
类加载机制
类加载器负责加载所有的类,系统为所有被载入内存中的类生成一个 java.lang.Class 实例。一旦一个类被载入 JVM 中,同个类就不会被再次载入了。现在的问题是,怎么样才算“同一个类”?
正如一个对象有一个唯一的标识一样,一个载入 JVM 中的类也有一个唯一的标识。在 Java 中,一个类用其全限定类名(包括包名和类名)作为标识:但在 JVM 中,一个类用其全限定类名和其类加载器作为唯一标识。例如,如果在 pg 的包中有一个名为 Person 的类,被类加载器 ClassLoader 的实例 k1 负责加载,则该 Person 类对应的 Class 对象在 JVM 中表示为(Person、pg、k1)。这意味着两个类加载器加载的同名类:(Person、pg、k1)和(Person、pg、k12)是不同的,它们所加载的类也是完全不同、互不兼容的。
当 JVM 启动时,会形成由三个类加载器组成的初始类加载器层次结构。
- Bootstrap ClassLoader:根类加载器。
- Extension ClassLoader:扩展类加载器。
- System ClassLoader:系统类加载器。
Bootstrap ClassLoader 被称为引导(也称为原始或根)类加载器,它负责加载 Java 的核心类。在Sun 的 JVM 中,当执行 java.exe 命令时,使用 -Xbootclasspath 或 -D 选项指定 sun.boot.class.path 系统属性值可以指定加载附加的类。
JVM的类加载机制主要有如下三种。
- 全盘负责。所谓全盘负责,就是当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显式使用另外一个类加载器来载入。
- 父类委托。所谓父类委托,则是先让 parent(父)类加载器试图加载该 Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
- 缓存机制。缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区中搜寻该 Class,只有当缓存区中不存在该 Class 对象时,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区中。这就是为什么修改了 Class 后,必须重新启动 JVM,程序所做的修改才会生效的原因。
除了可以使用 Java 提供的类加载器之外,开发者也可以实现自己的类加载器,自定义的类加载器通过继承 ClassLoader 来实现。JVM 中这4种类加载器的层次结构如下图所示。
注意:类加载器之间的父子关系并不是类继承上的父子关系,这里的父子关系是类加载器实例之间的关系
下面程序示范了访问 JVM 的类加载器。
public class ClassLoaderPropTest { public static void main(String[] args) throws IOException { // 获取系统类加载器 ClassLoader systemLoader = ClassLoader.getSystemClassLoader(); System.out.println("系统类加载器:" + systemLoader); /* * 获取系统类加载器的加载路径——通常由CLASSPATH环境变量指定 如果操作系统没有指定CLASSPATH环境变量,默认以当前路径作为 * 系统类加载器的加载路径 */ Enumeration<URL> em1 = systemLoader.getResources(""); while (em1.hasMoreElements()) { System.out.println(em1.nextElement()); } // 获取系统类加载器的父类加载器:得到扩展类加载器 ClassLoader extensionLader = systemLoader.getParent(); System.out.println("扩展类加载器:" + extensionLader); System.out.println("扩展类加载器的加载路径:" + System.getProperty("java.ext.dirs")); System.out.println("扩展类加载器的parent: " + extensionLader.getParent()); } }
运行上面的程序,会看到如下运行结果
系统类加载器:sun.misc.Launcher$AppClassLoader@73d16e93 file:/F:/EclipseProjects/demo/bin/ 扩展类加载器:sun.misc.Launcher$ExtClassLoader@15db9742 扩展类加载器的加载路径:C:\Program Files\Java\jre1.8.0_181\lib\ext;C:\Windows\Sun\Java\lib\ext 扩展类加载器的parent: null
从上面运行结果可以看出,系统类加载器的加载路径是程序运行的当前路径,扩展类加载器的加载路径是null(与 Java8 有区别),但此处看到扩展类加载器的父加载器是null,并不是根类加载器。这是因为根类加载器并没有继承 ClassLoader 抽象类,所以扩展类加载器的 getParent() 方法返回null。但实际上,扩展类加载器的父类加载器是根类加载器,只是根类加载器并不是 Java 实现的。
从运行结果可以看出,系统类加载器是 AppClassLoader 的实例,扩展类加载器 ExtClassLoader 的实例。实际上,这两个类都是 URLClassLoader 类的实例。
注意:JVM 的根类加载器并不是 Java 实现的,而且由于程序通常无须访问根类加载器,因此访问扩展类加载器的父类加载器时返回null。
类加载器加载 Class 大致要经过如下8个步骤。
- 检测此 Class 是否载入过(即在缓存区中是否有此Class),如果有则直接进入第8步,否则接着执行第2步。
- 如果父类加载器不存在(如果没有父类加载器,则要么 parent 一定是根类加载器,要么本身就是根类加载器),则跳到第4步执行;如果父类加载器存在,则接着执行第3步。
- 请求使用父类加载器去载入目标类,如果成功载入则跳到第8步,否则接着执行第5步。
- 请求使用根类加载器来载入目标类,如果成功载入则跳到第8步,否则跳到第7步。
- 当前类加载器尝试寻找 Class 文件(从与此 ClassLoader 相关的类路径中寻找),如果找到则执行第6步,如果找不到则跳到第7步。
- 从文件中载入 Class,成功载入后跳到第8步。
- 抛出 ClassNotFoundExcepuon 异常。
- 返回对应的 java.lang.Class 对象。
其中,第5、6步允许重写 ClassLoader的 findClass() 方法来实现自己的载入策略,甚至重写 loadClass() 方法来实现自己的载入过程。
创建并使用自定义的类加载器
JVM 中除根类加载器之外的所有类加载器都是 ClassLoader 子类的实例,开发者可以通过扩展 ClassLoader 的子类,并重写该 ClassLoader 所包含的方法来实现自定义的类加载器。查阅API文档中关于 ClassLoader 的方法不难发现,ClassLoader 中包含了大量的 protected 方法——这些方法都可被子类重写。
ClassLoader 类有如下两个关键方法。
- loadClass(String name, boolean resolve):该方法为 ClassLoader 的入口点,根据指定名称来加载类,系统就是调用 ClassLoader 的该方法来获取指定类对应的 Class 对象。
- findClass(String name):根据指定名称来查找类。
如果需要实现自定义的 ClassLoader,则可以通过重写以上两个方法来实现,通常推荐重写 findClass() 方法,而不是重写 loadClass() 方法。loadClass() 方法的执行步骤如下。
- 用 findLoadedClass(String) 来检查是否已经加载类,如果已经加载则直接返回。
- 在父类加载器上调用 loadClass() 方法。如果父类加载器为null,则使用根类加载器来加载。
- 调用 findClass(String) 方法查找类。
从上面步骤中可以看出,重写 findClass()方法可以避免覆盖默认类加载器的父类委托、缓冲机制两种策略:如果重写 loadClass() 方法,则实现逻辑更为复杂。
在 ClassLoader 里还有一个核心方法:Class defineClass(String name, byte[] b, int off,int len) 该方法负责将指定类的字节码文件(即 Class 文件,如 Hello.class)读入字节数组 byte[] b 内,并把它转换为 Class对象,该字节码文件可以来源于文件、网络等。
defineClass() 方法管理 JVM 的许多复杂的实现,它负责将字节码分析成运行时数据结构,并校验有效性等。不过不用担心,程序员无须重写该方法。实际上该方法是 final 的,即使想重写也没有机会。
除此之外,ClassLoader 里还包含如下一些普通方法。
- findSystemClass(String name):从本地文件系统装入文件。它在本地文件系统中寻找类文件,如果存在,就使用 defineClass() 方法将原始字节转换成 Class 对象,以将该文件转换成类。
- static getSystemClassLoader():这是一个静态方法,用于返回系统类加载器。
- getParent():获取该类加载器的父类加载器。
- resolveClass(Class<?> c):链接指定的类。类加载器可以使用此方法来链接类c。读者无须理会关于此方法的太多细节。
- findLoadedClass(String name):如果此 Java 虚拟机已加载了名为 name 的类,则直接返回该类对应的 Class 实例,否则返回null,该方法是 Java 类加载缓存机制的体现。
下面程序开发了一个自定义的 ClassLoader,该 ClassLoader 通过重写 findClass() 方法来实现自定义的类加载机制。这个 ClassLoader 可以在加载类之前先编译该类的文件,从而实现运行 Java 之前先编译该程序的目标,这样即可通过该 ClassLoader 直接运行 Java 源文件。
public class CompileClassLoader extends ClassLoader { // 读取一个文件的内容 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 r = fin.read(raw); if (r != len) throw new IOException("无法读取全部文件:" + r + " != " + len); return raw; } } // 定义编译指定Java文件的方法 private boolean compile(String javaFile) throws IOException { System.out.println("CompileClassLoader:正在编译 " + javaFile + "..."); // 调用系统的javac命令 Process p = Runtime.getRuntime().exec("javac " + javaFile); try { // 其他线程都等待这个线程完成 p.waitFor(); } catch (InterruptedException ie) { System.out.println(ie); } // 获取javac线程的退出值 int ret = p.exitValue(); // 返回编译是否成功 return ret == 0; } // 重写ClassLoader的findClass方法 protected Class<?> findClass(String name) throws ClassNotFoundException { Class clazz = null; // 将包路径中的点(.)替换成斜线(/)。 String fileStub = name.replace(".", "/"); String javaFilename = fileStub + ".java"; String classFilename = fileStub + ".class"; File javaFile = new File(javaFilename); File classFile = new File(classFilename); // 当指定Java源文件存在,且class文件不存在、或者Java源文件 // 的修改时间比class文件修改时间更晚,重新编译 if (javaFile.exists() && (!classFile.exists() || javaFile.lastModified() > classFile.lastModified())) { try { // 如果编译失败,或者该Class文件不存在 if (!compile(javaFilename) || !classFile.exists()) { throw new ClassNotFoundException("ClassNotFoundExcetpion:" + javaFilename); } } catch (IOException ex) { ex.printStackTrace(); } } // 如果class文件存在,系统负责将该文件转换成Class对象 if (classFile.exists()) { try { // 将class文件的二进制数据读入数组 byte[] raw = getBytes(classFilename); // 调用ClassLoader的defineClass方法将二进制数据转换成Class对象 clazz = defineClass(name, raw, 0, raw.length); } catch (IOException ie) { ie.printStackTrace(); } } // 如果clazz为null,表明加载失败,则抛出异常 if (clazz == null) { throw new ClassNotFoundException(name); } return clazz; } // 定义一个主方法 public static void main(String[] args) throws Exception { // 如果运行该程序时没有参数,即没有目标类 if (args.length < 1) { System.out.println("缺少目标类,请按如下格式运行Java源文件:"); System.out.println("java CompileClassLoader ClassName"); } // 第一个参数是需要运行的类 String progClass = args[0]; // 剩下的参数将作为运行目标类时的参数, // 将这些参数复制到一个新数组中 String[] progArgs = new String[args.length - 1]; System.arraycopy(args, 1, progArgs, 0, progArgs.length); CompileClassLoader ccl = new CompileClassLoader(); // 加载需要运行的类 Class<?> clazz = ccl.loadClass(progClass); // 获取需要运行的类的主方法 Method main = clazz.getMethod("main", (new String[0]).getClass()); Object[] argsArray = { progArgs }; main.invoke(null, argsArray); } }
上面程序中的粗体字代码重写了 findClass() 方法,通过重写该方法就可以实现自定义的类加载机制。在本类的 findClass() 方法中先检查需要加载类的 Class 文件是否存在,如果不存在则先编译源文件,再调用 ClassLoader 的 defineClass() 方法来加载这个 Class 文件,并生成相应的 Class 对象。
接下来可以随意提供一个简单的主类,该主类无须编译就可以使用上面的 CompileClassLoader 来运行它。
public class Hello { public static void main(String[] args) { for (String arg : args) { System.out.println("运行Hello的参数:" + arg); } } }
本示例程序提供的类加载器功能比较简单,仅仅提供了在运行之前先编译 Java 源文件的功能。实际上,使用自定义的类加载器,可以实现如下常见功能。
- 执行代码前自动验证数字签名。
- 根据用户提供的密码解密代码,从而可以实现代码混淆器来避免反编译 *.class 文件。
- 根据用户需求来动态地加载类。
- 根据应用需求把其他数据以字节码的形式加载到应用中。
URLClassLoader 类
Java 为 ClassLoader 提供了一个 URLClassLoader 实现类,该类也是系统类加载器和扩展类加载器的父类(此处的父类,就是指类与类之间的继承关系)。URLClassLoader 功能比较强大,它既可以从本地文件系统获取二进制文件来加载类,也可以从远程主机获取二进制文件来加载类。
在应用程序中可以直接使用 URLClassLoader 加载类,URLClassLoader 类提供了如下两个构造器。
- URLClassLoader(URL[] urls):使用默认的父类加载器创建一个 ClassLoader 对象,该对象将从 urls 所指定的系列路径来查询并加载类。
- URLClassLoader(URL[] urls, ClassLoader parent):使用指定的父类加载器创建一个 ClassLoader 对象,其他功能与前一个构造器相同。
一旦得到了 URLClassLoader 对象之后,就可以调用该对象的 loadClass() 方法来加载指定类。下面程序示范了如何直接从文件系统中加载 MySQL 驱动,并使用该驱动来获取数据库连接。通过这种方式来获取数据厍连接,可以无须将 MySQL 驱动添加到 CLASSPATH 环境变量中。
public class URLClassLoaderTest { private static Connection conn; // 定义一个获取数据库连接方法 public static Connection getConn(String url, String user, String pass) throws Exception { if (conn == null) { // 创建一个URL数组 URL[] urls = { new URL("file:mysql-connector-java-5.1.30-bin.jar") }; // 以默认的ClassLoader作为父ClassLoader,创建URLClassLoader URLClassLoader myClassLoader = new URLClassLoader(urls); // 加载MySQL的JDBC驱动,并创建默认实例 Driver driver = (Driver) myClassLoader.loadClass("com.mysql.jdbc.Driver").getConstructor().newInstance(); // 创建一个设置JDBC连接属性的Properties对象 Properties props = new Properties(); // 至少需要为该对象传入user和password两个属性 props.setProperty("user", user); props.setProperty("password", pass); // 调用Driver对象的connect方法来取得数据库连接 conn = driver.connect(url, props); } return conn; } public static void main(String[] args) throws Exception { System.out.println(getConn("jdbc:mysql://localhost:3306/mysql", "root", "32147")); } }
上面程序中的前两行粗体字代码创建了一个 URLClassLoader 对象,该对象使用默认的父类加载器,该类加载器的类加载路径是当前路径下的 mysql-connector-java-5.1.30-bin.jar 文件,将 MySQL 驱动复制到该路径下,这样保证该 ClassLoader 可以正常加载到 com.mysql.jdbc.Driver 类。
程序的第三行粗体字代码使用 ClassLoader 的 loadClass() 加载指定类,并调用 Class 对象的 newInstance() 方法创建了一个该类的默认实例——也就是得到 com.mysql.jdbc.Driver 类的对象,当然该对象的实现类实现了 java.sql.Driver 接口,所以程序将其强制类型转换为 Driver,程序的最后一行粗体字代码通过 Driver 而不是 DriverManager 来获取数据库连接,关于 Driver 接口的用法读者可以自行查阅API文档。
正如前面所看到的,创建 URLClassLoader 时传入了一个 URL 数组参数,该 ClassLoader 就可以从这系列 URL 指定的资源中加载指定类,这里的 URL 可以以 file: 为前缀,表明从本地文件系统加载;可以以 http: 为前缀,表明从互联网通过 HTTP 访问来加载;也可以以 ftp: 为前缀,表明从互联网通过 FTP访问来加载......功能非常强大。