Wilder's Blog.

JVM学习笔记(一):字节码的编译原理

字数统计: 2.4k阅读时长: 8 min
2018/03/20 Share

JVM:字节码的编译原理

​ JVM 并不会与 Java 语言 “ 终生绑定 ” ,任何语言编写的程序都可以运行在 JVM 中,前提是源码的编译结果满足并包含 Java 虚拟机的内部指令集、符号表以及其它的辅助信息,它就是一个有效的字节码文件,就能被虚拟机所识别并装载运行。

​ Java 源码编译为字节码时所要经历的步骤为:词法解析 –> 语法解析 –> 语义解析 –> 生成字节码

  • 词法解析 :词法解析就是将 Java 源码中的关键字和标示符等内容转换为符合 Java 语法规范的 Token 序列,然后按照指定的顺序规则进行匹配校验(所谓的关键字指的是 public , private等关键字);
  • 语法解析:语法解析就是将词法解析后的 Token序列整合为一棵结构化的抽象语法树;
  • 语义解析:语义解析的目的就是为了将之前语法解析步骤所产生的语法树扩充得更加完善,后续编译器将会使用语义解析后的语法树来生成字节码

词法解析步骤

​ 词法解析器的主要任务就是将 Java 源码中的关键字和标识符等内容转换为符合 Java 语法规范的 Token 序列,为之后的语法解析步骤做准备

编译过程

​ 如图所示的词法解析过程,我们要知道 Scanner 是主要任务是按照单个字符的方式 读取源文件中的标示符等内容。 但是 Scanner 的任务只是读取源码的字符集合,但是解析的主要步骤交给 JavacParser 来解决,主要调用了 parseCompilationUnit ( ) 方法 ;nextToken ( ) 方法则读取一个个字符交给 JavaParser 转换为 Token 序列。

Token 序列

​ Token 究竟是什么呢?其实 Token 无非就是一组对应源码字符集合的单词序列。

1
2
3
4
5
6
7
8
9
EOF,
ERROR,
IDENTIFIER.
ABSTRACT("abstract"),
ASSERT("assert"),
BOOLEAN("boolean"),
......
PACKAGE("package"),
......

源码字符集合与 Token 之间的对一个关系

​ 看了《 Java 虚拟机精讲 》之后,我自己对这两者的关系的理解大概如下:

  • 源码字符集合在转换为 Token序列之前会先将被一个字符转换为对应的 Name 对象,也就是说每一个字符会对应一个 Name 对象。
  • 负责实际 Token 转换的 Keywords 会将 Token 常量转换为 Name 对象,并将 Token 的信息储存在 Name 对象内部的 Table 类中
  • 这样的话源码字符和 Token 通过了 Name 对象就有了一定的关系
  • 用 Keywords 类中的数组 key 用于保存源码字符集合与 Token 之间的对应关系

调用 key( ) 方法获取指定 Token

​ 每一个源码字符集合其实就是一个 Name 对象,一旦源码字符集合与 Token 之间成功构建起对应关系之后,当词法解析期调用 Keywords 类的 key( ) 方法时,传入与 Token 对应的 Name 对象就可以成功获取指定的 Token:

1
2
3
public Token key (Name name) {
return (name.getIndex() > maxKey) ? IDENTIFIER : key[name.getIndex()];
}

​ 源码字符集合与 Token 之间的对应关系就保存在数组 key 中,其中 Name 类的 getIndex( ) 方法用于返回 Name 对象当前索引,通过这个索引就可以从数组 key 中获取他们的 Token。

调用 nextToken( )计算 Token 的获取规则

​ 当成功获取到指定的 Token 后,JavacParser 类就会匹配当前的第一个 Token 是否匹配 Token.PACKAGE ,如果匹配成功的话,再由词法解析期获取下一个 Token ,继续匹配是否是 Token.IDENTIFIER(标识符) ,接下来再匹配 Token.DOT(点) 、 Token.IDENTIFIER 和 Token.SEMI(分号)。这样的话,一个完整的 package 关键字声明就解析完成。

最后,调用 parseCompilationUnit( ) 方法进行匹配,当然 Token 的匹配顺序和 Token 的读取顺序要保持一致,parseCompilationUnit ( ) 方法会将 Token 序列整合为一棵结构化的抽象语法树。

语法解析步骤

​ 根据我的理解,语法解析的步骤起始也是在 parseCompilationUnit ( )方法中实现的,其实 parseCompilationUnit ( ) 里面调用了词法解析,语法解析,语义解析和生成字节码四个步骤的方法。怎么说呢,在词法解析之后,源码字符集变成了一个个 Token 序列,但是这些序列都是单一的没有任何的关联,通过语法解析,将匹配后的 Token 序列整合为一棵结构化的抽象语法树。比如说 try … catch 需要联系起来等情况。我们一起来看一下

​ 语法解析中一个重要的类就是 JCTree 类,它实际上与语法树中的每一个语法结点保持着密不可分的关系,因为语法树中的每一个语法结点实际上都直接或者间接地继承了 JCTree 类,并且这些语法结点对象都以静态内部类的形式定义在类中。根据理解我画出了关系图:

1
2
3
4
5
6
7
8
st=>operation: Token
op=>operation: 语法结点
cond=>operation: JCTree 类
achieve=>condition:实现对应 Tree 接口
e=>end
st->op->cond->e
achieve(yes)
cond(no)->op

调用 qualident ( ) 方法解析 package 语法结点

​ 当词法解析器成功地将 package 关键字声明转换为 Token 并完成词法解析后,就会调用 qualident ( ) 方法根据 Token.PACKAGE 解析为 package 语法结点

1
2
3
4
5
6
7
8
9
10
11
public JCExpression qualident() {
/* 解析为 JCIdent 语法结点*/
JCExpression t = toP (F.at(S.pos()).Ident(ident())) ;
while (S.token() == DOT){
int pos = S.pos();
S.nextToken();
/* 解析为 JCFieldAccess 语法节点*/
t = toP(F.at(pos).Select(t,ident()));
}
return t;
}

​ 上述代码中,假如 package 声明的关键字只有一级目录的时候就会调用 Ident( ) 方法将它解析成为一个 JCIdent 语法结点;反之当 package 关键字声明中有多级目录时,qualident( ) 方法就会通过循环迭代的方式调用语法解析器将 package 关键字声明解析为嵌套的 JCFieldAccess 语法结点,接下来我们来看看这两个方法的源代码:

1
2
3
4
5
public JCIdent Ident (Name name){
JCIdent tree = new JCIdent(name , null);
tree.pos = pos;
return tree;
}
1
2
3
4
5
6
public JCFieldAccess Select(JCExpression selected, Name selector){
// 根据Name 对象解析出嵌套 JCFieldAccess 语法结点
JCFieldAccess tree = new JCFieldAccess(selected, selector, null);
tree.pos = pos;
return tree;
}

​ 我们可以看到,JCIdent 和 JCFieldAccess 的方法参数需要的是 Name 对象,也就是说在解析语法树或者语法节点时,首先需要将 Token 转换为对应的 Name 对象。也就是说,调用 ident( ) 方法会返回一个与 Token 对应的 Name 对象。

调用 importDeclaration( ) 方法解析 import 语法树

​ 调用 importDeclaration ( ) 方法的步骤和解析package 语法结点是一样的,当 import 关键字声明中只有一层目录时就调用 Ident( ) 解析出一个 JCIdent 语法结点,如果是多级目录的话就调用 Select( ) 方法解析成为嵌套的 JCFieldAccess 语法节点。有一个需要注意的是 import 解析时会先判断是否有 Token.STATIC 匹配,检测 import 关键字声明中是否包含 static 静态导入。

​ import 解析完成之后,将会调用 Import ( ) 方法,将之前解析过的语法节点整合成一棵 JCImport 语法树。

调用 classDeclaration ( ) 方法解析 class 语法树

​ 当词法解析器成功将 import 关键字解析并整合成 JCImport 语法树后,在 parseCompilationUnit ( ) 方法内部就会通过 typeDeclaration( ) 方法调用 classOrInterfaceOrEnumDeclaration( ) 方法将 class 主体信息解析为一棵 JCClassDecl 语法树。从这个方法名可以看出,方法中在检验的时候会考虑到 class 、interface 、 enum 三种情况的校验,并且还要注意的就是不管是class、interface 还是 enum 最后都是解析成一棵 JCClassDecl 语法树。大致的理解就是这样子…

​ 当将 class 部分整合成一棵 JCClassDecl 语法树之后,parseCompilationUnit( ) 方法就会调用语法解析器的 TopLevel ( ) 将之前解析过的 package、import 和 class 语法树等内容信息全部整合成一棵 JCCompilationUnit 语法树。

生成树模型

语义解析步骤

​ 经过语法解析后的语法树还不够完善,主要会经历的操作如下:

  • 为没有构造方法的类型添加缺省的无参构造方法

  • 检查任何类型的变量在使用前是否都已经经历过初始化

  • 检查变量类型是否与值匹配

  • 将 String 类型的常量进行合并处理

  • 检查代码中的所有操作语句是否可达

  • 异常检查

  • 解除 Java 语法糖

    经历过一系列的语义解析步骤之后,就构成了一个完善的编译前提

生成字节码

​ javac编译器最后的任务就是调用 Gen 类将这棵语法树编译为 Java 字节码文件。所谓的编译字节码,就是将符合 Java 语法规范的 Java 代码转换为符合 JVM 规范的字节码文件。在此需要注意的是:JVM 的架构模型是基于栈的,在 JVM 中所有的操作都需要经过入栈和岀栈来完成。

好啦~大概的内容就到这里了,至于后面 JVM 的架构模型这一部分将在后面的章节进行整理

CATALOG
  1. 1. JVM:字节码的编译原理
    1. 1.1. 词法解析步骤
      1. 1.1.1. Token 序列
      2. 1.1.2. 源码字符集合与 Token 之间的对一个关系
      3. 1.1.3. 调用 key( ) 方法获取指定 Token
      4. 1.1.4. 调用 nextToken( )计算 Token 的获取规则
    2. 1.2. 语法解析步骤
      1. 1.2.1. 调用 qualident ( ) 方法解析 package 语法结点
      2. 1.2.2. 调用 importDeclaration( ) 方法解析 import 语法树
      3. 1.2.3. 调用 classDeclaration ( ) 方法解析 class 语法树
    3. 1.3. 语义解析步骤
    4. 1.4. 生成字节码