본문 바로가기

Study/Java

JDK 25 New Features

반응형

이번 버전부터 AI 번역입니다.

JDK의 버전별 변경 사항은 여기를 참고하세요

Spec

Java SE 24 Platform JSR 400 에 정의된 바와 같이 JSR 400 구현이 목표
실제 Spec은 Final Release Specification 문서를 참고

Final Release Specification Feature Summary

전체 JEP Feature 목록은 OpenJDK의 JDK 25 문서 에서 확인할 수 있다.

JEP Component Feature
JEP 470 security-libs / java.security PEM Encodings of Cryptographic Objects (Preview)
JEP 502 core-libs / java.lang Stable Values (Preview)
JEP 503 hotspot / other Remove the 32-bit x86 Port
JEP 505 core-libs Structured Concurrency (Fifth Preview)
JEP 506 core-libs Scoped Values
JEP 507 specification / language Primitive Types in Patterns, instanceof, and switch (Third Preview)
JEP 508 core-libs Vector API (Tenth Incubator)
JEP 509 hotspot / jfr JFR CPU-Time Profiling (Experimental)
JEP 510 security-libs / javax.crypto Key Derivation Function API
JEP 511 specification / language Module Import Declarations
JEP 512 specification / language Compact Source Files and Instance Main Methods
JEP 513 specification / language Flexible Constructor Bodies
JEP 514 hotspot / runtime Ahead-of-Time Command-Line Ergonomics
JEP 515 hotspot / compiler Ahead-of-Time Method Profiling
JEP 518 hotspot / jfr JFR Cooperative Sampling
JEP 519 hotspot / runtime Compact Object Headers
JEP 520 hotspot / jfr JFR Method Timing & Tracing
JEP 521 hotspot / gc Generational Shenandoah

JEP 502: Stable Values (Preview)

불변 데이터를 보관하는 객체인 stable values를 위한 API를 도입합니다.
Stable values는 JVM에 의해 상수로 취급되어, final 필드 선언으로 가능한 것과 동일한 성능 최적화를 가능하게 합니다.
하지만 final 필드와 비교하여, stable values는 초기화 시점에 대해 더 큰 유연성을 제공합니다. 이는 preview API입니다.

목표

  • 애플리케이션 시작 개선 — 애플리케이션 상태의 모놀리식 초기화를 분해하여 Java 애플리케이션의 시작을 개선합니다.
  • 생성과 초기화 분리 — 상당한 성능 패널티 없이 stable values의 생성과 초기화를 분리합니다.
  • 단일 초기화 보장 — 다중 스레드 프로그램에서도 stable values가 최대 한 번만 초기화됨을 보장합니다.
  • 상수 접기 최적화 활성화 — 이전에 JDK 내부 코드에서만 사용 가능했던 상수 접기 최적화를 사용자 코드에서 안전하게 활용할 수 있게 합니다.

비목표

  • Stable values를 선언하는 수단으로 Java 프로그래밍 언어를 향상하는 것은 목표가 아닙니다.
  • final 필드의 의미론을 변경하는 것은 목표가 아닙니다.

동기

대부분의 Java 개발자들은 "불변성을 선호하라" 또는 "가변성을 최소화하라"는 조언을 들어본 적이 있습니다 (Effective Java, 제3판, 항목 17).
불변성은 많은 장점을 제공합니다.
불변 객체는 하나의 상태만 가질 수 있고 따라서 여러 스레드 간에 자유롭게 공유될 수 있기 때문입니다.

Java Platform의 불변성 관리를 위한 주요 도구는 final 필드입니다.
불행히도 final 필드에는 제한사항이 있습니다.
인스턴스 필드의 경우 생성 중에, 정적 필드의 경우 클래스 초기화 중에 즉시 설정되어야 합니다.
또한 final 필드가 초기화되는 순서는 필드가 선언된 텍스트 순서에 의해 결정됩니다.
이러한 제한 사항들은 많은 실제 애플리케이션에서 final의 적용성을 제한합니다.

실무에서의 불변성

이벤트를 로거 객체를 통해 기록하는 간단한 애플리케이션 컴포넌트를 고려해보겠습니다:

class OrderController {

    private final Logger logger = Logger.create(OrderController.class);

    void submitOrder(User user, List<Product> products) {
        logger.info("order started");
        ...
        logger.info("order submitted");
    }

}

loggerOrderController 클래스의 final 필드이므로, 이 필드는 OrderController의 인스턴스가 생성될 때마다 즉시 초기화되어야 합니다.
이는 새로운 OrderController를 생성하는 것이 느릴 수 있음을 의미합니다.
결국 로거를 얻는 것은 때때로 구성 데이터를 읽고 파싱 하거나 로깅 이벤트가 기록될 저장소를 준비하는 것과 같은 비싼 작업을 수반하기 때문입니다.

더욱이, 애플리케이션이 로거를 가진 하나의 컴포넌트가 아니라 여러 컴포넌트로 구성된다면, 각각의 모든 컴포넌트가 자신의 로거를 즉시 초기화하기 때문에 애플리케이션 전체가 느리게 시작될 것입니다:

class Application {
    static final OrderController   ORDERS   = new OrderController();
    static final ProductRepository PRODUCTS = new ProductRepository();
    static final UserService       USERS    = new UserService();
}

이 초기화 작업은 애플리케이션의 시작에 해로울 뿐만 아니라 필요하지 않을 수도 있습니다.
결국 일부 컴포넌트는 이벤트를 로그 할 필요가 전혀 없을 수도 있는데, 왜 이 모든 비싼 작업을 미리 하는 것일까요?

더 유연한 초기화를 위한 가변성 수용

이러한 이유로, 우리는 종종 복잡한 객체의 초기화를 가능한 한 늦은 시점까지 지연시켜, 필요할 때만 생성되도록 합니다.
이를 달성하는 한 가지 방법은 final을 포기하고 가변 필드에 의존하여 최대 한 번의 변경을 표현하는 것입니다:

class OrderController {

    private Logger logger = null;

    Logger getLogger() {
        if (logger == null) {
            logger = Logger.create(OrderController.class);
        }
        return logger;
    }

    void submitOrder(User user, List<Product> products) {
        getLogger().info("order started");
        ...
        getLogger().info("order submitted");
    }

}

logger가 더 이상 final 필드가 아니므로, 그 초기화를 getLogger 메서드로 이동할 수 있습니다.
이 메서드는 로거 객체가 이미 존재하는지 확인하고, 그렇지 않다면 새 로거 객체를 생성하여 logger 필드에 저장합니다.
이 접근법은 애플리케이션 시작을 개선하지만 몇 가지 단점이 있습니다:

  • 새로운 불변조건: 코드에 미묘한 새로운 불변조건이 생깁니다: OrderControllerlogger 필드에 대한 모든 접근은 getLogger 메서드를 통해 중재되어야 합니다. 이 불변조건을 지키지 않으면 아직 초기화되지 않은 필드가 노출되어 NullPointerException이 발생할 수 있습니다.
  • 다중 스레드 문제: 가변 필드에 의존하는 것은 애플리케이션이 다중 스레드인 경우 정확성과 효율성 문제를 일으킵니다. 예를 들어, submitOrder 메서드에 대한 동시 호출은 여러 로거 객체가 생성되는 결과를 낳을 수 있습니다. 이것이 올바르다 하더라도 효율적이지는 않을 것입니다.
  • 최적화 제한: JVM이 logger 필드에 대한 접근을 최적화할 것으로 기대할 수 있습니다. 예를 들어, 이미 초기화된 logger 필드에 대한 접근을 상수 접기하거나 getLogger 메서드의 logger == null 검사를 제거하는 것입니다. 불행히도 필드가 더 이상 final이 아니므로, JVM은 초기 업데이트 후에 그 내용이 절대 변하지 않을 것이라고 신뢰할 수 없습니다.

가변 필드로 구현된 유연한 초기화는 효율적이지 않습니다.

지연된 불변성을 향하여

간단히 말해서, Java 언어가 필드 초기화를 제어할 수 있게 해주는 방법들은 너무 제약적이거나 너무 무제약적입니다.
한편으로는 final 필드가 너무 제약적이어서, 객체나 클래스의 생명주기 초기에 초기화가 발생하도록 요구하며, 이는 종종 애플리케이션 시작을 저하시킵니다.
다른 한편으로는 가변 비-final 필드를 사용한 유연한 초기화는 정확성에 대한 추론을 더 어렵게 만듭니다.
불변성과 유연성 사이의 긴장은 개발자들이 근본적인 문제를 해결하지 못하고 더욱 취약하고 유지 보수하기 어려운 코드를 만드는 불완전한 기법들을 채택하게 만듭니다.

우리에게 빠진 것은 필드가 사용될 때까지 초기화될 것이라고 약속하는 방법입니다.
최대 한 번 계산되고, 더 나아가 동시성과 관련하여 안전하게 계산되는 값으로 말입니다.
다시 말해, 우리는 불변성을 지연시키는 방법이 필요합니다.
이는 Java 런타임에 이러한 필드의 초기화를 스케줄링하고 최적화하는 데 있어 폭넓은 유연성을 주어, 대안들을 괴롭히는 패널티를 피하게 해 줄 것입니다.
지연된 불변성에 대한 일급 지원은 불변 필드와 가변 필드 사이의 중요한 공백을 메울 것입니다.

설명

Stable value는 단일 데이터 값인 그 내용을 보관하는 StableValue 타입의 객체입니다.
Stable value는 그 내용이 처음 검색되기 전 어느 시점에 초기화되어야 하며, 그 이후로는 불변입니다.
Stable value는 지연된 불변성을 달성하는 방법입니다.

다음은 로거에 stable value를 사용하도록 다시 작성된 OrderController 클래스입니다:

class OrderController {

    // OLD:
    // private Logger logger = null;

    // NEW:
    private final StableValue<Logger> logger = StableValue.of();

    Logger getLogger() {
        return logger.orElseSet(() -> Logger.create(OrderController.class));
    }

    void submitOrder(User user, List<Product> products) {
        getLogger().info("order started");
        ...
        getLogger().info("order submitted");
    }

}

logger 필드는 정적 팩토리 메서드 StableValue.of()로 생성된 stable value를 보관합니다.
처음에 stable value는 설정되지 않은 상태, 즉 아무 내용도 보관하지 않습니다.

getLogger 메서드는 stable value에서 logger.orElseSet(...)을 호출하여 그 내용을 검색합니다.
Stable value가 이미 설정되었다면 orElseSet 메서드는 그 내용을 반환합니다.
Stable value가 설정되지 않았다면 orElseSet 메서드는 제공된 람다 표현식을 호출하여 반환된 값으로 초기화하고, stable value가 설정된 상태가 되게 합니다.
그 후 메서드는 그 값을 반환합니다. orElseSet 메서드는 따라서 stable value가 사용되기 전에 초기화됨을 보장합니다.

Stable value가 일단 설정되면 불변이지만, 우리는 생성자나 정적 stable value의 경우 클래스 초기화자에서 그 내용을 초기화하도록 강제되지 않습니다.
대신, 필요에 따라 초기화할 수 있습니다.
더욱이 orElseSet 메서드는 logger.orElseSet(...)이 동시에 호출되더라도 제공된 람다 표현식이 한 번만 평가됨을 보장합니다.
이 속성은 람다 표현식의 평가가 부작용을 가질 수 있기 때문에 중요합니다. 예를 들어, Logger.create(...) 호출은 파일시스템에 새 파일을 생성할 수 있습니다.

이것은 기본적으로 비활성화된 미리 보기 API입니다

Stable Value API를 사용하려면 미리보기 기능을 활성화해야 합니다:

  • javac --release 25 --enable-preview Main.java로 프로그램을 컴파일하고 java --enable-preview Main으로 실행하거나,
  • 소스 코드 런처를 사용할 때는 java --enable-preview Main.java로 프로그램을 실행하거나,
  • jshell을 사용할 때는 jshell --enable-preview로 시작합니다.

Stable values를 사용한 유연한 초기화

Stable values는 불변 final 필드와 같은 초기화 보장을 제공하면서, 가변 비-final 필드의 유연성을 유지합니다. 따라서 이 두 종류의 필드 사이의 공백을 메웁니다:

필드 타입 업데이트 횟수 초기화 시점 상수 접기 가능 다중 스레드 안전
final field 1 생성자 또는 정적 초기화자 아니오
StableValue [0, 1] 어디든 예, 업데이트 후 예, 승자에 의해
Non-final field [0, ∞) 어디든 아니오

Stable values의 유연성은 전체 애플리케이션의 초기화를 재상상할 수 있게 해 줍니다.
특히, 다른 stable values로부터 stable values를 구성할 수 있습니다.
OrderController 컴포넌트에 로거를 저장하기 위해 stable value를 사용한 것처럼, OrderController 컴포넌트 자체와 관련 컴포넌트들을 저장하기 위해서도 stable value를 사용할 수 있습니다:

class Application {

    // OLD:
    // static final OrderController   ORDERS   = new OrderController();
    // static final ProductRepository PRODUCTS = new ProductRepository();
    // static final UserService       USERS    = new UserService();

    // NEW:
    static final StableValue<OrderController>   ORDERS   = StableValue.of();
    static final StableValue<ProductRepository> PRODUCTS = StableValue.of();
    static final StableValue<UserService>       USERS    = StableValue.of();

    public static OrderController orders() {
        return ORDERS.orElseSet(OrderController::new);
    }

    public static ProductRepository products() {
        return PRODUCTS.orElseSet(ProductRepository::new);
    }

    public static UserService users() {
        return USERS.orElseSet(UserService::new);
    }

}

애플리케이션의 시작 시간이 개선됩니다.
더 이상 OrderController와 같은 컴포넌트들을 미리 초기화하지 않기 때문입니다.
대신, 해당 stable value의 orElseSet 메서드를 통해 각 컴포넌트를 필요에 따라 초기화합니다.
각 컴포넌트는 또한 같은 방식으로 로거와 같은 하위 컴포넌트들을 필요에 따라 초기화합니다.

더욱이, stable values와 Java 런타임 사이에는 기계적 공감대가 있습니다.
내부적으로 stable value의 내용은 JDK 내부 @Stable 어노테이션이 달린 비-final 필드에 저장됩니다.
이 어노테이션은 저수준 JDK 코드의 일반적인 기능입니다.
필드가 비-final이더라도 JVM이 필드의 초기이자 유일한 업데이트 후에 필드의 값이 변하지 않을 것이라고 신뢰할 수 있다고 단언합니다.
이는 stable value를 참조하는 필드가 final이라는 조건 하에 JVM이 stable value의 내용을 상수로 취급할 수 있게 해 줍니다.
따라서 JVM은 여러 수준의 stable values를 통해 불변 데이터에 접근하는 코드, 예를 들어 Application.orders().getLogger()에 대해 상수 접기 최적화를 수행할 수 있습니다.

결과적으로, 개발자들은 더 이상 유연한 초기화와 최고 성능 사이에서 선택할 필요가 없습니다.

선언 지점에서 초기화 지정

지금까지의 예제들은 getLogger 메서드에서 logger.orElseSet(...)을 호출하여 사용 지점에서 stable values를 초기화했습니다.
이는 getLogger 메서드에서 사용 가능한 정보를 사용하여 내용을 계산할 수 있게 해 줍니다.
하지만 불행히도 logger stable value에 대한 모든 접근이 그 메서드를 통해 이루어져야 한다는 의미입니다.

이 경우 stable value를 선언할 때 실제로 초기화하지 않고 초기화 방법을 지정할 수 있다면 더 편리할 것입니다.
Stable supplier를 사용하여 이를 할 수 있습니다:

class OrderController {

    // OLD:
    // private final StableValue<Logger> logger = StableValue.of();
    //
    // Logger getLogger() {
    //     return logger.orElseSet(() -> Logger.create(OrderController.class));
    // }

    // NEW:
    private final Supplier<Logger> logger
        = StableValue.supplier(() -> Logger.create(OrderController.class));

    void submitOrder(User user, List<Product> products) {
        logger.get().info("order started");
        ...
        logger.get().info("order submitted");
    }

}

여기서 logger는 더 이상 stable value가 아니라 stable supplier입니다.
즉, 원래 Supplier로부터 생성된 기본 stable value의 내용의 Supplier로, 필요에 따라 내용을 계산할 수 있습니다.
Stable supplier가 StableValue.supplier(...)를 통해 처음 생성될 때, 기본 stable value의 내용은 아직 초기화되지 않습니다.

로거에 접근하기 위해 클라이언트는 getLogger() 대신 logger.get()을 호출합니다.
logger.get()의 첫 번째 호출은 원래 supplier, 즉 StableValue.supplier(...)에 제공된 람다 표현식을 호출합니다.
결과 값을 사용하여 stable supplier의 기본 stable value의 내용을 초기화하고, 그 후 결과를 클라이언트에 반환합니다.
logger.get()의 후속 호출들은 내용을 즉시 반환합니다.

Stable value 대신 stable supplier를 사용하는 것은 유지보수성을 개선합니다.
logger 필드의 선언과 초기화가 이제 인접해 있어 더 읽기 쉬운 코드가 됩니다.
OrderController 클래스는 더 이상 모든 로거 접근이 getLogger 메서드를 통해 이루어져야 한다는 불변조건을 문서화할 필요가 없으며, 이제 제거할 수 있는 메서드입니다.

JVM은 물론 stable suppliers를 통해 stable values의 내용에 접근하는 코드에 대해 상수 접기 최적화를 수행할 수 있습니다.

Stable values 집계

많은 애플리케이션들은 요소들 자체가 지연된 불변 데이터이고 유사한 초기화 로직을 공유하는 컬렉션들과 함께 작동합니다.

예를 들어, 단일 OrderController가 아닌 그러한 객체들의 풀을 생성하는 애플리케이션을 고려해 보겠습니다.
다른 애플리케이션 요청들은 다른 OrderController 객체들에 의해 서비스될 수 있어, 풀 전체에 걸쳐 부하를 공유합니다.
풀의 객체들은 즉시 생성되어서는 안 되고, 애플리케이션에 의해 새 객체가 필요할 때만 생성되어야 합니다.
Stable list를 사용하여 이를 달성할 수 있습니다:

class Application {

    // OLD:
    // static final OrderController ORDERS = new OrderController();

    // NEW:
    static final List<OrderController> ORDERS
        = StableValue.list(POOL_SIZE, _ -> new OrderController());

    public static OrderController orders() {
        long index = Thread.currentThread().threadId() % POOL_SIZE;
        return ORDERS.get((int)index);
    }

}

여기서 ORDERS는 더 이상 stable value가 아니라 stable list입니다.
즉, 각 요소가 기본 stable value의 내용인 List입니다. Stable list가 StableValue.list(...)를 통해 생성될 때, stable list는 고정된 크기를 가지며, 이 경우는 POOL_SIZE입니다.
리스트 요소들의 기본 stable values의 내용들은 아직 초기화되지 않습니다.

내용에 접근하기 위해 클라이언트는 ORDERS.orElseSet(...) 대신 인덱스를 전달하여 ORDERS.get(...)을 호출합니다.
특정 인덱스로 ORDERS.get(...)을 처음 호출하면 초기화 함수를 호출하는데, 이 경우는 인덱스를 무시하고 OrderController() 생성자를 호출하는 람다 함수입니다.
결과 OrderController 객체를 사용하여 인덱스 된 요소의 기본 stable value의 내용을 초기화하고, 그 후 그 객체를 클라이언트에 반환합니다.
같은 인덱스로 ORDERS.get(...)을 후속 호출하면 요소의 내용을 즉시 반환합니다.

Stable list의 요소들은 필요에 따라 독립적으로 초기화됩니다.
예를 들어, 애플리케이션이 단일 스레드에서 실행된다면 단 하나의 OrderController만이 생성되어 ORDERS에 추가될 것입니다.

Stable lists는 리스트의 요소들을 초기화하는 데 사용되는 함수가 리스트를 정의할 때 제공되므로 stable suppliers의 많은 이점을 유지합니다.
JVM은 평상시와 같이 stable lists를 통해 stable values의 내용에 접근하는 코드에 대해 상수 접기 최적화를 수행할 수 있습니다.

대안

지연된 불변성을 표현하는 많은 방법들이 오늘날 Java 코드에 존재합니다.
불행히도 알려진 기법들에는 제한된 적용성, 증가된 시작 비용, 그리고 상수 접기 최적화의 방해를 포함하는 단점들이 있습니다.

클래스 보유자 관용구

일반적인 기법은 소위 클래스 보유자 관용구입니다.
클래스 보유자 관용구는 JVM의 클래스 초기화 프로세스의 지연성을 활용하여 최대 한 번의 의미론으로 지연된 불변성을 보장합니다:

class OrderController {

    public static Logger getLogger() {

        class Holder {
            private static final Logger LOGGER = Logger.create(...);
        }

        return Holder.LOGGER;
    }
}

이 관용구는 상수 접기 최적화를 허용하지만, static 필드에만 적용 가능합니다. 더욱이 여러 필드를 처리해야 한다면 각 필드에 대해 별도의 보유자 클래스가 필요합니다.
이는 애플리케이션을 읽기 더 어렵게 만들고, 시작을 더 느리게 하며, 더 많은 메모리를 소비하게 만듭니다.

이중 확인 잠금

또 다른 대안은 이중 확인 잠금 관용구입니다.
기본 아이디어는 변수가 초기화된 후 그 값에 접근하는 빠른 경로와, 변수의 값이 설정되지 않은 것으로 보이는 가정된 드문 경우의 느린 경로를 사용하는 것입니다:

class OrderController {

    private volatile Logger logger;

    public Logger getLogger() {
        Logger v = logger;
        if (v == null) {
            synchronized (this) {
                v = logger;
                if (v == null) {
                    logger = v = Logger.create(...);
                }
            }
        }
        return v;
    }

}

logger가 가변 필드이므로, 상수 접기 최적화를 여기에 적용할 수 없습니다.
더 중요하게는, 이중 확인 관용구가 작동하려면 logger 필드가 volatile로 선언되어야 합니다.
이는 필드의 값이 여러 스레드에 걸쳐 일관되게 읽히고 업데이트됨을 보장합니다.

배열에서의 이중 확인 잠금

지연된 불변 값들의 배열을 지원할 수 있는 이중 확인 잠금 구조를 구현하는 것은 더 어렵습니다.
요소들이 volatile인 배열을 선언하는 방법이 없기 때문입니다.
대신, 클라이언트는 VarHandle 객체를 사용하여 배열 요소들에 대한 volatile 접근을 명시적으로 준비해야 합니다:

class OrderController {

    private static final VarHandle LOGGERS_HANDLE
        = MethodHandles.arrayElementVarHandle(Logger[].class);

    private final Object[] mutexes;
    private final Logger[] loggers;

    public OrderController(int size) {
        this.mutexes = Stream.generate(Object::new).limit(size).toArray();
        this.loggers = new Logger[size];
    }

    public Logger getLogger(int index) {
        // 완전히 초기화된 요소 객체만 볼 수 있음을 보장하기 위해 Volatile이 필요
        Logger v = (Logger)LOGGERS_HANDLE.getVolatile(loggers, index);
        if (v == null) {
            // 각 인덱스에 대해 구별되는 뮤텍스 객체 사용
            synchronized (mutexes[index]) {
                // 요소에 대한 업데이트가 항상 이 읽기와 같은 뮤텍스 하에서 
                // 이루어지므로 여기서는 평범한 읽기로 충분
                v = loggers[index];
                if (v == null) {
                    // 미래의 volatile 읽기와 happens-before 관계를 설정하기 위해 
                    // 여기서 Volatile이 필요
                    LOGGERS_HANDLE.setVolatile(loggers, index,
                                               v = Logger.create(... index ...));
                }
            }
        }
        return v;
    }

}

이 코드는 복잡하고 오류가 발생하기 쉽습니다:
이제 각 배열 요소에 대해 별도의 동기화 객체가 필요하고, 각 접근 시 올바른 연산(getVolatile 또는 setVolatile)을 지정하는 것을 기억해야 합니다.
설상가상으로, 상수 접기 최적화를 적용할 수 없기 때문에 배열 요소들에 대한 접근이 효율적이지 않습니다.

동시성 맵

지연된 불변성은 ConcurrentHashMap과 같은 스레드 안전 맵을 통해 computeIfAbsent 메서드를 사용하여 달성할 수도 있습니다:

class OrderController {

    private final Map<Class<?>,Logger> logger = new ConcurrentHashMap<>();

    public Logger getLogger() {
        return logger.computeIfAbsent(OrderController.class, Logger::create);
    }

}

JVM은 맵 엔트리의 내용이 맵에 처음 추가된 후 업데이트되지 않을 것이라고 신뢰할 수 없으므로, 상수 접기 최적화를 여기에 적용할 수 없습니다.
더욱이 computeIfAbsent 메서드는 null에 적대적입니다:
계산 함수가 null을 반환하면 새 엔트리가 맵에 추가되지 않아, 이 해결책을 일부 경우에 실용적이지 않게 만듭니다.

위험과 가정

JVM은 final 필드가 한 번만 업데이트될 수 있다고 신뢰할 수 있을 때만 상수 접기 최적화를 수행할 수 있습니다.

불행히도 코어 리플렉션 API숨겨진 클래스레코드의 멤버인 필드들을 제외하고 인스턴스 final 필드들을 임의로 업데이트할 수 있게 합니다.
장기적으로 우리는 기본적인 무결성으로의 더 넓은 전환의 일부로서 모든 인스턴스 final 필드들이 신뢰될 수 있도록 리플렉션 API를 제한할 의도가 있습니다.
하지만 그때까지는 대부분의 인스턴스 final 필드들의 가변성이 상수 접기를 불가능하게 만듭니다.

다행히 리플렉션 API는 static final 필드들을 임의로 업데이트할 수 있게 하지 않으므로, 그러한 필드들을 통한 상수 접기는 가능할 뿐만 아니라 일상적입니다.
따라서 위에서 보인 예제들 중 stable values, suppliers, 또는 lists를 static final 필드에 저장하는 것들은 좋은 성능을 가질 것입니다.

JEP 505: Structured Concurrency (Fifth Preview)

요약

구조화된 동시성을 위한 API를 도입하여 동시 프로그래밍을 단순화합니다.
구조화된 동시성은 서로 다른 스레드에서 실행되는 관련 작업 그룹을 단일 작업 단위로 취급하여 오류 처리와 취소를 간소화하고, 신뢰성을 향상하며, 관찰성을 개선합니다.
이는 preview API입니다.

역사

구조화된 동시성은 JDK 19에서 JEP 428을 통해, JDK 20에서 JEP 437을 통해 인큐베이팅되었습니다.
JDK 21에서 JEP 453을 통해 미리 보기 되었으며, fork 메서드가 Future 대신 Subtask를 반환하도록 변경되었습니다.

JDK 25에서는 여러 API 변경사항과 함께 API를 한 번 더 미리 보기로 제안합니다.
특히, StructuredTaskScope는 이제 공개 생성자 대신 정적 팩토리 메서드를 통해 열립니다.
매개변수가 없는 open 팩토리 메서드는 모든 하위 작업이 성공하거나 어떤 하위 작업이 실패할 때까지 기다리는 StructuredTaskScope를 생성하여 일반적인 경우를 다룹니다.
다른 정책과 결과는 적절한 Joiner를 더 풍부한 open 팩토리 메서드 중 하나에 제공하여 구현할 수 있습니다.

목표

  • 동시 프로그래밍 스타일 촉진 — 스레드 누수 및 취소 지연과 같은 취소 및 종료로 인해 발생하는 일반적인 위험을 제거할 수 있는 동시 프로그래밍 스타일을 촉진합니다.
  • 관찰성 개선 — 동시 코드의 관찰성을 개선합니다.

비목표

  • java.util.concurrent 패키지의 ExecutorService 또는 Future와 같은 동시성 구조를 대체하는 것은 목표가 아닙니다.
  • 모든 Java 프로그램을 위한 결정적인 구조화된 동시성 API를 만드는 것은 목표가 아닙니다. 다른 구조화된 동시성 구조는 타사 라이브러리나 향후 JDK 릴리스에서 정의할 수 있습니다.
  • 스레드 간에 데이터 스트림을 공유하는 수단(즉, 채널)을 정의하는 것은 목표가 아닙니다. 향후에 이를 제안할 수도 있습니다.
  • 기존 스레드 인터럽트 메커니즘을 새로운 스레드 취소 메커니즘으로 대체하는 것은 목표가 아닙니다. 향후에 이를 제안할 수도 있습니다.

동기

우리는 작업을 여러 하위 작업으로 분해하여 프로그램의 복잡성을 관리합니다.
일반적인 단일 스레드 코드에서 하위 작업은 순차적으로 실행됩니다.
하지만 하위 작업들이 서로 충분히 독립적이고 충분한 하드웨어 리소스가 있다면, 하위 작업을 동시에 실행하여 전체 작업을 더 빠르게, 즉 더 낮은 지연 시간으로 실행할 수 있습니다.
예를 들어, 여러 I/O 작업의 결과를 구성하는 작업은 각 I/O 작업이 자체 스레드에서 동시에 실행되면 더 빠르게 실행됩니다.
가상 스레드(JEP 444)는 모든 I/O 작업에 스레드를 전담하는 것을 비용 효율적으로 만들지만, 그 결과로 발생할 수 있는 엄청난 수의 스레드를 관리하는 것은 여전히 도전 과제입니다.

ExecutorService를 사용한 비구조화된 동시성

Java 5에서 도입된 java.util.concurrent.ExecutorService API는 하위 작업을 동시에 실행할 수 있습니다.

예를 들어, 서버 애플리케이션의 작업을 나타내는 handle() 메서드가 있습니다.
이 메서드는 ExecutorService에 두 개의 하위 작업을 제출하여 들어오는 요청을 처리합니다.
한 하위 작업은 findUser() 메서드를 실행하고, 다른 하위 작업은 fetchOrder() 메서드를 실행합니다.
ExecutorService는 각 하위 작업에 대해 즉시 Future를 반환하고, 스케줄링 정책에 따라 하위 작업들을 동시에 실행합니다.
handle() 메서드는 각 Future의 get() 메서드에 대한 블로킹 호출을 통해 하위 작업의 결과를 기다리므로, 작업이 하위 작업을 조인한다고 말합니다.

Response handle() throws ExecutionException, InterruptedException {
    Future<String> user = executor.submit(() -> findUser());
    Future<Integer> order = executor.submit(() -> fetchOrder());
    String theUser = user.get();   // findUser 조인
    int theOrder = order.get();    // fetchOrder 조인
    return new Response(theUser, theOrder);
}

하위 작업들이 동시에 실행되기 때문에, 각 하위 작업은 독립적으로 성공하거나 실패할 수 있습니다. (여기서 실패는 예외를 던지는 것을 의미합니다.)
종종 handle()과 같은 작업은 하위 작업 중 하나라도 실패하면 실패해야 합니다.
실패가 발생할 때 스레드의 수명을 이해하는 것은 놀랍도록 복잡할 수 있습니다:

  • 스레드 누수 문제: findUser()가 예외를 던지면 handle()user.get()을 호출할 때 예외를 던지지만, fetchOrder()는 자체 스레드에서 계속 실행됩니다. 이는 최상의 경우 리소스를 낭비하는 스레드 누수이며, 최악의 경우 fetchOrder() 스레드가 다른 작업과 간섭할 것입니다.
  • 인터럽트 전파 실패: handle()을 실행하는 스레드가 인터럽트 되면, 인터럽트가 하위 작업으로 전파되지 않습니다. findUser()fetchOrder() 스레드 모두 누수되어 handle()이 실패한 후에도 계속 실행됩니다.
  • 불필요한 대기: findUser()가 실행하는 데 오랜 시간이 걸리지만 그 사이에 fetchOrder()가 실패하면, handle()findUser()를 취소하지 않고 user.get()에서 블로킹하여 불필요하게 기다립니다. findUser()가 완료되고 user.get()이 반환된 후에야 order.get()이 예외를 던져 handle()이 실패합니다.

각 경우에서 문제는 우리 프로그램이 논리적으로 작업-하위작업 관계로 구조화되어 있지만, 이러한 관계가 우리 머릿속에만 존재한다는 것입니다.

작업 구조가 코드 구조를 반영해야 함

ExecutorService 하에서 자유분방한 스레드 집합과 대조적으로, 단일 스레드 코드의 실행은 항상 작업과 하위 작업의 계층을 강제합니다.
메서드의 본문 블록 {...}은 작업에 해당하고, 블록 내에서 호출되는 메서드들은 하위 작업에 해당합니다.
호출된 메서드는 그것을 호출한 메서드로 반환되거나 예외를 던져야 합니다.
호출된 메서드는 그것을 호출한 메서드보다 오래 살 수 없으며, 다른 메서드로 반환하거나 예외를 던질 수도 없습니다.
따라서 모든 하위 작업은 작업보다 먼저 완료되고, 각 하위 작업은 부모의 자식이며, 각 하위 작업의 다른 작업들과 작업에 대한 상대적 수명은 코드의 구문 블록 구조에 의해 제어됩니다.

예를 들어, 이 단일 스레드 버전의 handle()에서 작업-하위작업 관계는 구문 구조에서 명백합니다:

Response handle() throws IOException {
    String theUser = findUser();
    int theOrder = fetchOrder();
    return new Response(theUser, theOrder);
}

우리는 findUser() 하위 작업이 성공적으로든 실패든 완료될 때까지 fetchOrder() 하위 작업을 시작하지 않습니다.
findUser()가 실패하면 fetchOrder()를 전혀 시작하지 않고, handle() 작업이 암묵적으로 실패합니다.
하위 작업이 부모로만 반환할 수 있다는 사실이 중요합니다:
이는 부모 작업이 암묵적으로 한 하위 작업의 실패를 다른 미완료 하위 작업을 취소하고 자신이 실패하는 트리거로 취급할 수 있음을 의미합니다.

구조화된 동시성

구조화된 동시성은 작업과 하위 작업 간의 자연스러운 관계를 보존하는 동시 프로그래밍 접근법으로, 더 읽기 쉽고 유지보수 가능하며 신뢰할 수 있는 동시 코드로 이어집니다.
"구조화된 동시성"이라는 용어는 Martin Sústrik에 의해 만들어졌고 Nathaniel J. Smith에 의해 대중화되었습니다.

구조화된 동시성은 간단한 원칙에서 파생됩니다:
작업이 동시 하위 작업으로 분할되면, 모든 하위 작업이 같은 곳, 즉 작업의 코드 블록으로 반환합니다.

구조화된 동시성에서 하위 작업은 작업을 대신하여 작업합니다.
작업은 하위 작업의 결과를 기다리고 실패를 모니터링합니다.
단일 스레드의 코드에 대한 구조화된 프로그래밍 기법과 마찬가지로, 여러 스레드에 대한 구조화된 동시성의 힘은 두 가지 아이디어에서 나옵니다:
코드 블록을 통한 실행 흐름에는 잘 정의된 진입점과 탈출점이 있고, 작업의 수명은 코드에서의 구문적 중첩을 반영하는 방식으로 중첩됩니다.

설명

구조화된 동시성 API의 주요 클래스는 java.util.concurrent 패키지의 StructuredTaskScope입니다.
이 클래스를 사용하면 작업을 동시 하위 작업의 패밀리로 구조화하고 이들을 단위로 조정할 수 있습니다.
하위 작업은 개별적으로 포크하고 단위로 조인하여 자체 스레드에서 실행됩니다.
StructuredTaskScope는 하위 작업의 수명을 명확한 어휘 범위로 제한하며, 여기서 작업의 하위 작업과의 모든 상호 작용 — 포킹, 조인, 오류 처리, 결과 구성 — 이 이루어집니다.

다음은 StructuredTaskScope를 사용하도록 수정된 이전의 handle() 예제입니다:

Response handle() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {

        Subtask<String> user = scope.fork(() -> findUser());
        Subtask<Integer> order = scope.fork(() -> fetchOrder());

        scope.join();   // 하위 작업 조인, 예외 전파

        // 두 하위 작업이 모두 성공했으므로 결과 구성
        return new Response(user.get(), order.get());

    }
}

원래 예제와 대조적으로, 여기서 관련된 스레드의 수명을 이해하는 것은 쉽습니다:
모든 조건 하에서 수명은 어휘 범위, 즉 try-with-resources 문의 본문으로 제한됩니다.
더욱이 StructuredTaskScope의 사용은 여러 가치 있는 속성을 보장합니다:

  • 단락 회로와 함께하는 오류 처리findUser() 또는 fetchOrder() 하위 작업 중 하나가 예외를 던져서 실패하면, 다른 하나가 아직 완료되지 않았다면 취소됩니다(즉, 인터럽트됩니다).
  • 취소 전파handle()을 실행하는 스레드가 join() 호출 전이나 중에 인터럽트 되면, 스레드가 범위를 벗어날 때 두 하위 작업이 자동으로 취소됩니다.
  • 명확성 — 위 코드는 명확한 구조를 갖습니다: 하위 작업 설정, 완료되거나 취소될 때까지 기다림, 그다음 성공할지(그리고 이미 완료된 자식 작업의 결과 처리) 또는 실패할지(그리고 하위 작업이 이미 완료되었으므로 정리할 것이 더 없음) 결정.
  • 관찰성 — 아래에서 설명하는 스레드 덤프는 작업 계층을 명확하게 표시하며, findUser()fetchOrder()를 실행하는 스레드가 범위의 자식으로 표시됩니다.

StructuredTaskScope는 기본적으로 비활성화된 미리보기 API입니다

StructuredTaskScope API를 사용하려면 다음과 같이 미리보기 API를 활성화해야 합니다:

  • javac --release 25 --enable-preview Main.java로 프로그램을 컴파일하고 java --enable-preview Main으로 실행하거나,
  • 소스 코드 런처를 사용할 때는 java --enable-preview Main.java로 프로그램을 실행하거나,
  • jshell을 사용할 때는 jshell --enable-preview로 시작합니다.

StructuredTaskScope 사용하기

StructuredTaskScope<T, R> API는 T가 범위에서 포크 된 작업의 결과 타입이고 Rjoin 메서드의 결과 타입인 경우 다음과 같이 요약할 수 있습니다:

public sealed interface StructuredTaskScope<T, R> extends AutoCloseable {

    public static <T> StructuredTaskScope<T, Void> open();
    public static <T, R> StructuredTaskScope<T, R> open(Joiner<? super T,
                                                               ? extends R> joiner);

    public <U extends T> Subtask<U> fork(Callable<? extends U> task);
    public Subtask<? extends T> fork(Runnable task);

    public R join() throws InterruptedException;

    public void close();

}

StructuredTaskScope를 사용하는 코드의 일반적인 워크플로는 다음과 같습니다:

  1. 범위 열기: 정적 open 메서드 중 하나를 호출하여 새 범위를 엽니다. 범위를 여는 스레드가 범위의 소유자입니다.
  2. 하위 작업 포킹: fork 메서드를 사용하여 범위에서 하위 작업을 포크 합니다.
  3. 조인: join 메서드를 사용하여 범위의 모든 하위 작업을 단위로 조인합니다. 이는 예외를 던질 수 있습니다.
  4. 결과 처리: 결과를 처리합니다.
  5. 범위 닫기: 일반적으로 try-with-resources를 통해 암묵적으로 범위를 닫습니다. 이는 범위가 아직 취소되지 않았다면 취소하여 모든 남은 하위 작업을 취소하고 종료를 기다립니다.

Joiners

handle() 예제에서 하위 작업이 실패하면 join() 메서드는 예외를 던지고 범위가 취소됩니다.
모든 하위 작업이 성공하면 join() 메서드는 정상적으로 완료되고 null을 반환합니다.
이것이 기본 완료 정책입니다.

다른 정책들은 적절한 StructuredTaskScoped.Joiner와 함께 StructuredTaskScope를 생성하여 선택할 수 있습니다.
Joiner 객체는 하위 작업 완료를 처리하고 join() 메서드에 대한 결과를 생성합니다.

예를 들어, 팩토리 메서드 anySuccessfulResultOrThrow()는 성공적으로 완료되는 어떤 하위 작업의 결과든 산출하는 새 joiner를 반환합니다:

<T> T race(Collection<Callable<T>> tasks) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulResultOrThrow())) {
        tasks.forEach(scope::fork);
        return scope.join();
    }
}

하나의 하위 작업이 성공하자마자 범위가 취소되어 미완료 하위 작업들을 취소하고, join()은 성공한 하위 작업의 결과를 반환합니다.

관찰성

우리는 가상 스레드를 위해 추가된 JSON 스레드 덤프 형식을 확장하여 StructuredTaskScope가 스레드를 계층으로 그룹화하는 방법을 보여줍니다:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

각 범위에 대한 JSON 객체는 스택 추적과 함께 범위에서 포크 된 스레드들의 배열을 포함합니다.
범위의 소유자 스레드는 일반적으로 하위 작업이 완료되기를 기다리며 join 메서드에서 블로킹됩니다.
스레드 덤프는 구조화된 동시성에 의해 부과되는 트리 계층을 보여줌으로써 하위 작업의 스레드가 무엇을 하고 있는지 쉽게 볼 수 있게 해 줍니다.

대안

ExecutorService 인터페이스 개선

우리는 항상 구조를 강제하고 어떤 스레드가 작업을 제출할 수 있는지 제한하는 이 인터페이스의 구현을 프로토타입했습니다.
하지만 JDK와 생태계에서 ExecutorService와 그 부모 인터페이스 Executor의 대부분의 사용이 구조화되지 않기 때문에 문제가 있다는 것을 발견했습니다.
훨씬 더 제한적인 개념에 대해 같은 API를 재사용하는 것은 혼란을 야기할 수밖에 없습니다.

fork 메서드가 Future를 반환하도록 하기

StructuredTaskScope API가 인큐베이팅 중일 때, fork 메서드는 Future를 반환했습니다.
이는 기존 ExecutorService::submit 메서드와 유사하게 만들어 친숙함을 제공했습니다.
하지만 StructuredTaskScope가 구조화된 방식으로 사용되도록 의도된 반면 ExecutorService는 그렇지 않다는 사실이 명확성보다는 혼란을 가져왔습니다.

현재 API에서 Subtask::get()은 API가 인큐베이팅 중일 때 Future::resultNow()가 했던 것과 정확히 같게 동작합니다.

JEP 506: 범위 값 (Scoped Values)

요약

범위 값을 도입하여 메서드가 스레드 내에서 호출자들과 자식 스레드들과 불변 데이터를 공유할 수 있게 합니다.
범위 값은 스레드 로컬 변수보다 추론하기 쉽습니다.
또한 가상 스레드(JEP 444)와 구조화된 동시성(JEP 505)과 함께 사용할 때 특히 더 낮은 공간 및 시간 비용을 갖습니다.

역사

범위 값 API는 JEP 429 (JDK 20)에 의해 인큐베이션으로 제안되었고, JEP 446 (JDK 21)에 의해 미리 보기로 제안되었으며, 이후 JEP 464 (JDK 22), JEP 481 (JDK 23), JEP 487 (JDK 24)에 의해 개선되고 세련화되었습니다.

여기서 우리는 JDK 25에서 범위 값 API를 완성할 것을 제안하며, 한 가지 작은 변경사항이 있습니다:
ScopedValue.orElse 메서드는 더 이상 null을 인수로 받지 않습니다.

목표

  • 사용 편의성 — 데이터 흐름에 대해 추론하기 쉬워야 합니다.
  • 이해 가능성 — 공유 데이터의 수명이 코드의 구문 구조에서 명백해야 합니다.
  • 견고성 — 호출자가 공유한 데이터는 정당한 호출자들에 의해서만 검색 가능해야 합니다.
  • 성능 — 데이터는 많은 수의 스레드에 걸쳐 효율적으로 공유 가능해야 합니다.

비목표

  • Java 프로그래밍 언어를 변경하는 것은 목표가 아닙니다.
  • 스레드 로컬 변수로부터의 마이그레이션을 요구하거나 기존 ThreadLocal API를 deprecated 하는 것은 목표가 아닙니다.

동기

Java 애플리케이션과 라이브러리는 메서드를 포함하는 클래스들의 집합으로 구조화됩니다.
이러한 메서드들은 메서드 호출을 통해 통신합니다.

대부분의 메서드는 호출자가 데이터를 매개변수로 전달하여 메서드에 데이터를 전달할 수 있게 합니다.
메서드 A가 메서드 B에게 어떤 작업을 하도록 하고 싶을 때, 적절한 매개변수와 함께 B를 호출하고, B는 그 매개변수들 중 일부를 C에게 전달할 수 있습니다.
BB가 직접 필요한 것뿐만 아니라 BC에게 전달해야 하는 것들도 매개변수 목록에 포함해야 할 수 있습니다.

대부분의 경우 이 "간접 호출자들이 필요한 것을 전달하라" 접근법이 데이터를 공유하는 가장 효과적이고 편리한 방법입니다.
하지만 때로는 모든 간접 호출자가 필요할 수 있는 모든 데이터를 초기 호출에서 전달하는 것이 비실용적입니다.

예제

큰 Java 프로그램에서는 한 컴포넌트("프레임워크")에서 다른 컴포넌트("애플리케이션 코드")로 제어권을 이전한 다음 다시 돌아오는 것이 일반적인 패턴입니다.
예를 들어, 웹 프레임워크는 들어오는 HTTP 요청을 받아들이고 애플리케이션 핸들러를 호출하여 처리할 수 있습니다.
애플리케이션 핸들러는 데이터베이스에서 데이터를 읽거나 다른 HTTP 서비스를 호출하기 위해 프레임워크를 호출할 수 있습니다.

@Override
public void handle(Request request, Response response) {
    // 프레임워크에 의해 호출되는 사용자 코드
    ...
    var userInfo = readUserInfo();
    ...
}

private UserInfo readUserInfo() {
    // 프레임워크 호출
    return (UserInfo)framework.readKey("userInfo", context);
}

프레임워크는 인증된 사용자 ID, 트랜잭션 ID 등을 포함하는 FrameworkContext 객체를 유지하고 현재 트랜잭션과 연관시킬 수 있습니다.
모든 프레임워크 작업은 FrameworkContext 객체를 사용하지만, 사용자 코드에는 사용되지 않고 관련이 없습니다.

가장 간단한 방법은 호출 체인의 모든 메서드에 객체를 인수로 전달하는 것입니다:

@Override
void handle(Request request, Response response, FrameworkContext context) {
    ...
    var userInfo = readUserInfo(context);
    ...
}

private UserInfo readUserInfo(FrameworkContext context) {
    return (UserInfo)framework.readKey("userInfo", context);
}

하지만 이 접근법은 문제가 있습니다.
컨텍스트가 프레임워크의 내부 구현 세부사항이고 사용자 코드가 상호작용해서는 안 되는 것임에도 불구하고, 모든 중간 메서드들이 서명을 변경해야 합니다.

공유를 위한 스레드 로컬 변수

개발자들은 전통적으로 메서드 매개변수에 의존하지 않고 호출 스택의 메서드들 간에 데이터를 공유하기 위해 Java 1.2에서 도입된 스레드 로컬 변수를 사용해 왔습니다.
스레드 로컬 변수는 ThreadLocal 타입의 변수입니다.
일반 변수처럼 보이지만, 스레드 로컬 변수는 스레드마다 하나의 현재 값을 갖습니다.

다음은 같은 요청 처리 스레드에서 실행되는 두 프레임워크 메서드가 스레드 로컬 변수를 사용하여 FrameworkContext를 공유하는 방법의 예입니다:

public class Framework {

    private final Application application;

    public Framework(Application app) { this.application = app; }

    private static final ThreadLocal<FrameworkContext> CONTEXT 
                       = new ThreadLocal<>();    // (1)

    void serve(Request request, Response response) {
        var context = createContext(request);
        CONTEXT.set(context);                    // (2)
        Application.handle(request, response);
    }

    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();              // (3)
        var db = getDBConnection(context);
        db.readKey(key);
    }

}

스레드 로컬 변수의 문제점

불행히도 스레드 로컬 변수는 세 가지 고유한 설계 결함을 갖습니다:

  • 무제한 가변성 — 모든 스레드 로컬 변수는 가변입니다. 스레드 로컬 변수의 get 메서드를 호출할 수 있는 모든 코드가 언제든지 해당 변수의 set 메서드를 호출할 수 있습니다. 이는 스파게티 같은 데이터 흐름으로 이어질 수 있고, 어떤 메서드가 공유 상태를 어떤 순서로 업데이트하는지 파악하기 어려운 프로그램을 만들 수 있습니다.
  • 무제한 수명 — 스레드의 스레드 로컬 변수 사본이 set 메서드를 통해 설정되면, 그 값은 스레드의 수명 동안 또는 스레드의 코드가 remove 메서드를 호출할 때까지 유지됩니다. 불행히도 개발자들은 종종 remove를 호출하는 것을 잊어서, 스레드별 데이터가 필요 이상으로 오래 유지됩니다.
  • 비싼 상속 — 부모 스레드의 스레드 로컬 변수가 자식 스레드에 의해 상속될 수 있기 때문에 많은 수의 스레드를 사용할 때 스레드 로컬 변수의 오버헤드가 더 나빠질 수 있습니다. 자식 스레드는 부모 스레드에서 이전에 작성된 모든 스레드 로컬 변수에 대해 저장공간을 할당해야 합니다.

경량 공유를 향하여

스레드 로컬 변수의 문제들은 가상 스레드(JEP 444)의 가용성과 함께 더 긴급해졌습니다.
가상 스레드는 JDK에 의해 구현된 경량 스레드입니다. 많은 가상 스레드가 같은 운영 체제 스레드를 공유하여 매우 많은 수의 가상 스레드를 허용합니다.

가상 스레드가 Thread의 인스턴스이므로 가상 스레드도 스레드 로컬 변수를 가질 수 있습니다.
하지만 백만 개의 가상 스레드 각각이 스레드 로컬 변수의 자체 사본을 갖는다면 메모리 사용량이 상당할 수 있습니다.

요약하면, 스레드 로컬 변수는 일반적으로 데이터 공유에 필요한 것보다 더 복잡하고, 피할 수 없는 상당한 비용을 갖습니다.
Java Platform은 수천 또는 수백만 개의 가상 스레드에 대해 상속 가능한 스레드별 데이터를 유지하는 방법을 제공해야 합니다.

설명

범위 값은 데이터 값이 같은 스레드 내에서 메서드와 그 직접 및 간접 호출자들 사이에서, 그리고 자식 스레드들과 안전하고 효율적으로 공유될 수 있도록 하는 컨테이너 객체입니다.
메서드 매개변수에 의존하지 않습니다. 이는 ScopedValue 타입의 변수입니다.
일반적으로 static final 필드로 선언되며, 다른 클래스의 코드가 직접 접근할 수 없도록 접근성이 private로 설정됩니다.

스레드 로컬 변수와 마찬가지로, 범위 값은 스레드당 하나씩 여러 값이 연관됩니다.
사용되는 특정 값은 어떤 스레드가 그 메서드들을 호출하는지에 따라 다릅니다. 스레드 로컬 변수와 달리, 범위 값은 한 번 작성되고, 스레드 실행 중 제한된 기간 동안만 사용 가능합니다.

범위 값은 다음과 같이 사용됩니다:

static final ScopedValue<...> NAME = ScopedValue.newInstance();

// 어떤 메서드에서:
ScopedValue.where(NAME, <value>).run(() -> { ... NAME.get() ... call methods ... });

// 람다 표현식에서 직접 또는 간접적으로 호출되는 메서드에서:
... NAME.get() ...

코드의 구조는 스레드가 범위 값의 사본을 읽을 수 있는 기간을 구분합니다. 이 제한된 수명은 스레드 동작에 대한 추론을 크게 단순화합니다.

"범위"의 의미

범위(scope)는 무언가가 살아있는 공간입니다 — 사용될 수 있는 범위나 범위입니다.
예를 들어, Java 프로그래밍 언어에서 변수 선언의 범위는 프로그램 텍스트 내에서 간단한 이름으로 변수를 참조하는 것이 합법적인 공간입니다.

다른 종류의 범위는 동적 범위라고 불립니다.
어떤 것의 동적 범위는 프로그램이 실행될 때 그것을 사용할 수 있는 프로그램의 부분들을 가리킵니다.

웹 프레임워크 예제와 범위 값

앞서 보인 프레임워크 코드는 스레드 로컬 변수 대신 범위 값을 사용하도록 쉽게 다시 작성할 수 있습니다:

class Framework {

    private static final ScopedValue<FrameworkContext> CONTEXT
                        = ScopedValue.newInstance();    // (1)

    void serve(Request request, Response response) {
        var context = createContext(request);
        where(CONTEXT, context)                         // (2)
                   .run(() -> Application.handle(request, response));
    }

    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();                    // (3)
        var db = getDBConnection(context);
        db.readKey(key);
    }

}

(1)에서 프레임워크는 스레드 로컬 변수 대신 범위 값을 선언합니다.
(2)에서 serve 메서드는 스레드 로컬 변수의 set 메서드 대신 where ... run을 호출합니다.

run 메서드는 serve 메서드에서 readKey 메서드로 데이터의 일방향 공유를 제공합니다.
run에 전달된 범위 값은 run 호출의 수명 동안 해당 객체에 바인드 되므로, run에서 호출된 모든 메서드의 CONTEXT.get()은 그 값을 읽을 것입니다.

범위 값 재바인딩

범위 값에 set 메서드가 없다는 것은 호출자가 범위 값을 사용하여 같은 스레드의 호출자들에게 값을 안정적으로 통신할 수 있다는 의미입니다.
하지만 호출자 중 하나가 같은 범위 값을 사용하여 자체 호출자들에게 다른 값을 통신해야 하는 경우가 있습니다.
ScopedValue API는 후속 호출을 위해 새로운 중첩된 바인딩이 설정될 수 있도록 합니다:

private static final ScopedValue<String> X = ScopedValue.newInstance();

void foo() {
   where(X, "hello").run(() -> bar());
}

void bar() {
    System.out.println(X.get()); // hello 출력
    where(X, "goodbye").run(() -> baz());
    System.out.println(X.get()); // hello 출력
}

void baz() {
    System.out.println(X.get()); // goodbye 출력
}

barX의 값을 "hello"로 읽습니다.
이는 foo에서 설정된 범위의 바인딩이기 때문입니다. 하지만 barXgoodbye에 바인드 된 중첩된 범위를 설정하여 baz를 실행합니다.

"goodbye" 바인딩은 중첩된 범위 안에서만 효과가 있습니다. baz가 반환되면, bar 내부의 X 값은 "hello"로 되돌아갑니다.

범위 값 상속

웹 프레임워크 예제는 각 요청을 처리하기 위해 스레드를 전담하므로, 같은 스레드가 일부 프레임워크 코드, 그다음 애플리케이션 개발자의 사용자 코드, 그 다음 데이터베이스에 접근하기 위한 더 많은 프레임워크 코드를 실행합니다.
하지만 사용자 코드는 가상 스레드의 경량성을 활용하여 자체 가상 스레드를 생성하고 그 안에서 자체 코드를 실행할 수 있습니다.

요청 처리 스레드에서 실행되는 코드에 의해 공유되는 컨텍스트 데이터는 자식 스레드에서 실행되는 코드에서도 사용 가능해야 합니다.
교차 스레드 공유를 가능하게 하기 위해, 범위 값은 자식 스레드에 의해 상속될 수 있습니다.

사용자 코드가 가상 스레드를 생성하는 선호되는 메커니즘은 구조화된 동시성 API(JEP 505), 특히 StructuredTaskScope 클래스입니다.
부모 스레드의 범위 값은 StructuredTaskScope로 생성된 자식 스레드에 의해 자동으로 상속됩니다.

다음은 사용자 코드에서 뒤에서 일어나는 범위 값 상속의 예입니다:

@Override
public Response handle(Request request, Response response) {
      try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
          Supplier<UserInfo>     user  = scope.fork(() -> readUserInfo());  // (1)
          Supplier<List<Offer>> offers = scope.fork(() -> fetchOffers());   // (2)

          scope.join().throwIfFailed();  // 두 포크 모두 기다림
          return new Response(user.get(), order.get());
      } catch (Exception ex) {
          reportError(response, ex);
      }
}

StructuredTaskScope.fork는 요청 처리 스레드에서 — Framework.serve에서 — 만들어진 범위 값 CONTEXT의 바인딩이 자식 스레드에서 CONTEXT.get에 의해 읽히도록 보장합니다.

ScopedValue API

전체 ScopedValue API는 위에서 설명한 부분집합보다 더 풍부합니다. 우리는 ScopedValue<V>.where(V, <value>).run(...)을 사용하는 예제만 보여주었지만, API는 값을 반환하고 예외를 던질 수도 있는 call 메서드도 제공합니다:

try {
        var result = where(X, "hello").call(() -> bar());
        ... use result ...
    catch (Exception e) {
        handleFailure(e);
    }
        ...

또한, 호출 지점에서 여러 범위 값을 바인드 할 수 있습니다:

where(X, v).where(Y, w).run(() -> ... );

이 예제는 Xv에 바인드(또는 재바인드)되고 Yw에 바인드(또는 재바인드)된 상태로 작업을 실행합니다.

대안

범위 값의 많은 기능을 스레드 로컬 변수로 에뮬레이트하는 것이 가능하지만, 메모리 사용량, 보안, 성능 면에서 어느 정도 비용이 듭니다.

우리는 범위 값의 일부 특성을 지원하는 ThreadLocal의 수정된 버전을 실험했습니다. 하지만 스레드 로컬 변수의 추가적인 짐을 지는 것은 지나치게 부담스러운 구현이나 핵심 기능의 대부분에 대해 UnsupportedOperationException을 반환하는 API, 또는 둘 다를 초래합니다. 따라서 ThreadLocal을 수정하지 않고 범위 값을 완전히 별개의 개념으로 도입하는 것이 좋습니다.

JEP 507: Primitive Types in Patterns, instanceof, and switch (Third Preview)

요약

모든 패턴 컨텍스트에서 원시 타입을 허용하여 패턴 매칭을 향상하고, instanceofswitch를 모든 원시 타입과 함께 작동하도록 확장합니다. 이는 미리보기 언어 기능입니다.

역사

이 기능은 원래 JEP 455 (JDK 23)에 의해 제안되었고 JEP 488 (JDK 24)에 의해 변경 없이 재미리 보기 되었습니다. 여기서 우리는 변경 없이 세 번째로 미리 보기 할 것을 제안합니다.

목표

  • 균일한 데이터 탐색 — 원시 타입이든 참조 타입이든 모든 타입에 대해 타입 패턴을 허용하여 균일한 데이터 탐색을 가능하게 합니다.
  • 타입 패턴과 instanceof 정렬 — 타입 패턴을 instanceof와 정렬하고, instanceof를 안전한 캐스팅과 정렬합니다.
  • 중첩 및 최상위 패턴 컨텍스트 — 패턴 매칭이 중첩된 패턴 컨텍스트와 최상위 패턴 컨텍스트 모두에서 원시 타입을 사용할 수 있도록 합니다.
  • 안전한 구조 제공 — 안전하지 않은 캐스트로 인한 정보 손실 위험을 제거하는 사용하기 쉬운 구조를 제공합니다.
  • switch 확장 — Java 5의 switch 개선사항(enum switch)과 Java 7의 개선사항(string switch)에 이어, switch가 모든 원시 타입의 값을 처리할 수 있도록 합니다.

비목표

  • Java 언어에 새로운 종류의 변환을 추가하는 것은 목표가 아닙니다.

동기

원시 타입과 관련된 여러 제한사항이 패턴 매칭, instanceof, switch 사용 시 마찰을 일으킵니다.
이러한 제한사항을 제거하면 Java 언어가 더 균일하고 표현력이 향상될 것입니다.

switch를 위한 패턴 매칭이 원시 타입 패턴을 지원하지 않음

첫 번째 제한사항은 switch를 위한 패턴 매칭(JEP 441)이 원시 타입 패턴, 즉 원시 타입을 지정하는 타입 패턴을 지원하지 않는다는 것입니다.
case Integer i 또는 case String s와 같은 참조 타입을 지정하는 타입 패턴만 지원됩니다.

switch에서 원시 타입 패턴을 지원하면 다음 switch 표현식을 개선할 수 있습니다:

switch (x.getStatus()) {
    case 0 -> "okay";
    case 1 -> "warning";
    case 2 -> "error";
    default -> "unknown status: " + x.getStatus();
}

default 절을 매치된 값을 노출하는 원시 타입 패턴이 있는 case 절로 바꿀 수 있습니다:

switch (x.getStatus()) {
    case 0 -> "okay";
    case 1 -> "warning";
    case 2 -> "error";
    case int i -> "unknown status: " + i;
}

원시 타입 패턴을 지원하면 가드가 매치된 값을 검사할 수도 있습니다:

switch (x.getYearlyFlights()) {
    case 0 -> ...;
    case 1 -> ...;
    case 2 -> issueDiscount();
    case int i when i >= 100 -> issueGoldCard();
    case int i -> ... appropriate action when i > 2 && i < 100 ...
}

레코드 패턴의 원시 타입 지원 제한

또 다른 제한사항은 레코드 패턴이 원시 타입에 대한 제한된 지원을 갖는다는 것입니다.
레코드 패턴은 레코드를 개별 컴포넌트로 분해하여 데이터 처리를 간소화합니다.
컴포넌트가 원시 값일 때, 레코드 패턴은 값의 타입에 대해 정확해야 합니다.

예를 들어, 다음 레코드 클래스들을 통해 표현된 JSON 데이터를 처리한다고 가정해 봅시다:

sealed interface JsonValue {
    record JsonString(String s) implements JsonValue { }
    record JsonNumber(double d) implements JsonValue { }
    record JsonObject(Map<String, JsonValue> map) implements JsonValue { }
}

JSON은 정수와 비정수를 구분하지 않으므로, JsonNumber는 최대 유연성을 위해 double 컴포넌트로 숫자를 나타냅니다.
JsonNumber 레코드를 생성할 때 double을 전달할 필요가 없으며, 30과 같은 int를 전달할 수 있고 Java 컴파일러가 자동으로 intdouble로 확장합니다:

var json = new JsonObject(Map.of("name", new JsonString("John"),
                                 "age",  new JsonNumber(30)));

불행히도 레코드 패턴으로 JsonNumber를 분해하려고 할 때 Java 컴파일러는 그렇게 수용적이지 않습니다.
JsonNumberdouble 컴포넌트로 선언되었으므로, double에 대해 JsonNumber를 분해하고 수동으로 int로 변환해야 합니다:

if (json instanceof JsonObject(var map)
    && map.get("name") instanceof JsonString(String n)
    && map.get("age")  instanceof JsonNumber(double a)) {
    int age = (int)a;  // 피할 수 없는 (그리고 잠재적으로 손실을 일으키는!) 캐스트
}

원시 타입 패턴을 지원하면 이 제한을 해제할 수 있습니다.
패턴 매칭은 값을 원시 타입으로 변환하는 가능한 손실성 축소 변환을 보호할 수 있으며, 최상위 레벨과 레코드 패턴 내부에 중첩된 경우 모두에서 가능합니다.

instanceof를 위한 패턴 매칭이 원시 타입 패턴을 지원하지 않음

또 다른 제한사항은 instanceof를 위한 패턴 매칭(JEP 394)이 원시 타입 패턴을 지원하지 않는다는 것입니다.
참조 타입을 지정하는 타입 패턴만 지원됩니다.

원시 타입 패턴은 switch에서와 마찬가지로 instanceof에서도 유용할 것입니다.
instanceof의 목적은 광범위하게 말해서 값이 주어진 타입으로 안전하게 변환될 수 있는지 테스트하는 것입니다.
이것이 우리가 항상 instanceof와 캐스트 연산을 가까이에서 보는 이유입니다.

예를 들어, int 값을 byte로 변환하는 것은 명시적 캐스트로 수행되지만, 캐스트가 잠재적으로 손실을 일으킬 수 있으므로 힘든 범위 확인이 선행되어야 합니다:

if (i >= -128 && i <= 127) {
    byte b = (byte)i;
    ... b ...
}

instanceof의 원시 타입 패턴은 Java 언어에 내장된 손실성 변환을 포함하고 개발자들이 거의 30년 동안 손으로 코딩해 온 세심한 범위 확인을 피할 수 있게 해 줄 것입니다.
다음과 같이 다시 작성할 수 있습니다:

if (i instanceof byte b) {
    ... b ...
}

instanceof 연산자는 할당문의 편의성과 패턴 매칭의 안전성을 결합합니다.

instanceofswitch의 원시 타입

원시 타입 패턴 주변의 제한사항을 해제한다면, 관련된 제한사항도 해제하는 것이 도움이 될 것입니다:
instanceof가 패턴이 아닌 타입을 받을 때, 원시 타입이 아닌 참조 타입만 받습니다.

마지막으로, switchbyte, short, char, int 값은 받을 수 있지만 boolean, float, double, long 값은 받을 수 없다는 제한을 해제하는 것이 도움이 될 것입니다.

boolean 값에 대한 스위칭은 삼항 조건 연산자(?:)의 유용한 대안이 될 것입니다.
boolean 스위치는 표현식뿐만 아니라 명령문도 포함할 수 있기 때문입니다.

설명

Java 21에서 원시 타입 패턴은 레코드 패턴의 중첩된 패턴으로만 허용되며, 정확히 매치 후보의 타입을 명명할 때만 허용됩니다:

v instanceof JsonNumber(double a)

패턴 매칭으로 매치 후보 v의 더 균일한 데이터 탐색을 지원하기 위해 다음을 수행할 것입니다:

  1. 패턴 매칭 확장 — 원시 타입 패턴이 더 넓은 범위의 매치 후보 타입에 적용 가능하도록 패턴 매칭을 확장합니다.
  2. instanceof와 switch 향상 — 최상위 패턴으로 원시 타입 패턴을 지원하도록 instanceofswitch 구조를 향상합니다.
  3. instanceof 구조 추가 향상 — 패턴 매칭이 아닌 타입 테스팅에 사용될 때 모든 타입에 대해 테스트할 수 있도록 instanceof 구조를 추가로 향상합니다.
  4. switch 구조 추가 향상 — 모든 원시 타입과 함께 작동하도록 switch 구조를 추가로 향상합니다.

변환의 안전성

변환이 정확하다는 것은 정보 손실이 발생하지 않는다는 의미입니다.
변환이 정확한지 여부는 관련된 타입 쌍과 입력 값에 따라 다릅니다:

  • 무조건 정확한 변환 — 일부 쌍의 경우, 첫 번째 타입에서 두 번째 타입으로의 변환이 모든 값에 대해 정보를 잃지 않는다는 것이 컴파일 타임에 알려집니다. 예시로는 byte에서 int, int에서 long, String에서 Object 등이 있습니다.
  • 조건부 정확한 변환 — 다른 쌍의 경우, 값이 정보 손실 없이 첫 번째 타입에서 두 번째 타입으로 변환될 수 있는지 확인하기 위해 런타임 테스트가 필요합니다. 예시로는 long에서 int, int에서 float 등이 있습니다.

instanceof를 안전한 캐스팅의 전제조건으로

instanceof를 사용한 타입 테스트는 전통적으로 참조 타입에 제한되었습니다.
instanceof의 고전적 의미는 전제조건 확인으로서 "이 값을 이 타입으로 캐스트 하는 것이 안전하고 유용할까?"라고 묻는 것입니다.

원시 타입에서 instanceof 타입 테스트 연산자를 활성화하기 위해, 왼쪽 피연산자의 타입이 참조 타입이어야 하고 오른쪽 피연산자가 참조 타입을 지정해야 한다는 제한을 제거합니다.

다음은 확장된 instanceof가 캐스팅을 어떻게 보호할 수 있는지의 몇 가지 예입니다:

byte b = 42;
b instanceof int;         // true (무조건 정확)

int i = 42;
i instanceof byte;        // true (정확)

int i = 1000;
i instanceof byte;        // false (정확하지 않음)

int i = 16_777_217;       // 2^24 + 1
i instanceof float;       // false (정확하지 않음)
i instanceof double;      // true (무조건 정확)

instanceofswitch의 원시 타입 패턴

타입 패턴은 타입 테스트를 조건부 변환과 병합합니다.
이는 타입 테스트가 성공하면 명시적 캐스트의 필요성을 피하고, 타입 테스트가 실패하면 캐스트 되지 않은 값을 다른 분기에서 처리할 수 있게 합니다.

힘들고 오류 발생 가능성이 높은 코드:

int i = 1000;
if (i instanceof byte) {    // false -- i가 byte로 정확히 변환될 수 없음
    byte b = (byte)i;       // 잠재적으로 손실됨
    ... b ...
}

다음과 같이 작성할 수 있습니다:

if (i instanceof byte b) {
    ... b ...               // 정보 손실 없음
}

소진성 (Exhaustiveness)

switch 표현식, 또는 case 레이블이 패턴인 switch 문은 소진적이어야 합니다:
선택자 표현식의 모든 가능한 값이 switch 블록에서 처리되어야 합니다.

원시 타입 패턴의 도입과 함께, 소진성 결정에 새로운 규칙을 하나 추가합니다:
매치 후보가 어떤 원시 타입 P에 대한 래퍼 타입 Wswitch가 주어질 때, 타입 패턴 T tTP에서 무조건 정확하면 W를 소진합니다.

switch에서 확장된 원시 타입 지원

우리는 나머지 원시 타입, 즉 long, float, double, boolean과 해당 박싱된 타입을 다루도록 switch 구조를 향상합니다.

선택자 표현식이 long, float, double, 또는 boolean 타입을 갖는 경우, case 레이블에 사용되는 모든 상수는 선택자 표현식과 같은 타입이거나 해당 박싱된 타입이어야 합니다.

boolean 타입은 두 개의 고유한 값만 갖으므로, truefalse case를 모두 나열하는 switch는 소진적으로 간주됩니다:

boolean v = ...
switch (v) {
    case true -> ...
    case false -> ...
    // 또는: case true, false -> ...
}

향후 작업

Java 언어의 타입 비교와 패턴 매칭 주변의 규칙을 정규화한 후, 상수 패턴 도입을 고려할 수 있습니다.
현재 switch에서 상수는 case 상수로만 나타날 수 있습니다.

여기서 정의된 적용성 규칙 덕분에, 상수가 레코드 패턴에 나타날 수 있게 될 수 있습니다.
switch에서 case Box(42)case Box(int i) when i == 42를 의미할 것입니다.

JEP 511: 모듈 임포트 선언

요약

모듈에서 내보내는 모든 패키지를 간결하게 임포트 할 수 있는 기능으로 Java 프로그래밍 언어를 향상합니다.
이는 모듈형 라이브러리의 재사용을 단순화하지만, 임포트 하는 코드 자체가 모듈에 있을 필요는 없습니다.

역사

모듈 임포트 선언은 JEP 476 (JDK 23)에서 미리 보기 기능으로 제안되었고 이후 JEP 494 (JDK 24)에서 개선되었습니다.
여기서 우리는 변경 없이 JDK 25에서 이 기능을 최종화 할 것을 제안합니다.

목표

  • 전체 모듈을 한 번에 임포트 할 수 있게 하여 모듈형 라이브러리의 재사용을 단순화합니다.
  • 모듈에서 내보내는 API의 다양한 부분을 사용할 때 여러 타입-임포트-온-디맨드 선언(예: import com.foo.bar.*)의 노이즈를 피합니다.
  • 초심자들이 패키지 계층 구조에서 위치를 배우지 않고도 서드파티 라이브러리와 기본 Java 클래스를 더 쉽게 사용할 수 있게 합니다.
  • 모듈 임포트 선언이 기존 임포트 선언과 원활하게 작동하도록 보장합니다.
  • 모듈 임포트 기능을 사용하는 개발자가 자신의 코드를 모듈화 할 필요가 없도록 합니다.

동기

Object, String, Comparable과 같은 java.lang 패키지의 클래스와 인터페이스는 모든 Java 프로그램에 필수적입니다.
이러한 이유로 Java 컴파일러는 자동으로 모든 소스 파일의 시작 부분에 import java.lang.*;이 나타나는 것처럼 java.lang 패키지의 모든 클래스와 인터페이스를 온-디맨드로 자동 임포트합니다.

Java 플랫폼이 발전하면서 List, Map, Stream, Path와 같은 클래스와 인터페이스도 거의 필수적이 되었습니다.
하지만 이들 중 어느 것도 java.lang에 없으므로 자동으로 임포트 되지 않습니다;
대신 개발자들은 모든 소스 파일의 시작 부분에 수많은 import 선언을 작성하여 컴파일러를 만족시켜야 합니다.

설명

모듈 임포트 선언의 형태는 다음과 같습니다:

import module M;

이는 다음을 온-디맨드로 임포트 합니다:

  • 모듈 M이 현재 모듈에 내보내는 패키지의 모든 public 최상위 클래스와 인터페이스
  • 현재 모듈이 모듈 M을 읽음으로써 읽게 되는 모듈들이 내보내는 패키지의 모든 public 최상위 클래스와 인터페이스

구문과 의미론

우리는 임포트 선언의 문법을 확장하여 import module 절을 포함합니다:

ImportDeclaration:
  SingleTypeImportDeclaration
  TypeImportOnDemandDeclaration
  SingleStaticImportDeclaration
  StaticImportOnDemandDeclaration
  ModuleImportDeclaration

ModuleImportDeclaration:
  import module ModuleName;

import module은 모듈 이름을 받으므로 무명 모듈, 즉 클래스 패스에서 패키지를 임포트 하는 것은 불가능합니다.

모호한 임포트 해결

모듈을 임포트하는 것은 여러 패키지를 임포트하는 효과가 있으므로, 다른 패키지에서 같은 단순 이름을 가진 클래스를 임포트 할 수 있습니다.
단순 이름이 모호하므로 사용하면 컴파일 타임 오류가 발생합니다.

예를 들어:

import module java.base;      // java.util.Date 클래스를 내보냄
import module java.sql;       // java.sql.Date 클래스를 내보냄

import java.sql.Date;         // 단순 이름 Date의 모호성 해결!

Date d = ...                  // 성공! Date는 java.sql.Date로 해결됨

예제

// 지정된 알고리즘에 대한 KDF 객체 생성
import module java.base;      // 54개 패키지 임포트와 동일한 효과

// 이제 List, Map, Stream, Path 등을 직접 사용 가능
List<String> list = new ArrayList<>();
Map<String, Integer> map = new HashMap<>();
Stream<String> stream = list.stream();
Path path = Paths.get("file.txt");

컴팩트 소스 파일에서 모듈 임포트

이 JEP는 컴팩트 소스 파일과 인스턴스 메인 메서드 JEP와 함께 개발되었으며, 이는 java.base 모듈에서 내보내는 모든 패키지의 모든 public 최상위 클래스와 인터페이스가 컴팩트 소스 파일에서 자동으로 온-디맨드로 임포트 된다고 명시합니다.
즉, 모든 그러한 파일의 시작 부분에 import module java.base가 나타나는 것과 같습니다.

대안

  • import module ...의 대안은 java.lang보다 더 많은 패키지를 자동으로 임포트 하는 것입니다. 이는 더 많은 클래스를 범위에 가져와서 단순 이름으로 사용할 수 있게 하고, 초심자들이 어떤 종류의 임포트도 배워야 할 필요성을 늦출 것입니다. 하지만 어떤 추가 패키지를 자동으로 임포트해야 할까요?
  • 이 기능의 중요한 사용 사례는 암시적으로 선언된 클래스에서 java.base 모듈로부터 자동으로 온-디맨드 임포트하는 것입니다. 이는 대안적으로 java.base에서 내보내는 54개 패키지를 자동으로 임포트 하여 달성할 수 있습니다.

위험과 가정

하나 이상의 모듈 임포트 선언을 사용하면 다른 패키지들이 같은 단순 이름을 가진 멤버를 선언하기 때문에 이름 모호성의 위험이 있습니다.
이 모호성은 모호한 단순 이름이 프로그램에서 사용될 때까지 감지되지 않으며, 그때 컴파일 타임 오류가 발생합니다.
모호성은 단일-타입-임포트 선언을 추가하여 해결할 수 있지만, 그러한 이름 모호성을 관리하고 해결하는 것은 부담스럽고 취약하며 읽고 유지하기 어려운 코드로 이어질 수 있습니다.

JEP 512: 컴팩트 소스 파일과 인스턴스 메인 메서드

요약

초보자가 대형 프로그램을 위한 언어 기능을 이해하지 않고도 첫 Java 프로그램을 작성할 수 있도록 Java 프로그래밍 언어를 발전시킵니다.
별도의 언어 방언을 사용하는 것이 아니라, 초보자는 단일 클래스 프로그램을 위한 간결한 선언을 작성할 수 있고, 기술이 성장함에 따라 더 고급 기능을 자연스럽게 사용할 수 있습니다.
숙련된 개발자도 대형 프로그램을 위한 구조 없이 작은 프로그램을 간결하게 작성할 수 있습니다.

역사

이 기능은 JEP 445 (JDK 21)에서 미리 보기로 처음 제안되었고, JEP 463 (JDK 22), JEP 477 (JDK 23), JEP 495 (JDK 24)에서 개선되었습니다.
여기서는 JDK 25에서 기능을 최종화 하며, 여러 경험과 피드백을 반영한 소폭 개선이 포함됩니다.

  • 기본 콘솔 I/O를 위한 새로운 IO 클래스가 이제 java.lang 패키지에 포함되어 모든 소스 파일에서 암시적으로 임포트 됩니다.
  • IO 클래스의 static 메서드는 더 이상 컴팩트 소스 파일에 암시적으로 임포트 되지 않으므로, 메서드 호출 시 클래스명을 명시해야 합니다.
  • IO 클래스의 구현은 이제 java.io.Console 대신 System.outSystem.in을 기반으로 합니다.

목표

  • Java 프로그래밍에 부드러운 진입로(on-ramp)를 제공하여, 강사가 개념을 점진적으로 소개할 수 있도록 합니다.
  • 학생들이 간결하게 간단한 프로그램을 작성하고, 기술이 성장함에 따라 코드를 자연스럽게 확장할 수 있도록 합니다.
  • 스크립트, 커맨드라인 유틸리티 등 다른 종류의 작은 프로그램 작성 시 불필요한 형식을 줄입니다.
  • Java 언어의 별도 방언을 도입하지 않습니다.
  • 별도의 툴체인을 도입하지 않습니다. 작은 Java 프로그램도 대형 프로그램과 동일한 도구로 컴파일 및 실행할 수 있어야 합니다.

동기

Java는 대형, 복잡한 애플리케이션에 탁월한 언어입니다. 하지만 초보자는 대형 프로그램이 아닌 작은 프로그램을 혼자 작성합니다.
이때 캡슐화, 네임스페이스, 모듈 등은 필요하지 않습니다.
프로그래밍 교육에서도 변수, 제어 흐름, 서브루틴 등 프로그래밍-인-더-스몰 개념부터 시작합니다. 클래스, 패키지, 모듈 등은 나중에 배워도 됩니다.

예를 들어, Hello, World! 프로그램은 다음과 같이 작성됩니다:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

이 코드에는 초보자에게 불필요한 형식이 많습니다.
클래스 선언, public 접근 지정자, String[] args 파라미터, static 키워드 등은 대형 프로그램에는 유용하지만, 작은 예제에는 불필요합니다.
초보자는 이런 부분에서 혼란을 겪고, 언어가 복잡하다는 인상을 받습니다.

이 JEP의 목적은 단순히 형식을 줄이는 것이 아니라, 초보자가 변수, 제어 흐름, 서브루틴 등 기본 개념부터 배우고, 필요할 때 대형 프로그램 개념을 자연스럽게 익힐 수 있도록 하는 것입니다.
또한, 시스템 관리자, 도메인 전문가 등 작은 프로그램을 작성하는 모든 사람에게도 도움이 됩니다.

설명

인스턴스 main 메서드

기존에는 프로그램의 진입점이 public static void main(String[] args)로 고정되어 있었습니다.
이 JEP는 mainstatic이 아니어도 되고, public이나 배열 파라미터가 없어도 되도록 허용합니다.
예를 들어:

class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

이 프로그램은 HelloWorld.java 파일에 저장되어 있다면, 다음과 같이 실행할 수 있습니다:

$ java HelloWorld.java

런처는 지정된 클래스의 main 메서드를 선택하여 실행합니다.
String[] 파라미터가 있으면 그 메서드를, 없으면 파라미터 없는 메서드를 선택합니다.
static이면 바로 실행하고, 아니면 기본 생성자로 객체를 생성한 뒤 인스턴스 메서드를 실행합니다.

컴팩트 소스 파일

클래스 선언 없이 필드와 메서드만 있는 소스 파일을 컴파일러가 암시적으로 클래스를 선언한 것으로 간주합니다.
이를 컴팩트 소스 파일이라 부릅니다. 예를 들어:

void main() {
    System.out.println("Hello, World!");
}

컴팩트 소스 파일의 암시적 클래스는 다음과 같습니다:

  • 무명 패키지의 final 최상위 클래스
  • java.lang.Object를 상속하며 인터페이스는 구현하지 않음
  • 파라미터 없는 기본 생성자만 가짐
  • 소스 파일의 필드와 메서드를 멤버로 가짐
  • 실행 가능한 main 메서드가 반드시 있어야 함

필드와 메서드는 암시적 클래스의 멤버로 해석되므로, this로 현재 인스턴스를 참조할 수 있습니다.
하지만 new로 인스턴스를 생성할 수는 없습니다.

컴팩트 소스 파일은 기존 단일 파일 소스 코드 프로그램과 동일하게 실행할 수 있습니다.
javadoc 도구도 암시적 클래스의 멤버를 문서화할 수 있습니다.

콘솔과의 상호작용

초보자는 콘솔 입출력을 자주 사용합니다.
기존에는 System.out.println이 필요했지만, 초보자에게는 복잡합니다.
입력은 더 복잡한 코드가 필요합니다.
이를 단순화하기 위해 java.lang.IO 클래스를 추가하여 다음과 같은 static 메서드를 제공합니다:

public static void print(Object obj);
public static void println(Object obj);
public static void println();
public static String readln(String prompt);
public static String readln();

이제 초보자는 다음과 같이 간단하게 코드를 작성할 수 있습니다:

void main() {
    IO.println("Hello, World!");
}

void main() {
    String name = IO.readln("Please enter your name: ");
    IO.print("Pleased to meet you, ");
    IO.println(name);
}

IO 클래스는 java.lang 패키지에 있으므로 별도의 임포트 없이 사용할 수 있습니다.

java.base 모듈의 자동 임포트

컴팩트 소스 파일에서는 java.base 모듈이 내보내는 모든 패키지의 public 최상위 클래스와 인터페이스가 온-디맨드로 자동 임포트됩니다.
즉, List, Map, Stream, Path 등도 바로 사용할 수 있습니다.

void main() {
    var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
    for (var name : authors) {
        IO.println(name + ": " + name.length());
    }
}

경험 많은 개발자는 명시적 임포트를 사용할 수 있지만, 초보자는 임포트 없이도 주요 API를 바로 사용할 수 있습니다.

프로그램 확장

컴팩트 소스 파일의 작은 프로그램을 명시적 클래스 선언과 임포트 선언을 추가하여 대형 프로그램의 컴포넌트로 쉽게 확장할 수 있습니다.

대안

  • 콘솔 I/O 메서드를 자동 임포트 하는 방안도 고려했으나, 코드 확장 시 static 임포트 선언을 추가해야 하므로 채택하지 않았습니다.
  • java.base의 54개 패키지 중 일부만 자동 임포트하는 방안도 있었으나, 어떤 패키지를 선택할지 결정하기 어렵고, 플랫폼 진화에 따라 변경될 수 있으므로 모든 패키지를 임포트하는 것이 일관되고 합리적입니다.
  • 최상위 문장을 허용하는 방안도 있었으나, 메서드 선언이 불가능해지고 람다 등에서 변수 접근이 제한되어 채택하지 않았습니다.
  • JShell을 확장하는 방안도 있었으나, JShell은 실제 프로그램이 아닌 코드 스니펫의 연속이므로 현실적인 대안이 아닙니다.
  • 별도의 Java 방언을 도입하는 방안도 있었으나, 작은 프로그램을 대형 프로그램으로 자연스럽게 확장하는 것이 더 중요하므로 채택하지 않았습니다.

JEP 513: 유연한 생성자 본문 (Flexible Constructor Bodies)

요약

생성자 본문에서 명시적 생성자 호출(super(...) 또는 this(...)) 전에 구문을 허용합니다.
이러한 구문은 생성 중인 객체를 참조할 수 없지만, 필드 초기화나 기타 안전한 연산을 수행할 수 있습니다.
이 변경은 많은 생성자를 더 자연스럽게 표현할 수 있게 하며, 필드가 다른 코드(예: 슈퍼클래스 생성자에서 호출되는 메서드)에 노출되기 전에 초기화할 수 있도록 하여 안전성을 향상합니다.

역사

유연한 생성자 본문은 JDK 22의 JEP 447에서 미리 보기 기능으로 처음 제안되었으며, JEP 482(JDK 23)에서 수정 및 재미리 보기, JEP 492(JDK 24)에서 변경 없이 다시 미리 보기 되었습니다.
JDK 25에서 변경 없이 최종화 되었습니다.

목표

  • 생성자 내 코드에 대한 불필요한 제한을 제거하여, 인자를 슈퍼클래스 생성자 호출 전에 쉽게 검증할 수 있도록 함
  • 새 객체의 상태가 완전히 초기화된 후에만 코드가 이를 사용할 수 있도록 추가적인 보장 제공
  • 생성자들이 상호작용하여 완전히 초기화된 객체를 만드는 과정을 재설계

동기

생성자는 너무 제한적이다

생성자에 대한 top-down 규칙은 새로 생성된 인스턴스가 유효함을 보장하지만, 익숙하고 합리적인 프로그래밍 패턴을 금지합니다.
개발자들은 생성자에서 완전히 안전한 코드를 작성할 수 없다는 점에 종종 불만을 가집니다.

예를 들어, Person 클래스에 age 필드가 있고, 직원은 18~67세 사이여야 한다고 가정합니다.
Employee 생성자에서 age 인자를 Person 생성자에 전달하기 전에 검증하고 싶지만, 생성자 호출이 먼저 와야 하므로 검증은 그 뒤에 할 수밖에 없습니다.
이는 불필요하게 슈퍼클래스 생성자를 호출하게 만듭니다.

class Person {
    int age;
    Person(..., int age) {
        if (age < 0)
            throw new IllegalArgumentException(...);
        this.age = age;
    }
}
class Employee extends Person {
    Employee(..., int age) {
        super(..., age); // 불필요한 작업
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
    }
}

더 나은 방법은 슈퍼클래스 생성자 호출 전에 인자를 검증하여 빠르게 실패하는 것입니다.
하지만 생성자 호출이 먼저 와야 하므로, 검증 로직을 별도의 static 메서드로 분리해 생성자 호출 인자에서 사용해야 합니다.

class Employee extends Person {
    private static int verifyAge(int value) {
        if (value < 18 || value > 67)
            throw new IllegalArgumentException(...);
        return value;
    }
    Employee(..., int age) {
        super(..., verifyAge(age));
    }
}

이 규칙은 다른 상황에서도 문제를 일으킵니다.
예를 들어, 슈퍼클래스 생성자 호출 인자를 준비하기 위해 복잡한 계산이 필요하거나, 여러 인자에 공유되는 값을 준비해야 할 때도 마찬가지입니다.

슈퍼클래스 생성자가 서브클래스의 무결성을 침해할 수 있다

각 클래스는 자신의 필드가 유효한 상태임을 보장하는 명시적 또는 암묵적 명세를 가집니다.
올바르게 작성된 클래스는 자신의 슈퍼클래스, 서브클래스, 프로그램 내 다른 모든 클래스의 동작과 관계없이 오직 유효한 상태만을 설정·유지합니다.
즉, 모든 클래스는 무결성을 가져야 합니다. 인스턴스의 무결성은 해당 클래스와 모든 슈퍼클래스가 무결성을 가질 때 성립합니다.

top-down 규칙은 슈퍼클래스 생성자가 서브클래스 생성자보다 먼저 실행되어 슈퍼클래스의 필드가 제대로 초기화됨을 보장합니다.
하지만 이 규칙만으로는 인스턴스 전체의 무결성을 보장할 수 없습니다.
슈퍼클래스 생성자가 서브클래스의 필드를 서브클래스 생성자가 초기화하기 전에 간접적으로 접근할 수 있기 때문입니다.

예를 들어, Employee 클래스에 officeID 필드가 있고, Person 생성자가 오버라이드된 메서드를 호출하는 경우를 생각해 봅시다.

class Person {
    int age;
    void show() {
        System.out.println("Age: " + this.age);
    }
    Person(..., int age) {
        if (age < 0)
            throw new IllegalArgumentException(...);
        this.age = age;
        show();
    }
}
class Employee extends Person {
    String officeID;
    @Override
    void show() {
        System.out.println("Age: " + this.age);
        System.out.println("Office: " + this.officeID);
    }
    Employee(..., int age, String officeID) {
        super(..., age);
        if (age < 18  || age > 67)
            throw new IllegalArgumentException(...);
        this.officeID = officeID;
    }
}

new Employee(42, "CAM-FORA")를 실행하면 Office: null이 출력됩니다.
이는 Person 생성자가 show()를 호출할 때 officeID가 아직 초기화되지 않았기 때문입니다.
이 현상은 Employee 클래스의 무결성을 침해합니다. 심지어 final 필드도 초기화되기 전에 접근될 수 있습니다.

이런 문제는 생성자에서 오버라이드 가능한 메서드를 호출할 수 있기 때문입니다.
이는 나쁜 습관으로 간주되지만(Effective Java, Item 19), 실제로 자주 발생하며 미묘한 버그의 원인이 됩니다.
또 다른 예로, 슈퍼클래스 생성자가 현재 인스턴스를 다른 메서드에 전달해 서브클래스 필드가 초기화되기 전에 접근하는 경우도 있습니다.

더 표현력 있고 안전한 생성자를 향하여

결국 top-down 규칙은 생성자의 표현력을 제한합니다.
또한 클래스가 자신의 슈퍼클래스나 다른 코드에 의해 무결성이 침해되는 것을 방어할 방법이 거의 없습니다.
두 문제 모두 해결이 필요합니다.

기술적 설명

생성자 본문의 새로운 모델

top-down 규칙을 폐지하면 생성자 본문에 새로운 의미론적 모델이 적용됩니다.
생성자 본문은 두 단계로 나뉩니다:
명시적 생성자 호출 전의 코드(프로로그)와 호출 후의 코드(에필로그)입니다.

예를 들어, 다음과 같은 클래스 계층을 생각해 봅시다.

class Object {
    Object() {
        // Object 생성자 본문
    }
}
class A extends Object {
    A() {
        super();
        // A 생성자 본문
    }
}
class B extends A {
    B() {
        super();
        // B 생성자 본문
    }
}
class C extends B {
    C() {
        super();
        // C 생성자 본문
    }
}
class D extends C {
    D() {
        super();
        // D 생성자 본문
    }
}

현재는 new D()를 실행하면 생성자 호출과 본문 실행이 다음과 같이 흐릅니다.

D
--> C
    --> B
        --> A
            --> Object 생성자 본문
        --> A 생성자 본문
    --> B 생성자 본문
--> C 생성자 본문
D 생성자 본문

즉, 생성자 호출은 계층의 하위에서 상위로(bottom-up), 생성자 본문은 상위에서 하위로(top-down) 실행됩니다.

프로로그와 에필로그가 있는 생성자 본문에서는 다음과 같이 일반화할 수 있습니다.

class Object {
    Object() {
        // Object 생성자 본문
    }
}
class A extends Object {
    A() {
        // A 프로로그
        super();
        // A 에필로그
    }
}
class B extends A {
    B() {
        // B 프로로그
        super();
        // B 에필로그
    }
}
class C extends B {
    C() {
        // C 프로로그
        super();
        // C 에필로그
    }
}
class D extends C {
    D() {
        // D 프로로그
        super();
        // D 에필로그
    }
}

실행 순서:

D 프로로그
--> C 프로로그
    --> B 프로로그
        --> A 프로로그
            --> Object 생성자 본문
        --> A 에필로그
    --> B 에필로그
--> C 에필로그
D 에필로그

즉, 프로로그는 하위에서 상위로, 에필로그는 상위에서 하위로 실행됩니다.
각 서브클래스의 필드를 프로로그에서 유효한 값으로 할당하면, 에필로그에서는 인스턴스가 유효한 상태임을 보장받고 자유롭게 참조할 수 있습니다.

문법

생성자 본문 문법을 다음과 같이 수정합니다.
기존:

ConstructorBody:
    { [ExplicitConstructorInvocation] [BlockStatements] }

변경:

ConstructorBody:
    { [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
    { [BlockStatements] }

명시적 생성자 호출이 없는 경우, 프로로그는 비어 있고, 슈퍼클래스의 인자가 없는 생성자 호출(super())이 암시적으로 본문 시작에 삽입됩니다.
본문의 모든 구문은 에필로그가 됩니다.

에필로그에서는 return 문을 사용할 수 있지만, 반환값을 포함해서는 안 됩니다.
프로로그에서는 return 문 사용 불가입니다. 프로로그/에필로그 모두에서 예외를 던질 수 있습니다.
프로로그에서 예외를 던지는 것은 fail-fast 패턴에 적합합니다.

Early Construction Context(초기 생성 컨텍스트)

현재 명시적 생성자 호출 인자에 들어가는 코드는 static context로 간주되어 인스턴스 참조가 불가합니다.
이 규칙은 너무 강력하여, 안전하고 유용한 코드도 금지합니다.
이에 static context 대신 early construction context 개념을 도입합니다.
이는 명시적 생성자 호출 인자와 프로로그 전체를 포함합니다.
이 컨텍스트에서는 생성 중인 인스턴스를 참조할 수 없으며, 단순 대입만 허용됩니다.

예시:

class X {
    int i;
    String s = "hello";
    X() {
        System.out.print(this);  // 오류 - 인스턴스 참조
        var x = this.i;          // 오류 - 필드 참조
        this.hashCode();         // 오류 - 메서드 참조
        var y = i;               // 오류 - 암묵적 필드 참조
        hashCode();              // 오류 - 암묵적 메서드 참조
        i = 42;                  // OK - 초기화되지 않은 필드 대입
        s = "goodbye";           // 오류 - 이미 초기화된 필드 대입
        super();
    }
}

슈퍼클래스의 필드나 메서드도 초기 생성 컨텍스트에서 접근 불가합니다.

class Y {
    int i;
    void m() { ... }
}
class Z extends Y {
    Z() {
        var x = super.i;         // 오류
        super.m();               // 오류
        super();
    }
}

레코드

레코드 클래스의 생성자는 이미 일반 클래스보다 더 많은 제약을 받습니다.

  • 정형 레코드 생성자는 명시적 생성자 호출을 포함할 수 없습니다.
  • 비정형 레코드 생성자는 반드시 this(...) 호출을 포함해야 하며, super(...) 호출은 불가합니다.
    이 규칙은 유지됩니다.
    그 외에는 비정형 레코드 생성자에서 명시적 생성자 호출 전에 구문을 허용하는 등 본 JEP의 혜택을 받습니다.

enum

enum 클래스의 생성자는 this(...) 호출은 허용되지만, super(...) 호출은 불가합니다.
레코드와 마찬가지로 명시적 생성자 호출 전에 구문을 허용하는 등 본 JEP의 혜택을 받습니다.

중첩 클래스

클래스 선언이 중첩된 경우, 내부 클래스의 코드는 외부 인스턴스를 참조할 수 있습니다.
외부 인스턴스가 먼저 생성되므로, 내부 클래스 생성자 본문(초기 생성 컨텍스트 포함)에서 외부 인스턴스의 필드/메서드에 접근할 수 있습니다.

예시:

class Outer {
    int i;
    void hello() { System.out.println("Hello"); }
    class Inner {
        int j;
        Inner() {
            var x = i;             // OK - 외부 필드 참조
            var y = Outer.this.i;  // OK - 외부 필드 명시적 참조
            hello();               // OK - 외부 메서드 참조
            Outer.this.hello();    // OK - 외부 메서드 명시적 참조
            super();
        }
    }
}

반면, 외부 클래스 생성자에서는 내부 클래스 인스턴스화가 불가합니다.
이는 this.new Inner()와 같이 현재 인스턴스를 참조하기 때문입니다.

class Outer {
    class Inner {}
    Outer() {
        var x = new Inner();       // 오류 - 암묵적 인스턴스 참조
        var y = this.new Inner();  // 오류 - 명시적 인스턴스 참조
        super();
    }
}

테스트

  • 기존 단위 테스트와 새로운 테스트 케이스로 컴파일러 변경 사항 검증
  • JDK 전체 클래스 컴파일 결과 바이트코드 비교
  • 플랫폼별 추가 테스트 불필요

위험 및 가정

본 JEP의 변경은 소스 및 동작 호환성을 유지하며, 기존 프로그램의 의미를 변경하지 않습니다.
합법적인 Java 프로그램의 범위를 확장합니다.
다만, 코드 분석 도구, 스타일 검사기, IDE 등에서 규칙 변경에 따른 업데이트가 필요할 수 있습니다.

의존성

JVM

자바 언어의 유연한 생성자 본문은 JVM이 생성자 호출 전에 임의의 코드를 실행할 수 있도록 지원해야 합니다.
단, 생성 중인 인스턴스 참조는 불가하며, 초기화되지 않은 필드 대입만 허용됩니다.

JVM은 이미 다음을 지원합니다:

  • 생성자 본문에 여러 생성자 호출이 있을 수 있지만, 모든 경로에서 정확히 한 번만 호출되어야 함
  • 생성자 호출 전 임의의 코드 허용(단, 인스턴스 참조 불가)
  • 명시적 생성자 호출은 try 블록 내에 올 수 없음

JVM의 규칙:

  • 슈퍼클래스 초기화는 반드시 한 번만 발생
  • 초기화되지 않은 인스턴스는 필드 대입 외 접근 불가

따라서 JVM 명세 변경은 필요 없으며, 자바 언어 명세만 수정하면 됩니다.

JVM과 자바 언어 명세의 불일치는 과거에 컴파일러 생성 필드 초기화 문제를 해결하기 위해 JVM 명세를 완화했으나, 언어 명세는 이를 반영하지 않았기 때문입니다.

값 클래스

Project Valhalla(JEP 401)에서 제안된 값 클래스는 본 JEP의 모델을 기반으로 합니다.
값 클래스 생성자에 명시적 생성자 호출이 없으면, 암시적 호출이 본문 끝에 삽입되고, 본문 전체가 프로로그가 됩니다.

반응형