vlambda博客
学习文章列表

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型

以 TDD 方式实现 JVM 原型]

09-实现类加载器与双亲委派模型

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型

ClassInfo 完成了对 class 文件的封装,我们可以从输入流直接获得 ClasInfo 对象,从而获取类名等信息。但是我们使用 Java 加载某个类的时候,并不需要获取输入流,而只是指定一个全类名,如 Class.forName()。这次我们就来实现实现这个负责根据全类名创建 Class 对象的类加载器。

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型


首先简单回忆一下什么是双亲委派模型。


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;

正如上面这段代码所示,当一个类加载器收到一个加载请求时:

  1. 首先检查这个类是否已经加载,若是,直接使用

  2. 将加载委派给 parent 处理,若成功则返回

  3. 若 parent 加载失败,则调用 findClass() 加载

  4. 返回加载结果

这里的 findClass() 由具体的子类实现,因此不同的子类可以从不同的地方(当前目录、特定目录、甚至网络上)加载字节码。

我们再来看看 Java 有哪些类加载器:

  1. 启动类加载器(Bootstrap ClassLoader) 负责加载 <JRE_HOME>/lib 或 -Xbootclasspath 指定目录下特定 jar 包(如 rt.jar)中的类。由 C++ 实现,Java 中不能直接引用,需要使用时,只需设置 parent 为 null。这也是上面代码中判断 parent 为 null 时调用 findBootstrapClassOrNull 的原因。

  2. 拓展类加载器(Extension ClassLoader) 负责加载  <JAVA_HOME>/lib/ext/ 或环境变量 java.ext.dir 所指定的目录下的 所有类库。由 sun.misc.Launcher$ExtClassLoader 实现。

  3. 应用程序类加载器(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 参数,即双亲类加载器:

  1. 不指定该参数时,将使用 getSystemClassLoader 作为默认的 parent,从上文可以知道,这里返回的就是应用程序类加载器。

  2. 明确指定为 null 时,由 loadClass 方法可知,这就相当于指定 parent 为启动类加载器。

  3. 明确指定为一个其他类加载器时,如果为上文提到的 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 文件读取和从文件目录读取的方法。

再次运行单元测试,通过测试:

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型

然后我们来写 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; }
}

执行单元测试:

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型

最后我们把新增的单元测试也添加到 Suite 里面,一键运行单元测试:

[以 TDD 方式实现 JVM 原型] 09-实现类加载器与双亲委派模型


这样我们就完成了类加载器 ClassInfoLoader。




本次代码仓库

https://github.com/timeaftertime/jvm-demo/commit/f3fa52129b00717f0859bb9935133eedfa57f976



—  END  —