0%

10分钟教你如何hack掉Java编译器 | 编译流程,javac,JIT,注解处理器

导读

如标题所述,我们如何才能hack掉java编译器,也就是javac呢?为了摸索到这个套路,我们需要从一般的编译流程,javac的编译流程,以及插入式注解处理器说起,最后通过一个例子演示如何在编译期间篡改代码,并且介绍业界常见的应用场景。读完该篇文章,你可以了解到:

  1. 编译器一般编译流程
  2. javac的编译流程是怎样的
  3. 如何hack掉Java编译器
  4. 运行时DI和编译期DI的区别

1、程序编译执行流程

1.1、一般执行流程

一般情况下,一个程序从编译到执行,有以下这些阶段:

image-20200131183001934

1.2、编译案例

如下,以龙书中的例子为例,一个语句的编译流程:

image-20200131230607183

符号表:是一种用于数据结构,源程序中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。

在编译程序工作过程中,会不断收集、记录和使用源程序中一些语法符号的类型和特征等相关信息,这些信息一般以表格形式存储于系统中,如常数表、变量表、数组名表、过程名表、标号表等,这些统称为符号表。

2、Java程序编译类型

而在Java中,有几种编译模式,如果用的是前端编译+后端编译,则把以上流程进行划分,常用的组合是:javac前端编译器+JIT后端编译器:

image-20200111220207451

而在执行过程中,会进行混合模式执行:部分函数会解释执行,部分会编译执行。

2.1、Java程序编译执行过程

如下图,为Java代码从编译到执行的过程:

image-20200129233106291

  • 在前端编译时,把Java源文件编译为Class文件;

  • 在解释执行时,会收集运行数据,根据热点代码进行JIT编译优化,生成本地机器码,加快程序的执行。

更多关于类加载器,系统初始化,以及加载Class文件到JVM的过程,参考之前发布的两篇文章:

3、javac

3.1、javac中的主要类

image-20200117001110775

3.2、javac主要处理流程

主要处理流程入口:JavaCompiler.compile()

image-20200116230536483

compile2()方法中的默认编译策略:

image-20200116224550074

梳理一下以上的代码流程,如下图所示:

image-20200130180751598

  1. initProcessAnnotations

    1. 准备过程:初始化插入式注解处理器
  2. Parse:parseFiles(sourceFileObjects) 解析步骤,读取一系列的Java源文件,把解析的Token序列结果映射到AST-Nodes(抽象语法树各个节点):

    1. 词法分析:将字符流转换为标记(Token)集合(符号流);
    2. 语法分析:根据token序列构造抽象语法树,后续操作都建立在语法树上,语法分析相关类:Parser
  3. Enter:enterTrees 填充符号表,编译器将在其作用域范围内找到所有定义的符号,主要包含以下两个阶段:

    1. 第一阶段:注册所有类到其相应的作用域范围,在这一步编译器为每个类符号记录一个MemberEnter对象,该对象将用于第二阶段;
    2. 第二阶段:使用上面的MemberEnter对象继续完善类符号相关信息。主要包括:确定类的参数,超类和接口。
  4. Annotate:processAnnotations():

    1. 注解处理器的执行过程。如果存在注解处理器,并且请求了注解处理,则将处理在指定的编译单元中找到的所有注解。JSR 269定义了用于编写此类插件的接口,后面会有详细介绍。
  5. delegateCompiler.compile2():分析及字节码生成

    1. Attribute:语义分析过程,标注检查,主要包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等;同时会进行常量折叠(int a = 1+2 折叠为 int a =3);

    2. Flow:语义分析过程,数据及控制流分析。这一步是对程序上下文逻辑更进一步的验证,可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受检验异常都被正确处理了等问题。

      1. final类型的局部变量就是通过在这一步分析来保证不被重新赋值的;因为局部变量不像类变量,在Class文件中有CONSTANT_Fieldref_info符号引用,记录了访问标志。

    3. Desugar:解除语法糖(inner classes, class literals, assertions, foreach loops),重写AST;

    4. Generate:生成字节码,同时会进行少量代码添加和转换工作。如:

      1. 添加实例构造器<init>()方法和类构造器<clinit>()方法;
      2. 把字符串相加操作替换为StringBuffer或者StringBuilder(JDK 1.5+);

4、注解处理器

我们上一节讲解了javac的主要处理流程,其中在解析成抽象语法树之后,有一个处理注解流程,这个流程是通过提供一组插入式注解处理器的标准API(Java规范提案 JSR 269: Pluggable Annotation Processing API )在编译期间对注解进行处理。我们可以把它看做是一组编译器的插件,在插件中可以读取,修改和添加抽象语法树中的任意元素。

JSR269是从Java6开始提供;

在Java5 之前注解处理器尚未成熟,注解处理器的API并不是JDK标准,而是通过独立的apt工具(Annotation Processor Tool,分发于com.sun.mirror包下)来编写自定义处理器。

如果插入式注解处理器在处理注解期间修改了AST(抽象语法树),编译器将回到解析与填充符号表的过程重新处理,直到所有插入式注解处理器都没有在修改AST为止,每一次循环成为一个Round,如下图:

image-20200130182808717

我们也可以自己实现JSR 269的API,自定义一个插入式注解处理器,为javac自定义编译行为。

4.1、注解处理器与反射的区别

我们可以通过反射获取注解,但是这只能在运行时通过反射获取注解,运行效率比较低;另外反射无法做到在编译阶段进行代码检查;

Java 6开始,可以使用JSR 269的API编写注解处理器。JSR 269可以在javac编译期利用注解进行检查和改写语法树的能力,与反射的运行期干预不同,大大提高了执行效率。

4.2、如何实现一个注解处理器

自定义注解处理器的接口

注解处理器实现了javax.annotation.processing.Processor接口,遵循给定的协定。为了方便实现,同时提供了javax.annotation.processing.AbstractProcessor类实现具有自定义处理器通用功能的抽象实现。以下是该接口的关键需要实现的方法,注释处理期间,Java编译器将调用这两个方法:

1
2
3
4
5
6
7
8
9
10
11
/**
*第一个方法被调用一次以初始化插件
*/
public synchronized void init(ProcessingEnvironment processingEnv)

/**
* 在每次注释循环中被调用,在所有回合完成后再被调用一次
* @return 这些annotations注解是否由此 Processor 处理,返回ture表示该注解已经被处理, 不会再有后续其他处理器处理进行处理; 返回false表示仍可被其他后续处理器处理
*/
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv)

自定义注解处理器使用到的注解

  • javax.annotation.processing.SupportedAnnotationTypes:用于注册处理器支持的注解。有效值是注释类型的标准名称,允许使用通配符。
  • javax.annotation.processing.SupportedSourceVersion:用于注册处理器支持的源代码版本。
  • javax.annotation.processing.SupportedOptions:此注释用于注册允许通过命令行传递的自定义选项。

下面是一个注解处理器的例子,该例子源于:The Hacker’s Guide to Javac

这个例子主要是把以下格式的断言:

1
assert cond : detail;

在编译阶段替换为异常:

1
if (!cond) throw new AssertionError(detail);

4.2.1、写一个注解

1
2
public @interface ForceAssertions {
}

4.2.2、写一个注解处理器

注意,本例基于Java8,由于该例子中使用到了sun.tools包中的类,该包中的类非Java平台标准类,不同Java版本类方法有所不同,如果是Java6,参考上面源例子中的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/**
* 注意,此例使用到了sun.tools中的类,可能会导致不稳定.
* 开发者不应该调用sun包,Oracle一直在提醒开发者,调用sun.*包里面的方法是危险的。
* sun包并不包含在Java平台的标准中,它与操作系统相关,
* 在不同的操作系统如Solaris,Windows,Linux,Mac等中的实现也各不相同,并且可能随着JDK版本而变化。详细说明:
* http://www.oracle.com/technetwork/java/faq-sun-packages-142232.html
*
* Created by arthinking on 30/1/2020.
*/
@SupportedAnnotationTypes("com.itzhai.annotation.process.demo.ForceAssertions")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ForceAssertionsProcessor extends AbstractProcessor {

// 计数器用于向用户报告已应用的替换次数
private int tally;

// Trees JSR269的工具类,连接程序元素和树节点的桥梁。
// 例如,给定一个method元素,我们可以获得其关联的AST树节点
private Trees trees;

// TreeMaker 编译器的内部组件,用于创建树节点的工厂
private TreeMaker make;

// Name.Table 编译器的一个内部组件, Name是内部编译器字符串的抽象。
// 出于效率原因,Javac使用存储在公共大型缓冲区中的哈希字符串。
private Names names;

@Override
public synchronized void init(ProcessingEnvironment env) {
super.init(env);
trees = Trees.instance(env);
// 我们使用处理环境来处理必要的编译器组件。在编译器内,对编译器的每次调用都使用单个处理环境(或context上下文,内部称为上下文)。
// 把JSR269的ProcessingEnvironment转换为实际的编译器类型JavacProcessingEnvironment,以便能够调用更多的内部方法
JavacProcessingEnvironment javacProcessingEnvironment = (JavacProcessingEnvironment)env;
// 使用context上下文来确保每个编译器调用都存在每个编译器组件的单个副本。
Context context = javacProcessingEnvironment.getContext();
// 在编译器中,我们仅使用 Component.instance(context) 来获取对该阶段的引用
make = TreeMaker.instance(context);
names = Names.instance(context);
// tally 计数器用于向用户报告已应用的替换次数。
tally = 0;
}

@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
// 遍历所有的程序元素,并且重写每个类的AST
Set<? extends Element> elements = roundEnv.getRootElements();
for (Element each : elements) {
if (each.getKind() == ElementKind.CLASS) {
// 把JSR269的 Tree 转换为实际的JCTree类型,以便可以访问所有的AST元素。
JCTree tree = (JCTree) trees.getTree(each);
// 通过对TreeTranslator进行子类化来完成树翻译,
// TreeTranslator本身是TreeVisitor的子类。
// 这些类都不是JSR269的一部分,而是Java编译器内部的类。
TreeTranslator visitor = new Inliner();
tree.accept(visitor);
}
}
} else {
// 输出处理的断言语句的数量
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE, tally + " assertions inlined.");
}
return false;
}

/**
* Inliner类实现了AST的重写
*/
private class Inliner extends TreeTranslator {

/**
* 为了改变assert语句,我们这里重写了 visitAssert(JCAssert tree) 方法
* @param tree
*/
@Override
public void visitAssert(JCAssert tree) {
// 必须调用超类方法,以确保将转换也应用于节点的子代。
super.visitAssert(tree);
// 改写逻辑在makeIfThrowException这个方法中,结果赋值给 TreeTranslator.result
result = makeIfThrowException(tree);
tally++;
}

/**
* 具体的assert语句转换逻辑:
* assert cond : detail;
* 转换为:
* if (!cond) throw new AssertionError(detail);
*
* 该方法将一个断言语句作为参数,并返回一个if语句。
* 这是一个有效的返回值,因为两个树节点都是语句,因此与Java语法等效。
*
* @param node
* @return
*/
private JCStatement makeIfThrowException(JCAssert node) {
// make: if (!(condition) throw new AssertionError(detail);
// 获取断言的 detail
List<JCExpression> args = node.getDetail() == null
? List.<JCExpression>nil()
: List.of(node.detail);
// 创建了一个AST节点,该节点创建了“AssertionError”的新实例。
JCExpression expr = make.NewClass(
null,
null,
// 使用Name.Table获取编译器内部字符串表示形式
make.Ident(names.fromString("AssertionError")),
args,
null);
// 返回一个if语句
return make.If(
// 倒置 assert的条件
make.Unary(JCTree.Tag.NOT, node.cond),
// 创建一个 throw 表达式
make.Throw(expr),
null);
}
}
}

4.2.3、通过SPI注册你的注解处理器

项目目录如下:

image-20200131134249207

注意,红框部分的目录结构和命名要保持一致。

javax.annotation.processing.Processor文件中填写注解处理器,一行一个,本例子中该文件的内容为:

1
com.itzhai.annotation.process.demo.ForceAssertionsProcessor

4.2.4、打包并且使用你的lib包

这里以maven打包为例,您需要使用如下的maven插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<!-- 设置为true以打印有关编译器相关的日志 -->
<verbose>true</verbose>
<!-- 允许在单独的进程中运行编译器。如果为false,则使用内置编译器;如果为true,则使用可执行文件。
要使compilerVersion标签生效,需要将fork设为true,用于明确表示编译版本配置可用
-->
<fork>true</fork>
<!-- 指定插件将使用的编译器的版本 -->
<compilerVersion>1.8</compilerVersion>
<!-- 源代码使用的JDK版本 -->
<source>1.8</source>
<!--<executable>${JAVA_HOME}/bin/javac</executable>-->
<!-- 需要生成的目标class文件的编译版本 -->
<target>1.8</target>
<!-- 需要生成的目标class文件的编译版本 -->
<encoding>utf-8</encoding>
<!--
重点!
https://stackoverflow.com/questions/38926255/maven-annotation-processing-processor-not-found
默认的,编译器会找到Processor配置,并且执行注解处理器,但此时注解处理器还没编译好,所以会报错,为了避免这种错误,需要做一下参数配置:
-->
<proc>none</proc>
<!-- 这个选项用来传递编译器自身不包含但是却支持的参数选项 -->
<compilerArguments>
<!-- 重点!自定义注解处理器使用到了 com.sun.tools 包中的类,所以这里要确保引用 tools.jar-->
<classpath>${JAVA_HOME}/lib/tools.jar</classpath>
</compilerArguments>
</configuration>
</plugin>

注意以上标明重点!的地方,不能配错了,否则可能导致打包失败。

然后通过Maven打包成jar包,这样就可以在其他项目中引入jar包,在代码编译的时候编译器会自动查找到该注解处理器,对需要处理的类进行处理了。

4.2.5、使用案例

我们在一个新的项目中引入上面打的注解处理器jar包:

1
2
3
4
5
6
7
8
<dependencies>
<dependency>
<groupId>com.itzhai</groupId>
<artifactId>annotation-process</artifactId>
<version>0.0.1-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
</dependencies>

编写如下代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
public class ForceAssertExample {

/**
* java -ea com.itzhai.annotation.process.demo.ForceAssertExample
* @param args
*/
public static void main(String[] args) {
String str = null;
assert str != null : "Must not be null";
}

}

直接编译发现assert并没有被替换掉,可以通过javap -v查看对应的反汇编代码:

image-20200131133244859

原因是少了注解处理器对应的注解@ForceAssertions,我们把它加到类上面,重新编译,发现assert已经被替换掉了:

image-20200131133759062

该例子完整代码:https://github.com/arthinking/pluggable-annotation-processor

4.3、注解处理器其他相关应用

4.3.1、Lombok

使用Lombok,可以消除POJO中冗长的get, set, hashCode, equals, 构造参数等代码,这也是通过注解处理器来实现的。Lombok基于JSR 269,并且hack了javac和jdt以便能够访问和修改类的抽象语法树的内部实现。

如何编写一个类似Lombok的@Builder功能更,可以参考此文:Java奇技淫巧-插件化注解处理API(Pluggable Annotation Processing API)

4.3.2、Dagger

Dagger是一种快速,轻量级的依赖注入框架,该框架可用于Java和Android,该框架在编译时注入以获得更高的行能。Dagger是第一个实现标准javax.inject注解的DI框架(JSR 330)。其底层也是通过注解处理器实现的,其核心处理类是ComponentProcessor,继承了Google Auto提供的抽象注解处理框架的BasicAnnotationProcessor实现的。

依赖注入控制反转原理的具体应用,不同的框架以不同的方式实现依赖注入,这里我们对比以下两类:

  • 运行时依赖注入,通常基于反射,更易于使用,但是会导致运行时更慢,Spring就是运行时的DI框架;
  • 编译时生成具体的代码,这意味着所有繁重的操作都是在编译期间执行的,编译时DI增加了复杂性,但是通常执行的更快,Dagger就是编译时依赖注入

4.3.3、Checker

Checker是一个通过向Java语言中添加可插入类型系统来增加Java类型系统的框架。

在定义类类型限定符以及语义和编译器插件(注解处理器)之后,开发人员可以在其程序中编写类型限定符,并使用该插件检测或者防止错误,例如空指针异常,SQL注入,并发错误等等。

下面是一个使用例子,我们使用@NonNull注解表明ref必须引用到非空的对象:

1
2
3
4
5
6
import org.checkerframework.checker.nullness.qual.*;
public class Example {
void sample() {
@NonNull Object ref = null;
}
}

如果我们执行Checker:

1
javac -processor org.checkerframework.checker.nullness.NullnessChecker Example.java

会发现提示如下错误:

1
2
3
4
5
6
Example.java:4: incompatible types.
found : @Nullable
required: @NonNull Object
@NonNull Object ref = null;
^
1 error

更多Checker的注解:Checker Framework Manual.

References

What is JIT in Java?

Compilation and Execution of a Java Program

Javac编译器详解

Compiler Theory(编译原理)、词法/语法/AST/中间代码优化在Webshell检测上的应用

《The Dragon Book》

The Hacker’s Guide to Javac

十分钟搞懂Lombok使用与原理

Java奇技淫巧-插件化注解处理API(Pluggable Annotation Processing API)

JSR 269: Pluggable Annotation Processing API

Gwt and JSR 269's Pluggable Annotation Processing API

Code Generation using Annotation Processors in the Java language – part 2: Annotation Processors

Introduction to Dagger 2

Java Annotation: Dependency Injection and Beyond

欢迎关注我的其它发布渠道