[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型
以 TDD 方式实现 JVM 原型]
09-实现类加载器与双亲委派模型
ClassInfo 完成了对 class 文件的封装,我们可以从输入流直接获得 ClasInfo 对象,从而获取类名等信息。但是我们使用 Java 加载某个类的时候,并不需要获取输入流,而只是指定一个全类名,如 Class.forName()。这次我们就来实现实现这个负责根据全类名创建 Class 对象的类加载器。
首先简单回忆一下什么是双亲委派模型。
Java 把“根据全类名查找对应的类”这个工作交由 ClassLoader 来实现,而双亲委派的逻辑就在 loadClass 方法,其主要逻辑如下:
// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {try {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) {// If still not found, then invoke findClass in order// to find the class.c = findClass(name);}}return c;
正如上面这段代码所示,当一个类加载器收到一个加载请求时:
首先检查这个类是否已经加载,若是,直接使用
将加载委派给 parent 处理,若成功则返回
若 parent 加载失败,则调用 findClass() 加载
返回加载结果
这里的 findClass() 由具体的子类实现,因此不同的子类可以从不同的地方(当前目录、特定目录、甚至网络上)加载字节码。
我们再来看看 Java 有哪些类加载器:
启动类加载器(Bootstrap ClassLoader) 负责加载 <JRE_HOME>/lib 或 -Xbootclasspath 指定目录下特定 jar 包(如 rt.jar)中的类。由 C++ 实现,Java 中不能直接引用,需要使用时,只需设置 parent 为 null。这也是上面代码中判断 parent 为 null 时调用 findBootstrapClassOrNull 的原因。
拓展类加载器(Extension ClassLoader) 负责加载 <JAVA_HOME>/lib/ext/ 或环境变量 java.ext.dir 所指定的目录下的 所有类库。由 sun.misc.Launcher$ExtClassLoader 实现。
应用程序类加载器(Application ClassLoad) 负责加载用户路径 classpath 下的类。通常是 ClassLoader 的 getSystemClassLoader() 方法返回值,没有自定义类加载器的情况下,也是程序中的默认类加载器。由 sun.misc.Launcher$AppClassLoader 实现。
这里目录相关的讨论仅限 Java 8,在 Java 9 已经引入了模块化,把 jar 文件改为了多个 .jmod 文件。
双亲委派模型确保了 parent 加载器先于子加载器,这样确保了基础类的统一,举例来说,避免了用户自定义一个 java.lang.String 覆盖掉 Java 本身 String 类,而出现奇怪的问题。加载前的检验也确保了一个类只加载一次,不会占用多余内存。
需要注意的是,这里的父加载器并不是父类,而是 parent 字段,也就是说上面提到的启动类、拓展类、应用程序类加载器之间没有继承(extends)关系,仅仅通过 parent 字段关联。Java 有 java.lang.ClassLoader 抽象类作为所有用户自定义类加载器的父类。但启动类加载器由 C++ 实现,拓展类和应用程序类加载器即使可以在 Java 代码中引用,但它们都不是 ClassLoader 的实现类。
那 Java 代码中是怎么实现自定义加载器也符合双亲委派模型的呢?我们可以看看抽象类 ClassLoader 的构造方法:
protected ClassLoader(String name, ClassLoader parent) {this(checkCreateClassLoader(name), name, parent);}protected ClassLoader(ClassLoader parent) {this(checkCreateClassLoader(), null, parent);}protected ClassLoader() {this(checkCreateClassLoader(), null, getSystemClassLoader());}private ClassLoader(Void unused, String name, ClassLoader parent) {this.name = name;this.parent = parent;this.unnamedModule = new Module(this);if (ParallelLoaders.isRegistered(this.getClass())) {parallelLockMap = new ConcurrentHashMap<>();assertionLock = new Object();} else {// no finer-grained lock; lock on the classloader instanceparallelLockMap = null;assertionLock = this;}this.package2certs = new ConcurrentHashMap<>();this.nameAndId = nameAndId(this);}
可以看到,ClassLoader 的构造方法需要一个 parent 参数,即双亲类加载器:
不指定该参数时,将使用 getSystemClassLoader 作为默认的 parent,从上文可以知道,这里返回的就是应用程序类加载器。
明确指定为 null 时,由 loadClass 方法可知,这就相当于指定 parent 为启动类加载器。
明确指定为一个其他类加载器时,如果为上文提到的 3 个默认类加载器,自然就符合双亲委派模型,而如果是指定另一个自定义类加载器,那么其构造时必定也要指定一个 parent,最终都一定会指向启动类加载器,也就符合双亲委派模型。
类似 ClassInfo 对应 java 的 java.lang.Class 存储类信息,我们的类加载器也命名为 ClassInfoLoader。
简单起见,我们将启动类加载器、拓展类加载器、应用类加载器合并为一个 DefaultClassLoader(有兴趣的也可以自行实现 3 个类加载器)。
首先编写单元测试:
public class DefaultClassInfoLoaderTest {@Testpublic void testLoadJDKClass() {DefaultClassInfoLoader loader = new DefaultClassInfoLoader();String COMPARABLE = "java.lang.Comparable";ClassInfo comparable = loader.load(COMPARABLE);assertEquals(COMPARABLE, comparable.getName());assertEquals("java.lang.Object", comparable.getSuperName());assertEquals(0, comparable.getInterfacesName().length);}@Testpublic void testLoaderAppClass() {DefaultClassInfoLoader loader = new DefaultClassInfoLoader();ClassInfo hello = loader.load(ClassFileRes.HELLO_WORLD);assertNull(hello);loader.addClassPath(DefaultClassInfoLoaderTest.class.getResource("/").getFile());hello = loader.load(ClassFileRes.HELLO_WORLD);assertNotNull(hello);assertEquals(ClassFileRes.HELLO_WORLD, hello.getName());assertEquals("java.lang.Object", hello.getSuperName());assertEquals(0, hello.getInterfacesName().length);}}
这里我们编写了两个单元测试,分别加载来自 JDK 的 Comparable 类和我们之前定义的 HelloWorld 类。在加载自定义类时,我们希望类加载器只能加载 classpath 下的类,因此第一次未指定 classpath 时应该返回 null,而设置好 classpath 后则成功加载 HelloWorld 类。
此时空实现让编译通过后运行,自然是无法通过的,接着我们继续完成代码实现:
public final class DefaultClassInfoLoader {public static final String LIB_PATH = Env.JAVA_HOME + "lib/";public static final String EXT_PATH = Env.JAVA_HOME + "lib/ext/";public static final String JAR = ".jar";public static final String CLASS = ".class";private static final String PROTOCAL_JAR_FILE = "jar:file:";private List<String> classPaths = new ArrayList<>();public ClassInfo load(String name) {// Bootstrap ClassLoaderClassInfo classInfo = loadFromJars(LIB_PATH, name);// Extension ClassLoaderif (classInfo == null) {classInfo = loadFromJars(EXT_PATH, name);}// Application ClassLoaderif (classInfo == null) {for (String classPath : classPaths) {classInfo = loadFromPath(classPath, name);if (classInfo == null) {classInfo = loadFromJars(classPath, name);}if (classInfo != null) {return classInfo;}}}return classInfo;}private ClassInfo loadFromJars(String path, String name) {String classFilePath = ClassNames.toSlash(name) + CLASS;for (File file : new File(path).listFiles()) {if (file.getName().endsWith(JAR)) {ClassInfo classInfo = findInJar(file, classFilePath);if (classInfo != null) {return classInfo;}}}return null;}private ClassInfo loadFromPath(String path, String name) {File file = new File(path + ClassNames.toSlash(name) + CLASS);if (file.exists()) {try {return new ClassInfo(new ClassMetadata(new FileInputStream(file)));} catch (IOException e) {// TODO 日志}}return null;}private ClassInfo findInJar(File jar, String path) {String url = PROTOCAL_JAR_FILE + jar.getAbsolutePath().replace("\\", "/") + "!/" + path;try (InputStream in = new URL(url).openConnection().getInputStream()) {return new ClassInfo(new ClassMetadata(in));} catch (IOException e) {// TODO 日志}return null;}public void addClassPath(String classPath) {this.classPaths.add(classPath);}}
虽然简化为一个类,但是类的查找顺序依然按照启动类、拓展类、应用程序类来进行。因为 JDK 的类都在 .jar 文件中(Java 8 及之前),而自定义类则包含 jar 包和直接以 .class 文件的方式放在文件目录中文件,因此这里需要分别实现从 jar 文件读取和从文件目录读取的方法。
再次运行单元测试,通过测试:
然后我们来写 ClassInfoLoader 的测试类,但此时我们发现一个问题:怎么初始化 DefaultClassInfoLoader 呢?
因为 DefaultClassInfoLoader 需要对 classpath 初始化,这样才能加载到应用程序类,给 ClassInfoLoader 构造方法增加 classpath 参数?这样每个 ClassInfoLoader 都要在构造方法中传递 classpath,也使得给子类暴露了不必要的细节,难以使用。那么给 ClassInfoLoader 也增加一个 addClassPath 呢?同样给子类暴露了不必要的细节,试想:当你定义一个从网络中加载 class 文件的类加载器之后,还要去调用 addClassPath 设置本地类路径,这样是不是很奇怪?
回头想想,一个应用程序中,DefaultClassInfoLoader 应该是唯一的,即使定义多个 ClassInfoLoader,他们最终指向的应该都是同一个,而不是每个 ClassInfoLoader 都自行初始化一个。因此我们可以将 DefaultClassInfoLoader 改为单例,并提供初始化方法设置 classpath (这里忽略了线程安全问题,也就没有使用 DCL 等机制):
public class DefaultClassInfoLoaderInitializer {public static void initDefaultClassInfoLoader() {try {DefaultClassInfoLoader.init(Arrays.asList(DefaultClassInfoLoaderTest.class.getResource("/").getFile()));} catch (IllegalStateException e) {// TODO 日志}}}
public class DefaultClassInfoLoaderTest {\@Testpublic void testLoadJDKClass() {DefaultClassInfoLoaderInitializer.initDefaultClassInfoLoader();DefaultClassInfoLoader loader = DefaultClassInfoLoader.getInstance();String COMPARABLE = "java.lang.Comparable";ClassInfo comparable = loader.load(COMPARABLE);assertEquals(COMPARABLE, comparable.getName());assertEquals("java.lang.Object", comparable.getSuperName());assertEquals(0, comparable.getInterfacesName().length);}@Testpublic void testLoaderAppClass() {DefaultClassInfoLoaderInitializer.initDefaultClassInfoLoader();DefaultClassInfoLoader loader = DefaultClassInfoLoader.getInstance();ClassInfo hello = loader.load(ClassFileRes.HELLO_WORLD);assertNotNull(hello);assertEquals(ClassFileRes.HELLO_WORLD, hello.getName());assertEquals("java.lang.Object", hello.getSuperName());assertEquals(0, hello.getInterfacesName().length);}}
实际使用时 DefaultClassInfoLoader 应该只会在系统初始化时(解析完命令行参数之后)被初始化一次。但在单元测试中,不同单元测试之间的执行顺序没有确定性(不过 Junit 框架本身提供了设置执行顺序的方法),因此我们把其初始化抽取到单独的方法,通过 try-catch 来忽略多次初始化抛出的异常,并在每个单元测试中都调用一次,确保我们使用时已经被初始化。
再次运行单元测试,使其通过,确保我们的修改没有问题。这样我们就可以编写 ClassInfoLoader 的单元测试了:
public class ClassInfoLoaderTest {@Testpublic void testLoadClass() {DefaultClassInfoLoaderInitializer.initDefaultClassInfoLoader();String TEST_ERR_MSG = "测试消息";ClassInfoLoader loader = new ClassInfoLoader() {@Overrideprotected ClassInfo loadClass(String name) {throw new ClassInfoNotFoundException(TEST_ERR_MSG);}};String COMPARABLE = "java.lang.Comparable";ClassInfo comparable = loader.load(COMPARABLE);assertEquals(COMPARABLE, comparable.getName());assertEquals("java.lang.Object", comparable.getSuperName());assertEquals(0, comparable.getInterfacesName().length);ClassInfo hello = loader.load(ClassFileRes.HELLO_WORLD);assertNotNull(hello);assertEquals(ClassFileRes.HELLO_WORLD, hello.getName());assertEquals("java.lang.Object", hello.getSuperName());assertEquals(0, hello.getInterfacesName().length);String CLASS_NOT_FOUND = "class.not.found";try {loader.load(CLASS_NOT_FOUND);} catch (Exception e) {assertTrue(e instanceof ClassInfoNotFoundException);assertTrue(e.getMessage().contains(TEST_ERR_MSG) && !e.getMessage().contains(CLASS_NOT_FOUND));return;}fail();}}
try-catch 和 @Test(expected=XXX.class)
这里没有使用 @Test 自身的 expected 来测试抛出的异常,除了出于这里我们需要判断 message 信息以外,也是避免方法中抛出不符合预期的异常,掩盖了实际的错误,让我们以外测试通过了。例如,假如我们标注了 @Test(expected=Exception.class) 但实际执行时,第一行就抛出了异常,这样测试就会直接显示通过,而实际上后面的 assert 都没有执行,异常也不是我们预期的地方执行的,这样显然是危险的。因此个人认为大多数时候,手动 try-catch 的方式更加灵活也更加安全。
然后便是实现 ClassInfoLoader:
public class ClassInfoLoader {private Map<String, ClassInfo> loaded;private ClassInfoLoader parent;public ClassInfoLoader() {this(null);}public ClassInfoLoader(ClassInfoLoader parent) {this.parent = parent;loaded = new HashMap<>();}public synchronized ClassInfo load(String name) throws ClassInfoNotFoundException {if (loaded.containsKey(name)) {return loaded.get(name);}ClassInfo classInfo = null;if (parent == null) {classInfo = loadFromDefaultLoader(name);} else {try {classInfo = parent.load(name);} catch (ClassInfoNotFoundException e) {// TODO 日志}}if (classInfo == null) {classInfo = loadClass(name);}return classInfo;}protected ClassInfo loadClass(String name) {throw new ClassInfoNotFoundException(name);}private static ClassInfo loadFromDefaultLoader(String name) {return DefaultClassInfoLoader.getInstance().load(name);}public ClassInfoLoader getParent() { return parent; }}
执行单元测试:
最后我们把新增的单元测试也添加到 Suite 里面,一键运行单元测试:
这样我们就完成了类加载器 ClassInfoLoader。
本次代码仓库
https://github.com/timeaftertime/jvm-demo/commit/f3fa52129b00717f0859bb9935133eedfa57f976
— END —
