원문 캡처 · xpipe.io
원문 캡처 · xpipe.io

자바의 AOT(사전 컴파일) 캐시는 애플리케이션의 시작과 예열 시간을 줄이도록 설계됐지만, 실제 배포 환경에서는 CPU 차이로 JVM이 충돌하거나 클래스 연결 최적화가 빠질 수 있다. XPipe 팀은 캐시 생성과 적용 과정의 함정을 정리하고, 이를 피하기 위해 사용한 빌드 설정을 공개했다.

CPU 차이와 캐시 적용 문제

AOT 캐시는 애플리케이션을 훈련 실행해 JVM이 로드한 클래스와 호출한 메서드 등을 기록한 뒤, 다음 실행에서 초기화 작업을 건너뛰도록 한다. 애플리케이션 코드를 바꾸지 않고 시작 성능을 높일 수 있지만, XPipe 팀은 실제 사용자에게 배포할 때 캐시가 적용되지 않아 이득을 얻지 못하거나 JVM이 예기치 않게 충돌하는 문제를 겪었다. 이 글에는 성능 측정이나 비교 결과가 담기지 않았다.

캐시에는 클래스 데이터와 메서드 프로필, 자바 객체, 네이티브 코드 등이 들어갈 수 있다. AOT 클래스 연결은 그중 하나로, 클래스를 이미 연결된 상태로 저장해 시작 과정의 작업을 줄인다. AOT 캐시는 GraalVM Native Image와도 다르다. 애플리케이션은 일반 JVM에서 실행되며 자바의 동적 기능을 계속 사용할 수 있지만, Native Image처럼 시작 속도와 메모리 사용량을 개선하는 방식은 아니다.

문제는 JDK 25에서 추가된 어댑터 캐시다. 어댑터는 인터프리터 실행과 컴파일된 자바 호출 사이를 전환하는 작은 기계어 코드로, 캐시를 만드는 컴퓨터의 CPU 기능에 맞춰 생성된다. 이후 실행할 컴퓨터에 해당 기능이 없으면 JVM이 SIGILL 또는 EXCEPTION_ILLEGAL_INSTRUCTION 오류로 충돌할 수 있다. 이는 사용자에게 배포하는 데스크톱 앱뿐 아니라, CI나 컨테이너 빌드 환경에서 만든 캐시를 CPU가 다른 서버에 배포할 때도 발생할 수 있다.

XPipe 팀은 캐시 생성 시 다음 옵션을 지정해 어댑터 캐싱을 끄라고 설명한다. 이 옵션은 문서에 설명되어 있지 않으며, JDK 소스 코드를 확인해야 찾을 수 있었다고 한다.

-XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching

JDK 27에서는 캐시에 훈련 시스템의 CPU 기능을 기록하고, 실행 시스템과 기능이 정확히 일치할 때만 캐시된 어댑터를 사용하도록 바뀔 예정이다. 다만 JDK 28의 JEP 544는 사전 컴파일된 코드를 추가하더라도 CPU 기능이 일치하지 않으면 해당 코드를 불러오지 않는다. x86에서는 하이퍼스레딩을 제외한 CPU 기능이 모두 같아야 하므로, 더 많은 기능을 지원하는 최신 CPU에서도 캐시를 쓰지 못할 수 있다.

로그와 클래스 연결의 제약

-Xlog:aot는 캐시 생성과 로딩 상태를 확인할 때 쓸 수 있지만, 로그가 두 번의 JVM 실행 결과를 이어서 보여주는 점에 주의해야 한다. JDK 25의 간소화된 절차는 한 번의 명령처럼 보이지만 실제로는 별도의 실행 두 번을 수행한다. 캐시에 무엇이 들어갔는지는 두 번째인 조립 실행의 로그를 봐야 한다.

로그 메시지만으로 최적화가 적용됐는지 판단하기도 어렵다. 조립 실행 초반에 full module graph: disabled가 표시되더라도 정상일 수 있으며, 실제 적용 여부는 로그 끝부분의 Full module graph = enabled와 클래스 요약에서 확인해야 한다. XPipe 팀은 문서가 부족하고, 최적화가 비활성화된 이유를 로그가 알려주지 않는 경우도 있다고 지적한다. 클래스 연결이 빠져도 캐시 자체는 만들어지고 사용될 수 있지만, 클래스 연결에 따른 시작 성능 개선은 얻지 못한다. 이를 감지해 CI 작업을 실패시키는 방법도 없다고 설명한다.

클래스 연결에는 전체 모듈 그래프가 캐시에 포함되어야 하지만, 여러 조건이 이를 조용히 비활성화할 수 있다. JDK 24에서는 --add-opens, --add-exports, --add-reads가 호환되지 않는 옵션으로 취급됐고, 이 문제는 JDK 25에서 수정됐다. GraalVM을 사용하면 훈련 실행에 --add-modules jdk.internal.vm.ci를 지정해야 모듈 그래프 불일치를 해결할 수 있는 경우가 있다. JDK 26에서 --enable-final-field-mutation=...을 사용하면 최적화된 모듈 처리가 다시 비활성화될 수 있으며, JDK나 JavaFX의 인큐베이팅 모듈을 포함해도 AOT 클래스 연결이 불가능하다고 XPipe 팀은 설명한다.

캐시 경로와 배포 설정

캐시 생성 시에는 -XX:AOTCacheOutput으로 위치를 지정하지만, 애플리케이션을 실행할 때도 -XX:AOTCache로 캐시 위치를 알려줘야 한다. 기본 경로나 자동 검색은 없다. 상대 경로는 실행 파일이 아니라 작업 디렉터리를 기준으로 해석되므로, 사용자가 다른 작업 디렉터리에서 실행하면 캐시를 찾지 못할 수 있다.

XPipe 팀은 jlink와 jpackage로 만든 앱의 설치 경로가 운영체제마다 달라 플랫폼별 설정이 필요하다고 설명한다. jpackage 설정 파일에는 다음처럼 경로를 지정할 수 있다.

  • Windows: java-options=-XX:AOTCache=$ROOTDIR/runtime/bin/server/cache.aot
  • Linux: java-options=-XX:AOTCache=$APPDIR/../../lib/runtime/lib/server/cache.aot
  • macOS: java-options=-XX:AOTCache=$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot

$ROOTDIR와 $APPDIR는 jpackage 실행기가 시작할 때 실제 경로로 바꾼다. XPipe 팀은 Linux에서 $ROOTDIR이 잘 작동하지 않아 $APPDIR을 우회 방법으로 사용한다고 밝혔다.

EDR 에이전트가 JVMTI의 ClassFileLoadHook으로 클래스 로딩에 개입하면 AOT 클래스가 연결된 캐시를 불러오지 못할 수 있다. XPipe 팀은 SentinelOne이 실행되는 모든 자바 애플리케이션에 에이전트를 연결해 관련 오류를 표준 출력에 내보낸다고 설명한다. 이 때문에 API_KEY=$(myapp generate-key)처럼 명령의 출력에 의존하는 작업은 여러 줄의 오류가 섞여 깨질 수 있다. 팀은 배포 빌드에서 -Xlog:disable을 사용하거나 -Xlog:disable -Xlog:all=warning:stderr처럼 경고와 오류를 표준 오류로 보내는 방법을 제시한다.

공개한 Gradle 작업은 플랫폼별 앱 이미지에 AOT 캐시를 생성하고, jpackage 설정 파일에 캐시 경로와 로그 옵션을 추가한다. 훈련 실행에는 별도 데이터 디렉터리와 훈련 모드 속성을 사용한다. 앱이 일반 세션에서 쓰는 코드 경로를 거치도록 UI의 여러 페이지를 여는 등의 작업을 수행한 뒤 자동 종료하도록 구성했다고 XPipe 팀은 설명한다.

XPipe 팀은 AOT 캐시가 문서 부족과 이해하기 어려운 로그, 까다로운 적용 조건 때문에 실제 앱에 안정적으로 적용하기 어렵다고 평가한다. 이 글의 문제 제기는 XPipe 팀의 경험에 근거하며, 성능 수치나 비교는 제시하지 않았다.

부록: 원문 예시

text
A fatal error has been detected by the Java Runtime Environment:

SIGILL (0x4) at pc=0x00007fcc85ae2613, pid=4054, tid=4147

JRE version: OpenJDK Runtime Environment Zulu25.32+21-CA (25.0.2+10) (build 25.0.2+10-LTS)
Java VM: OpenJDK 64-Bit Server VM Zulu25.32+21-CA (25.0.2+10-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, linux-amd64)

Problematic frame:
v ~AdapterBlob 0x00007fcc85ae2613
text
#
# A fatal error has been detected by the Java Runtime Environment:
#
# EXCEPTION_ILLEGAL_INSTRUCTION (0xc000001d) at pc=0x000001d00d9f6c2b, pid=39700, tid=37844
#
# JRE version: OpenJDK Runtime Environment Zulu25.30+17-CA (25.0.1+8) (build 25.0.1+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Zulu25.30+17-CA (25.0.1+8-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, windows-amd64)
# Problematic frame:
# v ~AdapterBlob 0x000001d00d9f6c2b

원문에 실린 예시 3

text
[0.024s][info][aot] full module graph: disabled
[0.024s][info][aot] initial optimized module handling: enabled
[0.024s][info][aot] initial full module graph: disabled
[0.024s][info][aot] Using AOT-linked classes: false (static archive: no aot-linked classes)
[0.032s][info][aot] JVM_StartThread() ignored: java.lang.ref.Reference$ReferenceHandler
[0.032s][info][aot] JVM_StartThread() ignored: java.lang.ref.Finalizer$FinalizerThread
[0.050s][info][aot] Not starting management agent during creation of AOT cache.

원문에 실린 예시 4

text
def distDir = "${project.layout.buildDirectory.get()}/dist"

tasks.register('trainAot', DefaultTask) {
 doLast {
 // You usually want your application to behave a bit differently
 // during a training run, so we pass properties to do that
 // E.g. it should not block with a dialog if it is a GUI application
 // as it should run unattended
 def properties = [
 "-D" + propertyName("dataDir") + "=$distDir/local_train",
 "-D" + propertyName("aotTrainMode") + "=true",
 ]

 def jvmName = System.getProperty("java.vendor.version")
 def graal = jvmName != null && jvmName.toLowerCase().contains("graal")

 // The generated jlink/jpackage image
 def imageDir
 // The command to launch the jlink launcher script
 def command
 // The cache output location is relative to the working directory of the jlink launcher script
 def cacheOutput
 // The jpackage app configuration file
 def cfgFile
 // The AOT cache location as seen from the jpackage launcher configuration
 def cfgCache
 if (os.isWindows()) {
 // We generated the application image here during the build
 imageDir = "$distDir/${productName}"
 command = ['cmd', '/c', "$imageDir/runtime/bin/${jpackageExecutableName}.bat"]
 cacheOutput = './server/cache.aot'
 cfgFile = "$imageDir/app/${jpackageExecutableName}.cfg"
 cfgCache = '$ROOTDIR/runtime/bin/server/cache.aot'
 } else if (os.isLinux()) {
 // We generated the application image here during the build
 imageDir = "$distDir/${productName}"
 command = ["$imageDir/lib/runtime/bin/${jpackageExecutableName}"]
 cacheOutput = './lib/runtime/lib/server/cache.aot'
 cfgFile = "$imageDir/lib/app/${jpackageExecutableName}.cfg"
 cfgCache = '$APPDIR/../../lib/runtime/lib/server/cache.aot'
 } else {
 // We generated the application image here during the build
 imageDir = "$distDir/${productName}.app"
 command = ["$imageDir/Contents/runtime/Contents/Home/bin/${jpackageExecutableName}"]
 cacheOutput = './Contents/runtime/Contents/Home/lib/server/cache.aot'
 cfgFile = "$imageDir/Contents/app/${jpackageExecutableName}.cfg"
 cfgCache = '$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot'
 }

 def jvmOptions = [
 "-XX:+UnlockDiagnosticVMOptions",
 "-XX:-AOTAdapterCaching",
 "-XX:+AOTClassLinking",
 "-XX:AOTCacheOutput=$cacheOutput",
 "-Xlog:aot",
 ]
 if (graal) {
 jvmOptions += ["--add-modules", "jdk.internal.vm.ci"]
 }

 execOps.exec {
 commandLine((command + properties).collect { it.toString() })
 workingDir imageDir
 // We use the old variable CDS_JVM_OPTS of the default jlink launcher scripts
 // to pass custom AOT JVM arguments to the process
 // All jlink launcher scripts support this variable
 environment "CDS_JVM_OPTS", jvmOptions.join(" ")
 }

 def cfg = file(cfgFile)
 cfg.text = cfg.text + "java-options=-XX:AOTCache=$cfgCache\njava-options=-Xlog:disable\n"
 }
}