[JVM] JDK 25

장성호·2025년 10월 18일

JVM

목록 보기
3/3
post-thumbnail

JDK 25가 9월 16일에 release 됐다. Virtual Thread, @Stable, Object header 등 반가운 변경사항이 많은데 JDK Git Repo를 하나씩 살펴보자. JDK 25에 반영된 JEP들은 여기서 확인할 수 있다.

Virtual Thread

Virtual thread에 정말 많은 변경사항이 생겼다.

Virtual thread pinning issue

Synchronized Block을 활용한 동기화는 JVM이 내부적으로 사용하는 ObjectMonitor 라는 lock 구조체로 구현된다. 이를 기반으로 Thread들이 동시에 같은 객체에 접근하지 않도록 Mutual Exclusion 기능을 제공한다.

OpenJdk Git Repo 기준으로 jdk-21+35 에서는 try_set_owner_from 함수를 사용해 CAS(Compare-And-Swap)를 시도하고 성공하면 바로 락을 획득한다. 경합이 발생하면 TrySpin 이후 thread_blocked 상태로 바꾸고 진입을 대기한다. 실제로는 EnterI 함수에서 Queueing / Waiting 을 하고, 깨어나면 Lock 을 획득한다. 하지만 "Thread가 OS 차원에서 블록되었다"는 모델을 기반으로 하기에, Virtual Thread는 전혀 고려가 안된 로직이다. 따라서 Virtual thread가 synchronized block을 만나면 Thread가 blocking이 되고, 해당 Thread에 스케쥴링 되는 모든 Virtual thread도 pinning 된다.

jdk-25+36 에서는 spin_enter로 먼저 가볍게 락 획득을 시도하고, 경합으로 인해 락 획득을 실패하면 enter_with_contention_mark 함수 으로 넘어간다. 이 때부턴 아래 블록으로 Virtual Thread 여부를 체크한다.

// ContinuationEntry는 Continuation이 실제로 실행 중인 JavaThread와 연결되는 bridge 역할
// JavaThread 구조체 안에 현재 마지막 continuation을 가리키는 포인터(last_continuation)가 존재
ContinuationEntry* ce = current->last_continuation();
bool is_virtual = ce != nullptr && ce->is_virtual_thread();

그리고 아래 코드에서 Virtual Thread를 unmount 해서 OS Thread를 Scheduler에게 반환하려는 시도를 한다. freeze_ok는 반환에 성공했다는 뜻이다.

*freeze = 현재 Java 스택을 Continuation에 저장, OS 스레드에서 언마운트(unmount)

// Continuation는 JVM 내부에서 실행 스택(stack)을 캡슐화하고 저장/복원하는 단위
result = Continuation::try_preempt(current, ce->cont_oop(current));
if (result == freeze_ok) {
    ...
}

Scheduler 반환을 위해 _entry_list (대기 큐)에 넣으려는 사이에 운 좋게 락을 얻으면 아래를 진행한다.

  • preemption_cancelled 로 플래그 세팅 → 더 이상 언마운트하지 않고 그냥 실행 계속
  • 이벤트(post_monitor_contended_entered) 보고는 나중에 하도록 예약
bool acquired = vthread_monitor_enter(current);
if (acquired) {
  current->set_preemption_cancelled(true);
  ...
}

만약 락을 획득하지 못해 entry_list 에 진입했다면 Continuation에 스택 저장, OS 스레드에서 언마운트한다. 이후 락을 획득한 Thread가 ObjectMonitor::exit 를 호출하면, entry_list 에서 다음 대기자 Thread (successor) 를 선택하고 READY 상태로 바꾼다. 그리고 해당 Virtual Thread를 unpark/thaw 시킨다.

이렇게 Virtual Thread 여부를 판단해 적절하게 로직을 분기해 pinning issue 를 해결했다.

Structured concurrency

Go 나 Kotlin 처럼 Scope 레벨로 Thread Lifecycle을 조절할 수 있는 StructuredTaskScope와 ScopedValue가 추가되었다.

Java Structured ConcurrencyKotlin/Go 등 코루틴 세계설명
StructuredTaskScopeCoroutineScope코루틴 실행/관리 단위. 자식 태스크(코루틴)들의 생명주기를 scope 블록과 함께 관리
scope.fork(task)launch { ... } / async { ... }새로운 Virtual Thread/코루틴 실행
scope.join()awaitAll() / coroutineScope {}모든 작업 완료까지 대기
scope.throwIfFailed()supervisorScope / CoroutineExceptionHandler예외 발생 시 취소/전파 정책 결정
ShutdownOnFailurecoroutineScope { ... } (자식 하나 실패 시 전체 취소)Fail-fast 정책
ShutdownOnSuccessselect { ... } (가장 빨리 끝난 것만 수용)“레이스”나 “fastest-wins” 패턴
ShutdownOnCompletionawaitAll()모든 자식 완료 후 결과 수집
ScopedValueCoroutineContext (Immutable, ThreadLocal 대체)요청 ID, 사용자 정보, trace ID 등 컨텍스트 안전하게 전달

Coroutine 같은 재밌는 것들이 많이 추가되었고, 다양한 기능들을 가진 CustomScope 들이 계속 실험 중에 있는 것으로 보인다. 특히 ScopedValue 덕분에 ThreadLocal에 값 넣어놓고 remove 안해서, GC가 ThreadLocalMap[key] = null로 바꾸더라도 이전 value가 살아있어서 누수가 발생하는 현상이 줄어들 것 같다.

@Stable과 StableValue

@Stable은 변하지 않는 값이지만 final로 선언되어 있지 않은 코드에 대해, Lazy 초기화를 허용하면서도 JVM이 해당값을 불변 상수라고 믿고 최적화할 수 있도록 도와주는 Annotation이다. JIT 효율을 올려주지만 두 번 이상 변할 수 있는 값에 @Annotation을 추가하면 Compiler가 변화를 인식하지 못하는 버그를 발생시킨다. 해당 어노테이션이 제대로 동작하면 다음과 같은 이점들을 누릴 수 있다.

Inlining

class Config {
    final String name;

    Config(String name) {
        this.name = name;
    }
    
    public getName() { return name; }
}

// Inlining 이전
public static void main() {
  final var config = Config("hello");
  final var name = config.getName();
}

// Inlining 이후
public static void main() {
  final var config = Config("hello");
  final var name = config.name; // 메서드 호출 제거
}

메서드 호출을 함수 본문으로 대체하는 최적화 기법으로, 메서드 호출 오버헤드를 제거한다. 메서드 호출 오버헤드는 다음과 같다.

  • 현재 실행 중인 레지스터 상태를 저장 (caller-save / callee-save)
  • 스택 프레임 생성: 로컬 변수/리턴 주소/메서드 인자 기록
  • 리턴 주소 저장
  • 호출된 메서드로 점프
  • 끝나면 다시 돌아와서 스택 프레임 정리, 레지스터 복원

이 외에도 Constant Folding, Loop Unrolling, CSE, Escape Analysis (객체를 힙에 할당하지 않고 스택에 할당) 등 다양한 최적화 기법의 기회를 열어준다. 다만 메서드 크기가 너무 크면 Code cache 사용량이 커지기 때문에 주의해야한다.

Constant Folding

// 최적화 전
class Example {
    static final int A = 10;
    static final int B = 20;

    int sum() {
        return A + B;
    }
}

// 최적화 후, 덧셈 연산 제거
class Example {
    static final int A = 10;
    static final int B = 20;

    int sum() {
        return 30;
    }
}

Compiler가 상수끼리의 연산 결과를 컴파일 시간에 미리 계산해서 코드에 반영하는 최적화로, 런타임 계산이 줄어든다.

Common Subexpression Elimination (공통 부분식 제거)

// 최적화 전
int foo(int a, int b) {
    return (a * b) + (a * b);
}

// 최적화 후
int foo(int a, int b) {
    int tmp = a * b;   // 한 번만 계산
    return tmp + tmp;
}

프로그램 안에서 같은 계산을 여러 번 하면, 한 번만 계산하고 그 값을 재사용하는 최적화 기법이다.

JDK 21에도 @Stable은 있었지만 공개 API가 아니여서 JDK나 JVM 내부에서만 사용할 수 있었다. JDK 25에는 @Stable을 공개 API로 wrapping한 StableValue 클래스가 추가되었다. StableValueImpl 클래스가 실제로 구현하고 있고, 메서드들이 @ForceInline나 @DontInline 이 붙어있다. 그리고 초기화할 때는 synchronized block 으로 값에 대한 at-most-once 초기화를 보장해주기에, JIT 컴파일러가 해당 값은 절대 변하지 않는다고 신뢰할 수 있도록 해준다.

재밌는 점은 at-most-once 초기화 보장을 체크하기 위한 Read 연산에서는 synchronized block이 없고 UNSAFE.getReferenceAcquire()로 읽는다. volatile에서의 Read 연산처럼 동작하는 함수인데 ARMv8이나 RISC-V 같이 CPU 연산 순서 보장이 없는 아키텍처에서는 lock 연산이 들어가나, 강하게 연산 순서를 보장해주는 x86 에서는 lock 연산이 들어가지 않는다. volatile Read 연산은 lock 연산이 들어가기 때문에, 마이크로 최적화 중에 하나인 듯 보인다.

Object Header

Project Lilliput는 헤더 크기를 16Bytes -> 8Bytes (JDK 25) -> 4Bytes (TBD) 로 줄이는 것을 목표로 하고 있다. 헤더 크기를 줄일 수 있는 여러 가지 포인트가 있지만, 외부 자료 구조에서 값을 조회해와야 하는 방향이 많기에 메모리 절약과 latency 사이의 trade-off를 잘 계산해야할 것 같다.

대상기존 방식 (JDK 21 이전)줄이는 방법 (Lilliput 제안)기대 효과잠재적 문제점
Class Pointer (Klass Word)모든 객체마다 64비트 Klass 포인터 보관Compressed Klass Pointer (32bit) / Klass Index 방식객체당 4~8바이트 절약클래스 수가 많거나 대규모 힙에서 압축 범위 초과 → 간접 참조 비용 증가
Mark / Lock Word64비트 안에 hashCode, GC age, lock 상태, forwarding 등 과부하비트 필드 재배치, 일부 기능(side-table) 이동, 불필요 기능 축소헤더 크기 절감, 단순화비트 수 부족 시 충돌 가능, 외부 구조 접근 비용 증가
락/동기화 정보모든 객체가 락 관련 비트 포함 (thin lock, inflated lock)대부분 객체는 락 안 씀 → 별도 구조(Compact Lock, side-lock)로 외부화일반 객체 가벼워짐락을 자주 사용하는 객체는 락 획득 비용 증가
Identity HashCodeSystem.identityHashCode() 시 헤더에 hashCode 저장필요 시에만 side-table에 저장 (lazy allocation)hashCode 안 쓰는 객체의 공간 절약해시 충돌 가능성 ↑, side-table 접근 성능 저하
배열 길이 (Array length)배열 객체는 header 뒤에 길이 필드 포함Klass Pointer 압축 + 배열 길이 표현 최적화배열 객체 헤더 축소배열 길이 표현 범위 제한, 일부 경계 케이스 처리 복잡
구성 가능한 비트 사용모든 객체에 동일한 고정 비트 패턴 사용빌드/런타임에서 비트 구성 선택 가능유연성 ↑, 필요 없는 비트 제거JVM 구현 복잡도 증가, 툴링/리플렉션 호환성 문제

Header 크기 감소에 따른 latency 문제

평균적으로는 이득이겠지만 Lock, identityHashCode, GC 등 특정 경우에는 손해가 발생할 것이다. GC를 예로 잠깐 살펴보자면, JVM GC는 살아있는 객체를 새 메모리 영역으로 복사하고 헤더(Mark word)에 새 주소를 입력한다. 이 때 연산은 mov reg, [obj+offset] 처럼 객체 헤더에서 forwarding pointer offset 을 계산해 메모리를 1번만 조회한다.

Lilliput에서는 forwardingTable[oldObj] = newObj 방식으로 기존 메모리 주소와 새로운 메모리 주소를 매핑해놓는다. 외부 테이블에 정보를 저장하기 때문에 객체 헤더는 줄어들지만, 해시 테이블을 탐색해야하기 때문에 아래와 같은 overhead가 생긴다.

  1. oldObj 주소로 테이블을 조회해야 함 (메모리 접근 한 번 더 필요, L1/L2 cache miss 가능성 상승)
  2. 여러 스레드가 동시에 갱신/조회할 수 있으니 CAS/락 같은 동시성 제어 필요
  3. 해시테이블 충돌 → 버킷 탐색 → 메모리 접근 증가 → cache miss 가능성 상승
mov rax, [obj+offset]       ; oldObj에서 side-table 인덱스(또는 키) 읽기
mov rbx, [side+rax*scale]   ; side-table에서 newObj 주소 찾기

객체 헤더에 새로운 메모리 주소가 있을 때는 객체 바로 앞에 헤더가 있기 때문에 locality가 좋아서, 객체를 cache line에 올렸을 때 cache hit 확률이 매우 높았다. side-table 방식은 객체 헤더 → side-table로 접근하는 random access가 늘어나서, L1/L2 cache hit이 낮아지고 DRAM 접근까지 갈 수도 있어서 latency는 높아질 것이다.

일반적이면 이런 생각의 흐름을 거치지만 헤더 크기가 절반으로 줄어든만큼 64bit 시스템에서 캐시 라인에 더 많은 객체를 밀어 넣을 수 있게 되었으니, 오히려 locality가 상승해 cache hit이 증가하고 latency가 감소했을 수도 있다. 요부분은 벤치마크가 필요한 사항이니 JDK 25 사용 전, 체크해보면 좋을 것 같다.

Compress word

Compress Oops를 알고 있다면 내용에 이해가 더 될 것이다. 32bit 시스템에서 적용되는 사항은 아니고, 64bit 시스템에서 적용되는 내용이다. 참고로 JDK 25부턴 32bit 시스템 코드가 전부 제거됌 64bit JVM 에서는 주소값 자체가 64bit 를 차지한다. C 언어에서 sizeof(void*) 로 포인터 크기를 계산해보면 64bit 시스템에서 8 Bytes가 나오는 것과 동일한 내용이다.

아무튼 헤더 크기가 32bit JVM 대비 2배나 커져 메모리 압박이 생겼기 때문에, JDK 6u23부터 Compressed Oops와 JDK 8부터 Compressed Klass Pointers를 도입했다. 그리고 Klass 메타데이터를 Compressed Class Space (4GB 범위) 안에 강제로 배치한다. 사실 기능 자체는 JDK 8부터 있었지만 압축으로 8 Bytes Klass word pointer가 4 Bytes가 된 들, GC와 같은 JVM의 다른 기능 호환성과 안정성 때문에 4 Bytes padding으로 나머지를 채우고 있었다. 그러다가 이번 기회에 padding을 없애고, Mark word 까지 4 Bytes로 줄인 것이다.

요부분에 대한 trade-off는 "바로 메모리 읽기 vs offset을 읽고 Heap 또는 Compressed Class Space 읽기" 이다. 아래처럼 2개의 연산이 추가되는데 shift와 add는 아주 싼 명령어라 1~2 cycle 안에 끝나기에 영향이 거의 없다. 오히려 크기가 줄어들어 cache hit 확률이 올라가기에 정말 좋은 trade-off이다. 대신에 메모리 크기가 최대 32GB, Compressed Class Space는 최대 4GB로 제한이 있을 뿐이다.

// Oops 미적용
mov rax, [obj+field_offset]   ; 헤더에서 oop 직접 로드
mov rcx, [rax+payload_off]    ; 객체 필드 접근

// Oops 적용
mov eax, [obj+field_offset]   ; 32비트 oop 로드
shl rax, 3                    ; ALU 연산 1
add rax, oop_base             ; ALU 연산 2
mov rcx, [rax+payload_off]    ; 객체 필드 접근

Generational ZGC 기본 활성화

JDK 23부터 -XX:+UseZGC 옵션을 활성화하면 Generational ZGC를 사용한다. 기본 GC 자체는 아직 G1 GC이고 JDK 21에서도 ZGC는 도입은 되었지만 Non-generational 모드였다. JDK 21에서 Generational 모드를 활성화하려면 -XX:+UseZGC -XX:+ZGenerational 옵션을 활성화 해야한다.

Vector API

개발자가 Vector 연산을 다룰 수 있도록 Vector<E> 같은 클래스가 계속 incubating 되고 있다. Vector 연산을 사용하면 아래와 같은 여러 번의 스칼라 연산을, 더 적은 횟수의 벡터 연산으로 해결할 수 있다.

// 스칼라 연산
void scalarComputation(float[] a, float[] b, float[] c) {
   for (int i = 0; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
   }
}

// 벡터 연산
// CPU가 한 번에 처리할 수 있는 벡터 길이 정보
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

void vectorComputation(float[] a, float[] b, float[] c) {
    int i = 0;
    int upperBound = SPECIES.loopBound(a.length);
    for (; i < upperBound; i += SPECIES.length()) {
        // FloatVector va, vb, vc;
        // a[i] ~ a[i+N-1] 까지를 한 번에 벡터 레지스터로 로드
        var va = FloatVector.fromArray(SPECIES, a, i);
        
        // b[i] ~ b[i+N-1] 까지를 한 번에 벡터 레지스터로 로드
        var vb = FloatVector.fromArray(SPECIES, b, i);

		// 수십개의 연산을 한꺼번에 수행
        var vc = va.mul(va) // [a0*a0, a1*a1, ...]
                   .add(vb.mul(vb)) // [b0*b0, b1*b1, ...] -> [a0² + b0², a1² + b1², ...]
                   .neg(); // 음수로 -> -(a0² + b0²), ...
                   
        // 벡터 레지스터 값을 한 번에 8개/16개씩 메모리로 저장
        vc.intoArray(c, i);
    }

    // a.length가 SPECIES.length()로 나누어 떨어지지 않는 경우, 남은 tail은 스칼라로 연산 처리
    for (; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
    }
}

CPU는 개념적으로 아래처럼 동작한다.

// 스칼라 연산
a: [ 1 ] [ 2 ] [ 3 ] [ 4 ]
b: [ 5 ] [ 6 ] [ 7 ] [ 8 ]
         ↓   ↓   ↓   ↓
c: [ 6 ] [ 8 ] [ 10 ] [ 12 ]

LOAD a[0]
LOAD b[0]
ADD -> c[0]

LOAD a[1]
LOAD b[1]
ADD -> c[1]
... (반복)

// 벡터 연산
a: [ 1  2  3  4 ]
b: [ 5  6  7  8 ]
     ↓ SIMD ADD
c: [ 6  8 10 12 ]

LOAD 4 elements of a into VECTOR_REGISTER V1
LOAD 4 elements of b into VECTOR_REGISTER V2
VADD V1, V2 -> V3   (한 번에 4개 더함)
STORE V3 -> c[0..3]

JVM이 자동으로 벡터화를 진행하고 산술 표현을 SIMD 명령어로 바꾸려고 노력은 하지만, 스칼라 연산을 벡터 연산으로 바꿀 수 있는 것이 제한적이었다. 그래서 개발자가 직접 벡터 연산 의도를 명시함으로써 고성능 벡터화를 유도하려는 API가 추가됐다. Value-based class로 설계돼서 identityHashCode(), synchronized 같은 금지/비권장이고, 힙 할당 없이 레지스터 값으로 다루도록 특수 처리된 상황이다. 이후 Project Valhalla가 완료되면 Value class로 넘어갈 예정이다. 그렇다보니 아직은 객체로 처리하는 중이라 벡터 연산에 불리함이 있다. 벡터 연산은 배열로 처리하는 경우가 잦을텐데, Value-based class는 배열 요소 접근시 포인터로 접근해야하기 때문에 Cache miss가 날 확률이 높다. Value class는 포인터 없이 연속된 메모리로 할당되기 때문에 Cache hit 확률이 높을거고, Vector 연산 및 복사에 굉장히 유리하다. 그래서 JEP 508 에서도 Project Valhalla 언급이 많이 된다.

// Value-based class
class Point { int x; int y; }
Point[] a = new Point[N];

┌─────────────── 배열 헤더 ────────────────┐
│ length=N ...                           │
└─┬──────┬──────┬──────┬──────┬──────┬───┘
  │      │      │      │      │      └─ a[N-1]에 대한 참조(포인터)
  │      │      │      │      └─ a[3]  ─┐
  │      │      │      └─ a[2]  ─┐      │
  │      │      └─ a[1]  ─┐      │      │
  │      └─ a[0]  ─┐      │      │      │
  ▼                 ▼      ▼      ▼      ▼
 [obj]Point0:  x | y     [obj]Point1    [obj]Point2    ... (흩어져서 임의 위치)
   ↑ 객체 헤더 + 필드        ↑ 헤더+필드      ↑ 헤더+필드


// Value class
primitive class Point { int x; int y; }
Point[] a = new Point[N];  (값 클래스/인라인 클래스 가정)

┌─────────────── 배열 헤더 ─────────────────┐
│ length=N ... (정렬/요소크기 메타)           │
└┬───────────────────────────────────────┬┘
 │   a[0]의 x  a[0]의 y  a[1]의 x  a[1]의 y  a[2]의 x  a[2]의 y ...
 ▼
[ x | y ][ x | y ][ x | y ][ x | y ][ x | y ][ x | y ]  (연속 저장)

Vector 클래스는 여기서 확인할 수 있다.

기대되는 것들

아무래도 Project Valhalla가 언제 Production으로 release 될지 기대된다. Java object = heap 이라는 공식을 깨고 스택 / 레지스터 영역으로 넓히는 프로젝트니까, 메모리 모델이 아예 달라져 새로운 기회가 많이 생길 것으로 보이기 때문이다. C/C++의 힘을 빌리지 않고도 reference 참조 없이 연속적인 메모리 값을 쓸 수 있으니, 억지로 JNI 쓰기보다는 저레벨 수준의 고급 연산을 편하게 쓸 수 있지 않을까 싶다. 요게 되면 Project Panama나 AI 관련 프로젝트도 훨씬 잘 될 것 같다.

그 외에도 자료를 찾다보니 GC에 종속되는 AOT 컴파일의 문제를 해결하기 위한 Load barriers 개념에 대한 논문도 발견했었다. 지금은 A 라는 GC로 AOT 컴파일 한 뒤, B 라는 GC로 애플리케이션을 시작하면 실행이 되지 않는다. GC 별로 최적화하는 방법이 굉장히 다르기에, 서로의 코드를 인식하기 어려운 사례가 많기 때문이다. 논문은 "Write Once, Run Anywhere"를 지키기 위해서 AOT 컴파일 결과물에 placeholders를 넣고, GC 구현체가 자신에게 적합한 코드로 변경하는 방식으로 해결하려고 했다. 성능이 문제일뿐 진짜 되길래 이게 되네..? 라는 생각을 했다.

Java가 오래된 언어로 취급받지만 Virtual thread처럼 정말 좋은 발전들이 꾸준히 일어나고 있는만큼, 다양한 프로젝트도 성공해서 GPU / 벡터 처리 등에도 강점을 보이는 날이 왔으면 좋겠다. Native 영역 코딩하기 힘들어요.. 알아서 힙 말고 다른데도 딸깍 올려주세요. Kotlin도 해줘

profile
일벌리기 좋아하는 사람

1개의 댓글

comment-user-thumbnail
2025년 10월 24일

java를 좋아하는 저로써는 정말 희소식이네요

답글 달기