[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型
以 TDD 方式实现 JVM 原型]
09-实现类加载器与双亲委派模型
ClassInfo 完成了对 class 文件的封装,我们可以从输入流直接获得 ClasInfo 对象,从而获取类名等信息。但是我们使用 Java 加载某个类的时候,并不需要获取输入流,而只是指定一个全类名,如 Class.forName()。这次我们就来实现实现这个负责根据全类名创建 Class 对象的类加载器。
首先简单回忆一下什么是双亲委派模型。
Java 把“根据全类名查找对应的类”这个工作交由 ClassLoader 来实现,而双亲委派的逻辑就在 loadClass 方法,其主要逻辑如下:
// First, check if the class has already been loaded
Class<?> 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 instance
parallelLockMap = 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 {
@Test
public 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);
}
@Test
public 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 ClassLoader
ClassInfo classInfo = loadFromJars(LIB_PATH, name);
// Extension ClassLoader
if (classInfo == null) {
classInfo = loadFromJars(EXT_PATH, name);
}
// Application ClassLoader
if (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 {\
@Test
public 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);
}
@Test
public 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 {
@Test
public void testLoadClass() {
DefaultClassInfoLoaderInitializer.initDefaultClassInfoLoader();
String TEST_ERR_MSG = "测试消息";
ClassInfoLoader loader = new ClassInfoLoader() {
@Override
protected 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 —