[用 Java 写一个 JVM] (一)刚好够运行 HelloWorld

2017-09-12 17:05:29 +08:00
 caoyangmin

1. 前言

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

我将用 Java 实现一个 JAVA 虚拟机(源码在这下载,加 Star 亦可),一开始它会非常简单,实际上简单得只够运行 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()方法的执行过程:

下面将详细说明。

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() {...}                  //当前方法的字节码
}

说明:

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,并奉上更多文章。

5101 次点击
所在节点    Java
28 条回复
microhz
2017-09-12 17:23:08 +08:00
膜拜大佬
zhouyou457
2017-09-12 17:26:42 +08:00
6666666
azicat
2017-09-12 17:27:47 +08:00
收藏下来慢慢看
0915240
2017-09-12 17:28:52 +08:00
支持大佬

之前看 go 有一本用 go 写 jvm 的


感觉这系列的都非常非常赞。
Keyes
2017-09-12 17:30:05 +08:00
佩服给跪

想起来以前做恶意代码分析的时候,实在调试的头疼了,然后用 C 写了个 x86 模拟器出来

等等,故事还没讲完,接着往下看

最后发现有一个叫做 bochs 的神器。。。
AnsonQAQ
2017-09-12 17:59:20 +08:00
膜拜一下
18914940609
2017-09-12 18:09:52 +08:00
mark
codingKingKong
2017-09-12 18:24:52 +08:00
mark
tt0411
2017-09-12 18:26:40 +08:00
给大佬递女装
skyfore
2017-09-12 18:38:21 +08:00
mark
lizhenda
2017-09-12 18:40:35 +08:00
大佬,屌
af463419014
2017-09-12 19:08:25 +08:00
膜拜收藏,晚上慢慢看
sjj050121014
2017-09-12 19:10:05 +08:00
大佬,求膜拜,已 star
yrom
2017-09-12 19:26:33 +08:00
厉害了
已关注
YORYOR
2017-09-12 19:43:29 +08:00
666
pubby
2017-09-12 20:04:29 +08:00
@Keyes 以前也干过类似的事情

破解一个 swf 内的解密算法,这个解密算法又经常变,于是用 php 写了一个 ABC 虚拟机( AS3 ByteCode )专门运行那段解密代码
PythonAnswer
2017-09-13 08:39:31 +08:00
nb 脚本大叔 仰视大佬!
xiaohuihui
2017-09-13 09:47:54 +08:00
6666
jc4myself
2017-09-13 11:05:16 +08:00
大佬的那个动图很棒棒!
caoyangmin
2017-09-13 11:34:39 +08:00
@jc4myself 谢谢。动图上实际上还有个错误,在栈帧被创建时,第一步应该是将 main 的 args 参数加入“局部变量表”,我做完这个 gif 后才发现,要更正就得重新截 20+的图片...

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/390110

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX