null예외 오류 처리와 로깅, 프로파일링, 설치프로그램 만들기가 강화된 Java 17에 대해서 알아봅시다.
이번에는 Java8에서 Java17로 업그레이드시 도움이 되는 변경사항을 JVM과 JDK도구의 관점에서 정리하였습니다.
Java8에서로 마이그레이션할 때 가장 큰 이슈는 앞에서 설명했던 Jigsaw이지만, 버전별 큰 차이는 그외에도 애플리케이션 개발 및 운용시 큰 영향을 주는 변경부분이 있습니다.
앞에서도 언급했듯이, Java 사양 변경은 모두 CSR에서 관리되고 있습니다. 변경 버전이 Java9부터 Java17에서 설정된 HotSpot(OpenJDK JVM)의 CSR에 올라온 내용이 2021년 8월 기준으로 200건이 넘습니다. 이 중 70%이상이 기능 추가 및 삭제에 직접 영향을 주는 경우가 많고, 명령줄 옵션의 변경에 관한 것입니다. 다 소개하기는 어렵고 비교적 큰 변화가 있는 부분을 표로 정리하였습니다.
어떤 방법이 JIT컴파일되는지 확인 및 JIT컴파일러의 버그를 해결하기 위해 어떤 방법을 JIT컴파일 대상에서 제외하는지등 JIT컴파일러의 디테일한 제어는 지금까지 -XX:CompileCommand이 사용되고 있었습니다. 그러나, Java7부터 도입된 Tiered Compilation(계층형 컴파일)을 효율적으로 관리하고, 실행중인 프로세스에 대한 설정을 변경할 수 없습니다. 이를 해결하기 위해 JIT제어를 JSON으로 할 수 있는 수단이 JEP 165에서 도입되었습니다.
예를 들어, String::equals서버 컴파일(C2)만 중지하는 것을 생각해 봅시다.JSON에는 다음과 같이 match메소드를 지정하고 c2에 Exclude를 지정합니다.
[
{
"match": "java.lang.String::equals",
"c2": {
"Exclude": true
}
}
]
처음부터 규칙을 적용하려면, Java실행시 인수에 XX:+UnlockDiagnosticVMOptions -XX:CompilerDirectivesFile=<JSON파일>로 적용합니다. 실행중인 프로세스에 적용할 경우, jcmd < PID> Compiler.directives_add를 사용합니다. 적용되는 규칙은 아래와 같이 Compiler.directives_print 에서 확인할 수 있습니다.
$ jcmd <PID> Compiler.directives_print
(생략)
Directive :
//String::equals에 적용되는 규칙
matching: java/lang/String.equals
c1 directives:
inline: -
Enable:false Exclude:false // C1에서는 Exclude되지 않음
(생략)
c2 directives:
inline: -
Enable:true Exclude:true //C2는 Exclude됨
(생략)
JIT컴파일된 기계어가 저장되는 메모리영역이 코드캐시입니다. 이곳은 Linux x64에서 기본적으로 240MB메모리가 확보되지마 Tired Compilation이 도입된 것으로 코드 캐시가 이용빈도가 높아, 더 효율적인 운용방법이 필요했습니다. 그래서 도입된 것이 코드캐시 세그먼트(JEP 197)입니다.
하나의 메모리 공간을 코드캐시를 JVM내 스텁코드등을 포함하는 non-nmethods클라이언트 컴파일(C1)최적화 정보를 포함한 profiled 및 C2 또는 초기 C1컴파일결과를 저장하는 non-profiled 3개로 분할합니다. 이로 인해 삭제된 컴파일된 코드의 회수 및 JIT컴파일된 코드 저장을 효율적으로 할 수 있게 처리향 향상을 기대할 수 있습니다.
각 메모리공간의 용량과 사용량은 JMX(Java Management Extensions)등에서 확인할 수 있습니다. JMX의 경우 MemoryPool에 각각 배치되어 있습니다.
JVM에는 가비지컬렉션(GC)이외에도 많은 STW(Stop The World, 완전 정지시간)이 존재합니다. 예를 들어, 예외발생과 같이 JIT컴파일의 전제가 되는 최적화 조건이 뒤집히는 경우, JVM은 STW를 생성하고 그것을 이용하고 있는 스레드에 대해 JIT컴파일된 코드를 파기합니다. 그러나, 해당코드를 사용하지 않은 스레드까지 멈추어 버리는 것은 성능에 좋지 않습니다. 그래서 도입된 것이 Thread-Local Handshake입니다. 이 방법을 사용하면 특정 스레드만을 타겟팅해서 고정처리할 수 있습니다. 이 기능은 JVMTI(JVM Tool Interface)호출스택얻기등 디버거를 위한 API에서도 효과를 발휘합니다.
API와 JVM뿐만 아니라, JDK와 같이 제공되는 도구에도 큰 변화가 있습니다.
프로그램을 작성하고 "이것이 어떻게 실행될까?"를 테스트해보고 싶어 될 수 있습니다. Python인터프리터처럼 입력한 프로그램으로 바로 실행하는 구조가 jshell로 Java 9부터 도입되었습니다. 코드탭 완성과 인수정보, Javadoc참조합니다.
shell> String.format // 탭을 누르면
Signatures :
String String.format (String format, Object ... args)
String String.format (Locale l, String format, Object ... args)
<press tab again to see documentation>
jshell> String.format // 다시 탭을 누르면
String String.format (String format, Object ... args)
Returns a formatted string using the specified format string and arguments.
(생략)
Java에서 네이티브 라이브러리를 호출하는 JNI(Java Native Interface)호환 라이브러리를 필요합니다. 이 개발을 위해 Java클래스 파일에서 C언어 헤더파을을 작성하는 javah가 있었지만, Java 10에서 제외되었습니다. 제외된 이후, javac에서 -h <대상디렉토리>로 개발합니다. Makefile등으로 컴파일시 자동으로 헤더파일을 작성하는 경우를 까먹지말고 대처합니다.
//Test.java에서 헤더 파일을 만듬
$ javac -h include Test.java
메모리 누수등 GC관련 문제가 발생했을 때 분석에 큰 도움이 될 수 있는 힙 덤프는 이진데이터 전용도구가 필요했습니다. JDK에서는 힙덤프내용을 표시하는 웹서버 기능이 제공되는 jhat가 제공되었지만, Java에서는 삭제되었습니다. JDK는 앞으로 비슷한 도구는 제공되지 않습니다. 힙 덤프를 분석하려면 Memory Analyzer (MAT) 또는 JDK Mission Control(JMC)와 같은 별도 도구가 필요합니다.
Java8부터 Java 17의 변경사항을 살펴봤지만, 여기에서는 JVM관련 기능이 어떻게 발전되었고 사용하기 쉽게 되었는지를 살퍄보겠습니다.
Java 개발자라면 한번은 겪어본적이 있는 NullPointerException입니다. null객체를 참조시 발생하는 예외는 Java 경력자도 발생하기 쉬운 문제입니다. 이는 Java8에 비해 더 좋게 대응하기 쉬워졌습니다.
예제1 코드를 보면서 생각해 봅시다. 이를 실행해보면 결과1과 같은 예외 발생입니다. 예외가 발생하는 행번호를 알 수 있지만, 구체적으로 어디가 null인지를 알 수 없습니다. 예제2와 같이 변수 접근이 null인 경우 결과1와 같은 예외스택이 출력됩니다.
예제1
System.out.println(map.get("bug").getValue().length());
결과1
Exception in thread "main" java.lang.NullPointerException at NPETest.methodCall(Test.java:12)
예제2
SubClass test = map.get("bug");
System.out.println(test.getValue().length());
예제1을 Java 17에서 실행하면 결과2와 같이 Map.get()이 null이었다는 것을 알 수 있습니다. 예제2의 경우 결과3과 같이 변수접근에 문제가 있음을 알 수 있습니다.
결과3에서 "< local2>"이라고 되어 있는 부분이 변수입니다. 바이트코드를 확인하지 않으면 어떤 변수인지 식별하는 것이 어렵습니다. javac로 컴파일시 -g옵션에서 디버깅 정보를 부여하는 것이 결과4와 같이 변수들이 문제인지 알 수 있습니다.
결과2
Exception in thread "main" java.lang.
NullPointerException: Cannot invoke "NPETest$SubClass.
getValue()" because the return value of "java.util.
Map.get(Object)" is null at NPETest.methodCall(Test.java:12)
결과3
Exception in thread "main" java.lang.
NullPointerException: Cannot invoke "NPETest$SubClass.
getValue()" because "<local2>" is null at NPETest.varAccess(Test.java:17)
결과4
Exception in thread "main" java.lang.
NullPointerException: Cannot invoke "NPETest$SubClass.
getValue()" because "test" is null at NPETest.varAccess(Test.java:17)
디버깅 정보를 부여하면 신경이 쓰이는 것이 동작시 영향입니다. 디버깅 정보는 코드 실행에는 영향을 주지 않습니다. 유일한 영향이 있는 클래스 파일 크기입니다. 지금까지 예제들은 NETTest를 OpenJDK 17에서 빌드한 것을 비교하면 디버그 정보 없이 831byte, 디버깅정보 포함시 1122byte였습니다. 디버깅 정보는 변수 갯수와 변수명 길이등으로 변화하기 떄문에 일률적으로 몇% 증가한다고 말하기는 어렵지만, 극단적으로 비대화하는 것은 없을 것입니다.
기능 개선을 통해 주의해야할 부분이 보안입니다. 웹애플리케이션에서 예외스택을 최종 사용자 화면에 나오는 것은 논외이지만, 악의적인 기술에서 발견되면 리버스엔지니어링될 수 있습니다. 이런 경우 -XX:-ShowCodeDetailsInExceptionMessages을 Java시작 옵션에 추가하여 이 기능을 해제할 수 있습니다. 또한, jcmd < PID> set_flag에서 실행중인 프로세스에 대해 동적으로 변경할 수 있습니다.
운용시 건전성 및 문제발생시, 상황확인에 필요한 로깅 기능이지만, Java9부터 크게 향상되었습니다.
Unified(통합)으로 지키는 것이 의미하는 것처럼 JVM로그를 중앙에서 처리하는 방법입니다. 기존에는 GC로그를 비롯하여 JVM 일부 기능을 기록하는 방법이 흩어져 있었습니다. 이외에 JVM내 모든 이벤트가 로깅되는 것은 없습니다. 따라서, JVM모든 기능을 기록하는 Unified JVM Logging이 나왔습니다.
Unified JVM Logging에서는 -Xlog옵션을 사용하여 아래와 같이 설정합니다.
-Xlog:<태그-로그수준>:<대상>:<데코>:<옵션>
태그는 출력하는 로그 종류를 지정하는 것입니다. GC라면 gc, JIT컴파일러라면 jit처럼 직접적인 것이 준비되어 있지만, Java 17에서 161개나 준비되어 있습니다. 하나뿐만 아니라 쉼표(,)로 열거하여 나열할 수 있습니다. 대표적인 태그와 출력내용은 아래 내용을 참조하십시오. 모든 태그는 -Xlog:help확인할 수 있습니다. 어떤 태그에서 어떤 로그를 얻을 수 있을지는 태그와 관련된 모든 것을 로깅대상으로 (예: gc)로 얻어보는 것도 좋습니다.
주요태그조합
로그레벨은 출력끄기off에서 가상 상세한 trace까지 6단계로 조정할 수 있습니다. 출력량이 많은 순서대로 나열하면 아래와 같습니다.
(1) trace (최하위)
(2) debug
(3) info
(4) warning
(5) error (오류 출력)
(6) off (출력 중지)
데코는 로그 출력에 추가하는 정보입니다. 출력시간과 로그 수준뿐만 아니라, 태그도 데코 취급합니다. 데코는 여러개 지정이 가능하며, 쉼표(,)로 열거합니다. 순간 볼 수 있는 t와 로그수준 |, 태그 tg를 지정하면 로그를 볼 떄 유용합니다. 이에 대해 다음과 같이 로그가 출력됩니다. 괄호로 묶인 부분이 장식됩니다. 왼쪽부터 t, |, tg에 출력된 것입니다.
로그를 출렧시 뺴놓을 수 없는 것이 세대관리입니다. 1세대당 어느정도의 양으로, 어느 타이밍에 회전시킬지를 생각해야 합니다. Linux인 경우 logrotate로 세대관리하는 경우가 많지만, Unified JVM Logging에서는 모두 JVM에 일임합니다.
로그세대 관리하는 경우 저장하는 파라미터는 2개가 있습니다. 로그 생성수를 지정하는 filecount 및 1세대당 파일크기를 지정하는 filesize입니다. 예를 들어, GC로그를 10MB에서 순환하고 5세대까지 저장하려면 -Xlog:gc=info:file=gc.log:filesize=10m,filecount=5로 지정합니다.
기록중인 파일(현 세대)는 file로 지정된 파일에서 회전할때마다 파일명끝에 번호가 있는 것이 만들어져 있습니다. 이 번호는 처음부터 시작"filecount-1"까지 저장됩니다. 일반 로그 관리 도구와 달리 접미사가 최근것만큼 새로운 파일이 아님에 주의해야 합니다. 회전시 현세대파일은 0, 1, 2, ....와 순차적으로 기록된 것이 끝까지 도달하면 또한 2.0파일에서 쓰기를 시작합니다. 분석시에는 반드시 로그의 타임스탬프를 참조하도록 합시다.
크기 기반이 아니라, "매일 0:00로테이션하고 싶다"라는 요구도 있습니다. 그런 경우, jcmd < PID> VM.log rotate외부에서 회전을 거는 것도 좋습니다. 사이즈기반의 로테이션을 완전히 차단하려면 filesize=0을 지정합니다. 그러나 이 경우 로드가피일이 증가하기 떄문에 정기적으로 외부에서 jcmd를 잊지 말고 실행하십시오.
Unufied JVM Logging로그파일 비동기 쓰기도 지원합니다. 예를 들어, GC로그는 그 성격상 GC에서 작성됩니다. 즉, STW중 파일쓰기가 발생하기 때문에 쓰기 처리량이 감소해버리면 그것이 STW시간에도 영향을 버립니다. 이를 완화하기 위해 -Xlog:async를 우선 설정하는 것으로 로그기록을 비동기할 수 있습니다. gc로그의 경우, -Xlog:async -Xlog:gc=info:file=gc.log처럼 연속적으로 지정합니다.
JDK 17에서는 이 비동기 쓰기는 파일 출력의 경우에만 지원하지 않습니다. 즉, file=이 지정되지 않으면 기존대로 동기화 쓰기가 됩니다. 로그를 표준 출력 리다이렉션하는 경우 효과가 없으므로 주의해야 합니다.
애플리케이션 프로파일 및 배포등 지금까지 Java가 상대적으로 약했던 부분이 새로운 도구를 추가하여 향상되었습니다.
원래 Oracle JDK의 상용기능이었던 Flight Recorder(JFR)이 오픈소스화되어 OpenJDK에서도 사용할 수 있게 되었습니다. 이번에는 기본적인 사용법만 설명합니다.
프로파일링하기 위해 애플리케이션 코드의 실행 상황등을 상세히 기록해야 합니다. Java코드를 실행하는 JVM을 사용하지만, JFR정보수집 부분은 그 JVM에 포함되어 있습니다. 따라서, IDE디버거 또는 타사 도구보다 상세한 정보를 낮은 오버헤드에서 얻을 수 있습니다.
GC와 JIT컴파일등 JVM내부 이벤트도 직접 JFR에 흐르기 떄문에 애플리케이션 처리량에 영향을 최소화할 수 있습니다.
JFR에서 정보수집 방법에는 두가지가 있습니다. 우선 Java실행 옵션입니다. 기본적으로는 -XX:StartFlightRecording=filename=< 파일>을 지정합니다. 이 옵션은 JVM시작에서 종료할때까지의 기록이 JVM종료시 파일로 덤프됩니다.
두번째는 실행중인 프로세스에 JFR을 내장합니다. jcmd < PID> JFR.start filename=<파일>에서 -XX:StartFlightRecording와 동일한 설정을 할 수 있습니다. 이는 실행중인 시스템의 문제해결에 유용합니다.
JFR은 지정된 기간 또는 데이터양을 제한하고 이전것은 덮어버립니다. 기본적으로 250MB가 제한됩니다. 데이터 저장 상태는 애플리케이션 제작 및 동작에 따라 달라지기 떄문에 조기에 확인하는 것이 좋습니다.
정보수집 수준은 기본적으로 제품화환경에 큰 영향을 지자 안ㅁㅎ는 기본값이 설정되어 있습니다. 그러나 이는 메소드 실행빈도등 개발단계에서 프로파일링은 정보가 충분하지 않습니다. 그런 경우 settings=profile을 쉽표로 계속 추가해서 상세함을 높일 수 있습니다.
얻은 비행기록은 이진데이터입니다. 해석에는 전용도구를 사용해야 합니다. 우선 GUI도구인 JMC입니다. "파일"메뉴에서 파일을 로드할 때 해당 화면이 그려집니다. 얻은 데이터를 콘솔에서 쉽게 확인하거나 가공하여 다른 도구로 활용할 수 있습니다. 이때 사용하는 것이 jfr명령입니다. 이벤트 필터링 기능을 갖추고 있어 이벤트가 뚜렷하게 유용합니다. 다음은 JVM이 실행중인 하이퍼바이저를 확인하는 예제입니다.
$ jfr print --events jdk.VirtualizationInformation test.jfr
jdk.VirtualizationInformation {
startTime = 17:38:25.320
name = "Hyper-V virtualization"
}
Java 애플리케이션을 널리 사용하게 하려면 반드시 배포 방법이 필요합니다. 어디에 설치해야하는지, 우선 Java를 설치해야 한다는 것이 사용자에게는 큰 벽입니다.
그래서 나온 것이 바로 jpackage입니다. jpackage에서 자바 애플리케이션을 Windows, macOS, Linux에 알맞게 설치를 대응합니다.
jpackage는 모듈화하고 있지 않아도 대응할 수 있지만 애플리케이션이 모듈화하면 배포크기를 줄일 수 있습니다.
> jpackage --type msi --win-console -n HelloWorld -p <모듈 경로> -m <모듈 이름> / <메인 클래스>
콘솔 응용프로그램의 콘솔전용 런처를 생성하는 -win-console을 추가합니다.