V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
caoyangmin
V2EX  ›  Java

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

  caoyangmin ·
caoym · 2017-09-12 17:05:29 +08:00 · 5354 次点击
这是一个创建于 2664 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

说明:

  • 局部变量表

    保存当前方法的局部变量、实例的 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,并奉上更多文章。

28 条回复    2018-03-09 16:51:47 +08:00
microhz
    1
microhz  
   2017-09-12 17:23:08 +08:00
膜拜大佬
zhouyou457
    2
zhouyou457  
   2017-09-12 17:26:42 +08:00
6666666
azicat
    3
azicat  
   2017-09-12 17:27:47 +08:00
收藏下来慢慢看
0915240
    4
0915240  
   2017-09-12 17:28:52 +08:00 via iPhone
支持大佬

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


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

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

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

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

破解一个 swf 内的解密算法,这个解密算法又经常变,于是用 php 写了一个 ABC 虚拟机( AS3 ByteCode )专门运行那段解密代码
PythonAnswer
    17
PythonAnswer  
   2017-09-13 08:39:31 +08:00
nb 脚本大叔 仰视大佬!
xiaohuihui
    18
xiaohuihui  
   2017-09-13 09:47:54 +08:00
6666
jc4myself
    19
jc4myself  
   2017-09-13 11:05:16 +08:00
大佬的那个动图很棒棒!
caoyangmin
    20
caoyangmin  
OP
   2017-09-13 11:34:39 +08:00
@jc4myself 谢谢。动图上实际上还有个错误,在栈帧被创建时,第一步应该是将 main 的 args 参数加入“局部变量表”,我做完这个 gif 后才发现,要更正就得重新截 20+的图片...
kylefeng
    21
kylefeng  
   2017-09-15 14:45:31 +08:00
之前看过一个 Go 写的: https://book.douban.com/subject/26802084/
Saltyx
    22
Saltyx  
   2017-09-17 15:43:57 +08:00 via Android
mark
taomk
    23
taomk  
   2017-09-21 10:00:33 +08:00
已收藏,给大佬 star
cs4814751
    24
cs4814751  
   2017-09-21 19:03:21 +08:00
向大佬低头,star 好好学习。
misaka19000
    25
misaka19000  
   2017-10-23 19:20:09 +08:00
楼主可不可以稍微介绍下项目的整体结构,看了一下感觉好混乱。。。
misaka19000
    26
misaka19000  
   2017-10-23 19:31:51 +08:00
感觉楼主的 VM 主要是包含了字节码的解析和对栈的操作上面,基本上都是在操作栈帧,不知道理解的对不对。。。
Poarry
    27
Poarry  
   2017-10-25 11:52:40 +08:00
膜拜大佬
JvTom
    28
JvTom  
   2018-03-09 16:51:47 +08:00
没有全懂,mark 一下。研究一段时间再来看
关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2543 人在线   最高记录 6679   ·     Select Language
创意工作者们的社区
World is powered by solitude
VERSION: 3.9.8.5 · 26ms · UTC 11:27 · PVG 19:27 · LAX 03:27 · JFK 06:27
Developed with CodeLauncher
♥ Do have faith in what you're doing.