About JVM
- JVM은 스택 기반의 해석 머신으로, 실행 결과를 스택에 쌓아두며 스택의 값들을 가져와 계산한다.
- Java Compiler(javac)는 .java파일을 읽어 바이트코드로 구성된 .class 파일을 생성하는데, 이는 특정 아키텍쳐에 종속적이지 않은 중간 표현형(Intermediate Representation)이다. JVM은 이 바이트코드를 읽어 실제 동작을 수행한다.
자바 클래스 로딩 메커니즘
: 자바 프로세스가 초기화되면 연결된 클래스 로더가 차례차례 동작한다.
- 첫 번째로, 부트스트랩 클래스가 자바 런타임 코어 클래스를 로드한다. (JAVA 8 이전까지는 rt.jar에서 가져오지만, JAVA 9 이후부터는 런타임이 모듈화되고 클래스로딩 개념이 많이 바뀌었다.)
- 부트스트랩 클래스 로더는 다른 클래스로더가 나머지 시스템에 필요한 클래스를 로드할 수 있게 최소한의 필수 클래스(java.lang.Object 등)만 로드한다.
- 다음으로, 부트스트랩클래스로더를 부모로 설정하는 확장 클래스로더가 생기는데 이는 특정 OS나 플랫폼에 native code를 제공하거나 기본환경을 오버라이드 할 수 있다.
- 마지막으로, 애플리케이션 클래스로더가 생성되고 지정한 클래스패스에 위치한 유저 클래스를 로드한다.
이 과정에서 처음보는 새로운 클래스를 dependency에 로드하고, 클래스를 찾지못하면 자신의 부모 클래스로더에게 넘기는데, 이렇게 거슬러올라 부트스트랩도 찾지 못하면 ClassNotFoundException
예외가 발생한다.
About Hotspot & JIT(Just In Time)
Zero-cost abstraction(Zero-overhead principle)
C++과 같은 언어는 사용하지 않는 것에는 대가를 치르지 않는다.
다시말해 개발자가 작성한 언어보다 더 나은 코드를 컴퓨터는 건네주지않는다.
따라서, 컴퓨터와 OS가 실제로 어떻게 작동해야하는지 개발자가 아주 세세한 저수준가지 일러주어야 한다.
: Java는 개발자의 생산성에 무게를 두고 Zero-overhead abstraction에 반대하는 방향으로 발전했다. 따라서 C/C++과 같이 AOT(Ahead Of Time) 컴파일 방식이 아닌, 인터프리터를 이용해 바이트코드를 실행한다. 이와 더불어 자바는 JIT(Just In Time) 컴파일을 이용해, 프로그램 단위(메소드, 루프)로 바이트코드에서 네이티브 코드로 컴파일한다.
핫스팟은 인터프리트 모드로 실행하는 동안 애플리케이션을 모니터링하면서 가장 자주 실행되는 코드 파트를 발견해 JIT 컴파일을 수행한다(어느 threshold를 넘어가면 profiler가 특정 코드 섹션을 컴파일 & 최적화). 이는 컴파일러가 해석단계에서 추집한 추적 정보를 근거로 최적화를 결정하므로 상황별로 핫스팟이 올바른 방향으로 최적화 할 수 있게 만들어준다는 점이 큰 장점이다. 이러한 PGO(Profile Guided Optimization) 를 사용하면 AOT에서는 불가능했던 동적 인라이닝, 가상 호출, 특정 CPU에 최적화 등으로 성능을 개선할 수 있다. 하지만 바이트 코드를 네이티브 코드로 컴파일하는 비용은 런타임에 지불됨에 유의해야 한다.
다음으로 JIT 컴파일 로깅하는 방법을 살펴보자.java -XX:+PrintCompilation ${JavaProject}
을 통해 컴파일 이벤트 로그를 확인할 수 있다. 추가적으로 -XX:+LogCompilation
와 -XX:+UnlockDiagnosticVMOptions
로 핫스팟 JIT 컴파일러가 어떤 결정을 내렸는지 자세한 정보를 얻을 수 있다.
핫스팟 JVM에는 C1, C2라는 두 JIT 컴파일러가 있는데 각각 클라이언트 컴파일러, 서버 컴파일러라고 불린다. C1, C2 모두 메소드 호출 횟수에 따라 컴파일이 트리거링 되는데, 이 한계치에 이르면 VM이 해당 메소드를 컴파일 큐에 넣고 컴파일이 진행된다.
컴파일 프로세스는 가장 먼저 메소드의 내부 표현형을 생성한 뒤, 인터프리티드 단계에서 수집한 프로파일링 정보를 바탕으로 최적화 로직을 적용한다. 여기서, C1과 C2가 생성하는 내부 표현형은 전혀 다른데, C1은 C2보다 컴파일 시간이 짧고 더 단순하계 설계된 까닭에 풀 최적화는 되지 않는다.
자바 6부터는 단계별 컴파일(tierd compile) 을 지원하는데, 이는 인터프리티드 모드로 실행되다 단순한 C1 컴파일 형식으로 바뀌고, 이를 C2가 고급 최적화 수행하는 방식으로 단계를 바꾸는 것을 말한다.
JIT compiled code는 코드 캐시 라는 메모리 영역에 저장되는데 JVM 시작 시 코드 캐시는 설정된 값으로 고정되어 확장이 불가능하다. 따라서 코드 캐시 영역이 가득차면 더이상 JIT 컴파일은 수행되지 않으며, 컴파일되지 않은 코드는 인터프리터에서만 실행된다. 네이티브 메소드가 새로 저장되면 compiled code를 담기에 크기가 충분한 블록을 free list에서 찾는데 만약 그런 블록이 없으면 여유 공간이 충분한 코드 캐시 사정에 따라 unallocated region에서 새 블록을 생성한다. 또한 추측정 최적화를 적용한 결과 틀린것으로 판단되어 역 최적화(deoptimization)될 때, 다른 컴파일 버전으로 교체(단계별 컴파일)되었을 때, 메소드를 지닌 클래스가 언로딩 될 대 네이티브 코드는 코드 캐시에서 제거된다. 이러한 코드 캐시의 최대 크기는 -XX:ReservedCodeCacheSize=<n>
으로 설정할 수 있다.
JIT 튜닝 방법
PrintCompilation
옵션을 이용해 애플리케이션을 실행한다.- 어떤 메소드가 컴파일되었는지 로그를 확인한다.
ReservedCodeCacheSize
를 통해 코드 캐시를 늘린다.- 애플리케이션을 재 실행한다.
- 확장된 캐시에서 컴파일드 메소드를 살펴본다.
JVM의 메모리 관리
: C/C++에서는 개발자가 직접 동적 할당된 메모리를 관리해주어야 한다. 하지만 JAVA에서는 GC(Garbage Collector)를 이용해 힙 메모리를 자동으로 관리한다.
이 GC는 non-deterministic한 방식이므로 어느 순간에 GC가 실행되는지 정확히 알 수는 없다. 일반적으로 GC가 실행되면 애플리케이션은 모두 실행 중지(STW: Stop The World)되므로, 이 중단시간은 최대 짧도록 GC 알고리즘이 발전해 왔다. GC의 알고리즘에 대해서는 다음 게시물을 통해 더욱 상세히 알아보자.
'개발언어 > JAVA' 카테고리의 다른 글
GC Algorithm and Garbage Collectors (Java Performance Fundamental) (0) | 2021.04.29 |
---|---|
CMS 튜닝 (Java Optimization) (0) | 2021.03.30 |
Parallel GC 튜닝 (Java Optimization) (0) | 2021.03.28 |
GC 튜닝 (Java Optimization) (0) | 2021.03.22 |
GC Logging (Java Optimization) (0) | 2021.03.21 |