JDK-8394468 기여기: 상수 조건식과 null이 만난 javac 크래시
보통 P4인 리팩터링에 가까운 작업들을 진행해왔는데 이번에는 P3 이슈도 해결해보고 싶어 적절한 이슈를 찾다가 JDK-8394468 를 발견하였다. P3 이슈이고 만들어진지 한달은 되지 않았지만 그럼에도 담당자가 따로 정해져 있지 않았어서 적절한 이슈로 판단하고 문제 파악을 시작하였다.
이슈의 제목은 다음과 같다.
Compiler Bug “Invalid pool entry”
이슈 내용
아래 코드를 컴파일하면 javac가 AssertionError를 내고 죽는다.
public class JavacBug {
public static void main(String[] args) {
int n = (true ? null : "abc").length();
System.out.println(n);
}
}
참고로 이 코드는 정상적으로 컴파일되면 실행 시 NullPointerException이 나야 한다. 조건식(삼항 연산자, conditional operator) true ? null : "abc"의 타입은 String이고(JLS 15.25), 값이 null이기 때문이다.
재현
로컬에 있는 JDK들로 돌려 봤다.
$ javac JavacBug.java
An exception has occurred in the compiler (21). ...
java.lang.AssertionError: Invalid pool entry
at jdk.compiler/com.sun.tools.javac.code.Type.poolTag(Type.java:129)
at jdk.compiler/com.sun.tools.javac.jvm.PoolWriter$WriteablePoolHelper.writeConstant(PoolWriter.java:367)
at jdk.compiler/com.sun.tools.javac.jvm.PoolWriter$WriteablePoolHelper.writeIfNeeded(PoolWriter.java:358)
at jdk.compiler/com.sun.tools.javac.jvm.PoolWriter.putMember(PoolWriter.java:126)
at jdk.compiler/com.sun.tools.javac.jvm.Code.emitInvokevirtual(Code.java:487)
at jdk.compiler/com.sun.tools.javac.jvm.Items$MemberItem.invoke(Items.java:534)
at jdk.compiler/com.sun.tools.javac.jvm.Gen.visitApply(Gen.java:1936)
...
배경지식: javac 파이프라인과 Lower
javac는 어떤 단계를 거치나
javac는 대략 이런 순서로 소스를 처리한다.
Parse → Enter → Attr(타입 결정) → Flow(흐름 검사)
→ Desugar: TransTypes → TransPatterns → Lower → LambdaToMethod
→ Gen(바이트코드 생성)
- Parse: 소스를 AST(
JCTree, javac 내부의 트리 노드 클래스)로 만든다. - Enter: 클래스, 메서드 같은 심볼을 등록한다.
- Attr: 각 식의 타입을 결정하고 이름이 어떤 심볼을 가리키는지 연결한다. 이 단계가 끝나면 트리 노드마다
type과sym이 붙는다. - Flow: 초기화 안 된 변수 사용, 도달 불가능한 코드 같은 흐름 관련 검사를 한다.
- Desugar: 문법 설탕(syntactic sugar)을 걷어 내는 단계들이다. 제네릭 소거(TransTypes), 패턴 매칭 변환(TransPatterns), 그리고 이번 주인공인
Lower가 여기에 속한다. - Gen: 트리를 바이트코드로 옮긴다.
Lower는 무엇을 하나
Lower.java의 클래스 주석은 이렇게 시작한다.
/** This pass translates away some syntactic sugar: inner classes,
* class literals, assertions, foreach loops, etc.
이 단계는 내부 클래스, 클래스 리터럴, assert 문, foreach 루프 같은 문법적 설탕(syntactic sugar)을 풀어서 없앤다.
Attr과 Flow를 통과한 트리는 의미적으로는 검증이 끝났지만 아직 내부 클래스, enhanced for, string switch처럼 바이트코드에 바로 대응하지 않는 문법이 남아 있다. JVM에는 이런 문법이 없다. 어떻게 풀지는 타입과 캡처 변수 같은 분석 결과를 알아야 정할 수 있어서 분석이 끝난 뒤에 푸는 것이다. Lower는 이걸 의미는 그대로 두고 더 원시적인 트리로 풀어 준다. 이후 단계인 Gen에서는 풀린 트리를 받아 노드마다 대응하는 바이트코드를 내보내기만 하면 된다.
Lower가 바꾸는 것들을 몇 가지 꼽아 보면 다음과 같다.
| 소스 문법 | Lower가 바꾸는 형태 |
|---|---|
| 내부 클래스, 지역/익명 클래스 | 별도 클래스(Outer$Inner), 바깥 인스턴스 참조 this$0, 캡처 변수 val$x 필드 |
| 중첩 클래스 간 private 멤버 접근 | 예전에는 access$000 같은 합성 메서드. 지금은 nestmate 덕분에 대부분 필요 없음 |
enhanced for (for (x : xs)) | Iterator 루프 또는 인덱스 루프 |
| enum, enum switch | values()/valueOf() 생성, $SwitchMap$ 배열 |
| string switch | hashCode() switch + equals() 비교 |
assert | $assertionsDisabled 확인 후 throw new AssertionError |
| try-with-resources | try/finally와 close() 호출 |
| 오토박싱/언박싱 | Integer.valueOf(), intValue() 호출 삽입 |
| 상수 조건 | if (true), true ? a : b 같은 죽은 분기 제거 |
nestmate는 Java 11(JEP 181)에서 들어온 JVM 기능이다. 컴파일된
Outer와Outer$Inner는 JVM 입장에서 서로 다른 클래스라 private 멤버에 접근할 수 없었고, 그래서 컴파일러가access$000같은 우회 메서드를 만들어 넣었다. 이제는 javac가 클래스 파일에NestHost/NestMembers속성을 기록해 같은 nest임을 표시하고, JVM은 이를 보고 private 접근을 바로 허용한다.
이번 버그와 관련된 건 마지막 줄, 상수 조건을 접는 부분이다.
구현은 TreeTranslator를 상속한 방문자 패턴(visitor pattern, 설명은 글 하단 참고)이다. 노드 종류마다 visitXxx 메서드가 있고, 바뀐 트리를 result에 넣어 돌려준다. 어떤 문법이 어떻게 풀리는지 궁금하면 해당 visitXxx를 찾아보면 된다.
원인 분석
Lower에 들어오기 전 트리
Attr이 끝난 뒤의 트리는 컴파일러 API로 직접 확인할 수 있다. JavacTask.analyze()를 호출하면 Attr과 Flow까지만 진행되고 이 상태에서 Trees.getTypeMirror()로 각 노드에 붙은 타입을 꺼내 볼 수 있다.
이를 이용해 트리를 출력하는 작은 도구(DumpAttr.java, 전체 코드는 글 하단 참고에 있다)를 만들어 재현 코드에 돌렸다.
java DumpAttr.java JavacBug.java
java DumpAttr.java: JDK 11부터 지원하는 소스 파일 모드다. 따로javac로 컴파일하지 않아도.java파일을 바로 실행한다.JavacBug.java:DumpAttr에 넘기는 인자로, 분석할 소스 파일이다.JavacTask와Trees는jdk.compiler모듈의 공개 API(com.sun.source.*)라서 별도 옵션 없이 쓸 수 있다.- 이때 분석에 쓰이는 javac는
java를 실행한 JDK에 들어 있는 javac다.
이번 버그는 generate()(Lower와 Gen)에서 터지고 이 도구는 analyze()까지만 진행하므로, 버그가 있는 javac에서도 크래시 없이 결과가 나온다. 실행 결과는 다음과 같다.
METHOD_INVOCATION (true ? null : "abc").length() type=int
MEMBER_SELECT ....length type=()int sym=java.lang.String.length()
PARENTHESIZED (true ? null : "abc") type=java.lang.String
CONDITIONAL_EXPRESSION true ? null : "abc" type=java.lang.String
BOOLEAN_LITERAL true type=boolean
NULL_LITERAL null type=<nulltype>
STRING_LITERAL "abc" type=java.lang.String
이를 정리하면 Attr이 끝난 시점의 트리는 대략 이렇다.
Apply
└ Select(.length)
└ Conditional type = String ← JLS 15.25에 따라 결정된 타입
├ cond: true
├ true: null type = <nulltype> (BOT)
└ false: "abc"
javac는 null 리터럴의 타입을 BOT이라는 특별한 태그로 표현한다. 출력할 때 이름이 <nulltype>이다. 조건식 전체의 타입은 String으로 제대로 붙어 있다.
Lower.visitConditional
Lower가 조건식(삼항 연산자) 노드인 JCConditional을 만나면 visitConditional이 호출된다.
public void visitConditional(JCConditional tree) {
JCTree cond = tree.cond = translate(tree.cond, syms.booleanType);
if (isTrue(cond) && noClassDefIn(tree.falsepart)) {
result = convert(translate(tree.truepart, tree.type), tree.type);
addPrunedInfo(cond);
} else if (isFalse(cond) && noClassDefIn(tree.truepart)) {
result = convert(translate(tree.falsepart, tree.type), tree.type);
addPrunedInfo(cond);
} else {
// Condition is not a compile-time constant.
tree.truepart = translate(tree.truepart, tree.type);
tree.falsepart = translate(tree.falsepart, tree.type);
result = tree;
}
}
//where
private JCExpression convert(JCExpression tree, Type pt) {
if (tree.type == pt || tree.type.hasTag(BOT))
return tree;
JCExpression result = make_at(tree.pos()).TypeCast(make.Type(pt), tree);
result.type = (tree.type.constValue() != null) ? cfolder.coerce(tree.type, pt)
: pt;
return result;
}
조건이 상수 true면 조건식 노드를 통째로 버리고 truepart만 남긴다. 이때 convert()로 원래 조건식 타입(String)으로 캐스트를 씌워 타입을 맞추려고 한다.
convert(tree, pt)의 pt는 javac 코드에서 관례적으로 쓰는 이름으로, proto-type, 즉 이 식이 가져야 하는 기대 타입을 뜻한다. convert()는 tree의 타입이 pt와 다르면 (pt) tree 캐스트를 씌워서 타입을 pt로 맞춘다.
visitConditional은 pt로 조건식 전체의 타입(tree.type)을 넘긴다. 조건식을 한쪽 분기로 바꿔치기해도 원래 조건식의 타입은 그대로 유지되어야 하기 때문이다. 이번 코드라면 tree는 null, pt는 String이니 원래대로라면 (String) null이 만들어져야 한다.
그런데 convert()의 첫 줄을 보면 남은 식의 타입이 BOT이면 캐스트 없이 그대로 돌려준다. 이 분기가 왜 있는지는 뒤의 수정 내용에서 다룬다. 그 결과 트리는 이렇게 바뀐다.
Apply
└ Select(.length)
└ null type = BOT ← String이라는 정보가 사라짐
Gen에서 터지는 이유
Gen은 메서드 호출이나 필드 접근을 만들 때 constant pool에 “어느 클래스의 멤버인지”를 기록한다. 이 클래스를 정하는 기준이 site다.
site란
javac 코드에서 site는 멤버에 어떤 타입을 통해 접근하는가를 뜻한다. expr.member라면 점 앞에 있는 식 expr의 정적 타입이 site다. Resolve의 findMethod(env, site, name, ...)도 “site 타입에서 이 이름의 메서드를 찾는다”는 의미다.
Gen에서 binaryQualifier()를 부르는 곳을 보면 site로 무엇을 넘기는지 알 수 있다.
| 호출 위치 | 넘기는 site | 상황 |
|---|---|---|
Gen.java:2501 | tree.selected.type | expr.m()처럼 한정자가 있을 때, 점 앞 식의 타입 |
Gen.java:2444, 2448 | env.enclClass.type | 그냥 m()처럼 한정자가 없을 때, 현재 클래스 타입 |
멤버가 선언된 클래스와 site는 다를 수 있다. 어떤 이름이 클래스 파일에 들어가는지 직접 확인해 봤다.
class A { void m() {} }
class B extends A {}
class Site {
void test(B b, A a) {
b.m();
a.m();
b.hashCode();
}
}
invokevirtual #7 // Method B.m:()V
invokevirtual #12 // Method A.m:()V
invokevirtual #15 // Method java/lang/Object.hashCode:()I
m()은 A에 선언돼 있지만 b.m()은 B.m으로 기록된다. site가 B이기 때문이다. 이렇게 하는 이유는 바이너리 호환성(JLS 13.1)이다. 나중에 B가 m()을 직접 오버라이드하거나 계층 구조가 바뀌어도 호출하는 쪽을 다시 컴파일하지 않고 JVM이 런타임에 B부터 메서드를 찾게 하려는 것이다. 단, Object에서 상속한 메서드는 예외로 Object를 그대로 쓴다.
binaryQualifier
이 결정을 하는 게 Gen.binaryQualifier()다.
Symbol binaryQualifier(Symbol sym, Type site) {
...
if (sym.owner == site.tsym ||
(sym.flags() & (STATIC | SYNTHETIC)) == (STATIC | SYNTHETIC)) {
return sym;
}
// leave alone methods inherited from Object
// JLS 13.1.
if (sym.owner == syms.objectType.tsym)
return sym;
return sym.clone(site.tsym);
}
선언 클래스가 site와 같거나 Object의 메서드면 그대로 두고, 그 외에는 sym.clone(site.tsym)으로 owner를 site 타입으로 바꾼 복사본을 만든다.
이번 코드에서 정상이라면 site는 조건식의 타입인 String이다. 그런데 Lower가 조건식을 null 리터럴로 접어 버렸기 때문에 tree.selected.type은 null 리터럴의 타입, 즉 BOT이 된다.
site가 String이었다면 sym.owner == site.tsym이라 String.length가 그대로 나갔을 것이다. 하지만 site가 BOT이니 마지막 줄에서 owner가 <nulltype>인 length 심볼이 만들어진다. 이 심볼을 constant pool에 쓰려고 owner 타입의 poolTag()를 부르면, BOT은 pool에 들어갈 수 있는 타입이 아니라서 AssertionError: Invalid pool entry가 난다.
Lower가 상수 조건식을 접으면서 식의 정적 타입(String)을 잃어버렸고, Gen은 그 잃어버린 타입으로 멤버의 소유 클래스를 정하다가 죽는다. Lower는 의미를 바꾸지 않고 트리를 단순화해야 하는데, 여기서는 타입이라는 의미 정보가 빠져 버렸다.
수정 내용
convert()의 BOT 분기는 어디서 왔나
고치기 전에 convert()가 왜 BOT을 건너뛰는지부터 알아야 했다. 이력을 따라가 보니 이 분기는 처음부터 있던 게 아니라 2010년 JDK-6587674(“NoClassdefFound when anonymously extending a class”)에서 추가됐다.
private JCTree convert(JCTree tree, Type pt) {
- if (tree.type == pt) return tree;
+ if (tree.type == pt || tree.type.tag == TypeTags.BOT)
+ return tree;
함께 추가된 테스트(T6587674.java)를 보면 어떤 상황이었는지 알 수 있다.
private static final Vector<String> list =
true ? null : new Vector<String>() { };
이 조건식의 타입은 false 분기에 있는 익명 클래스 타입이다. 그런데 Lower가 상수 조건을 보고 false 분기를 버리면 그 익명 클래스는 클래스 파일로 만들어지지 않는다. 이 상태에서 convert()가 null에 그 익명 클래스 타입으로 캐스트를 씌우면 바이트코드에 checkcast Outer$1이 들어간다. 실행하면 존재하지 않는 클래스를 찾다가 NoClassDefFoundError가 난다.
그래서 “null이면 캐스트하지 말자”는 우회책이 들어간 것이다. 이번 버그는 이 우회책이 타입 정보까지 같이 버리면서 생긴 부작용이다.
noClassDefIn
이 우회책이 막으려던 문제는 2023년 JDK-8314621(“ClassNotFoundException due to lambda reference to elided anonymous inner class”)에서 다른 방식으로 다시 다뤄졌다. 이때 추가된 게 noClassDefIn()이다.
private boolean noClassDefIn(JCTree tree) {
var scanner = new TreeScanner() {
boolean noClassDef = true;
@Override
public void visitClassDef(JCClassDecl tree) {
noClassDef = false;
}
};
scanner.scan(tree);
return scanner.noClassDef;
}
트리를 끝까지 훑으면서 클래스 정의(JCClassDecl)가 하나라도 있으면 false를 돌려주는 단순한 함수다. 익명 클래스나 지역 클래스가 있는지 검사한다.
이 함수는 상수 조건으로 분기를 버리는 두 곳, visitConditional과 visitIf에 조건으로 붙었다.
- if (isTrue(cond)) {
+ if (isTrue(cond) && noClassDefIn(tree.falsepart)) {
result = convert(translate(tree.truepart, tree.type), tree.type);
이 조건 때문에 버려질 분기에 클래스 정의가 있으면 아예 접지 않는다.
8314621의 테스트를 보면 6587674와 같은 계열의 문제라는 걸 알 수 있다.
var o2 = true ? null : new Object() {};
r = () -> {
System.out.println(o2 == o2);
};
var로 받았기 때문에 o2의 타입은 익명 클래스 타입이 된다. 람다가 o2를 캡처하면 람다를 구현하는 합성 메서드의 파라미터 타입에 그 익명 클래스가 들어간다. 그런데 분기가 버려져 클래스가 만들어지지 않았으니 실행할 때 ClassNotFoundException이 난다.
두 버그 모두 원인은 같다. 버려진 분기 안의 클래스 타입이 조건식의 타입을 타고 바깥으로 새어 나온다. 6587674가 피해 간 건 그 타입으로 캐스트하는 경우 하나뿐이다. 근본 원인은 8314621이 클래스를 버리지 않는 쪽으로 막았다.
그래서 BOT 분기는 이제 필요 없다
noClassDefIn은 버려질 분기의 트리에 클래스 선언이 문법적으로 있는지만 본다. 이번 버그의 코드는 noClassDefIn을 통과해서 BOT 타입 null이 그대로 convert()까지 갔다.
두 경우를 나란히 놓으면 차이가 보인다.
| 코드 | 남는 식 (tree) | pt (조건식 타입) | 버려질 분기에 클래스 선언 | convert() 호출 |
|---|---|---|---|---|
true ? null : "abc" (이번 버그) | null (BOT) | String | 없음 | 함 |
true ? null : new Vector<String>() { } (6587674) | null (BOT) | 익명 클래스 Outer$1 | 있음 | 안 함 (접지 않음) |
남는 식은 둘 다 null이다. 차이는 pt다. 6587674에서 위험했던 건 캐스트할 대상 타입 pt가 생성되지 않을 클래스였기 때문이다. null 자체는 문제가 아니었다.
조건식의 타입이 생성되지 않을 클래스가 되려면 그 클래스가 버려질 분기 안에서 선언돼야 한다. noClassDefIn은 정확히 그런 경우를 접지 않도록 걸러 낸다. 그러니 convert()가 호출될 때 pt는 항상 실제로 존재하는 클래스이고, 그 타입으로 null에 캐스트해도 안전하다.
6587674의 우회책은 증상(null일 때의 캐스트)을 피했고, 8314621은 원인(버려진 클래스의 타입이 새는 것)을 막았다. 원인이 막혔으니 BOT 분기는 원래 목적을 잃고 타입을 버리는 부작용만 남은 상태가 됐다.
수정 후: 통과 확인
convert()를 고친 뒤 다시 돌렸다. 이번 테스트뿐 아니라 BOT 분기를 만든 6587674와 그 원인을 근본적으로 막은 8314621 관련 테스트도 같이 돌렸다. 우회책을 지워도 괜찮다는 논리가 맞다면 이 테스트들이 통과해야 한다.
make test TEST="test/langtools/tools/javac/T6587674.java \
test/langtools/tools/javac/lambda/ClassNotFoundExceptionDueToPrunedCodeTest.java"
TEST TOTAL PASS FAIL ERROR SKIP
jtreg:test/langtools/tools/javac/T6587674.java 1 1 0 0 0
jtreg:test/langtools/tools/javac/lambda/ClassNotFoundExceptionDueToPrunedCodeTest.java
1 1 0 0 0
마지막으로 javac 테스트 전체를 돌렸다.
make test TEST=langtools_javac
TEST TOTAL PASS FAIL ERROR SKIP
jtreg:test/langtools:langtools_javac 4050 4043 0 0 7
실패 없이 전부 통과했다.
바이트코드 확인
테스트가 컴파일만 확인하기 때문에 만들어진 클래스 파일도 javap로 직접 열어 봤다.
// 수정 전 (JDK 11. 이후 버전은 여기까지 오지 못하고 크래시)
0: aconst_null
1: invokevirtual #2 // Method "<nulltype>".length:()I
// 수정 후
0: aconst_null
1: checkcast #9 // class java/lang/String
4: invokevirtual #11 // Method java/lang/String.length:()I
null에 String 캐스트가 붙으면서 site가 String으로 돌아왔고, owner도 String.length로 제대로 나간다. 실행하면 기대한 대로 NullPointerException이 난다.
마무리
코드로 보면 convert()에서 BOT을 건너뛰는 조건 하나를 지운 게 전부다. 그런데 그 조건을 지워도 되는지 판단하려면 2010년의 6587674가 왜 그 조건을 넣었는지, 2023년의 8314621이 같은 문제를 어떻게 다시 막았는지까지 따라가야 했다. 원인이 막힌 뒤에도 그 조건은 그대로 남아 결국 타입을 버리는 부작용만 하는 코드가 되어 있었다.
이번에 Lower란 것에 대해서 처음 알게되었다. 아직 모르는 것들이 많다. 앞으로도 이슈를 하나씩 개선하면서 Java에 대해 좀 더 알아가 보고 싶다.
참고
방문자 패턴
트리 구조와 그 위에서 할 작업을 분리하는 디자인 패턴이다. 노드 클래스에는 accept(visitor)만 두고, 실제 작업은 노드 종류별 visitXxx 메서드를 가진 방문자(visitor) 쪽에 모은다.
// JCConditional: 자기 종류에 맞는 visit 메서드를 불러 준다
public void accept(Visitor v) { v.visitConditional(this); }
javac처럼 노드 종류는 거의 고정이고 그 위에서 하는 작업(Attr, Flow, Lower, Gen 등)이 여러 개인 구조에 잘 맞는다. 새 작업을 추가할 때 노드 클래스를 고치지 않고 방문자 클래스 하나만 만들면 된다. Lower는 TreeTranslator를, 앞에서 본 noClassDefIn은 TreeScanner를 상속해서 필요한 visitXxx만 오버라이드한다.
DumpAttr.java
Attr이 끝난 시점의 트리와 타입을 출력하는 데 쓴 코드다. 출력이 길어지지 않도록 변수 n의 초기화 식 안에서 관심 있는 노드 종류만 골라서 출력한다.
import com.sun.source.tree.*;
import com.sun.source.util.*;
import javax.tools.*;
import java.util.List;
public class DumpAttr {
public static void main(String[] args) throws Exception {
JavaCompiler c = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fm = c.getStandardFileManager(null, null, null);
JavacTask task = (JavacTask) c.getTask(null, fm, null, List.of("-proc:none"), null,
fm.getJavaFileObjects(args[0]));
Trees trees = Trees.instance(task);
for (CompilationUnitTree cu : task.parse()) {
task.analyze(); // Attr + Flow
new TreePathScanner<Void, Integer>() {
@Override
public Void scan(Tree t, Integer depth) {
if (t == null) return null;
if (getCurrentPath() != null && isInteresting(t)) {
var tm = trees.getTypeMirror(new TreePath(getCurrentPath(), t));
var el = trees.getElement(new TreePath(getCurrentPath(), t));
System.out.printf("%s%-22s %-28s type=%s%s%n", " ".repeat(depth), t.getKind(),
t.toString().replace("\n", " "), tm,
el != null && t.getKind() == Tree.Kind.MEMBER_SELECT ? " sym=" + el.getEnclosingElement() + "." + el : "");
}
return super.scan(t, depth + (isInteresting(t) ? 1 : 0));
}
boolean isInteresting(Tree t) {
return switch (t.getKind()) {
case METHOD_INVOCATION, MEMBER_SELECT, PARENTHESIZED, CONDITIONAL_EXPRESSION,
NULL_LITERAL, STRING_LITERAL, BOOLEAN_LITERAL -> true;
default -> false;
};
}
@Override
public Void visitVariable(VariableTree v, Integer d) {
if (v.getName().contentEquals("n")) super.visitVariable(v, d);
return null;
}
@Override
public Void visitExpressionStatement(ExpressionStatementTree e, Integer d) { return null; }
}.scan(new TreePath(cu), 0);
}
}
}