박종훈 기술블로그
close menu

JDK-8379512 / JDK-8379513 기여기: C2 매크로 노드 플래그 동기화와 guaranteed_safepoint 개선

이번 글에서는 서로 연결된 두 이슈 JDK-8379512JDK-8379513에 기여한 과정을 정리한다. 매크로 노드 플래그 관리를 중앙화하는 작업과, 그 과정에서 guaranteed_safepoint()의 boxing method 처리를 개선한 작업이다.

이슈 내용

JDK-8379512: 매크로 노드 플래그 동기화

add_macro_node()remove_macro_node()_macro_nodes 리스트만 조작하고, 노드의 Flag_is_macro 플래그는 건드리지 않는다. 플래그는 각 노드 생성자가 개별적으로 호출하여 설정해야 했다. 하지만 로직상 그럴 필요가 없다. add_macro_node()remove_macro_node() 호출 시 함께 처리되도록 개선 한다.

JDK-8379513: guaranteed_safepoint()의 boxing method 처리

guaranteed_safepoint()가 boxing CallStaticJavaNode에 대해 true를 반환하는데, 실제로는 해당 노드가 매크로 확장으로 제거될 수 있어서 safepoint가 보장되지 않는다. 이 문제는 JDK-8378005에서 발견되었고, 당시에는 loopnode.cpp에서 boxing method를 별도로 예외 처리하는 임시 workaround로 해결했다. JDK-8379512에서 매크로 노드 관리를 정리한 뒤 이 workaround를 올바르게 대체하는 것이 이 이슈의 목표다.

배경 지식

매크로 노드와 Flag_is_macro

매크로 노드(macro node)란, 최적화 단계에서는 고수준 형태를 유지하다가 나중에 저수준 노드들로 확장(expand)되어야 하는 노드를 말한다. Flag_is_macro는 이 매크로 노드에 달리는 플래그로, “이 노드는 아직 최종 형태가 아니다”라는 표시다.

C2 컴파일러의 흐름을 보면 이해가 쉽다.

파싱 → 최적화 (GVN, 루프 등) → 매크로 확장 → 매칭 → 코드 생성

매크로 노드는 최적화 단계에서 고수준 형태를 유지하다가, 매크로 확장 단계에서 저수준 노드들로 분해된다.

매크로 노드 예시

고수준 형태를 유지하면 최적화에 유리하다. 대표적인 예시를 보자.

AllocateNode — escape analysis로 할당 제거

escape analysis는 객체가 메서드 밖으로 빠져나가는지(escape) 분석한다. new Point(x, y)로 만든 객체가 메서드 안에서만 쓰이고 밖으로 나가지 않으면, 굳이 힙에 할당할 필요 없이 객체를 만들지 않고 내부 필드값만 꺼내서 CPU 레지스터나 스택에 놓는 것(scalar replacement)으로 대체할 수 있다.

하지만 AllocateNode가 이미 TLAB 할당 + GC 배리어 등 수십 개 노드로 확장된 뒤라면, “이것이 하나의 할당이었다”는 정보가 사라져서 이 분석이 불가능해진다.

LockNode — lock coarsening/elimination

LockNode는 Java의 synchronized 블록이 C2 IR에서 표현된 것이다.

같은 객체에 대해 synchronized 블록이 연속으로 나타나면 lock/unlock을 두 번 하던 것을 한 번으로 합칠 수 있다(coarsening). escape analysis 결과 해당 객체가 현재 스레드에서만 쓰인다면 다른 스레드와 경쟁할 일이 없으므로 lock 자체를 제거(elimination)할 수도 있다.

LockNode 하나로 남아 있어야 “이것이 lock이다”라는 의미가 보존되어 이런 판단이 가능하다. 이미 CAS + 조건 분기 + slow path 등으로 확장된 뒤라면 원래 lock이었다는 정보가 사라져서 최적화할 수 없다.

Boxing call — scalar replacement로 객체 생성 제거

Java에서 Integer x = 42처럼 원시 타입을 래퍼 객체로 변환하는 것을 autoboxing이라 하며, 컴파일러는 이를 Integer.valueOf(42) 호출로 변환한다. 이 boxing call이 매크로 노드로 남아 있으면, escape analysis가 결과 객체의 탈출 여부를 분석할 수 있다.

탈출하지 않는다고 판단되면 Integer 객체를 굳이 힙에 만들지 않고, 안에 든 int 값만 꺼내서 레지스터에 유지하는 것으로 대체(scalar replacement)할 수 있다.

_macro_nodes 리스트와 Flag_is_macro의 관계

  • Compile::_macro_nodes – 확장 대상 노드 리스트. 매크로 확장 단계에서 이 리스트를 순회하며 각 노드를 확장한다.
  • Flag_is_macro – 노드 자체에 달린 플래그. is_macro() 같은 빠른 체크에 사용된다.

이번 변경의 핵심은 이 둘을 동기화하는 것이다.

매크로 노드의 종류

이 글을 쓰는 시점에서 코드베이스에 존재하는 매크로 노드 타입은 아래와 같다. 이전 글에서 소개한 클래스 계층과의 관계를 함께 표시했다.

Node
├── CallNode
│   ├── CallJavaNode
│   │   └── CallStaticJavaNode    ← boxing method일 때 매크로
│   ├── CallLeafNode
│   │   └── CallLeafPureNode
│   │       ├── ModFloatingNode   ← ModD, ModF 매크로
│   │       └── PowDNode          ← 매크로
│   ├── AllocateNode              ← 매크로
│   ├── LockNode                  ← 매크로
│   └── UnlockNode                ← 매크로
├── CmpNode
│   └── SubTypeCheckNode          ← 매크로
├── AddNode (MinMaxNode 계열)
│   ├── MaxLNode                  ← 매크로
│   └── MinLNode                  ← 매크로
├── Opaque1Node                   ← 매크로
├── OpaqueConstantBoolNode        ← 매크로
├── OpaqueInitializedAssertionPredicateNode ← 매크로
├── LoopNode
│   └── OuterStripMinedLoopNode   ← 매크로
├── LoopLimitNode                 ← 매크로
├── ArrayCopyNode                 ← 매크로
└── VectorNode 계열
    ├── VectorBoxNode             ← 매크로
    ├── VectorBoxAllocateNode     ← 매크로
    └── VectorUnboxNode           ← 매크로

매크로 노드는 별도의 클래스 계층이 아니라, 다양한 클래스 계층에 걸쳐 분포하며 “나중에 확장이 필요하다”는 공통 속성을 Flag_is_macro로 표현한다.

매크로 노드가 JVM에서 처리되는 흐름

매크로 노드는 compile.cppCompile::Optimize() 안에서 3단계로 처리된다.

[파싱] → [IGVN 최적화] → [Escape Analysis] →

  ① eliminate_macro_nodes
     - Escape Analysis 결과를 바탕으로 제거 가능한 노드 제거
     - Allocate → scalar replacement로 힙 할당 제거
     - Lock/Unlock → lock elimination으로 불필요한 동기화 제거
     - CallStaticJava (boxing) → eliminate_boxing_node로 제거

→ [루프 최적화] →

  ② eliminate_opaque_looplimit_macro_nodes
     - Opaque1Node → 감싸고 있던 값을 노출
     - OpaqueConstantBoolNode → 상수(true/false)로 치환
     - LoopLimitNode → IGVN worklist로 이동
     - MaxLNode/MinLNode → CMoveL 노드로 치환

  ③ expand_macro_nodes
     - 제거되지 못하고 남은 노드들을 저수준 노드들로 확장
     - 처리 순서: ArrayCopy → Lock/Unlock → SubTypeCheck
       → ModD/ModF/PowD → Allocate (마지막)

→ [Barrier Expansion] → [매칭] → [레지스터 할당] → [코드 생성]

변경 전 상태

add_macro_node()은 리스트에 추가만 하고, 플래그를 설정하지 않는다. 반대로 remove_macro_node()도 플래그를 해제하지 않는다.

// compile.hpp — 변경 전
void add_macro_node(Node * n) {
    assert(!_macro_nodes.contains(n), "duplicate entry in expand list");
    _macro_nodes.append(n);
}

void remove_macro_node(Node* n) {
    _macro_nodes.remove_if_existing(n);
    // Flag_is_macro는 해제하지 않음!
    ...
}

플래그 설정은 각 노드 생성자에서 개별적으로 수행한다.

// 예: callnode.hpp - LockNode
LockNode(Compile* C, const TypeFunc *tf) : AbstractLockNode( tf ) {
    init_class_id(Class_Lock);
    init_flags(Flag_is_macro);   // 각자 개별 설정
    C->add_macro_node(this);
}

이런 패턴이 코드베이스 전체에 17곳 존재한다. 새로운 매크로 노드를 추가할 때 init_flags(Flag_is_macro) 호출을 빠뜨리기 쉬운 구조다.

변경 내용

1. add_macro_node / remove_macro_node에서 플래그 동기화

헤더에는 선언만 두고, 구현을 compile.cpp로 이동했다.

// compile.hpp
void add_macro_node(Node* n);
void remove_macro_node(Node* n);

// compile.cpp
void Compile::add_macro_node(Node* n) {
    assert(!_macro_nodes.contains(n), "duplicate entry in expand list");
    n->add_flag(Node::Flag_is_macro);
    _macro_nodes.append(n);
}

void Compile::remove_macro_node(Node* n) {
    _macro_nodes.remove_if_existing(n);
    n->remove_flag(Node::Flag_is_macro);
    if (coarsened_count() > 0) {
        remove_coarsened_lock(n);
    }
}

구현을 .cpp로 이동한 이유는 compile.hpp 시점에서 Node가 forward declaration만 되어 있어 멤버 접근이 불가능하기 때문이다. compile.cpp에서는 node.hpp가 이미 include되어 있으므로 n->add_flag() 같은 멤버 접근이 가능하다.

2. 개별 생성자에서 init_flags(Flag_is_macro) 제거

add_macro_node()에서 중앙 처리하므로 기존에 개별적으로 호출되던 코드들을 모두 제거했다. 이제 새로운 매크로 노드를 추가할 때는 add_macro_node()만 호출하면 플래그가 자동으로 설정된다.

3. is_boxing_method()에서 is_macro() 의존성 제거

기존 is_boxing_method()is_macro()에 의존하고 있었다.

// 변경 전
bool is_boxing_method() const {
    return is_macro() && (method() != nullptr) && method()->is_boxing_method();
}

문제는 remove_macro_node()에서 플래그를 해제하면, 인라인 직후에도 is_boxing_method()false를 반환하여 여러 곳에서 오작동이 생긴다는 것이다.

별도 멤버 _is_boxing_method를 도입하여 해결했다.

class CallStaticJavaNode : public CallJavaNode {
    bool _is_boxing_method;
    ...
    CallStaticJavaNode(Compile* C, ..., ciMethod* method)
        : ..., _is_boxing_method(
            C->eliminate_boxing() && method && method->is_boxing_method()) {
        if (_is_boxing_method) {
            C->add_macro_node(this);
        }
    }
    bool is_boxing_method() const { return _is_boxing_method; }
};

4. guaranteed_safepoint() 오버라이드 (JDK-8379513)

이 부분이 JDK-8379513의 핵심이다. CallNodeguaranteed_safepoint()는 기본적으로 true를 반환한다. 하지만 boxing CallStaticJavaNode은 매크로 확장 과정에서 제거될 수 있으므로 safepoint가 보장되지 않는다.

// CallNode (부모) — 기본값 true
virtual bool guaranteed_safepoint() { return true; }

// CallStaticJavaNode (자식) — boxing 매크로일 때만 false
virtual bool guaranteed_safepoint() {
    return !(is_boxing_method() && is_macro());
}
  • 매크로 상태 (인라인 전): false → 루프 최적화에서 safepoint로 취급하지 않음
  • 매크로 확장 후: is_macro() = falsetrue → PcDesc 정상 생성

5. loopnode.cpp에서 boxing 예외 처리 제거

guaranteed_safepoint() 자체가 boxing을 처리하므로 별도 예외 처리가 불필요해졌다.

// 변경 전 (두 곳)
if (n->is_Call() && n->as_Call()->guaranteed_safepoint()
    && !(n->is_CallStaticJava()
         && n->as_CallStaticJava()->is_boxing_method())) {

// 변경 후
if (n->is_Call() && n->as_Call()->guaranteed_safepoint()) {

마무리

코드 변경의 방향 자체는 단순하다. 플래그 설정을 17곳에서 개별적으로 하던 것을 한 곳으로 모은 것이다. 이번 작업을 통해 매크로 노드가 왜 사용되며, 어떤 의미를 가지는지 이해할 수 있었다.

categories: 개발

tags: JDK , OpenJDK , HotSpot , C2 , 오픈소스 기여