【用Java写一个JVM】(一)刚好够运行 HelloWorld

1. 前言

没错这又是一篇介绍 JVM 的文章,这类文章网上已经很多,不同角度、不同深度、不同广度,也都不乏优秀的。为什么还要来一篇?首先对于我来说,我正在学习 Java,了解JVM的实现对学习Java当然很有必要,但我已经做了多年C++开发,就算我用C++实现一个JVM,我还是个C++码农,而用 Java实现,即能学习 Java 语法,又能理解 JVM,一举两得。其次,作为读者,hotspot或者其他成熟JVM实现的源码读起来并不轻松,特别是对没有C/C++经验的人来说,如果只是想快速了解JVM的工作原理,并且希望运行和调试一下JVM的代码来加深理解,那么这篇文章可能更合适。

我将用Java实现一个JAVA虚拟机(源码在这下载),一开始它会非常简单,实际上简单得只够运行HelloWorld。虽然简单,但是我尽量让其符合 JVM 标准,目前主要参考依据是《Java虚拟机规范 (Java SE 7 中文版)》

2. 准备

先写一个HelloWorld,代码如下:

package org.caoym;

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

我期望所实现的虚拟机(姑且命名为JJvm吧),可以通过以下命令运行:

$ java org.caoym.jjvm.JJvm org.caoym.HelloWorld
Hello World

接下来我们开始实现JJvm,下面是其入口代码,后面将逐步介绍:

public void run(String[] args) throws Exception {
    Env env = new Env(this);
    //加载初始类
    JvmClass clazz = findClass(initialClass);
    //找到入口方法
    JvmMethod method = clazz.getMethod(
            "main",
            "([Ljava/lang/String;)V",
            (int)(AccessFlags.JVM_ACC_STATIC|AccessFlags.JVM_ACC_PUBLIC));
    //执行入口方法
    method.call(env, clazz, (Object[]) args);
}

3. 加载初始类

我们将包含 main 入口的类称为初始类,JJvm 首先需要根据org.caoym.HelloWorld类名,找到.class 文件,然后加载并解析、校验字节码,这些步骤正是 ClassLoader(类加载器)做的事情。HelloWorld.class内容大致如下:

cafe babe 0000 0034 0022 0a00 0600 1409
0015 0016 0800 170a 0018 0019 0700 1a07
001b 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 124c 6f63
616c 5661 7269 6162 6c65 5461 626c 6501
0004 7468 6973 0100 164c 6f72 672f 6361
6f79 6d2f 4865 6c6c 6f57 6f72 6c64 3b01
0004 6d61 696e 0100 1628 5b4c 6a61 7661
...

没错是紧凑的二进制格式,需要按规范解析,不过我并不打算自己写解析程序,可以直接用com.sun.tools.classfile.ClassFile,这也是用JAVA写好处。下面是HelloWorld.class解析后的内容(通过javap -v HelloWorld.class输出):

public class org.caoym.HelloWorld
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // Hello World
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // org/caoym/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lorg/caoym/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello World
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               org/caoym/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public org.caoym.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/caoym/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

可以看到HelloWorld.class 文件中主要包含几部分:

  1. 常量池(Constant pool)

    常量池中记录了当前类中用到的常量,包括方法名、类名、字符串常量等,如:#3 = String #23, #3为此常量的索引,字节码执行时通过此索引获取此常量,String为常量类型, 还可以是Methodref (方法引用)、Fieldref(属性引用)等。

  2. 方法定义

    此处定义了方法的访问方式(如 PUBLIC、STATIC)、字节码等,关于字节码的执行方式将在后面介绍。

以下为类加载器的部分代码实现:

/**
 * 虚拟机的引导类加载器
 */
public class JvmClassLoader {
    // ... 此处省略部分代码    
    public JvmClass loadClass(String className) throws ClassNotFoundException{
        String fileName = classPath + "/"+className.replace(".", "/")+".class";
        Path path = Paths.get(fileName);
        //如果文件存在,加载文件字节码
        //否则尝试通过虚拟机宿主加载指定类,并将加载后的类当做 native 类
        if(Files.exists(path)){
             return JvmOpcodeClass.read(path);
        }else{
            return new JvmNativeClass(Class.forName(className.replace("/",".")));
        }
    }
}

类加载器可以加载两种形式的类:JvmOpcodeClassJvmNativeClass,均继承自JvmClass。其中JvmOpcodeClass 表示用户定义的类,通过字节码执行,也就是这个例子中的HelloWorldJvmNativeClass表示JVM 提供的原生类,可直接调用原生类执行,比如 java.lang.System。这里把所有非项目内的类,都当做原始类处理,以便简化虚拟机的实现。

4. 找到入口方法

JVM规定入口是static public void main(String[]),为了能够查找指定类的方法,JvmOpcodeClassJvmNativeClass都需要提供getMethod方法, 当然 main 方法肯定存在JvmOpcodeClass中:

public class JvmOpcodeClass implements JvmClass{

    private JvmOpcodeClass(ClassFile classFile) throws ConstantPoolException {
        this.classFile = classFile;
        for (Method method : classFile.methods) {
            String name = method.getName(classFile.constant_pool);
            String desc = method.descriptor.getValue(classFile.constant_pool);
            methods.put(name+":"+desc, new JvmOpcodeMethod(classFile, method));
        }
    }

    @Override
    public JvmMethod getMethod(String name, String desc, int flags) throws NoSuchMethodException {
        JvmOpcodeMethod method = methods.get(name+":"+desc);
        //... check method != null
        return method;
    }
}

5. 执行非 Native(字节码定义的)方法

下图为以HelloWorldmain()方法的执行过程:

《【用Java写一个JVM】(一)刚好够运行 HelloWorld》

下面将详细说明。

5.1. 虚拟机栈

每一个虚拟机线程都有自己私有的虚拟机栈(Java Virtual Machine Stack),用于存储栈帧。每一次方法调用,即产生一个新的栈帧,并推入栈顶,函数返回后,此栈帧从栈顶推出。以下为 JJvm中虚拟机栈的部分代码:

public class Stack {
    //创建新栈并推入栈顶,用于 native 方法调用
    public StackFrame newFrame() {
        StackFrame frame = new StackFrame(null, null, 0, 0);
        frames.push(frame, 1);
        return frame;
    }
        //创建新栈并推入栈顶,用于 opcode 方法调用
    public StackFrame newFrame(ConstantPool constantPool,
                               Opcode[] opcodes,
                               int variables,
                               int stackSize) {
        StackFrame frame = new StackFrame(constantPool, opcodes, variables, stackSize);
        frames.push(frame, 1);
        return frame;
    }
    public StackFrame currentFrame(){...} //获取当前正在执行的栈帧
    public StackFrame popFrame(){...} //从栈顶退出一个栈帧
}

5.2. 栈帧

栈帧用于保存当前函数调用的上下文信息,以下为 JJvm 中栈帧的部分代码:

public class StackFrame {  
    private int pc=0;  //程序计数器
    public StackFrame(ConstantPool constantPool,
                      Opcode[] opcodes,
                      int variables,
                      int stackSize) {
        this.constantPool = constantPool;               //常量池
        this.opcodes = opcodes;                         //当前方法的字节码
        this.operandStack = new SlotsStack(stackSize);  //操作数栈
        this.localVariables = new Slots(variables);     //局部变量表
    }
    public Slots<Object> getLocalVariables() {...}      //局部变量表
    public SlotsStack<Object> getOperandStack() {...}   //操作数栈
    public ConstantPool getConstantPool() {...}         //常量池
    public void setPC(int pc) {...}                     //设置程序计数器
    //设置方法返回值,一旦设置,此帧需要被退出栈顶,并将返回值推入上一个栈帧的操作数栈
    public void setReturn(Object returnVal, String returnType) {...}  
    public Object getReturn() {...}                     //获取当前方法返回值
    public String getReturnType() {...}                 //获取当前方法返回值类型
    public boolean isReturned() {...}                   //判断当前方法是否已经返回
    public int getPC() {...}                            //获取程序计数器
    public int increasePC() {...}                       //递增程序计数器
    public Opcode[] getOpcodes() {...}                  //当前方法的字节码
}

说明:

  • 局部变量表

    保存当前方法的局部变量、实例的this指针和方法的实参。函数执行过程中,部分字节码会操作或读取局部变量表。局部变量表的长度由编译期决定。

  • 常量池

    引用当前类的常量池。

  • 字节码内容

    以数组形式保存的当期方法的字节码。

  • 程序计数器

    记录当前真在执行的字节码的位置。

  • 操作数栈

    操作数栈用来准备字节码调用时的参数并接收其返回结果,操作数栈的长度由编译期决定。

5.3. 方法调用

方法调用的过程大致如下:

  1. 新建栈帧,并推入虚拟机栈。
  2. 将实例的this和当前方法的实参设置到栈帧的局部变量表中。
  3. 解释执行方法的字节码。

以下为 JJvm 中的部分代码:

public class JvmOpcodeMethod implements JvmMethod {
    public void call(Env env, Object thiz, Object ...args) throws Exception {
        // 每次方法调用都产生一个新的栈帧,当前方法返回后,将其栈帧设置为已返回,BytecodeInterpreter.run会在检查到返回后,将栈帧推
        // 出栈,并将返回值(如果有)推入上一个栈帧的操作数栈
        StackFrame frame = env.getStack().newFrame(
                classFile.constant_pool,
                opcodes,
                codeAttribute.max_locals,
                codeAttribute.max_stack);

        // Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的 参数将会传递至从 0 开始的连续的局部变量表位置
        // 上。特别地,当一个实例方法被调用的时候, 第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”
        // 关键字)。后续的其他参数将会传递至从 1 开始的连续的局部变量表位置上。
        Slots<Object> locals = frame.getLocalVariables();
        int pos = 0;
        if(!method.access_flags.is(AccessFlags.ACC_STATIC)){
            locals.set(0, thiz, 1);
            pos++;
        }
        for (Object arg : args) {
            locals.set(pos++, arg, 1);
        }
        //解释执行字节码
        BytecodeInterpreter.run(env);
    }
}

5.4. 解释执行字节码

字节码的执行过程如下:

  1. 获取栈顶的第一个栈帧。
  2. 获取当前栈的程序计数器(PC,其默认值为0)指向的字节码,程序计数器+1。
  3. 执行上一步获取的字节码,推出操作数栈的元素,作为其参数,执行字节码。
  4. 字节码返回的值(如果有),重新推入操作数栈。
  5. 如果操作数为return等,则设置栈帧为已返回状态。
  6. 如果操作数为invokevirtual等嵌套调用其他方法,则创建新的栈帧,并回到第一步。
  7. 如果栈帧已设置为返回,则将返回值推入上一个栈帧的操作数栈,并推出当前栈。
  8. 重复执行1~7,直到虚拟机栈为空。

以下为JJvm中解释执行字节码的部分代码:

public class BytecodeInterpreter {
    
    //执行字节码
    public static void run(Env env) throws Exception {
        //只需要最外层调用执行栈上操作
        if(env.getStack().isRunning()) return;
        
        StackFrame frame;
        Stack stack = env.getStack();
        stack.setRunning(true);

        while ((frame = stack.currentFrame()) != null){
            //如果栈帧被设置为返回,则将其返回值推入上一个栈帧的操作数栈
            if(frame.isReturned()){
                stack.popFrame();
                //如果有返回值,则将返回值推入上一个栈帧的操作数栈。
                if(!"void".equals(frame.getReturnType())){
                    frame = stack.currentFrame();
                    if(frame != null){
                        frame.getOperandStack().push(frame.getReturn());
                    }
                }
                continue;
            }
            Opcode[] codes = frame.getOpcodes();
            int pc = frame.increasePC();
            codes[pc].call(env, frame);
        }
    }
    // opcode 的实现
    static {
        //return: 从当前方法返回 void。
        OPCODES[Constants.RETURN] = (Env env, StackFrame frame, byte[] operands)->{
            frame.setReturn(null, "void");
        };

        //getstatic: 获取对象的静态字段值
        OPCODES[Constants.GETSTATIC] = (Env env, StackFrame frame, byte[] operands)->{
            int arg = (operands[0]<<4)|operands[1];
            ConstantPool.CONSTANT_Fieldref_info info
                    = (ConstantPool.CONSTANT_Fieldref_info)frame.getConstantPool().get(arg);
            //静态字段所在的类
            JvmClass clazz = env.getVm().findClass(info.getClassName());
            //静态字段的值
            Object value = clazz.getField(
                    info.getNameAndTypeInfo().getName(),
                    info.getNameAndTypeInfo().getType(),
                    AccessFlags.ACC_STATIC
                    );

            frame.getOperandStack().push(value, 1);
        };

        //ldc: 将 int,float 或 String 型常量值从常量池中推送至栈顶
        OPCODES[Constants.LDC] = (Env env, StackFrame frame, byte[] operands)->{
            int arg = operands[0];
            ConstantPool.CPInfo info = frame.getConstantPool().get(arg);
            frame.getOperandStack().push(asObject(info), 1);
        };

        //invokevirtual: 调用实例方法
        OPCODES[Constants.INVOKEVIRTUAL] = (Env env, StackFrame frame, byte[] operands)->{
            int arg = (operands[0]<<4)|operands[1];

            ConstantPool.CONSTANT_Methodref_info info
                    = (ConstantPool.CONSTANT_Methodref_info)frame.getConstantPool().get(arg);

            String className = info.getClassName();
            String name = info.getNameAndTypeInfo().getName();
            String type = info.getNameAndTypeInfo().getType();

            JvmClass clazz  = env.getVm().findClass(className);
            JvmMethod method = clazz.getMethod(name, type, 0);

            //从操作数栈中推出方法的参数
            Object args[] = frame.getOperandStack().dumpAll();
            method.call(env, args[0], Arrays.copyOfRange(args,1, args.length));
        };
        // ... 以下省略
    }
}

6. 执行 Native 方法

Native方法的调用要更简单一些,只需调用已存在的实现即可,代码如下:

public class JvmNativeMethod implements JvmMethod {

    private Method method;
    @Override
    public void call(Env env, Object thiz, Object... args) throws Exception {
        StackFrame frame = env.getStack().newFrame();
        Object res = method.invoke(thiz, args);
        //将返回值推入调用者的操作数栈
        frame.setReturn(res, method.getReturnType().getName());
    }
}

7. 结束

当目前为止,我们的“刚好够运行 HelloWorld”的 JVM 已经完成,完整代码可在这里下载。当然这个JVM 并不完整,缺少很多内容,如类和实例的初始化、多线程问题、反射、GC 等等。我争取逐步完善JJvm,并奉上更多文章。

    原文作者:JVM
    原文地址: https://juejin.im/entry/59b6cc075188257e8c54d227
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞