한 줄 정의

핵심 메시지

자바 21에 아직 들어오지 않은 기능들 — 가상 스레드가 열어젖힌 새로운 동시성 패턴(구조적 동시성·스코프 값), 그리고 JVM의 미래를 좌우할 세 OpenJDK 프로젝트(파나마·라이덴·발할라) — 을 조망하는 장입니다.

본문 전체가 LTS의 “최종(final)” 기능만 다룬 것과 달리, 이 장은 인큐베이터·프리뷰 상태의 비최종(non-final) 기능을 모아 따로 논의합니다. 공통 주제는 하나입니다 — “동적인 JVM의 유연함을 잃지 않으면서, 정적인 세계의 성능을 어디까지 끌어올 수 있는가.”

쉽게 말하면

자바의 성능 이야기는 늘 같은 긴장 위에 서 있습니다. 한쪽에는 런타임에 코드를 관찰하고 추측적으로 최적화하는 핫스팟의 동적 세계가 있고, 반대쪽에는 컴파일 시점에 모든 걸 확정해버리는 C++·정적 네이티브의 세계가 있습니다.

이 장의 다섯 주제는 전부 이 경계선 위에서 벌어지는 일입니다.

  • 구조적 동시성 / 스코프 값 — 가상 스레드라는 “거의 공짜인 스레드”가 생기자, 동시성 코드를 쓰는 방식 자체가 바뀝니다.
  • 파나마 — JVM 바깥의 네이티브 메모리·함수에 안전하게 손을 뻗습니다.
  • 라이덴 — 런타임에 하던 워밍업(JIT 컴파일·클래스 로딩)을 미리 끝내두어 시작을 빠르게 합니다.
  • 발할라int처럼 메모리에 빽빽이 박히는 사용자 정의 타입(값 클래스)을 만듭니다.

백엔드 관점에서 이 다섯은 추상적인 미래 기술이 아닙니다. 컨테이너 콜드 스타트, 외부 라이브러리 바인딩, GC 압박 같은 지금 매일 부딪히는 문제의 차세대 해법입니다.

왜 중요한가?

”비최종 기능”을 미리 보는 것이 곧 로드맵을 읽는 것입니다

자바의 릴리스 주기와 프리뷰 API는 실제 환경의 피드백을 조기에 확보하는 장치입니다. 인큐베이터·프리뷰 상태라고 “아직 멀었다”고 넘기면, 정식 도입 시점에 이미 경쟁자는 적응을 끝낸 뒤입니다. 구조적 동시성과 스코프 값은 가상 스레드(자바 21 정식)와 한 묶음으로 등장했으므로, 가상 스레드를 쓰기 시작하는 순간 자연스럽게 마주치게 됩니다.

클라우드 네이티브 환경이 이 기능들을 “필수”로 만들고 있습니다

서버리스·컨테이너 오토스케일링 환경에서 자바의 고질병은 느린 시작 시간입니다. 라이덴(시작 시간 단축)과 발할라(메모리 밀도·캐시 효율)는 이 약점을 정면으로 겨냥합니다. 파나마는 GPU 오프로딩이나 네이티브 라이브러리 연동 같은, JNI로는 고통스러웠던 작업을 순수 자바로 끌어옵니다.

핵심 내용

15.1 새로운 동시성 패턴

가상 스레드가 정식 기능이 되면서, 그 위에서 “등장한(emergent)” 두 가지 API가 자바 21에 프리뷰로 들어왔습니다. 구조적 동시성(structured concurrency, JEP 453)과 스코프 값(scoped values, JEP 446)입니다. JDK 21 기준 둘 다 프리뷰이므로 프로덕션에서는 쓸 수 없습니다.

15.1.1 구조적 동시성

구조적 동시성은 스레드 관리용 API입니다. 핵심 발상은 협력적 작업(cooperating tasks, 보통 가상 스레드)을 하나의 하위 작업 집합으로 묶어 단일 단위처럼 다루는 것입니다.

데이터 병렬 vs 작업 병렬

암달의 법칙을 논할 때의 동시성은 데이터 병렬(data-parallel) 문제 — 같은 연산을 큰 데이터 조각에 나눠 적용 — 였습니다. 반면 구조적 동시성이 겨냥하는 건 작업 병렬(task-parallel) 문제입니다. 서로 다른 일을 동시에 시키고 결과를 모으는 패턴입니다.

이 차이가 실무에서 결정적입니다. 구조적 동시성은 가상 스레드와 궁합이 좋아 원격 서비스 호출 같은 I/O 작업에 유용합니다. 반대로 메모리 내 데이터만 다루는 CPU 바운드 작업에는 상대적으로 덜 유용한데, 가상 스레드들이 서로 CPU 시간을 두고 경합하기 때문입니다.

백엔드 매핑

“외부 API 3개를 동시에 호출해서 합친다”가 전형적인 작업 병렬입니다. 기존에는 CompletableFuture를 엮거나 ExecutorService + Future를 다뤘는데, 예외 전파·취소·자원 누수가 늘 골칫거리였습니다. 구조적 동시성은 이 패턴을 try-with-resources 블록 하나로 정리합니다.

일반적인 흐름

구조적 동시성 작업은 다음 6단계를 따릅니다.

  1. 스코프 생성 — 생성한 스레드가 해당 스코프를 소유하며, 이 스코프로 하위 작업 그룹을 구성·조정합니다.
  2. 하위 작업 포크 — 스코프 내에서 동시 하위 작업을 포크합니다. 각 하위 작업은 가상 스레드로 실행됩니다.
  3. 조인 — 스코프 소유자가 스코프(모든 하위 작업)를 하나의 단위로 조인합니다.
  4. 차단join()은 모든 하위 작업이 완료될 때까지 차단됩니다.
  5. 처리 — 조인 후 포크된 하위 작업에서 발생한 오류를 처리하고 결과를 처리합니다.
  6. 스코프 닫기 — try-with-resources로 암시적으로 처리됩니다.
fork()가 Future 대신 Subtask를 반환합니다

자바 20 → 21의 가장 중요한 변화는 fork()Future 대신 Subtask(이는 Supplier를 구현)를 반환한다는 점입니다.

왜 굳이 Future를 버렸을까요? 구조적 동시성은 여러 하위 작업을 하나의 단위로 취급하므로, 결과는 오직 join() 이후에만 조회됩니다. 그 결과 get()으로 차단하는 방식이나 하위 작업의 개별 예외 처리를 쓰지 않게 되는데, 바로 그 두 가지가 Future의 존재 이유였습니다. 그래서 Future는 어색한 선택이 되고, 대신 체크 예외가 없는(checked-exception-free) Subtask 인터페이스가 설계되었습니다.

예제 — 주식 추천 계산

주식의 태도 강도(sentiment)와 향후 24시간 가격 변동 가능성(delta24)을 외부 프로세스가 계산한다고 가정합니다. 둘 다 계산에 시간이 걸리고 네트워크 트래픽이 발생할 가능성이 높습니다 — 전형적인 I/O 작업입니다.

record StockTip(String symbol, double sentiment, double delta24) {}
String symbol = "IBM";
 
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Callable<Double> getSentiment = () -> getSentiment(symbol);
    Subtask<Double> fSentiment = scope.fork(getSentiment);
 
    Callable<Double> getDelta = () -> getDelta24(symbol);
    Subtask<Double> fDelta = scope.fork(getDelta);
 
    scope.join();
    scope.throwIfFailed();
 
    return new StockTip(symbol, fSentiment.get(), fDelta.get());
} catch (ExecutionException | InterruptedException e) {
    throw new RuntimeException(e);
}

스코프 종료는 try-with-resources를 통해 암시적으로 처리됩니다. 이 블록은 완료되지 않은 하위 작업이 모두 끝날 때까지 대기합니다. 추가로 두 가지를 알아둘 만합니다.

  • shutdown()을 호출하면 하위 작업의 조인을 취소할 수 있습니다.
  • join()에는 시간 제한이 있는 변형 joinUntil()이 있으며, 마감 시한을 Instant로 받습니다.
두 가지 기본 종료 정책

StructuredTaskScope에는 두 가지 기본 종료 정책이 있으며, 사용자 정의 정책도 지원됩니다.

정책종료 트리거대표 시나리오
ShutdownOnFailure하위 작업 중 하나라도 실패하면 전체 취소모든 결과가 다 필요한 합성 작업
ShutdownOnSuccess하위 작업 중 하나라도 성공하면 전체 취소가장 빠른 응답만 쓰는 경쟁(race)

ShutdownOnSuccess는 여러 하위 작업을 경쟁시키고 가장 먼저 성공한 결과가 나오면 나머지를 즉시 중단하는 패턴입니다.

<T> T race(List<Callable<T>> tasks, Instant deadline)
        throws InterruptedException, ExecutionException, TimeoutException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) {
            scope.fork(task);
        }
        return scope.joinUntil(deadline)
                .result();  // 어느 하위 작업도 성공하지 못하면 예외 발생
    }
}

정반대로 모든 작업이 완료될 때까지 실행하되, 하나라도 실패하면 전체를 취소하려면 ShutdownOnFailure를 씁니다.

<T> List<T> runAll(List<Callable<T>> tasks)
        throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        List<? extends Subtask<T>> handles =
            tasks.stream().map(scope::fork).toList();
 
        scope.join()
                .throwIfFailed();  // 하나라도 실패하면 예외 전파
 
        // 모두 성공했으므로 결과를 합성
        return handles.stream().map(Subtask::get).toList();
    }
}
스코프 트리와 부작용 기반 작업

포크로 생성한 하위 작업이 자체적으로 다시 스코프를 생성할 수 있습니다. 이렇게 하면 자연스럽게 스코프와 하위 작업의 트리 구조가 형성되며, 하위 작업 트리에서 최종 값을 도출할 때 유용합니다.

코드의 주요 목적이 결과 반환이 아니라 부작용(side effect)이라면 StructuredTaskScope<Void>를 씁니다. 들어오는 소켓마다 작업을 포크하는 서버가 그 예입니다.

void serveScope(ServerSocket serverSocket) throws IOException, InterruptedException {
    try (var scope = new StructuredTaskScope<Void>()) {
        try {
            while (true) {
                final var socket = serverSocket.accept();
                Callable<Void> task = () -> {
                    handle(socket);
                    return null;
                };
                scope.fork(task);
            }
        } finally {
            // 오류·인터럽트 시 요청 수신 중단
            scope.shutdown();  // 모든 활성 연결 종료
            scope.join();
        }
    }
}

다만 이런 경우는 종종 newVirtualThreadPerTaskExecutor() 같은 실행 후 삭제(fire-and-forget) 패턴이 더 적절할 수도 있습니다. 여기서는 Callable<Void>가 명시적으로 null을 반환해야 하는 등 제네릭 관련 사소한 불편이 생깁니다.

결국 핵심은 설계 사고입니다

모든 패턴에서 반복되는 핵심은, 이런 기술을 활용하려면 설계 사고(design thinking)와 도메인·컨텍스트 이해가 필수라는 점입니다. 어떤 스레드를 가상 스레드로 변환하기에 적합한지 100% 정확하게 판단해주는 도구는 존재하지 않습니다. 작업을 하위 작업으로 재구성하고 적절한 스코프를 정의하는 일은 프로그래머가 하위 작업 간 데이터 의존성을 충분히 이해해야만 가능합니다.

15.1.2 스코프 값

스코프 값(scoped values)은 자바 21에 프리뷰로 들어온 또 다른 API입니다. java.lang 패키지의 새 클래스 ScopedValue<T>를 기반으로, 특정 스코프 내에서 변수에 값을 바인딩하는 개념입니다. 이 값은 한 번만 기록되며, 스코프마다 불변으로 유지됩니다.

ThreadLocal의 현대적 대안

스코프 값은 스레드 로컬 변수의 현대적 대안으로 볼 수 있습니다. 결정적 차이는 set() 메서드가 존재하지 않는다는 점입니다 — 멀리 떨어진 코드에서 스코프 값을 변경할 수 없습니다. 이 불변성 덕분에 런타임에 값이 바뀌지 않음을 확신할 수 있어 미래의 런타임 최적화가 가능해집니다.

.get()으로 값을 읽고, 메서드 실행이 완료되면 바인딩이 해제됩니다. 스코프 값의 진정한 강점은 호출 체인·스코프·하위 스코프가 아무리 복잡해도 값에 여전히 접근 가능하다는 점입니다. 명시적으로 호출 체인 아래로 전달할 필요가 없습니다 — 이는 암묵적(implicitly) 접근이지만, 스칼라의 암묵적 매개변수보다 훨씬 더 제어된 자바 스타일입니다.

API의 목표

  • 스레드 내부와 스레드 사이에서 데이터를 공유합니다.
  • 제어된 범위 내에서 값의 수명 주기를 관리합니다.
  • 코드 구조에서 값의 수명 주기를 명확히 파악할 수 있습니다.
  • 불변성을 통해 다수의 스레드에서 안전하게 공유합니다.

스코프 값은 실행 후 삭제(fire-and-forget) 가상 스레드 패턴과 잘 결합되므로, 거의 모든 사용 사례에서 ThreadLocal이 점진적으로 대체될 가능성이 큽니다.

렉시컬 스코프 vs 동적 스코프

스코프 값의 전체 목적은 자바에 이전에는 없던 동적 스코프(dynamic scope)를 제공하는 것입니다. 쉘·리습·펄 같은 언어에서 찾아볼 수 있는 개념입니다.

graph TB
    subgraph LEX["렉시컬 스코프 (lexical scope)"]
        L1["변수 범위가 코드 구조로 결정"]
        L2["중괄호 쌍으로 정의"]
        L3["예: private final static 필드<br/>(클래스 로드 시 초기화)"]
    end
    subgraph DYN["동적 스코프 (dynamic scope)"]
        D1["변수 범위가 호출 체인으로 결정"]
        D2["반드시 메서드 내에서 생성"]
        D3["예: ScopedValue.where(...).run(...)"]
    end

동적 스코프 예제의 핵심 패턴은 다음과 같습니다.

  • 스코프 값을 저장할 static final 필드를 사용합니다.
  • 클래스 스코프에서 ScopedValue 인스턴스를 선언합니다.
  • 메서드 내에서 동적 스코프를 생성합니다(runWhere(), where(...).run(...) 등).
  • 람다로 스코프 본문을 정의합니다(여기서 호출 체인이 실행됨).
예제 — 웹 서버 다시 쓰기

매개변수를 명시적으로 넘기는 대신 스코프 값으로 소켓을 전달합니다.

public class ServerSV {
    private final static ScopedValue<Socket> SOCKETSV =
            ScopedValue.newInstance();
 
    void serve(ServerSocket serverSocket) throws IOException, InterruptedException {
        while (true) {
            var socket = serverSocket.accept();
            ScopedValue.where(SOCKETSV, socket)
                    .run(() -> handle());
        }
    }
 
    private void handle() {
        var socket = SOCKETSV.get();
        // 발생하는 트래픽 처리
    }
}

handle()이 더 이상 매개변수를 받지 않는다는 점이 핵심입니다. 소켓은 스코프 값을 통해 접근됩니다 — 앞서 말한 암묵적 가용성입니다. ScopedValue.where는 스코프 값과 바인딩될 객체를 제공하고, run이 실행되면 값이 바인딩되며, 이는 현재 스레드에 대한 개별 복사본입니다.

예제 — 재바인딩과 권한 폴백

스코프 값은 트랜잭션 컨텍스트(transaction context)와 다양한 환경 컨텍스트(ambient context) 데이터를 전달하는 데 유용합니다. 구조적 동시성과도 잘 어울려, 특정 스코프 생성 후 하위 스코프에서 다시 바인딩될 수 있습니다. 다시 바인딩되지 않은 값은 하위 스코프로 상속되며, 이를 활용하면 권한 상승(privilege escalation) 같은 패턴을 구현할 수 있습니다.

enum SecurityLevel { USER, ADMIN }

현재 보안 수준을 저장할 스코프 값과 현재 요청 번호를 저장할 스코프 값을 함께 씁니다.

private static final ScopedValue<SecurityLevel> securitySV =
        ScopedValue.newInstance();
private static final ScopedValue<Integer> requestSV =
        ScopedValue.newInstance();
 
private final AtomicInteger req = new AtomicInteger();
 
public void run() {
    ScopedValue.where(securitySV, level())          // 현재 보안 수준 바인딩
            .where(requestSV, req.getAndIncrement()) // 현재 요청 번호 바인딩
            .run(() -> process());
}

ADMIN 권한을 사용할 수 없는 상황을 가정하면, ADMIN 권한 시도는 모두 사용자 권한으로 폴백됩니다 — 하위 스코프에서 securitySVUSER로 다시 바인딩하는 방식입니다.

private void process() {
    if (!securitySV.isBound()) {
        throw new RuntimeException(
            "ScopedValue not bound - this should not happen");
    }
 
    var level = securitySV.get();
    if (level == SecurityLevel.USER) {
        System.out.println("User privileges granted for " +
            requestSV.get() +" on: "+ Thread.currentThread());
    } else {
        // ADMIN은 현재 버전에서 사용 불가
        System.out.println("Admin privileges requested for " +
            requestSV.get() +" on: "+ Thread.currentThread());
        System.out.println(
            "System is in lockdown. Falling back to user privileges");
        // USER 수준으로 다시 바인딩한 후 새 보안 수준으로 재실행
        ScopedValue.where(securitySV, SecurityLevel.USER)
                .run(() -> process());
    }
}

저수준 빌딩 블록

자바 21에는 가상 스레드와 기타 구성 요소를 위한 연속성(continuation)과 저수준 빌딩 블록 클래스들이 존재합니다. 다만 jdk.internal.vm 패키지에 포함되어 있어, 현재 릴리스에서는 자바 프로그래머가 직접 사용하는 것을 목적으로 하지 않습니다.

15.2 파나마

파나마 프로젝트(Project Panama)는 주요 OpenJDK 프로젝트 중 하나로, 이름은 대서양과 태평양을 연결하는 파나마 운하에서 유래했습니다. 목표는 JVM과 네이티브 코드를 연결하는 것입니다 — C 프로그래머가 흔히 사용하는 인터페이스를 포함해, 명확히 정의된 외부(자바가 아닌) API와의 연결을 개선·확장합니다.

파나마는 두 가지 주요 영역으로 구성됩니다.

  • 외부 함수와 메모리(Foreign Function and Memory, FFM) API
  • 벡터 API

FFM API — JNI의 후계자

FFM API는 자바 19에서 프리뷰로 처음 제안되어 자바 20·21에서 개선되었고, 자바 22에서 정식 기능으로 확정되었습니다. 다만 자바 22는 LTS가 아니므로, 현재 이 API를 최종 기능으로 포함하는 LTS 버전이 없습니다 — 그래서 이 미래 장에서 다룹니다.

  • 자바 21: jdk.incubator.foreign 패키지의 jdk.incubator.foreign 모듈
  • 자바 22 정식: java.lang.foreign 패키지의 java.base 모듈

직접 지원하는 기능은 외부 메모리 할당, 구조화된 외부 메모리 조작, 외부 리소스의 수명 주기 관리, 외부 함수 호출입니다. 메서드 핸들·변수 핸들 API를 기반으로 구축되었으며, 설계 목표는 다음과 같습니다.

목표의미
생산성취약한 네이티브 메서드와 JNI 메커니즘을 간결하고 가독성 높은 순수 자바 API로 대체
성능JNI 또는 sun.misc.Unsafe와 동등하거나 더 나은 오버헤드로 외부 함수·메모리 접근
광범위한 플랫폼 지원JVM이 실행되는 모든 플랫폼에서 네이티브 라이브러리 탐색·호출
일관성네이티브·영구(persistent)·관리 힙(managed heap) 등 다양한 메모리에서 무제한 크기 데이터 처리
건전성여러 스레드에서 메모리를 할당·해제해도 use-after-free 버그가 발생하지 않도록 보장
무결성안전하지 않은 작업을 허용하되 기본적으로 경고를 제공

왜 JNI를 대체하려 하는가

JNI는 네이티브 코드 연동의 표준이었지만, C 헤더와 보일러플레이트, use-after-free 같은 메모리 안전성 문제로 악명 높았습니다. FFM은 이를 순수 자바 API로 옮기면서 JNI 이상의 성능까지 노립니다. 백엔드에서 이미지 처리·암호화·ML 추론 같은 네이티브 라이브러리를 끌어쓸 때 진입 장벽이 크게 낮아집니다.

아레나와 메모리 세그먼트

파나마에서 가장 중요한 개념 두 가지는 아레나(arena)와 메모리 세그먼트(memory segment)입니다. 4GiB 크기의 int 영역을 할당해 채우는 예제로 살펴봅니다.

public class Main {
    private static final int INT_SIZE = 4;
    private static final long ARENA_SIZE = 4 * 1024 * 1024 * 1024L;
 
    public static void main(String[] args) {
        long l = 0;
        try (var arena = Arena.ofConfined()) {
            MemorySegment segment = arena.allocate(INT_SIZE * ARENA_SIZE);
            for (l = 0 ; l < ARENA_SIZE ; l += 1) {
                segment.setAtIndex(ValueLayout.JAVA_INT, l, (int)(l % 16));
            }
        }
        System.out.println("l = "+ l);
    }
}

여기서 중요한 점들입니다.

  • Arena 클래스는 메모리 세그먼트의 수명 주기를 제어합니다.
  • 아레나는 try-with-resources로 결정적 해제를 보장하며, 여러 세그먼트 간에 조정될 수도 있습니다.
  • 메모리 세그먼트는 아레나에서 할당됩니다.
  • allocate()long 인수를 받으므로, ByteBuffer(또는 배열)가 허용하는 크기보다 큰 메모리 블록을 할당할 수 있습니다.

이 예제는 제한된 아레나(confined arena)를 씁니다 — 현재 스레드에서만 사용 가능한 가장 단순한 경우입니다. 파나마는 이 밖에 공유 아레나, 글로벌 아레나, 자동 아레나(JVM GC가 관리)도 지원합니다.

벡터 API는 발할라를 기다립니다

벡터 API를 따로 깊이 다루지 않은 이유는, 특정 필수 기능이 발할라 프로젝트에서 프리뷰로 제공될 때까지 인큐베이팅 상태를 유지하기로 결정했기 때문입니다. 그래서 벡터 API는 다른 기능들보다 도입 시기가 더 멀고, 상대적으로 더 실험적인 단계에 있습니다.

15.3 라이덴

라이덴 프로젝트(Project Leyden)는 18세기 네덜란드 라이덴에서 발명된 초기 전기 축전기인 라이덴 병(Leyden jar)에서 이름을 따왔습니다. 축전기는 콘덴서(condenser)라고도 불리는데, 이것이 프로젝트 명명과 중요한 의미를 가집니다.

전체 목표는 자바 프로그램의 시작 시간, 최고 성능 도달 시간, 메모리 사용량을 개선하는 것입니다. 비유적으로 ‘병 속에 번개를 가두는(capturing lightning in a bottle)’ 것을 연상시킵니다 — 자바 프로그램의 의미를 유지하면서도, 핫스팟이 제공하는 동적 기능의 오버헤드를 피하는 것이 목표입니다.

정적과 동적의 균형

핵심 발상은 JVM이 정적·동적 접근을 모두 균형 있게 활용한다는 것입니다. 이는 다른 언어의 ‘하나를 선택하면 다른 하나를 포기하는(choose one, lose one)’ 방식과 차별화됩니다.

언어선택포기
C++정적 추론·컴파일동적 기능(dynamism)
파이썬동적 분석이후 정적 추론 추가가 어려움
라이덴이 지향하는 자바둘 다 — 동적 상태를 시작 전에 미리 최적화(제약 수용을 통해 조정)

핫스팟은 런타임에 동적 상태를 추측적으로 최적화하여 결과적으로 이를 정적 상태로 변환합니다. 라이덴의 목표는 이런 최적화를 애플리케이션 시작 전에 수행하고, 추측적 최적화(speculative optimization)를 미리 적용하는 것입니다. 단순히 ‘AOT 컴파일을 제공한다’는 것보다 더 일반적인 접근으로, 결과(outcome)와 메커니즘을 구별하며 라이덴은 결과에 초점을 둡니다.

라이덴은 그랄VM·쿼커스 같은 프로젝트에서 얻은 실용적 경험을 일반화하여 OpenJDK·자바 표준의 핵심에 통합하는 것을 목표로 합니다. 두 가지 핵심 메커니즘은 콘덴서프리메인 아카이브(premain archives)입니다.

15.3.1 이미지, 제약, 콘덴서

정적 런타임 이미지와 폐쇄 세계

가장 중요한 개념은 정적 런타임 이미지(static run-time image)입니다 — 애플리케이션과 JDK에서 파생되어, 해당 특정 애플리케이션에서만 실행되는 독립 실행형 프로그램입니다.

이와 관련된 개념이 폐쇄 세계(closed world) 제약입니다. 이 제약을 따르는 애플리케이션은 로드 가능한 클래스에 엄격한 제한을 수용합니다 — 런타임 중 이미지 외부에서 클래스를 로드할 수 없고, 동적으로 클래스를 생성할 수도 없습니다.

폐쇄 세계 제약은 런타임 리플렉션이나 클래스 로딩을 사용할 수 없게 만듭니다. 그런데 많은 자바 라이브러리·프레임워크가 이런 기능에 의존하므로 모든 애플리케이션에 적합한 것은 아닙니다. 그래서 라이덴은 폐쇄 세계를 절대적 목표로 삼지 않고, 점진적·단계적 접근을 추구합니다 — 완전히 폐쇄된 환경이 아니더라도 의미 있는 최적화를 제공할 수 있는, 더 완화된 제약 조건을 탐색합니다.

연산 시프트 — 라이덴의 핵심 개념

라이덴에서 가장 중요한 개념은 연산 시프트(computation shifting)입니다. 애플리케이션의 시작·준비 단계에서 수행되는 연산을 더 이른(또는 경우에 따라 더 늦은) 단계로 이동하는 기법입니다.

이동시킬 수 있는 연산은 두 유형입니다.

  • 프로그램에서 직접 표현된 작업 (예: 메서드 호출)
  • 프로그램을 대신하여 수행되는 작업 (예: 메서드를 네이티브 코드로 컴파일)

자바 구현에는 이미 일부 연산을 자동으로 이동하는 기능이 있습니다.

기능이동 방향
컴파일 타임 상수 폴딩더 이른 시점
사전 처리된 클래스 데이터 아카이브더 이른 시점
지연된 클래스 로딩·초기화더 늦은 시점
가비지 컬렉션더 늦은 시점

쿼커스의 빌드 타임 계산과 그랄VM의 AOT 컴파일도 연산을 더 이른 시점으로 이동하는 예시입니다. 다만 이런 기능은 특정 프레임워크에 종속되며 표준화된 기능은 아닙니다. 연산을 이동할 때는 항상 자바 명세에 따라 프로그램의 의미를 유지해야 하며, 이는 호환성 보장에 필수적입니다.

콘덴서

콘덴서(condenser)는 전체 프로그램 이미지를 분석하여 실행 시점의 연산을 더 이른 단계로 이동시키는 변환 과정입니다 — 즉 프로그램의 의미를 유지하는 전역적 프로그램 변환입니다. 콘덴서는 프로그램 이미지를 새 이미지로 변환하며, 새 이미지에는 다음이 포함될 수 있습니다.

  • 새로운 코드 (AOT 컴파일된 메서드)
  • 새로운 데이터 (직렬화된 힙 객체)
  • 새로운 메타데이터 (사전 로드된 클래스 등)
  • 새로운 제약 조건

콘덴서는 조합 가능하도록 설계되어, 한 콘덴서의 출력 이미지가 다른 콘덴서의 입력이 될 수 있고, 특정 콘덴서를 여러 번 적용할 수도 있습니다.

연산을 이동시키는 것은 일반적으로 제약을 수용한다는 의미이며, 선택하는 콘덴서를 통해 기능과 성능을 맞바꿀 수 있습니다. 충분히 강력한 콘덴서를 쓰면 완전히 정적인 네이티브 이미지를 생성할 수도 있지만, 이를 위해서는 많은 제약을 수용해야 합니다. 따라서 라이덴은 완전 정적 네이티브 이미지를 직접 명시할 필요가 없습니다 — 계산을 충분히 이동시키고 동적 동작을 제한하면, 정적 네이티브 이미지가 자연스럽게 발생하는 결과물이 되도록 합니다.

테스트 시 주의

쿼커스 네이티브 모드의 경험에 따르면, 유닛 테스트나 디버깅을 할 때는 프로그램 변환을 수행하지 않고 그냥 정상적으로 실행하는 것이 좋습니다. 변환된 상태로 테스트하는 것은 프레임워크를 테스트하는 결과만 초래할 뿐 실질적 가치를 제공하지 않습니다.

2024년 8월 기준 콘덴서 관련 작업은 아직 메인라인에 본격 반영되지 않았으며, 초기 개발 단계입니다. 컴파일 시점에 가능한 경우 invokedynamic 연결을 미리 해결하는 방식(람다의 경우)과, 지연 계산되는 정적 최종 필드 개발 같은 아이디어를 탐색하는 것으로 시작되었습니다.

15.3.2 라이덴 프리메인

라이덴 프리메인(Leyden premain)의 목표는 준비 과정 활동을 줄이는 것입니다. 준비 과정 활동이란 애플리케이션이 아닌 JVM이 최적 성능에 도달하기 위해 수행하는 최적화 작업을 의미합니다.

준비 과정 시간의 정의

최적 성능은 통계적 최대치로 정의되며, 일부 노이즈가 존재합니다. JVM은 일반적으로 상당한 노이즈(보통 3~5% 범위)를 포함하는 환경이므로, 최대 처리량의 95% 이상에 도달하면 최적 성능에 도달한 것으로 간주하는 규칙을 정의할 수 있습니다. 이에 따라 준비 과정 시간은 95% 처리량에 도달하는 데 걸리는 시간으로 정의됩니다.

CDS에서 출발합니다

라이덴 프리메인은 클래스 데이터 공유(class-data sharing, CDS) 개념을 기반으로 구축됩니다. 새로운 개념은 아닙니다 — CDS는 자바 8부터 제공되었고, 자바 17 LTS부터 기본 설치에 포함되었습니다.

graph LR
    CDS["CDS (자바 8~)<br/>표준 라이브러리 클래스"]
    APP["AppCDS<br/>+ 애플리케이션 클래스 경로"]
    DYN["동적 AppCDS (자바 17~)<br/>학습 실행 중 자동 기록"]
    PRE["라이덴 프리메인<br/>+ 프로파일 + JIT 코드까지 캡처"]
    CDS --> APP --> DYN --> PRE

CDS의 기본 발상은 JVM이 시작될 때 공유 아카이브(shared archive)를 메모리에 매핑하여, 선택된 클래스의 읽기 전용 JVM 메타데이터를 즉시 사용할 수 있게 하는 것입니다 — 이를 통해 시작 시간을 단축합니다. 애플리케이션 클래스 데이터 공유(AppCDS)는 CDS를 확장하여 애플리케이션 클래스 경로의 선택된 클래스까지 포함합니다.

자바 17부터는 동적 AppCDS 아카이브(dynamic AppCDS archive)를 생성할 수 있습니다. 초기 학습 실행(training run) 중에 메타데이터를 기록하고, 이후 배포 실행(deployment run)에서 -XX:SharedArchiveFile=<dynamic archive> 옵션으로 재활용합니다.

학습 실행과 배포 실행

라이덴 프리메인은 이를 한층 발전시켜, 학습 실행을 활용해 더 많은 메타데이터와 코드까지 캡처하고 배포 실행에서 재사용하는 것을 목표로 합니다.

학습 실행은 애플리케이션의 대표적인 실행 과정입니다 — 일반적인 입력·설정으로 애플리케이션을 실행하고, 예상되는 경로·상태를 거쳐 시작 단계를 완료하며, 안정적 상태까지 준비합니다. 유사·반복 작업이 많은 시스템에서 가장 효과적이며, 안정적인 최고 성능(peak performance)에 도달할 수 있습니다. 학습 실행 중 JVM은 초기 상태·프로파일·JIT 컴파일된 코드를 수집하여 로그나 CDS 아카이브에 저장합니다. 여러 번의 학습 실행으로 데이터 로그를 병합해 더 정교한 최적화도 가능합니다. 이후 애플리케이션은 콘덴서를 적용해 정제(distill)되고 최적화된 버전으로 변환됩니다.

배포 실행은 이 최적화된 애플리케이션을 실행하는 것입니다. 초기 상태에서 시작하되, 아카이브된 프로파일과 코드의 혜택을 받습니다.

무엇이 캡처되는가 — C1 코드의 재사용

일반적으로 애플리케이션 시작 단계에서는 심볼을 해석하고, 클래스 초기화 메서드(<clinit>)를 실행하며, invokedynamic 부트스트랩 메서드(BSM)를 실행합니다(람다). 이 작업들을 학습 실행 중에 수행·저장한 뒤 배포 실행에서 재사용하며, 일부 초기화 상태와 코드도 함께 저장합니다.

코드는 핫스팟 계층적 컴파일러의 다양한 계층에서 재사용됩니다.

  • C1 — 추측적 최적화를 수행하지 않는 ‘보수적’ JIT 컴파일러로, 디옵티마이즈가 필요 없습니다.
  • C2 (Tier 4) — 최적화된 코드입니다.

C1 컴파일된 코드는 인터프리터 코드 대신 사용할 수 있어, 온라인 재컴파일과 인터프리터 실행을 피함으로써 시작 성능을 개선합니다. 특히 컴파일되지 않을 가능성이 있는 비 핫코드 경로(non-hot code paths)나, 시작 시점에는 실행되지만 이후에는 실행되지 않는 코드 경로에서 유용합니다. 또한 필요할 경우 JIT 코드가 저장된 프로파일에서 다시 생성될 수 있도록 하는 것이 목표입니다.

동적 관찰의 정적 활용 — ‘특이한 날’ 문제

고수준에서 보면 학습 실행(애플리케이션을 관찰하는 과정)은 정적 애플리케이션 분석의 동적 반대 측면(dynamic flip side)이자, 2차 형태의 프로파일 기반 최적화로 볼 수 있습니다. 동적 관찰 결과는 디옵티마이제이션 가능성을 유지하는 한, 마치 정적으로 도출된 것처럼 사용할 수 있습니다 — 데이터가 캡처되면 ‘정적으로 보이지만 실제로는 동적으로 생성된’ 상태가 되고, 변화가 발생하면 최적화를 다시 수행합니다. 이 추측적 기법(speculative techniques)과 예기치 않은 상황을 처리하는 비상 탈출구(escape hatches)의 조합이 핫스팟의 핵심 역량입니다.

라이덴이 완전 AOT보다 실용적인 이유

워크로드에는 ‘특이한 날(unusual days)‘이 존재합니다. 금융 산업에서는 미국 비농업 고용지수 발표일이나 옵션 만기일(분기별 1회) 등이 이에 해당합니다. 이런 날에는 완전한 AOT 컴파일 시스템(그랄VM 네이티브 이미지 등)이 동적 JVM이 개입하는 시스템보다 성능이 훨씬 낮아질 수 있습니다 — 완전 정적 AOT는 학습 실행에서 도출된 코드 경로 가정을 변경할 수 없기 때문입니다. 반면 라이덴은 디옵티마이제이션·재컴파일이 가능하므로 이 문제를 해결합니다.

2024년 8월 기준 프리메인 작업 진행 상태입니다.

  • 프리메인 활동은 학습 실행에서 자동으로 도출됩니다.
  • 프리메인을 위해 생성된 최적화 가능한 상태는 아카이브에 저장됩니다.

향후에는 사용자 정의 활동(user-defined activities)도 포함될 가능성이 있습니다. 다만 이를 위해서는 어떤 사용자 코드가 순수한 코드로 신뢰될 수 있는지 특성화하는 작업(새로운 순수성 주석(purity annotations) 추가 등)이 필요합니다.

15.4 발할라

발할라 프로젝트(Project Valhalla)는 JVM을 매우 깊은 수준에서 재정렬하는 것을 목표로 하는 장기 프로젝트입니다. 주요 목표는 다음과 같습니다.

  • JVM 메모리 레이아웃 동작을 현대 하드웨어의 비용 모델과 정렬합니다.
  • 기본형·값·void를 포함한 모든 타입에 대한 추상화가 가능하도록 제네릭을 확장합니다.
  • 기존 라이브러리(특히 JDK)가 이 기능을 완전히 활용하도록 호환성을 유지하며 발전합니다.

이 설명 속에 가장 복잡한 노력이 숨어 있습니다 — 바로 JVM에서 값 클래스(value classes)의 가능성을 탐색하는 것입니다.

정보의 유효기간에 주의

발할라는 2014년에 시작되어 지난 10년간 구현 설계가 여러 차례 크게 변경되었습니다. 발할라에 대한 정보를 읽을 때는 반드시 최신 정보인지 주의해야 합니다 — 이 책 첫 번째 판의 설명조차 이제 완전히 틀린 정보입니다.

문제 — 객체 배열의 간접 참조

버전 21까지 자바에는 기본형과 객체 참조, 두 가지 유형의 값만 존재했습니다. 즉 메모리 레이아웃에 대한 저수준 제어를 의도적으로 제공하지 않았습니다. 모든 복합 데이터 유형(composite data type)은 참조를 통해서만 접근 가능합니다.

배열의 메모리 레이아웃을 비교하면 문제가 드러납니다.

int[]      [ len=3 | 24 | 9 | 17 ]          ← 값이 메모리에 인접 배치 (캐시 친화적)

Integer[]  [ len=3 | ref | ref | ref ]
                       │     │     │
                       ▼     ▼     ▼
                     [  9 ][ 17 ][ 24 ]      ← 각 참조가 별도 힙 객체 (간접 참조)

기본형 int 배열은 값들이 객체가 아니므로 메모리에서 인접한 위치에 배치됩니다. 반면 박싱된 Integer는 객체이므로 참조로 처리되어, Integer 객체의 배열은 곧 참조의 배열이 됩니다.

25년 이상 이 메모리 레이아웃 패턴이 자바 플랫폼이 작동하는 방식이었습니다. 단순성(simplicity)이라는 장점이 있지만 성능상의 트레이드오프가 존재합니다 — 객체 배열을 다룰 때 불가피한 간접 참조(indirections)와 이에 따른 캐시 누락(cache misses)이 발생합니다. 그래서 성능을 중시하는 개발자들은 메모리에 더 효율적으로 배치되는 타입을 정의할 수 있기를 원하며, 각 복합 데이터 항목마다 전체 객체 헤더(object header)가 필요한 오버헤드를 제거하고 싶어 합니다.

값 클래스 — ‘클래스처럼 코딩하고 int처럼 동작한다’

3차원 공간의 한 점(Point3D)은 세 개의 공간 좌표만으로 구성됩니다. 자바 21 기준으로는 세 필드를 가진 객체 타입으로 표현됩니다.

public record Point3D(double x, double y, double z) {}

이 점의 배열은 여전히 참조의 배열이라 각 항목의 좌표를 가져올 때마다 추가 간접 참조가 필요하고, 캐시 누락이 발생할 수 있습니다 — 실질적 이점 없이 성능이 저하됩니다. 게다가 Point3D 타입에는 객체 동일성(object identity)이 의미가 없습니다. 모든 필드가 동일할 때만 두 객체가 동일한 것으로 간주되는데, 이는 값 클래스라는 개념이 의미하는 바와 정확히 일치합니다.

Point3D[] (value type)
[ len=3 | 3.3|1.8|-2 | 2.7|-1|1.9 | 2.6|4.1|0.3 ]   ← 인라인 배치, 헤더 비용 절감

값 클래스로 구현되면 좌표가 배열에 인라인으로 빽빽이 배치되어 메모리·캐시 지역성이 훨씬 효율적이고, 각 개별 객체의 헤더 비용을 절감합니다. 나아가 기본형과 유사한 방식으로 동작하는 사용자 정의 타입(user-defined types)의 가능성이 새롭게 열립니다.

현재 발할라의 설계는 ‘클래스처럼 코딩하고 int처럼 동작한다’는 원칙을 실현하는 것을 목표로 합니다. 새로운 키워드 value 단 하나만 추가되어 해당 클래스가 값 클래스임을 나타냅니다. 반면 기존의 모든 클래스는 이제 정체성 클래스(identity class)로 간주됩니다(지금까지는 필요 없던 개념입니다). JVM 바이트코드도 일부 사소한 변경만 이루어졌으며, 새로운 바이트코드를 추가로 정의할 필요는 없습니다.

파나마·GPU 오프로딩과의 연결

‘struct-like’ 배열을 사용하면 파나마 프로젝트를 통해 외부 함수를 호출할 가능성이 생깁니다. 예를 들어 구조체를 GPU로 오프로드하여 벡터 연산을 수행하면 더 빠르고 전력 효율적인 처리가 가능합니다. 발할라·파나마·벡터 API가 한 방향으로 수렴하는 지점입니다.

단일 루트가 아닌 타입 시스템

값 클래스에는 자바 초창기의 설계 결정에서 비롯된 개념적 어려움이 있습니다. 자바 타입 시스템에는 최상위 타입(top type)이 존재하지 않습니다 — Objectint 모두의 슈퍼타입이 되는 타입이 없습니다. 즉 Java 타입 시스템은 단일 루트(single-rooted) 구조가 아닙니다.

자바 5에서 제네릭이 추가될 당시, 타입 변수(type variable)는 참조 타입(Object의 하위 타입)만 대상으로 하도록 결정되었습니다. 따라서 List<int> 같은 표현을 일관된 의미로 구성하는 명확한 방법이 없습니다. 대신 자바는 타입 소거(type erasure)를 사용하여 기존 참조 타입 기반의 제네릭을 구현했으며, 이는 하위 호환성(backward compatibility)을 유지하기 위한 선택이었습니다.

타입 소거에 대한 흔한 오해

타입 소거에 불만을 제기하는 사람이 많지만, 이 메커니즘이 최상위 타입의 부재와 그로 인한 기본형 컬렉션의 부재를 초래한 것은 아닙니다. 원인과 결과를 혼동하지 말아야 합니다.

자바 플랫폼이 값 타입을 포함하도록 확장된다면, 자연스럽게 값 타입을 타입 매개변수(type parameter)로 사용할 수 있는지가 문제가 됩니다. 만약 사용할 수 없다면 값 타입의 활용성이 크게 제한됩니다. 따라서 값 타입 설계에는 항상 ‘강화된 형태의 제네릭에서 타입 매개변수 값으로 사용할 수 있어야 한다’는 전제가 포함되어 있었습니다.

브라이언 괴츠

발할라는 성능 개선을 목표로 하지만, 더 나은 관점에서 보면 추상화·캡슐화·안전성·표현력·유지보수성(maintainability)을 향상시키면서도 성능을 희생하지 않는 것이라고 할 수 있습니다.

값 비교의 재귀적 동등성 문제

가장 눈에 띄는 변화 중 하나는 값 비교(if_acmpeq 바이트코드)의 구현 방식입니다.

현재 자바에서 이 비교는 단순한 비트 단위 비교(bitwise comparison)입니다. 두 기본형 값은 비트가 동일하면 같고, 두 객체 참조는 동일한 메모리 위치를 가리킬 때만 같습니다. 그러나 값 객체의 비교는 더 복잡합니다 — 두 값 객체는 모든 필드 값이 동일할 때만 동일한 객체로 간주됩니다. 값 클래스의 필드 역시 값 클래스일 수 있기 때문에 문제가 됩니다.

public value record VR0(VR1 vr1) {}
public value record VR1(VR2 vr2) {}
public value record VR2(VR3 vr3) {}
// ... 등등
public value record VRN(int i) {}

VR0 두 객체는 내부에 포함된 VRN 인스턴스가 동일한 int 값을 가질 때만 동일합니다. N=3인 경우의 예시입니다.

var vr0a = new VR0(new VR1(new VR2(new VR3(42))));
var vr0b = new VR0(new VR1(new VR2(new VR3(73))));
var vr0c = new VR0(new VR1(new VR2(new VR3(42))));
 
System.out.println(vr0a == vr0b);   // false
System.out.println(vr0a == vr0c);   // true
System.out.println(vr0b == vr0c);   // false

이 비교를 수행하려면 JVM은 중간에 존재하는 모든 타입의 동등성 정의를 재귀적으로 탐색해야 합니다. if_acmpeq 바이트코드에서 임의 깊이(arbitrary-depth)의 재귀적 동작이 발생할 수 있으며, 이는 성능 측면에서 중요한 부정적 영향을 미칠 수 있습니다. 또한 이 종속성 체인(dependency chain)은 반드시 종료되어야 합니다.

그래서 값 클래스는 순환 종속성(cyclic dependency)을 유발하는 필드를 가질 수 없습니다. 순환 종속성이 존재하면 해당 타입의 객체를 메모리에 배치하는 데 필요한 공간을 예측할 수 없기 때문입니다. JIT 컴파일 관점에서 가장 큰 영향은 C2 컴파일러에서 필요한 지원입니다 — 기본적으로 할당을 최대한 피하고, 값 객체에 대해 ‘고급 박싱(fancy boxing)‘을 구현하는 것이 핵심 목표입니다.

결과적으로 이런 JVM 변경 사항은 매우 신중하게 구현되어야 합니다. 최악의 경우에도 발할라가 비활성화된 상태에서는 기존 코드의 성능 저하가 발생하지 않아야 합니다. 2024년 8월 기준으로 값 타입이 어느 자바 릴리스에서 정식(production) 기능으로 도입될지는 아직 불확실합니다(번역 시점인 2025년 4월에도 동일하며, 자바 SE 24에는 도입되지 않았고 SE 25에도 도입될지 확정되지 않았습니다).

15.5 결론

성능을 중시하는 자바 엔지니어에게 JVM 실행 모델과 가비지 컬렉션에 대한 기본적인 지식(journeyman’s knowledge)만으로는 충분하지 않습니다. 오케스트레이션(orchestration)과 관측성 같은 클라우드 네이티브 기술은 이제 대부분의 자바 개발자·운영 팀에게 일상 업무가 되었습니다.

동시에 소프트웨어 성능 엔지니어링의 기본 원칙(어떤 환경에서든)은 변하지 않았으며, 여전히 같은 지식과 철저한 적용(diligent application)이 요구됩니다. 다만 이제 엔지니어들은 더 많은 계층과 더 복잡한 다변수 최적화(multivariate optimization) 문제를 매일 해결해야 합니다. 지식의 범위가 계속 확장되면서 엔지니어 간의 전문화와 협업이 더욱 중요해지고 있습니다.

비교 / 트레이드오프

세 프로젝트의 역할 분담

파나마·라이덴·발할라는 경쟁이 아니라 JVM 성능의 서로 다른 축을 담당하는 보완 관계입니다.

프로젝트겨냥하는 축핵심 메커니즘백엔드 효용
파나마JVM ↔ 네이티브 경계FFM API (아레나·메모리 세그먼트)네이티브 라이브러리 연동, GPU 오프로딩
라이덴시작 시간·워밍업콘덴서, 프리메인(학습→배포 실행)컨테이너 콜드 스타트 단축
발할라메모리 밀도·캐시 효율값 클래스 (value 키워드)데이터 집약 처리의 GC·캐시 압박 완화
라이덴 vs 그랄VM 네이티브 이미지

둘 다 시작 성능을 개선하지만 철학이 다릅니다. 그랄VM은 완전 정적 AOT로 가서 동적 기능을 포기하는 대신 극단적인 시작 속도를 얻습니다. 라이덴은 연산을 이동시키되 디옵티마이제이션·재컴파일 능력을 남겨둡니다. ‘특이한 날’ 문제 — 학습 실행과 다른 트래픽 패턴이 들어왔을 때 — 에서 라이덴이 우위에 서는 이유입니다. 강한 보장(완전 정적)은 항상 적응력을 대가로 한다는, 13·14장에서 반복된 법칙이 여기서도 작동합니다.

스코프 값 vs ThreadLocal
항목ThreadLocalScopedValue
변경 가능성set()으로 언제든 변경불변 — set() 없음
수명 주기명시적 remove() 필요(누수 위험)스코프 종료 시 자동 해제
가상 스레드 적합성스레드 수만큼 복사본 → 부담fire-and-forget 패턴과 잘 결합
런타임 최적화어려움불변성 덕분에 가능

내 생각

”동적의 유연함을 지키는 정적화”가 일관된 메시지다

이 장 전체를 관통하는 건 “정적이냐 동적이냐의 양자택일을 거부한다” 는 자바의 고집입니다. 라이덴이 그랄VM과 다른 길을 가는 이유, 발할라가 ‘발할라 비활성화 시 성능 저하 없어야 한다’를 최우선으로 두는 이유가 같습니다 — 25년간 쌓인 동적 생태계(리플렉션·동적 프록시·바이트코드 조작에 의존하는 스프링·하이버네이트)를 깨지 않으면서 성능을 끌어올려야 하기 때문입니다. C++이나 Go처럼 처음부터 정적이었다면 쉬웠을 일을, 호환성이라는 족쇄를 차고 해내려는 시도입니다.

백엔드 입장에서 가장 먼저 와닿는 건 라이덴이다

서버리스·오토스케일링에서 자바의 콜드 스타트는 실질적 비용입니다. 그동안의 우회로는 그랄VM 네이티브 이미지였지만, 리플렉션 설정 지옥과 ‘특이한 날’ 성능 절벽이 발목을 잡았습니다. 라이덴의 학습 실행→배포 실행 모델은 이미 우리가 쓰는 AppCDS의 자연스러운 연장선이라 도입 장벽이 낮고, 디옵티마이제이션을 남겨둔다는 점에서 운영 안정성도 챙깁니다. CDS → AppCDS → 동적 AppCDS → 프리메인으로 이어지는 점진적 로드맵이 인상적입니다.

발할라의 재귀적 동등성 문제는 추상화의 비용을 잘 보여준다

value record의 == 가 임의 깊이 재귀 탐색이 된다는 대목은, ‘편해 보이는 추상화 뒤에 숨은 런타임 비용’의 교과서적 사례입니다. 순환 종속성 금지, ‘fancy boxing’, C2 지원 같은 제약들이 전부 이 한 줄 비교를 값싸게 만들기 위한 장치입니다. 우리가 무심코 쓰는 equals() 한 번이 JVM 수준에서 얼마나 정교하게 설계되어야 하는지를 새삼 느낍니다.

관련 개념

출처

  • 『자바 최적화 2판』 15장 현대적 성능과 미래