-
CH2. JVM 이야기DevBook/Optimizing Java 2024. 4. 14. 00:13
이 장에서는 JVM이 자바 코드를 실행하는 방법을 소개한다.
성능을 공부하려면, 기본 JVM 기술 스택의 구조를 알아야 한다.
들어가기 전에, JVM을 큰 그림으로 한번 보자.
2.1 인터프리팅과 클래스로딩
- JVM은 스택 기반의 해석 머신임
- 레지스터는 없지만 일부 결과를 실행 스택에 보관하며, 이 스택의 맨 위에 쌓인 값들을 가져와 계산을 함
1) 인터프리팅
- JVM 인터프리터(해석기)의 기본 로직은 'while 루프 안의 switch문' 으로 비유할 수 있음
- 이는 JVM이 바이트코드를 실행하는 방식을 설명하기 위한 비유임
- 각 바이트코드에 대한 실행 로직은 switch문에서 결정되며, while 루프를 통해 계속해서 새로운 바이트코드를 읽어들여 실행함
- while 루프
- 프로그램이 계속해서 실행되는 루프
- JVM이 실행 중에 계속해서 명령어를 해석하고 실행하는 과정을 나타내고, JVM은 바이트코드를 한 줄씩 읽어들여 실행하는 동작을 반복함
- switch문
- 현재 실행 중인 바이트코드의 종류를 판별하여 해당하는 작업을 수행하는 역할
- 바이트코드의 종류에 따라 다른 작업이 이루어지므로, switch문을 통해 각 바이트코드에 대한 실행 로직이 결정됨
- while 루프
- 평가 스택을 이용해 중간값들을 담아두고 가장 마지막에 실행된 명령어와 독립적으로 프로그램을 구성하는 opcode를 하나씩 순서대로 처리함
2) 클래스로딩
클래스로딩이 필요한 이유
- 자바 어플리케이션을 실행하면 운영체제는 가상 머신 프로세스(자바 바이너리)를 구동함
- 자바 가상 환경이 구성되고 스택 머신이 초기화된 다음, 실제로 유저가 작성한 클래스 파일이 실행됨
- 어플리케이션의 진입점(entry point)은 HelloWorld.class에 있는 main() 메서드이고, 제어권을 이 클래스로 넘기려면 가상 머신의 실행이 시작되기 전에 이 클래스를 로드해야 함
- 여기에 자바 클래스로딩 메커니즘이 관여함
클래스로더 알아보기
- 자바 프로세스가 새로 초기화되면 줄지어 연결된 클래스로더가 차례차례 작동함
- 작동 순서 : 부트스트랩 클래스로더 --> 확장 클래스로더 --> 어플리케이션 클래스로더
- 클래스로더별 특징
- 부트스트랩 클래스로더 (Bootstrap Class Loader)
- 부트스트랩 클래스가 자바 런타임 코어 클래스를 로드함
- 런타임 코어 클래스는 자바 8 이전까지는 rt.jar 파일에서 가져오지만, 자바 9 이후부터는 런타임이 모듈화되고 클래스로딩 개념 자체가 많이 달라짐
- 어떻게 달라졌을까?
- Java 9에서는 모듈 시스템이 도입되었음. 모듈 시스템은 코드를 모듈화하여 라이브러리 간의 종속성을 잘 관리할 수 있도록 도움
- 이로인해 부트스트랩 클래스로더가 모듈 시스템의 모듈 경로를 검사하도록 변경되었음
- 이전에는 Java runtime Jar 파일(rt.jar)과 같은 기본적으로 제공되는 클래스를 로드했지만, Java 9부터는 모듈 경로에서 모듈을 로드함. 따라서 런타임 코어 클래스를 이러한 모듈에서 로드하게 됨
- 부트스트랩 클래스로더의 주임무는 다른 클래스로더가 나머지 시스템에 필요한 클래스를 로드할 수 있게 최소한의 필수 클래스(ex: java.lang.Object, Class, Classloader)만 로드함
- 부트스트랩 클래스가 자바 런타임 코어 클래스를 로드함
- 확장 클래스로더 (Extension Class Loader)
- 부트스트랩 클래스로더를 자기 부모로 설정하고 필요할 때 클래스로딩 작업을 부모에게 넘김
- 널리 쓰이지는 않지만, 확장 클래스로더를 이용하면 특정한 OS나 플랫폼에 네이티브 코드(native code)를 제공하고 기본 환경을 오버라이드할 수 있음
- 자바 8에 탑재된 자바스크립트 런타임 내시혼(Nashorn)을 확장 클래스로더가 로드함
- 어플리케이션 클래스로더 (Application Class Loader)
- 끝으로 해당 클래스로더가 생성되고 지정된 클래스패스에 위치한 유저 클래스를 로드함
- 주의
- 시스템 클래스는 로드하지 않기 때문에 어플리케이션 클래스로더를 '시스템' 클래스로더라고 부르지 않도록 주의하자!
- 부트스트랩 클래스로더 (Bootstrap Class Loader)
클래스로딩 방식
- 자바는 프로그램 실행 중 처음 보는 새 클래스를 dependency(의존체)에 로드함
- 추가하자면, 이는 자바의 '동적 로딩'을 의미함
- 자바 프로그램이 실행될 때, 프로그램이 사용하는 클래스들은 필요할 때 동적으로 로드됨
- 이때 자바는 프로그램 실행 중 처음으로 사용되는 클래스를 발견하면 해당 클래스와 그 클래스가 의존하는 다른 클래스들을 메모리에 로드함
- 클래스를 찾지 못한 클래스로더는 기본적으로 자신의 부모 클래스로더에게 대신 룩업(찾아보기)을 넘김
- 이렇게 부모의 부모로 거슬러 올라가 결국 부트스트랩도 룩업하지 못하면 ClassNotFoundException 예외가 발생함
- 따라서 빌드 프로세스 수립 시 운영 환경과 동일한 클래스패스로 컴파일하는 것이 좋음
cf)
https://velog.io/@ddangle/Java-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%A1%9C%EB%8D%94%EB%9E%80
2.2 바이트코드 실행
- 자바 소스 코드는 실행되기까지 많은 변환 과정을 거침
- 첫 단계는 자바 컴파일러 javac를 이용해 컴파일하는 것으로, 보통 전체 필드 프로세스의 한 부분으로 수행함
javac의 역할
- 자바 소스 코드를 바이트코드로 구성된 .class 파일로 바꾸는 것
- javac는 컴파일하는 동안 최적화하는 거의 하지 않기 때문에 결과로 생성된 바이트코드는 쉽게 해독할 수 있음
- 바이트코드는 특정 컴퓨터 아키텍처에 특정하지 않은, 중간 표현형(Intermediate Representation)임
- 컴퓨터 아키텍처의 지배를 받지 않으므로 이식성이 좋아 컴파일된 소프트웨어는 JVM 지원 플랫폼 어디서든 실행 가능하고 자바 언어에 대해서도 추상화되어 있음
컴파일러가 생성한 클래스 파일 구조
- JVM은 클래스를 로드할 때 올바른 형식을 준수하고 있는지 검사함
cf) https://blog.lse.epita.fr/2014/04/28/0xcafebabe-java-class-file-format-an-overview.html
- 매직넘버 (Magic)
- 모든 클래스 파일은 해당 파일이 클래스 파일(.class)임을 나타내는 4바이트 16진수인 0xCAFEBABE라는 매직 넘버로 시작함
- 클래스 파일 포맷 버전 (Major/Minor version)
- 그 다음 4바이트는 클래스 파일을 컴파일할 때 꼭 필요한 메이저/마이너 버전 숫자임
- 클래스를 실행하는 대상 JVM이 컴파일한 JVM보다 버전이 낮으면 안되기 때문에 클래스로더의 호환성 보장을 위해 검사하고 호환되지 않는 버전의 클래스 파일을 만나면 런타임에 UnsupportedClassVersionError 예외가 발생함
- 상수 풀 (Constant Pool)
- 코드에 등장하는 상수값(ex: 클래스명, 인터페이스명, 필드명)이 포함됨
- JVM은 코드를 실행할 때 런타임에 배치된 메모리 대신, 상수 풀 테이블을 찾아보고 필요한 값을 참조함
- 엑세스 플래그 (Access Flags)
- 클래스에 적용한 수정자를 결정함
- 구성
- 플래그 첫 부분은 일반 프로퍼티로, public 클래스인지, 그 다음은 상속이 금지된 final 클래스인지 나타냄
- 이 클래스 파일이 인터페이스인지, 추상 클래스인지 표시함
- 플래그 끝 부분은 클래스 파일이 소스 코드에 없는 합성 클래스인지, annotation 타입인지, enum인지 각각 나타냄
- this 클래스, 슈퍼클래스, 인터페이스 엔트리
- 클래스에 포함된 타입 계층을 나타내며, 각각 상수 풀을 가리키는 인덱스로 표시함
- 필드, 메서드
- 시그니처 비슷한 구조를 정의하고 여기에 수정자도 포함되어 있음
- 속성
- 더 복잡하고 크기가 고정되지 않은 구조를 나타내는데 쓰임
- ex) 메서드는 Code 속성으로 특정 메서드와 연관된 바이트코드를 나타냄
예시로 살펴보기
> 자바 코드
public class HelloWorld { public static void main(String[] args) { for (int i = 0; i < 10; i++) { System.out.println("Hello World"); } } }
> 클래스 파일
- javap라는 역어셈블러를 이용해 클래스 파일 내부를 볼 수 있음
- javap란
- 클래스 파일을 분석해 해당 클래스의 구조를 보여주는 데 사용됨
- 클래스 파일(javac에 의해 컴파일된 자바 코드)의 바이트코드를 디코딩하여 해당 클래스의 구조와 멤버를 표시함 (클래스의 메서드, 필드, 상위 클래스, 인터페이스 등을 보여줌)
- -v 옵션을 추가해 클래스 파일 헤더 전체 정보, 상수 풀 세부 정보 등 더 자세한 내용까지 확인 가능
- javap란
Compiled from "HelloWorld.java" public class HelloWorld { public HelloWorld(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: iconst_0 1: istore_1 2: iload_1 3: bipush 10 5: if_icmpge 22 8: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 11: ldc #13 // String Hello World 13: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 16: iinc 1, 1 19: goto 2 22: return }
- 소스 파일에는 메서드가 main() 하나뿐이지만 컴파일 후 변환된 클래스 파일에는 javac가 클래스 파일에 디폴트(기본) 생성자를 자동 추가하므로 메서드가 총 2개 생성됨
- public HelloWorld();
- aload_0 : 제일 먼저 생성자에서 this 레퍼런스를 스택 상단에 올려놓음
- invokespecial : super 생성자들을 호출하고 객체를 생성하는 등 특정 작업을 담당하는 인스턴스 메서드 실행
- HelloWorld 클래스는 디폴트 생성자를 오버라이드한 코드가 없으므로 Object 디폴트 생성자가 매치됨
- public static void main(java.lang.String[]);
- iconst_0 : 정수형 상수 0을 평가 스택에 push
- istore_1 : 위 상수값을 오프셋 1에 위치한 지역 변수(루프의 i)에 저장
- 지역 변수 오프셋은 0부터 시작하며, 인스턴스 메서드에서 0번째 엔트리는 무조건 this임
- iload_1 : 오프셋 1의 변수를 스택으로 다시 로드
- bipush : 상수 10을 push
- if_icmpge : 두 값을 비교함 (정수값이 10보다 같거나 큰가)
- getstatic : System.out의 정적 메서드 해석
- ldc : 상수 풀에서 "Hello World"라는 문자열을 로드
- invokevirtual : 이 클래스에서 속한 인스턴스 메서드 실행
- iinc : 정수값 1 증가
- goto : 2번 명령(iload_1)으로 되돌아감
- 해당 과정이 if_icmpge 테스트가 성공할 때(i >= 10일 경우)까지 반복되다가 마지막에 22번 명령으로 제어권이 넘어가 메서드가 반환됨
2.3 핫스팟 입문
Java HotSpot
- 핫스팟은 프로그램의 런타임 동작을 분석하고 성능에 가장 유리한 방향으로 영리한 최적화를 적용하는 가상 머신
- 핫스팟 VM의 목표는 개발자가 자연스럽게 자바 코드를 작성하고 바람직한 설계 원리를 따르도록 하는 것
- 자바 핫스팟 등장 이후 자바는 C/C++ 같은 언어에 필적할 만한 성능을 보여주며 진화를 거듭함
사전 개념
- 언어는 크게 2가지로 구분할 수 있음
- '제로 코스트(비용이 들지 않는) 추상화' 사상에 근거한 '기계에 가까운' 언어 - a)
- 개발자의 생산성에 무게를 두고 엄격한 저수준 제어 '일을 대행하는' 언어 - b)
- a)의 특징
- 컴퓨터와 OS가 실제로 어떻게 작동해야 하는지 개발자가 저수준까지 세세하게 일러주어야 함
- 해당 언어로 작성한 소스 코드를 빌드하면 플랫폼에 특정한(해당 플랫폼에서만 사용 가능한) 기계어로 컴파일됨
- 이것은 AOT(Ahead-Of-Time, 사전) 컴파일 이라고 함
2.3.1 JIT 컴파일러란?
등장 배경
- 자바 프로그램은 바이트코드 인터프리터가 가상화한 스택 머신에서 명령어를 실행하며 시작됨
- CPU를 추상화한 구조라서 다른 플랫폼에서도 클래스 파일을 문제없이 실행할 수 있지만, 프로그램이 성능을 최대로 내려면 네이티브 기능을 활용해 CPU에서 직접 프로그램을 실행시켜야 함
역할
- 이를 위해 핫스팟은 프로그램 단위(메서드와 루프)를 인터프리티드 바이트코드 --> 네이티브 코드로 컴파일함
- 해당 기술이 바로 JIT(Just-In-Time, 적시, 그때그때 하는) 컴파일임
동작 방식
- 핫스팟은 인터프리티드 모드로 실행하는 동안 어플리케이션을 모니터링하며 가장 자주 실행되는 코드 파트를 발견해 JIT 컴파일을 수행함
- 이렇게 분석을 하는 동안 미리 프로그래밍한 추적 정보가 취합되며 더 정교하게 최적화할 수 있음
- 특정 메서드가 어느 한계치(임계점)를 넘어가면 프로파일러가 특정 코드 섹션을 컴파일/최적화함
장점
- 무엇보다 컴파일러가 해석 단계에서 수집한 추적 정보를 근거로 최적화를 결정하는 것이 가장 큰 장점임
- 상황별로 수집한 다양한 정보를 토대로 핫스팟이 더 올바른 방향으로 최적화 가능
- 자바처럼 프로필 기반 최적화(PGO, profile-guided optimization)를 응용하는 환경에서는 대부분의 AOT 플랫폼에서 불가능한 방식으로 런타임 정보를 활용할 수 있어 동적 인라이닝(dynamic inlining) 또는 가상 호출(virtual call) 등으로 성능을 개선할 수 있음
- 핫스팟 VM은 시동 시 CPU 타입을 정확히 감지해 가능하면 특정 프로세서의 기능에 맞게 최적화를 적용할 수 있음
- 프로세서 기능을 정밀하게 감지하는 기법은 JVM 인트린직(intrinsics)라고 함
2.4 JVM 메모리 관리
- 핫스팟의 컴파일 서브 시스템과 더불어 초창기부터 자바를 독보적인 언어로 만들었던 특징은 바로 '자동 메모리 관리' 기능임
- 자바는 'Garbage Collection' 이라는 프로세스를 이용해 힙 메모리를 자동 관리함
- 가비지 수집이란 한 마디로, JVM이 더 많은 메모리를 할당해야 할 때 불필요한 메모리를 회수하거나 재사용하는 불확정적(nondeterministic) 프로세스임
- GC는 자바 성능 최적화의 중심 주제이므로 6, 7, 8장에서 자세하게 다룰 예정..!
2.5 스레딩과 자바 메모리 모델(JMM)
자바의 멀티스레드
- 자바는 1.0부터 멀티스레드 프로그래밍을 기본 지원함
- 원래 자바 환경 자체가 JVM처럼 멀티스레드 기반인 까닭에 자바 프로그램이 작동하는 방식은 복잡해졌고 성능 분석 작업을 하기도 까다로워졌음
- 주류 JVM 구현체에서 자바 어플리케이션 스레드는 각각 정확히 하나의 전용 OS 스레드에 대응됨
- 공유 스레드 풀을 이용해 전체 자바 어플리케이션 스레드를 실행하는 방안(그린 스레드)도 있지만, 만족할 만한 수준의 성능은 나오지 않는 것으로 밝혀짐 --> 이를 계기로 JDK 21의 virtual thread가 등장하게 된 것..!
자바 메모리 모델(JMM)
- 1990년대 후반부터 자바의 멀티스레드 방식은 다음 3가지 기본 설계 원칙에 기반함
- 자바 프로세스의 모든 스레드는 가비지가 수집되는 하나의 공용 힙을 가진다.
- 한 스레드가 생성한 객체는 그 객체를 참조하는 다른 스레드가 액세스할 수 있다.
- 기본적으로 객체는 변경 가능(mutable)하다. 즉, 객체 필드에 할당된 값은 final 키워드로 불변(immutable) 표시하지 않는 이상 바뀔 수 있다.
- JMM은 서로 다른 실행 스레드가 객체 안에 변경되는 값을 어떻게 바라보는지를 기술한 공식 메모리 모델임
- ex) 스레드 A와 스레드 B가 둘 다 객체 obj를 참조할 때 스레드 A가 obj 값을 바꾸면 스레드 B는 무슨 값을 참조하게 될까요?
- OS 스케줄러가 CPU 코어에서 강제로 스레드를 방출 할 수 있기 때문에 의외로 복잡하다. 스레드 A가 아직 처리 중인 객체를 스레드 B가 시작되면서 참조할 때 잘못된, 무효 상태의 객체를 바라보게 될 수 있다.
- 상호 배타적 락(mutual exclusion lock)은 코드가 동시 실행되는 도중 객체가 손상되는 현상을 막을 수 있는 자바의 유일한 방어 장치지만 실제로 어플리케이션에 사용하려면 복잡하다.
- JMM의 상세한 작동 원리와 실제로 스레드/락을 다루는 방법은 12장에서 소개한다..!
2.6 JVM 구현체 종류
오라클이 제작한 핫스팟 이외에도 제각기 다른 방법으로 구현한 자바 구현체가 많음
- OpenJDK, 오라클 자바(Oracle), 줄루(Zulu), 아이스티(IcedTea), 징(Zing), J9, 애비안(Avian), 안드로이드(Android)
2.7 JVM 모니터링과 툴링
- JVM은 실행 중인 어플리케이션을 instrumentation, 모니터링, 관측하는 다양한 기술을 제공함
- instrumentation : 오류 진단이나 성능 개선을 위해 어플리케이션에 특정한 코드를 끼워 넣는 것
- 이런 종류의 툴에 쓰이는 몇 가지 중요한 기술 리스트
- JMX (Java Management Extensions, 자바 관리 확장)
- Java Agent
- JVMTI (JVM 툴 인터페이스)
- SA (Serviceability Agent)
JMX
- JVM을 관리하는 기본 수단으로, JVM과 그 위에서 동작하는 어플리케이션을 제어하고 모니터링하는 강력한 범용 툴
- 메서드를 호출하고 매개변수를 변경할 수 있음
Java Agent
- 자바 언어로 작성된 툴 컴포넌트로, java.lang.instrument 인터페이스로 메서드 바이트코드를 조작함
- 다음과 같이 JVM에 시작 플래그를 추가해 설치함
- -javaagent:<에이전트 jar 파일이 위치한 경로>=<옵션>
- 특징
- 에이전트 jar 파일에서 manifest.mf 파일은 필수임
- Premain-Class 속성에 에이전트 클래스명을 반드시 지정해야 하고, 이 클래스는 자바 에이전트의 등록 후크 역할을 수행하는 public static premain() 메서드를 구현해야 함
- 자바 instrument API는 JVM이 로드한 바이트코드를 조작하는 기능을 제공하며, premain() 메서드를 이름처럼 어플리케이션 main() 메서드 이전에 실행됨
JVMTI
- 자바 instrument API로 가져올 수 없는 정보의 경우 JVMTI를 대신 사용할 수 있음
- JVMTI는 JVM의 네이티브 인터페이스이기 때문에 JVMTI를 사용하는 에이전트는 C/C++ 같은 네이티브 컴파일 언어로 작성해야 함
- 네이티브 에이전트가 JVM 이벤트를 모니터링하며 알림을 받을 수 있도록 만든 통신 인터페이스이기 때문.
- 네이티브 에이전트 설치 플래그
- -agentlib:<에이전트 라이브러리명>=<옵션>
- -agentpath:<에이전트 경로>=<옵션>
- 특징
- JVMTI 에이전트를 네이티브 코드로 개발해야 하므로 실행중인 어플리케이션 및 JVM에 영향을 미치지 않도록 주의해야 함
SA
- 자바 객체, 핫스팟 자료 구조 모두 표출 가능한 API와 툴을 모아놓은 것
- SA를 이용하면 대상 JVM에서 코드를 실행할 필요가 없음
- 핫스팟 SA는 symbol lookup 같은 기본형을 이용하거나 프로세스 메모리를 읽는 방식으로 디버깅함
- SA는 코어 파일(크래시 덤프 파일) 및 아직 실행중인 자바 프로세스까지 디버깅할 수 있음
2.7.1 VisualVM
- NetBeans 플랫폼 기반의 시각화 툴인 VisualVM은 jconsole을 대체하는 툴임
- VisualVM은 JVM attach mechanism 을 이용해 실행 프로세스를 실시간 모니터링함
- attach mechanism : 자바 Attach API를 이용해 어플리케이션을 타깃 JVM에 부착하는 장치를 말함
- 원격 프로세스에 연결하려면 원격지로부터 JMX를 통한 인바운드 접속이 허용돼야 함 (원격 호스트에서 jstatd가 실행되고 있어야 함)
- VisualVM은 플러그인 아키텍처로 구성되어 다른 툴을 쉽게 추가해 핵심 기능을 보충할 수 있음
- ex) VisualGC 플러그인
VisualVM이 제공하는 다섯 가지 탭
1) Overview
- 자바 프로세스에 관한 요약 정보 표시
- 프로세스에 전달한 전체 플래그와 시스템 프로퍼티, 실행 중인 자바 버전 제공
2) Montior
- CPU, 힙 사용량 등 JVM을 고수준에서 원격 측정한 값들이 표시됨
- 로드/언로드 된 클래스 개수 및 실행 중인 스레드 개수 등 현황도 알 수 있음
3) Thread
- 실행 중인 어플리케이션 각 스레드(어플리케이션 스레드 + VM 스레드)가 시간대별로 표시됨
- 필요 시 thread dump를 뜰 수 있음
4) Sample and Profiler
- CPU 및 메모리 사용률에 관한 단순 샘플링 결과가 표시됨
'DevBook > Optimizing Java' 카테고리의 다른 글
14장. 고성능 로깅 및 메시징 (0) 2024.07.31 CH10. JIT 컴파일의 세계로 (0) 2024.06.24 CH1 성능과 최적화 (0) 2024.04.09