相关链接

基础知识

很多具体的内容都可以在 相关链接 里面找到。这里写一些基本的信息,让没时间深入理解的人也可以大概了解情况。

编译器前端的基本组成

javac的作用是把java源程序翻译成中间代码,以编译原理的角度,就是一个编译器前端。下面简述一个编译器前端的组成。

  • 词法分析(lexical analysis, lexer, scanner): 把源程序(一串字符流)进行分词,产生词汇流(token list)。这些词汇也叫token。
  • 语法分析(parser): 读取token流,分析语法是否正确,产生抽象语法树(abstract syntax tree, AST)。
  • 语义分析(semantic analysis): 分析AST的语义是否正确,生成一些分析的结果(可以简单称这些结果为属性attribute),形成一个新的树(也可以叫AST)。
  • 中间代码生成(code generate): 根据AST树,产生中间代码。

javac的基本步骤

具体语言的编译器前端都有一些特殊的内容,javac的具体步骤如下:

  • 1.Parser(使用scanner和tokenizer): 词法分析和语法分析。把scanner和parser两步一起完成,减少一次完整遍历,是编译器的常规操作。
  • 2.Enter: 生成符号,初始化符号表。同时也生成一些类型信息,验证一些注解(使用了Attr)是否正确。
  • 3.Annotation processing: 注解处理。
  • 4.Attr: 分析名称和表达式。
  • 5.Flow: 分析程序流程。
  • 6.Desugar: 简化语法糖。
  • 7.Code generation: 中间代码生成。

简单的理解和归纳:上文的第1点就是编译器前端的词法分析和语法分析,上文的2-6就是语义分析,7就是中间代码生成。

问题描述

详见链接 JDK-8254557 。下面简单描述一下。有人使用类似下面这段代码的时候,javac编译出错。这个错误不是指javac认为源程序有问题而报错,而是javac本身出现问题,没办法继续运行了。

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
import java.util.Iterator;
import java.util.function.Function;

public class T8254557 {
// test anonymous class in if statement
public <T> void testIf(boolean b) {
test(rs -> {
if (b) {
return new Iterator<>() {
@Override
public boolean hasNext() {
return true;
}

@Override
public T next() {
return null;
}
};
} else {
return new Iterator<>() {
@Override
public boolean hasNext() {
return true;
}

@Override
public T next() {
return null;
}
};
}
});
}

private void test(Function function) { }
}

JDK-8254557 里面也有一个例子,和我这个类似。上面的例子是我最后提交补丁的时候带的单元测试例子。而报告者也指出如果把new Iterator<>写成new Iterator<T>,就可以编译成功,不会报错。

解决过程

寻找原因

  • JDK-8254557 里的报错信息可以大概知道: javac在判断一个类型是不是另一个类型的子类型时,javac出错了。
  • 我根据错误信息里的调用栈,在代码里打断点一步一步的调试。因为对代码不熟悉,我选择从调用栈第一步jdk.compiler/com.sun.tools.javac.Main.main(Main.java:45)开始。
  • 调试完后,我知道了问题出现的具体场景: 验证注解@Override能否作用在方法(method) public boolean hasNext()上。@Override大家都很常用,作用在子类的重写方法上,很明显例子代码的这种写法是对的。
  • 这个验证流程可以抽象为:
    • 先获取@Override可以作用的类型,@Override只能作用在方法(method)上。
    • 获取public boolean hasNext()的类型,这是一个方法,理论上类型应该是方法。
    • 然后比较两个类型是否兼容(是否是子类型),这就和刚刚说的报错信息相对应了。
  • 按照上面的验证流程,为什么会报错呢?原来获取public boolean hasNext()的类型为Unknown,而不是method。这个流程根本不懂得怎么处理一个Unknown类型,只能向上一层抛出错误。
  • 原因总结: javac前面的步骤把本应该是method类型的信息解析成了一个Unknown类型,使得验证注解@Override的可作用类型时出错。

尝试解决

  • 思路: 既然这次验证不懂得怎么处理Unknown类型,那我在遇到Unknown类型的时候,先调用适当的方法重新分析类型,再进行验证。
  • 我发现错误信息的调用栈上有一个方法Annotation.attributeAnnotationValues,它在验证之前先判断类型是否为空,为空的话,就分析其类型。代码如下:
1
2
3
4
5
6
7
8
9
private List<Pair<MethodSymbol, Attribute>> attributeAnnotationValues(JCAnnotation a,
Type expected, Env<AttrContext> env)
{
Type at = (a.annotationType.type != null ?
a.annotationType.type : attr.attribType(a.annotationType, env));
a.type = chk.checkType(a.annotationType.pos(), at, expected);

// 省略下面的代码
}
  • 那能不能再判断一下类型是否为Unknown,是Unknown的话,就分析其类型呢?我基于这个想法,把代码修改如下。这是现在总结的时候临时写的,当时具体的代码已经没有了。使用这段代码可能报错。
1
2
3
Type at = ((a.annotationType.type != null && a.annotationType.type != UnknownType) ?
a.annotationType.type : attr.attribType(a.annotationType, env)); // 加了对`Unknown`类型的判断
a.type = chk.checkType(a.annotationType.pos(), at, expected); // 这句没变
  • 我写了一个单元测试,测试我的解决方案是否正确。单元测试代码 类似 问题描述那节的例子代码和这个 bug JDK-8254557 里的代码。
    • OpenJDK的单元测试使用了一个叫jtreg的内部工具,因为OpenJDK开始开发的时候,junit这些单元测试框架还没出现,OpenJDK内部只能自己写一个单元测试工具。jtreg的详细内容读者可以自己看上面的链接,而我最终补丁里的单元测试内容可以看我的PR
  • 结果是单元测试没通过,我的解决方案中,返回的变量at依然是Unknown
  • 总结: 这个解决方案不管Unknown类型是怎么得来的,只对Unknown类型的内容再重新分析类型,最终还是没有解决问题。

寻找根本原因

  • 思路: 找到最开始分析出Unknown类型的地方,那里的代码极有可能出错了。 这个bug出现的地方属于上面javac基本步骤里的Attr,那分析出Unknown类型的代码应该在前面的Parser和Enter阶段。
  • 我继续调试代码,主要是Parse和Enter阶段的代码,也有Attr阶段的代码。(我发现Enter的有些过程用到了Attr的一些方法,Attr也会使用Enter做一些操作,这个和我之前理解的严格分阶段有些不同,文档也没有强调说明,这种坑只能自己踩了)
  • 最终发现问题所在:
    • 首先,Attr会递归分析所有名称和表达式的类型。但是当分析匿名内部类的时候,有一种情况,Attr会跳过匿名内部类,在分析完所有地方之后,再回来分析刚刚忽略的匿名内部类。这种情况极其特殊,很难出现,具体判断代码为: if (isDiamond && ((tree.constructorType != null && inferenceContext.free(tree.constructorType)) || (tree.clazz.type != null && inferenceContext.free(tree.clazz.type))))。isDiamond表示是否为棱型泛型,也就是<>,注意尖括号里面什么都没有。看到这个是不是觉得有点熟悉,前面问题描述里面提到把<>改成<T>就没有bug了。 这种情况还要求默认构造函数需要含有泛型,或者匿名类类型含有泛型。换句话说,就是你写代码的时候匿名类上写的是<>,而javac分析之后,把你的类型或者构造函数类型变成了<T>。这种条件极其苛刻,以至于你把问题描述例子代码里的 方法test()、lambda表达式、if语句、使用<>匿名类 的其中一层去掉,都无法重现问题。
    • 另一方面,Attr会在分析完 lambda表达式、if语句、while语句、do while语句、for语句 之后,调用一个preFlow方法,preFlow把这些语句里面 未分析出类型的所有内容,都置成了Unknown类型。这样做是合理的,因为分析完一棵子树之后,没分析出一些内容的类型,说明这些内容可能出现了语义错误。为了避免以后的处理出现空指针,只能把这些类型置为Unknown
    • 刚刚说的两个方面都是合理的。因为<>匿名类可能需要其他地方的信息才能推导出具体的泛型信息,所以要推迟分析; 而类型为空,会让之后的flow阶段报空指针错误,所以要置为Unknown类型。但是这两种情况一结合,就出现bug了。在 lambda表达式、if语句、while语句、do while语句、for语句 里面的 满足第一种情况的<>匿名类,会被preFlow把匿名类里面所有内容的类型置为Unknown。从而导致了后来Attr使用类型时,遇到Unknown报错。
    • 分析到这里,我们大概知道解决方案:修改preFlow方法,让它不修改刚刚跳过的<>匿名类内容的类型(即不把刚刚跳过的内容置为Unknown,让它为空)。
  • 在看preFlow方法的时候,根据一些注释内容,我发现一些额外信息:
    • 之前有一个类似的bug JDK-8203277。对应的解决方法在 这里。它解决了lambda表达式里面使用preFlow出现的问题。
    • JDK-8231826 解决过程中,提交了一些 代码。这些代码在 Attr分析 if语句、while语句、do while语句、for语句 的时候使用preFlow, 导致了现在这个问题 JDK-8254557。这可以说是bug JDK-8203277 的回退(regression)。
  • 总结: preFlow导致了不必要的Unknown类型产生,有些需要使用类型的程序无法理解和解析Unknown,从而抛出错误。

最终解决方案

  • JDK-8203277解决方案 重写了PostAttrAnalyzervisitClassDefvisitLamda方法,如下所示。可以看到visitClassDefvisitLamda方法里面没有内容,表示所有匿名类和lambda表达式preFlow都不检查(即对里面真正错误的内容都没管,没设置为Unknown)。这其实不是最优的方案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void preFlow(JCLambda tree) {
attrRecover.doRecovery();
new PostAttrAnalyzer() {
@Override
public void scan(JCTree tree) {
if (tree == null ||
(tree.type != null &&
tree.type == Type.stuckType)) {
//don't touch stuck expressions!
return;
}
super.scan(tree);
}

@Override
public void visitClassDef(JCClassDecl that) {
// or class declaration trees!
}

public void visitLambda(JCLambda that) {
// or lambda expressions!
}
}.scan(tree.body);
}
  • 我的解决方案在它基础上改善了一些内容。我在visitClassDefvisitLamda方法里面做了条件判断,让preFlow不检查这种没解析过的匿名类和lambda表达式。而对于已经解析过的匿名类和lambda表达式,preFlow一样检查和设置Unknown类型。代码如下所示:
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
void preFlow(JCTree tree) {
attrRecover.doRecovery();
new PostAttrAnalyzer() {
@Override
public void scan(JCTree tree) {
if (tree == null ||
(tree.type != null &&
tree.type == Type.stuckType)) {
//don't touch stuck expressions!
return;
}
super.scan(tree);
}

@Override
public void visitClassDef(JCClassDecl that) {
if (that.sym != null) {
// Method preFlow shouldn't visit class definitions
// that have not been entered and attributed.
// See JDK-8254557 and JDK-8203277 for more details.
super.visitClassDef(that);
}
}

@Override
public void visitLambda(JCLambda that) {
if (that.type != null) {
// Method preFlow shouldn't visit lambda expressions
// that have not been entered and attributed.
// See JDK-8254557 and JDK-8203277 for more details.
super.visitLambda(that);
}
}
}.scan(tree);
}
  • JDK-8254557 描述里面只有 if语句 对应的测试案例。我在阅读源码时候,知道 while语句、do while语句、for语句也有对应的问题。所以我加上了对应的测试案例,完善了我之前写的单元测试。 具体代码

提交补丁以及社区交流

  • OpenJDK从JDK16开始从Mercurial迁移到Git,并且使用Github的Pull Request进行代码review。详情请见: JDK 16, JEP 357: Migrate from Mercurial to GitJEP 369: Migrate to GitHub
  • 和常规的Github Pull Request操作一样。新建分支JDK-8254557,提交对应的代码,push到自己的 Github仓库, 然后提 Pull Request.
  • 如果没有签OCA的话,需要签OCA,我已经签过了。签OCA的流程如下: 下载 OCA,写相关信息并签名,拍一个照片发给邮箱oracle-ca_us@oracle.com,Oracle审核完之后,就会回复你。更多详情如下: OCA contributor
  • 签完OCA后,第一次在Github贡献代码,需要在PR的评论里面写/signed,表示你已经签过OCA,然后Oracle的人就会发邮件问你这个Github帐号是不是你的。回复邮件确定就可以了。可以看 这里 了解具体操作。
  • 之后就会有人来review你的代码,代码需要1到多个人review,具体人数 根据代码修改的内容来定,如果是虚拟机的代码就需要至少2个人review。
  • review完成后,需要在评论里面写/integrate进行集成,如果你的帐号有commit权限(OpenJDK的Committer以上才有),代码就会自动被合成。如果没有,就会有一个Committer以上的人在评论里写/sponsor,以他的帐号提交你的代码(这个patch的贡献者还是你)。一般review你代码的人,会顺便sponsor的,如果隔几天没人sponsor,你可以在评论里面问一下。
  • 遇到问题可以多看一下别人的PR,和根据机器人指示看对应的文档。

题外话

  • 注意: JDK-8203277 是一个影响OpenJDK9、10、11的bug,在当时的开发主线(main line, 也就是当前正在开发的最新版本)JDK12得到了解决。2018年9月OpenJDK11发布,这个问题在2018年11月解决,刚好错过了。我现在使用OpenJDK11的最新代码测试,bug还存在。不知道什么原因,这个补丁没有backport(向后移植)到之前的版本。
    而我解决的 JDK-8254557 则是在OpenJDK11、14、15、16(9和10已经不再维护,14也将不再维护)都出现的问题,然而我的补丁也只是提交到开发主线(当前的开发主线OpenJDK16),也就是说JDK11、14、15还是没有解决这个问题的。
  • 友情提示: 公司技术选型,一定要选8和11这种长期支持的版本,而且要及时更新!要不然掉坑里都不知道。
  • 如果大家对OpenJDK感兴趣,欢迎发邮件和我交流。