首页 > 技术交流 > 手写 JVM —— 1. 加载 class 文件

手写 JVM —— 1. 加载 class 文件

头像
何人听我楚狂声
编辑于 2021-03-03 17:59:35 APP内打开
赞 14 | 收藏 16 | 回复7 | 浏览2003

前言

关于动手写 JVM 的资源也不是没有,张秀宏大佬就有一本书叫《自己动手写 Java 虚拟机》,不过那本书的例子使用 go 语言实现的,而 Jre 中的 JVM 一般用 cpp 实现,这很合理。毕竟作为 Java 程序的运行环境,肯定是需要用一个比较底层一些的语言来实现的。

当然,一切为校招服务。在简历上加上 cpp/go 实现的 JVM,总有些不伦不类,不知道这算是个 java 项目,还是个 cpp/go 项目

所以才有了这个系列。用 Java 实现 JVM,看起来有些套娃,但是作为一个纯正的 Java 项目,顺便还能引导面试官切入 JVM 相关八股文,着实不错,真不错(茄子脸)。

这个系列的教程主线也是跟随张秀宏老师的《自己动手写 Java 虚拟机》一书的思路,可以说是其项目的 Java 移植版了。

本章实现基本的 JVM 参数读取和 class 文件字节码的读取。

解析简单的参数

命令行参数

当然首先新建个 maven 项目,不用多说~

我们以实现常用命令 java -version 为例,来看看如何解析命令行参数。

首先在 pom.xml 中导入依赖 jcommander, 这个库可以帮助我们解析参数。

    <dependencies>
        <!-- 解析命令行参数 -->
        <dependency>
            <groupId>com.beust</groupId>
            <artifactId>jcommander</artifactId>
            <version>1.78</version>
        </dependency>
    </dependencies>

我们首先来定义一个类 Cmd 来表示命令行选项和参数:

public class Cmd {

    @Parameter(names = {"-?", "-help"}, description = "输出帮助信息", order = 3, help = true)
    boolean helpFlag = false;

    @Parameter(names = "-version", description = "输出 jvm 版本号并退出", order = 2)
    boolean versionFlag = false;

    @Parameter(names = {"-cp", "-classpath"}, description = "类路径", order = 1)
    String classpath;

    @Parameter(description = "主类和参数")
    List<String> mainClassAndArgs;

    boolean ok;
}

这里使用到的注解 @Parameter 就是 Jcommander 提供的。注解的参数中,names 可以是一个数组,表示参数的形式;description 参数表示参数的描述信息,在输出帮助信息时会输出;order 表示这个参数在帮助信息中的位置。

这里定义了三种参数,其中 -help-version 都使用 boolean 类型接收,表示运行时是否加上这些参数决定了它们的值。而 -cp 参数则使用 String 接收,表示这个参数后面需要跟随一个字符串,这个字符串将作为 classpath 的值。

mainClassAndArgs 是一个 List,用于存储 java 命令后面的所有字符串,包括参数和运行的主类名称。布尔值 ok 表示是否解析成功。

接下来就是解析了,这是 Jcommander 框架的固定写法。注意,JCommander 是使用 Builder 模式来构建的:

    public static Cmd parse(String[] argv) {
        Cmd args = new Cmd();
        JCommander cmd = JCommander.newBuilder().addObject(args).build();
        cmd.parse(argv);
        args.ok = true;
        return args;
    }

返回的就是一个构建好的命令行选项和参数的包装了。

主类

主类很简单,但是是我们今后整个 JVM 启动的主要流程框架。

public class Main {

    public static void main(String[] args) {
        Cmd cmd = Cmd.parse(args);
        if(!cmd.ok || cmd.helpFlag) {
            System.out.println("Usage: <main class> [-options] class [args...]");
            return;
        }
        if(cmd.versionFlag) {
            System.out.println("java version \"1.8.0\"");
            return;
        }
        startJVM(cmd);
    }

    private static void startJVM(Cmd cmd) {
        System.out.printf("classpath:%s class:%s args:%s\n",
                cmd.classpath,
                cmd.getMainClass(),
                cmd.getArgs());
    }

}

首先会根据 jvm 参数来提供一些服务,我们目前支持的仅有两种,一种是输出帮助信息,一种是输出版本信息。如果都不需要的话,就可以正式启动虚拟机加载主类了。当然啊,当前我们还没法加载主类,所以我们就输出一下 classpath 啥的信息就结束了。

可以看到,我们实现了一个滥竽充数的 java 版本号输出,任何情况下都会输出 1.8.0 版本。

那么就是运行了,在 Intellij IDEA 中,点击锤子右边的图标,Edit Configuration,在 program arguments 中输入 -version 即可,这样程序在启动时就会带上 -version 参数了。运行一下:

java version "1.8.0"

程序成功读到了参数,并且根据参数输出了版本号。

搜索并读取 class 文件

类路径(classpath)

我们都知道 java 命令运行一个 class 文件的流程:首先解析命令行参数,启动 JVM,接着将主类加载进 JVM,最后调用主类的 main() 方法。我们来看一个最简单的 HelloWorld 程序:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

在加载 HelloWorld 之前,我们首先需要加载它的父类,即 java.lang.Object 类。main() 方法的参数是 String 数组,所以我们也需要加载 java.lang.Stringjava.lang.String[] 类。使用 println() 方法时还需要加载 java.lang.System 类。

仅仅是启动一个最简单的 Java 程序,就需要加载这么多类。那么 JVM 从哪里去寻找这些类呢?

Oracle 的 JVM 虚拟机实现,将根据类路径(classpath)来搜索类,根据搜索的先后顺序,类路径分为如下三种:

  1. 启动类路径,一般在 jre/lib 目录中,Java 标准库中的类都位于该路径
  2. 扩展类路径,一般在 jre/lib/ext 目录中
  3. 用户类路径,默认就在当前目录了,也可以通过 -cp 或者 -classpath 参数来指定。

当 JVM 需要一个类时,就会按照上述顺序在类路径中查找,如果在启动类路径中找不到,就会去扩展类路径寻找,最后才去用户类路径寻找。

这种查找顺序实现了 Java 中的一个重要的概念——双亲委派机制。所以说,你自己定义一个 java.lang.Object 类,对 JVM 来说是没啥影响的——它根本不加载你的类,因为这个类首先在启动类路径中找到的。除非打破双亲委派机制,那又是另一件事了。

我们的 JVM 虚拟机需要用启动类路径来加载 Java 标准库,所以我们定义一个参数 -Xjre 来指定 jre 目录的位置。于是修改 Cmd 类,增加一个属性:

@Parameter(names = "-Xjre", description = "path to jre", order = 4)
String jre;

JVM 可读取的类路径项,分四种类型:目录形式、压缩包形式(jar 和 zip)、通配符形式和混合形式(就是以上三种的混合体,以路径分隔符分割)。

我们首先来定义一个接口 Entry 来抽象上述四种类型:

public interface Entry {

    byte[] readClass(String className) throws IOException;

    static Entry create(String path) {
        if(path.contains(File.pathSeparator)) {
            return new CompositeEntry(path);
        }

        if (path.endsWith("*")) {
            return new WildcardEntry(path);
        }

        if(path.endsWith(".jar") || path.endsWith(".JAR") ||
                path.endsWith(".zip") || path.endsWith(".ZIP")) {
            return new ZipEntry(path);
        }

        return new DirEntry(path);
    }

}

readClass() 方法留给具体实现类来实现,注意这个方法的参数是 class 的相对路径,例如读取 java.lang.Object 类,应传入 java/lang/Object 。静态方法 create() 根据传入的路径字符串,来判断具体创建哪种实现类。

四种类路径的实现

首先来看最简单的 DirEntry,它表示目录形式的类路径,它只需要一个字段,用来存放目录的绝对路径:

public class DirEntry implements Entry {

    private final Path absolutePath;

    public DirEntry(String path) {
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        return Files.readAllBytes(absolutePath.resolve(className));
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }
}

readClass 的实现也很简单,利用 java.nio.file.Path 类,可以直接在路径下找到对应文件,再使用 Files.readAllBytes() 直接读取到字节数组。

ZipEntry 表示读取一个压缩文件,例如以 .zip.jar 为后缀的文件。这种情况下,class 的相对路径就是压缩内部的目录的路径。ZipEntry 实现如下:

public class ZipEntry implements Entry {

    private final Path absolutePath;

    public ZipEntry(String path) {
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        try (FileSystem zipFs = FileSystems.newFileSystem(absolutePath, null)) {
            return Files.readAllBytes(zipFs.getPath(className));
        }
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }
}

java.nio 包提供了一个 FileSystem 类,可以快速读取压缩包。这个实现就变得非常简单了。

CompositeEntry 表示以文件分隔符(如 ;)分割的多个路径,它实际上可以分割成更小的 Entry 去处理,Entry 接口中的静态方法正好可以直接判断各个路径该如何创建 Entry:

public class CompositeEntry implements Entry {

    private final List<Entry> entryList = new ArrayList<>();

    public CompositeEntry(String pathList) {
        String[] paths = pathList.split(File.pathSeparator);
        for(String path : paths) {
            entryList.add(Entry.create(path));
        }
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        for(Entry entry : entryList) {
            try {
                return entry.readClass(className);
            } catch (Exception e) {
                // ignored
            }
        }
        throw new IOException("找不到类:" + className);
    }
}

readClass() 时也遍历 entryList 逐个读取即可。

WildcardEntry 是结尾通配符的类路径,它实际上也就是 CompositeEntry,所以可以直接继承自 CompositeEntry。

public class WildcardEntry extends CompositeEntry {

    public WildcardEntry(String pathList) {
        super(toPathList(pathList));
    }

    private static String toPathList(String wildcardPath) {
        String baseDir = wildcardPath.replace("*", "");
        try {
            // 遍历文件夹下所有文件,拼接所有 jar 文件的路径
            return Files.walk(Paths.get(baseDir))
                    .filter(Files::isRegularFile)
                    .map(Path::toString)
                    .filter(p -> p.endsWith(".jar") || p.endsWith(".JAR"))
                    .collect(Collectors.joining(File.pathSeparator));
        } catch (IOException e) {
            return "";
        }
    }
}

toPathList 中,将遍历 baseDir 下所有文件,挑选出 jar 包,拼接成字符串之后返回,在构造中这个字符串传递给父类构造,实际上这个过程就是将通配符转换为多个有效路径。

Classpath

Classpath 就用来读取上面提到的三种类路径了,在构造方法中就解析路径并构造 Entry:

public class Classpath {

    // JVM 启动时必须要加载的三类类路径
    private Entry bootstrapClasspath;       // 启动类路径
    private Entry extensionClasspath;       // 扩展类路径
    private Entry userClasspath;            // 用户类路径

    public Classpath(String jreOption, String cpOption) {
        // 启动类和扩展类由 jre 提供
        parseBootAndExtensionClasspath(jreOption);
        // 解析用户自定义的类的路径
        parseUserClasspath(cpOption);
    }
}

两个 parse 方法就是根据传入的参数路径,构造 Entry 用的,很简单,就是个路径的拼接:

    private void parseBootAndExtensionClasspath(String jreOption) {
        String jreDir = getJreDir(jreOption);

        // 启动类在 jre/lib/*
        String jreLibPath = Paths.get(jreDir, "lib") + File.separator + "*";
        bootstrapClasspath = new WildcardEntry(jreLibPath);

        // 扩展类在 jre/lib/ext/*
        String jreExtPath = Paths.get(jreDir, "lib", "ext") + File.separator + "*";
        extensionClasspath = new WildcardEntry(jreExtPath);

    }

    private void parseUserClasspath(String cpOption) {
        // 默认的用户类路径就是当前文件夹
        if(cpOption == null) {
            cpOption = ".";
        }
        userClasspath = Entry.create(cpOption);
    }

getJreDir 方法用于获取 jre 的路径,用户指定路径优先:

    private static String getJreDir(String jreOption) {
        /*
         * 寻找 jre 路径顺序
         * 1. 用户指定路径
         * 2. 当前文件夹下的 jre 文件夹
         * 3. 系统环境变量 JAVA_HOME 指定的文件夹
         */
        if(jreOption != null && Files.exists(Paths.get(jreOption))) {
            return jreOption;
        }
        if(Files.exists(Paths.get("./jre"))) {
            return "./jre";
        }
        String jh = System.getenv("JAVA_HOME");
        if(jh != null) {
            return Paths.get(jh, "jre").toString();
        }
        throw new RuntimeException("找不到 jre 路径!");
    }

最后,就是从整个 classpath 中读取一个类的方法了。

    public byte[] readClass(String className) throws Exception {
        // 根据类名获取一个类的字节码
        // 根据双亲委派机制,按顺序读取,并且在前两个读取不到时不会报错
        className = className + ".class";

        try {
            return bootstrapClasspath.readClass(className);
        } catch (Exception e) {
            // ignored
        }

        try {
            return extensionClasspath.readClass(className);
        } catch (Exception e) {
            // ignored
        }

        return userClasspath.readClass(className);
    }

可以看到,如果启动类路径中读取到的话就会直接返回,读取不到时会出现异常,但是这个异常不会被处理而是直接忽略,扩展类路径也是类似,最后直接返回用户类路径的读取结果,如果用户类路径还是读取不到,说明这个类没有被定义,可以直接抛出异常了。

读取 class 测试

main() 函数可以不用改动,我们只需要重写 startJVM() 函数即可:

    private static void startJVM(Cmd cmd) {
        Classpath cp = new Classpath(cmd.jre, cmd.classpath);
        System.out.printf("classpath:%s class:%s args:%s\n", cp, cmd.getMainClass(), cmd.getArgs());
        // 获取主类的目录名
        String className = cmd.getMainClass().replace(".", "/");
        try {
            byte[] classData = cp.readClass(className);
            for(byte b : classData) {
                System.out.print(String.format("%02x", b & 0xff) + " ");
            }
        } catch (Exception e) {
            System.out.println("找不到主类:" + cmd.getMainClass());
            e.printStackTrace();
        }
    }

startJVM() 会先打印出命令行参数,然后读取主类数据,并打印到控制台。

我们还需要一个主类,本节最开头提到的 HelloWorld 类就可以。将它通过 javac 命令编译成 class 文件,并放置在项目的 resources 下。

启动参数修改为:

-Xjre "C:\Program Files\Java\jdk1.8.0_271\jre" C:\Users\guozi\Desktop\SpicyChickenJVM\src\main\resources\HelloWorld

其中,xjre 的内容应当是你本机的 jre 的路径,而 HelloWorld 也是真实存在的文件的路径。

启动之后如下:

classpath:top.guoziyang.jvm.classpath.Classpath@736e9adb class:C:\Users\guozi\Desktop\SpicyChickenJVM\src\main\resources\HelloWorld args:null
ca fe ba be 00 00 00 34 00 1d 0a 00 06 00 0f 09 00 10 00 11 08 00 12 0a 00 13 00 14 07 00 15 07 00 16 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 04 6d 61 69 6e 01 00 16 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 01 00 0a 53 6f 75 72 63 65 46 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64 2e 6a 61 76 61 0c 00 07 00 08 07 00 17 0c 00 18 00 19 01 00 0d 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 07 00 1a 0c 00 1b 00 1c 01 00 0a 48 65 6c 6c 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 00 07 00 08 00 01 00 09 00 00 00 1d 00 01 00 01 00 00 00 05 2a b7 00 01 b1 00 00 00 01 00 0a 00 00 00 06 00 01 00 00 00 01 00 09 00 0b 00 0c 00 01 00 09 00 00 00 25 00 02 00 01 00 00 00 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 01 00 0a 00 00 00 0a 00 02 00 00 00 03 00 08 00 04 00 01 00 0d 00 00 00 02 00 0e 

以十六进制的形式输出了 HelloWorld 类的内容,虽然这些数据我们还看不懂,但是数据最前面的 8 个十六进制数,是 CAFEBABE。了解过字节码的同学应该知道,这正是字节码最开头的 magic number,看来我们读取成功了。

下一节我们会尝试解析这些“乱码”一样的数据。

7条回帖

回帖
加载中...
话题 回帖

相关热帖

技术交流近期热帖

近期精华帖

热门推荐