JDK 21에서 JDK 25로: 성능 업데이트

이번 포스팅에서는 JDK 21에서 JDK 25까지의 JDK 25 성능 개선 사항에 대해서 정리하고자 한다. 2026년 3월 Jfokus 컨퍼런스에서 Oracle의 Claes Redestad와 Per-Ake Minborg가 Inside Java에 발표한 내용을 중심으로, 표준 라이브러리부터 JIT 컴파일러, 가비지 컬렉터까지 13가지 개선 사항을 살펴본다. Timefold가 실제 애플리케이션으로 측정한 결과 JDK 25는 JDK 21 대비 약 8% 빠른 것으로 나타났는데, JAR 파일은 그대로 두고 JVM 버전만 올렸을 때의 수치다. JDK 25는 JDK 21의 뒤를 잇는 다음 LTS 릴리즈다. Spring Boot 3.x 환경이라면 업그레이드만으로 JDK 25 성능 개선의 효과를 즉시 볼 수 있다.


JDK 25 성능 개선의 전체 그림: 13가지 개선, 3개 레이어

JDK 25는 3,200개 이상의 이슈를 수정했으며, 이 중 약 1,000개가 개선사항이고 약 100개가 성능 관련으로 명시되었다. 발표에서 강조된 13가지 구체적인 JDK 25 성능 개선 사항은 크게 세 레이어로 구분된다.

레이어주요 개선 사항대표 수치
표준 라이브러리String hashCode, Memory Segment, BigDecimal, Vector API최대 14배 향상
JIT 컴파일러C2 자동 벡터화, 상수 폴딩, 마스크 체크최대 10,000배 향상
가비지 컬렉터Compact Object Headers, G1 Remembered Set, Generational ZGC힙 20% 절감

각 레이어의 개선은 독립적으로 작동하므로, 특정 워크로드에서는 세 레이어의 효과가 중첩되어 훨씬 큰 폭의 JDK 25 성능 개선을 체감할 수 있다.


라이브러리 레벨: 코드 한 줄 안 바꾸고 얻는 성능 향상

JDK 25는 자주 쓰는 내장 클래스들을 내부적으로 최적화했다. 코드는 그대로 두고 JVM만 올려도 다음 항목에서 성능 향상이 체감된다.

  • String hashCode (8배 향상): HashMap/HashSet에서 쓰이는 해시 계산이 빨라진다. 문자열을 키로 자주 쓰는 서비스 레이어나 캐시 코드에서 자연스럽게 혜택을 받는다.
  • BigDecimal (6~9배 향상): BigDecimal.valueOf() 호출이 빨라진다. 금액 계산이나 통계 처리 코드에 직접 효과가 있다.
  • Math 함수 (3~5배 향상): Math.max(), Math.min(), Math.cbrt() 같은 기본 수학 함수가 빨라진다.
  • 네이티브 메모리 할당 (최대 2배 향상): FFM API로 네이티브 메모리를 직접 다루는 코드에서 할당 속도가 빨라진다.

JIT 컴파일러가 더 스마트해졌다

JIT 컴파일러는 실행 중인 Java 코드를 기계어로 변환하는 역할을 한다. JDK 25에서는 이 과정에서 불필요한 연산을 더 잘 제거한다.

  • 배열 루프 처리 (33배 향상): 숫자 배열을 합산하거나 변환하는 루프가 내부적으로 여러 요소를 한 번에 처리하도록 자동 변환된다. 배치 처리나 대용량 데이터 계산 코드에 효과가 나타난다.
  • 조건 분기 제거 (최대 10,000배 향상): static final 상수를 이용한 조건 검사에서 결과가 항상 동일하다고 판단되면 해당 루프를 코드에서 아예 제거한다. 수치가 극단적으로 큰 것은 루프 자체가 없어지기 때문이다.
  • Math 연산 (3~5배 향상): Math.max(), Math.min() 같은 기본 연산이 단일 CPU 명령어로 변환된다.

GC 혁신: 메모리도 멈춤도, 모두 잡았다

GC(가비지 컬렉터)는 사용이 끝난 객체를 메모리에서 정리하는 JVM 컴포넌트다. JDK 25에서는 메모리 사용량과 GC 빈도 모두 개선됐다.

  • 객체 헤더 압축 (JEP 519): 힙에 있는 모든 객체의 헤더가 4바이트 줄어든다. 힙 전체 메모리가 최대 20% 감소하고 GC 발생 빈도도 15% 낮아진다. -XX:+UseCompactObjectHeaders 플래그 하나로 즉시 적용된다.

Java25 Compact Object Header로 HotSpot JVM에서 메모리 절약 글 참조

  • G1 GC 내부 최적화: G1이 내부적으로 관리하는 참조 추적 데이터 크기가 60% 줄었다. 64GB 힙 기준 2GB → 0.75GB 수준이다. 별도 설정 없이 JDK 25로 올리면 자동 적용된다.
  • ZGC (GC CPU 25~40% 절감): java -XX:+UseZGC 하나면 Generational ZGC가 기본 적용된다. GC에 소비되는 CPU가 크게 줄어 레이턴시 우선 환경에서 유용하다.

세 항목 모두 Spring Boot 애플리케이션에 코드 수정 없이 적용된다. 그 중 -XX:+UseCompactObjectHeaders는 플래그 한 줄이라는 낮은 비용으로 가장 즉각적인 효과를 볼 수 있다.


Stable Values 완전 정복: 지연 초기화와 JIT 최적화를 동시에

왜 Stable Values가 필요한가?

JDK 25 성능 개선 중 가장 주목받는 신기능인 Stable Values(JEP 502)는 기존 Java에 존재하던 딜레마를 해결한다. 지금까지 개발자는 두 가지 선택지밖에 없었다.

첫 번째는 final 필드다. JIT 컴파일러가 상수로 인식하여 최적화(constant folding)를 적용할 수 있지만, 반드시 생성자나 정적 초기화 블록에서 즉시 초기화해야 한다. 애플리케이션 시작 시 모든 컴포넌트를 한꺼번에 초기화하면 시작 시간이 길어진다.

두 번째는 이중 확인 잠금(Double-Checked Locking) 패턴이다. 초기화를 지연시킬 수 있지만 volatile 선언이 필요하고, JIT 컴파일러가 상수로 인식하지 못해 최적화가 적용되지 않는다.

StableValue<T>는 이 둘을 동시에 해결한다. 초기화 시점은 자유롭게 미룰 수 있고, 초기화가 끝나면 JIT이 그 값을 상수로 인식해서 최적화를 적용한다.

StableValue 기본 사용법

import java.lang.StableValue; // java.lang 패키지 — 실제로는 자동 임포트되어 생략 가능
import java.util.function.Supplier;

// 컴파일 및 실행 시 --enable-preview 필요 (JDK 25 프리뷰 기능)
public class OrderService {

    // final처럼 선언하되, 초기화는 처음 사용 시점으로 미룸
    private final StableValue<AuditLogger> auditLogger = StableValue.of();

    // 처음 호출 시 초기화, 이후 JIT이 상수로 처리
    private AuditLogger getAuditLogger() {
        // 람다가 처음 한 번만 실행됨 (멀티스레드 안전, 승자 방식)
        return auditLogger.orElseSet(() -> AuditLogger.create(OrderService.class));
    }

    public void placeOrder(Order order) {
        // 주문 처리 로직
        getAuditLogger().log("Order placed: " + order.getId());
    }
}
Java

orElseSet()은 첫 호출 시만 람다를 실행하고 이후에는 캐시된 값을 반환한다. 멀티스레드 환경에서도 초기화는 최대 한 번만 보장되고, 초기화가 끝나면 JIT이 해당 값을 불변 상수로 취급한다.

Stable Supplier: 더 읽기 좋은 패턴

public class ProductRepository {

    // Supplier 형태로 선언하면 선언부와 초기화 로직이 인접하여 가독성 향상
    private final Supplier<DatabaseConnection> dbConnection =
        StableValue.supplier(() -> DatabasePool.acquire("product-db"));

    public Product findById(Long id) {
        // dbConnection.get() 호출 시 처음에만 풀에서 커넥션 획득
        return dbConnection.get().query(
            "SELECT * FROM products WHERE id = ?", id
        );
    }
}
Java

StableValue.supplier()Supplier<T>를 반환하므로 기존 코드와 호환성이 좋다. 선언과 초기화 로직이 한 곳에 모여 있어 나중에 코드를 읽기가 수월하다.

Stable List: 스레드 로컬 풀 패턴

public class ConnectionPoolManager {

    private static final int POOL_SIZE = Runtime.getRuntime().availableProcessors();

    // 고정 크기 목록으로 지연 초기화 — 각 요소는 독립적으로 초기화됨
    private static final List<RedisConnection> REDIS_POOL =
        StableValue.list(POOL_SIZE, index -> RedisConnection.create("localhost", 6379));

    public static RedisConnection getConnection() {
        // 스레드 ID 기반으로 커넥션 선택 — 경합 없이 각 스레드가 고정 커넥션 사용
        int index = (int)(Thread.currentThread().threadId() % POOL_SIZE);
        return REDIS_POOL.get(index); // 처음 접근 시만 초기화
    }
}
Java

StableValue.list()는 같은 초기화 패턴이 반복되는 객체 풀에 적합하다. 각 인덱스는 독립적으로 지연 초기화되므로 실제로 사용되는 커넥션만 초기화 비용을 치른다.

final, StableValue, volatile 비교

항목final 필드StableValue<T>volatile 필드
초기화 시점생성자/정적 초기화 블록 (즉시)임의의 시점 (지연 가능)임의의 시점
상수 폴딩 최적화가능 (항상)가능 (초기화 이후)불가능
멀티스레드 안전N/A (변경 불가)안전 (승자 방식)안전 (volatile 보장)
애플리케이션 시작 영향높음 (모두 즉시 초기화)낮음 (필요 시 초기화)낮음

Stable Values 활성화 방법

# 컴파일 시 프리뷰 기능 활성화 (JDK 25)
javac --release 25 --enable-preview src/main/java/**/*.java

# Maven에서 활성화
# pom.xml에 아래 설정 추가
ShellScript
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
        <!-- JDK 25 Stable Values 등 프리뷰 기능 활성화 -->
        <compilerArgs>
            <arg>--enable-preview</arg>
        </compilerArgs>
        <release>25</release>
    </configuration>
</plugin>
XML

Maven 컴파일러 플러그인에 --enable-preview<release>25</release>를 추가하면 Stable Values를 포함한 JDK 25 프리뷰 기능을 쓸 수 있다. 다만 프리뷰는 JDK 26에서 변경될 수 있으니 운영 환경 도입 전에는 별도 검증이 필요하다.


Project Leyden: AOT 메서드 프로파일링으로 시작 시간 15~25% 단축

JEP 515(Ahead-of-Time Method Profiling)는 Project Leyden의 일환으로 JDK 25에 도입된 기능이다. 이 기능은 훈련 실행(training run) 중 수집된 메서드 실행 프로파일을 캐시하여, 다음 실행 시 JIT 컴파일러가 워밍업을 기다리지 않고 즉시 최적화된 네이티브 코드를 생성할 수 있게 한다.

# JEP 515 AOT 방법론: Project Leyden의 CDS 기반 3단계 워크플로

# 1단계: 훈련 실행 — 메서드 실행 프로파일 및 CDS 설정 기록
java -XX:AOTMode=record \
     -XX:AOTConfiguration=myapp.aotconf \
     -jar myapp.jar

# 2단계: AOT 캐시 생성 — 수집된 프로파일로 최적화된 캐시 빌드
java -XX:AOTMode=create \
     -XX:AOTConfiguration=myapp.aotconf \
     -XX:AOTCache=myapp.aot

# 3단계: AOT 캐시를 사용하여 실행 — 워밍업 없이 즉시 최적화된 코드 실행
java -XX:AOTCache=myapp.aot -jar myapp.jar

# Spring Boot 예시: 3단계 적용 후 15-25% 빠른 시작
java -XX:AOTCache=springapp.aot \
     -Dspring.profiles.active=prod \
     -jar spring-app.jar
ShellScript

훈련 실행(1단계)에서 수집된 프로파일에는 어떤 메서드가 얼마나 자주, 어떤 타입으로 호출되었는지가 기록된다. 이후 실행에서 JIT은 이 데이터를 바탕으로 워밍업 없이 바로 최적화된 네이티브 코드를 생성한다. 시작 시간 15~25% 단축이라는 수치는 실제 측정값이고, “Hello World” 수준의 단순 앱도 28.7ms에서 25.5ms로 줄었다(12% 향상).


JDK 21 → JDK 25 마이그레이션 실전 가이드

Maven/Gradle 프로젝트 설정 업데이트

<!-- pom.xml: Java 25 기반 Spring Boot 프로젝트 설정 -->
<properties>
    <!-- Java 버전을 21에서 25로 변경 -->
    <java.version>25</java.version>
    <spring-boot.version>3.5.0</spring-boot.version>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <release>25</release>
                <!-- Stable Values 등 프리뷰 기능 사용 시만 추가 -->
                <!-- <compilerArgs><arg>--enable-preview</arg></compilerArgs> -->
            </configuration>
        </plugin>
    </plugins>
</build>
XML
// build.gradle: Gradle 프로젝트 설정
java {
    // Java 21에서 25로 변경
    sourceCompatibility = JavaVersion.VERSION_25
    targetCompatibility = JavaVersion.VERSION_25
}

tasks.withType(JavaCompile).configureEach {
    options.release = 25
    // Stable Values 등 프리뷰 기능 사용 시만 추가
    // options.compilerArgs += ['--enable-preview']
}

tasks.withType(Test).configureEach {
    // 테스트 실행 시 프리뷰 기능 활성화 (사용하는 경우)
    // jvmArgs(['--enable-preview'])
}
Groovy

Maven과 Gradle 모두 release를 25로 바꾸는 것만으로 라이브러리 최적화, JIT 향상, GC 개선을 모두 얻는다. --enable-preview는 Stable Values처럼 프리뷰 기능을 직접 사용하는 경우에만 필요하다.

권장 JVM 플래그 조합

# 프로덕션 환경 권장 JVM 플래그 (JDK 25 기준)
java \
  -XX:+UseG1GC \                         # G1 GC (JDK 25 기본값, 명시적 지정)
  -XX:+UseCompactObjectHeaders \          # 객체 헤더 4바이트 축소, 힙 20% 절감
  -Xms2g -Xmx4g \                        # 힙 크기 (환경에 맞게 조정)
  -XX:MaxGCPauseMillis=200 \             # G1 목표 중단 시간
  -XX:+ParallelRefProcEnabled \          # 병렬 참조 처리
  -Xlog:gc*:file=gc.log:time,uptime \   # GC 로그
  -jar myapp.jar

# 레이턴시 우선 환경: ZGC 조합
java \
  -XX:+UseZGC \                          # Generational ZGC (JDK 25에서 기본값)
  -XX:+UseCompactObjectHeaders \          # 객체 헤더 최적화
  -Xms4g -Xmx8g \
  -jar myapp.jar
ShellScript

-XX:+UseCompactObjectHeaders는 JDK 25에서 실험 딱지가 떼인 정식 기능이다. G1과 ZGC 모두와 호환되고, 별도 힙 튜닝 없이도 GC 로그에서 메모리 감소 효과를 바로 확인할 수 있다.

기존 코드의 Double-Checked Locking을 Stable Values로 전환

// Before (JDK 21): 이중 확인 잠금 — JIT 상수 폴딩 불가, volatile 필수
public class MetricsRegistry {
    private static volatile MeterRegistry instance;

    public static MeterRegistry getInstance() {
        if (instance == null) {
            synchronized (MetricsRegistry.class) {
                if (instance == null) {
                    // volatile 필드라 JIT이 상수로 처리 불가
                    instance = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
                }
            }
        }
        return instance;
    }
}

// After (JDK 25): Stable Values — 지연 초기화 + JIT 상수 폴딩 동시 적용
public class MetricsRegistry {
    // static final로 선언하면 JIT이 완전한 상수 폴딩 최적화 적용
    private static final StableValue<MeterRegistry> INSTANCE = StableValue.of();

    public static MeterRegistry getInstance() {
        return INSTANCE.orElseSet(
            () -> new PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
        );
    }
}
Java

Double-Checked Locking을 StableValue.of()로 교체하면 코드가 짧아지고, 초기화 이후에는 JIT이 상수로 인식해 성능도 좋아진다. volatilesynchronized가 사라지면서 코드의 의도도 훨씬 읽기 쉬워진다.


마치며

지금까지 JDK 25 성능 개선 사항에 대해서 정리해 보았다. 라이브러리(String hashCode 8배, Memory Segment 2배), JIT(C2 자동 벡터화 33배, 마스크 체크 10,000배), GC(힙 20% 절감, G1 Remembered Set 60% 감소)라는 세 레이어가 동시에 개선되었고, Timefold 벤치마크에서 코드 변경 없이 JDK 21 대비 약 8% 처리량이 향상되었다. JDK 21 LTS를 운영 중이라면, pom.xml 버전 번호 하나와 -XX:+UseCompactObjectHeaders 플래그 하나가 지금 당장 할 수 있는 가장 효율적인 성능 튜닝이다.

참고 자료