머리말
자바로 개발된 소스는 컴파일이라는 과정을 거쳐 클래스 파일이 생성된다. 이 클래스 파일은 자바 가상 머신(JVM)이 해석할 수 있도록 자바 소스를 바이트 코드로 변환한 결과물이다. 자바 프로그램의 성능을 끌어올리기 위해서는 자바 소스 단계에서 수많은 튜닝 포인트가 존재한다. 하지만 자바 소스는 결국 컴파일 과정을 거쳐 클래스 파일이 된 후 JVM에 의해 실행되기 때문에, 클래스 파일을 실행할 JVM 튜닝 역시 빠질 수 없는 성능 분석 단계다.
자바 소스만 튜닝해서는 최적의 성능을 보장할 수 없다. 바이트 코드로 변환된 클래스 파일을 최적의 상태로 수행할 수 있도록 JVM 옵션을 튜닝하는 과정까지 거쳐야 최적의 성능을 발현할 수 있다. JVM의 수행 환경을 최적의 상태로 튜닝하기 위해서는 JVM Heap 메모리 구조에 대해 알아야 정확한 진단이 가능하다. 이러한 관점에서 본 포스팅은 Hotspot JVM Heap 메모리 구조에 대해서 설명한다.
Hotspot JVM?
미국의 Longview Technologies LLC라는 회사에서 처음 발표된 JVM이다. 1999년에 처음 발표되었으며, 당해연도에 SUN에 인수되어 1.3 버전부터 SUN의 기본 JVM으로 자리 잡게 되었다. Hotspot JVM은 가장 일반적인 JVM 중 하나로써 Windows, Linux, Max, Unix 운영체제에도 구동이 가능하다. Hotspot JVM과 양대산맥을 이루는 JVM은 IBM JVM이 있다. WebSphere 등 IBM 솔루션에서는 IBM JVM을 기본으로 사용하지만, 그 외 대부분의 솔루션에서는 Hotspot JVM을 사용한다.
Hotspot JVM 아키텍처(Architecture)
아래의 그림은 자바 프로그램이 컴파일되고 실행되는 과정을 도식화한 JVM 아키텍처다. 자바 소스(Java Source)는 자바 컴파일러(Java Compiler)를 통해 컴파일 과정을 거치며 자바 바이트 코드(Java Byte Code)로 변환된다. 자바 바이트 코드는 우리가 흔히 이야기하는 클래스(Class) 파일이다. 변환된 바이트 코드는 클래스 로더(Class Loader)를 통해 수행 데이터 영역(Runtime Data Area)으로 적재되며 바이트 코드에서 참조한 라이브러리와 클래스 파일도 함께 적재된다. 수행 데이터 영역은 각 역할에 따라 세분화되는데, 클래스 로더에 의해 적재된 바이트 코드들은 세분화된 영역의 역할에 알맞게 적절히 분배되어 적재된다. 수행 데이터 영역은 운영체제로부터 할당받은 메모리 공간에 바이트 코드를 적재한 상태에서, 수행 엔진(Execution Engine)에게 바이트 코드 데이터를 전달한다. 이러한 과정으로 자바 프로그램이 실행된다.
JVM 아키텍처는 위에 설명한 내용과 같이 각각의 역할에 따라 유기적으로 수행된다. 여기서 우리가 조금 더 자세히 짚고 넘어가야 할 내용이 수행 데이터 영역과 힙(Heap) 영역이다. 특히 힙 영역은 Java8 버전에서 구조적인 변경사항이 발생했기 때문에 버전별로 유의해서 알아 둘 필요가 있다. 우선 아래의 그림에서 설명한 각 항목들에 대해 자세히 설명한 뒤, 힙 영역에 대해서 추가로 설명하도록 한다.
- Java Source: 사용자 또는 개발자가 작성한 자바 소스이며 확장자는 .java로 생성하는 규칙이 있다. 자바 확장자 규칙을 준수하지 않고 컴파일하면 'error: Class names, 'wookoa.jaavaa', are only accepted if annotation processing is explicitly requested' 오류가 발생한다.
- Java Compiler: 자바 소스를 JVM이 해석할 수 있도록 자바 바이트 코드로 변환하는 작업을 수행한다. 자바 설치 경로에서 bin 디렉터리 밑에 javac 이름으로 존재하며 윈도우 운영체제인 경우 .exe 확장자가 추가된다. JRE가 아닌 JDK 환경에서만 자바 컴파일러가 제공되기 때문에 자바 개발 도구쯤으로 이해할 수 있다.
- Java Byte Code: 자바 컴파일러의 수행 결과물이며, JVM이 적재하고 실행할 수 있도록 생성된 클래스 파일이다. 확장자는 .class로 생성된다. 자바 바이트 코드는 인간 친화적인 형태로써 JVM이 프로그램을 실행하기 위해서는 기계어로 변환하는 작업이 한번 더 필요하다.
- Class Loader: JVM 아키텍처 내부에 존재하는 모듈이다. 자바 컴파일러에 의해 생성된 바이트 코드를 수행 데이터 영역에 적재하기 위해 로딩(Loading), 링킹(Linking), 초기화(Initialization) 3가지 역할을 수행한다. 자바는 프로그램 수행 시간에 동적으로 바이트 코드를 적재하는 특징이 있으며, 이러한 동적 적재를 담당하는 모듈이 클래스 로더다. 아래와 같이 3가지 역할에 대해서 개략적으로 설명한다. 기회가 된다면 별도의 포스팅에서 관련 내용을 자세히 다룰 예정이다.
- Loading: 자바 바이트 코드를 수행 데이터 영역에 적재하는 역할을 담당한다. 자바에서 기본으로 제공되는 클래스 파일과 더불어, 자바 바이트 코드에서 참조된 다른 클래스 파일도 함께 적재된다. 자바에서 기본으로 제공되는 라이브러리인지 개발자가 추가로 반입한 라이브러리인지 혹은 개발자가 직접 작성한 클래스인지에 따라 로딩의 수준도 3가지로 분류되어 처리한다. 본 포스팅에서는 로딩의 세부 분류까지는 별도로 설명하지 않는다.
- Linking: 로딩된 바이트 코드를 검증하고 사용할 수 있게 준비하는 역할을 담당한다. 링킹 과정에서는 자바 바이트 코드가 유효한지 검증하고 클래스 및 인터페이스에 필요한 정적 변수 값들을 수행 데이터 영역에 할당시킨다. 또한 로딩된 자바 바이트 코드에 접근할 수 있도록 메모리 주소값을 변환하는 역할도 수행한다.
- Initialization: 적재된 바이트 코드 내에서 사용된 클래스와 인터페이스를 기본 설정 값으로 초기화하거나 초기화 메서드를 수행시키는 역할을 담당한다. 이 단계에서 JVM은 멀티 스레드로 동작하는데, 클래스가 동시에 초기화될 우려가 있으므로 스레드 동기화를 항상 고려해야 한다. 본 초기화 작업까지 완료되면 JVM은 클래스파일을 구동시킬 준비가 끝난다.
- Execution Engine: 클래스 로더에 의해 수행 데이터 영역에 적재된 바이트 코드를 실행하는 역할을 담당한다. 바이트 코드는 인간 친화적인 형태에 가깝기 때문에 기계가 읽을 수 있는 형태로 변환하는 작업이 필요하다. 이렇듯 기계어로 변환하는 작업은 바이트 코드 한 줄씩 동적으로 수행되기 때문에 속도가 비교적 느리다. 이를 보완하기 위해 Just-In-Time 컴파일 방식이 디자인되었다. JIT 컴파일 방식은 자주 사용되는 바이트 코드를 캐싱해서 불필요한 기계어 변환작업을 최소화하는데 목적을 둔다. 이 외에도 수행엔진(Execution Engine)은 GC(Garbage Collection)도 수행한다. 마찬가지로 본 포스팅에서는 GC에 대해서 자세히 다루지 않으며, 별도의 포스팅으로 자세히 설명할 예정이다.
- Runtime Data Area: JVM이 자바 바이트 코드를 실행하기 위해 운영체제로부터 할당받은 메모리 공간이다. 수행 데이터 영역은 자바 옵션을 통해 튜닝이 가능하며 프로그램의 타입에 따라 튜닝 방향도 결정되기 때문에 보다 자세히 다룰 필요가 있다. 아래의 내용에서는 수행 데이터 영역에 대해서 세부적으로 설명한다.
Java Threads? Native Threads?
프로세스는 프로그램이 실행된 상태를 의미하는데, 프로세스보다 작은 단위를 스레드라 일컫는다. 스레드는 최소 실행 단위로써 모든 프로세스가 하나 이상의 스레드를 소유한다. 자바 프로그램이 실행되면 메인 스레드가 생성되며, 메인 스레드에 의해 다른 스레드가 추가로 생성될 수 있다.
위와 같은 일반적인 스레드의 개념을 이해하는데 큰 어려움은 없을 것이다. 다만, 자바 스레드(Java Threads)와 네이티브 스레드(Native Threads)를 이해하기 위해서는, 상대적으로 논리적이냐 물리적이냐에 따라 구분을 지어서 이해할 필요가 있다. 운영체제는 하드웨어 자원을 직접 관리한다. 프로세스 또는 스레드 따위의 프로그램 단위도 하드웨어 자원을 관리하기 위해 운영체제에서 정의한 컨셉이다. 운영체제에서 관리하는 스레드 단위를 네이티브 스레드라 말한다. 직접 하드웨어의 자원을 제어하기 때문에 상대적으로 물리적인 스레드에 가깝다고 표현했다. 네이티브 스레드는 커널 레벨 스레드(Kernel Level Threads)라고 불리기도 한다.
이와 반면에 자바 스레드의 경우 하드웨어 자원을 관리하는 운영체제에게 작업을 요청하는 수준이다. 자바 스레드를 새로 생성함으로써 스레딩 처리를 요청할 뿐이지 실제적으로 몇 개의 스레드로 처리되는지 알 수 없다. 운영체제가 관리하는 스레드를 직접 사용하는 것이 아니기 때문에 상대적으로 논리적인 스레드에 가깝다고 표현했다. 자바 스레드는 유저 레벨 스레드(User Level Threads)라고 불리기도 한다.
Native Method Libraries
자바 메서드 라이브러리는 자바와 다른 언어인 C, C++로 작성된 라이브러리다. 다른 언어로 작성된 코드를 자바에서 호출할 수 있도록 명명된 규칙이며, 현재는 C, C++ 코드에 대한 호출만 지원한다. 이는 모든 운영체제에서 실행이 가능하다는 자바의 컨셉과 정반대 되는 개념이다. 하지만 속도 이슈가 있는 계산 연산이나 하드웨어 제어와 같은 기능을 수행하기 위한 한계점이 존재하기 때문에 네이티브 메서드 라이브러리가 탄생하게 되었다.
Hotspot JVM Heap 구조
Hotspot JVM의 힙 영역 구조는 크게 Young Generation, Old Generation 영역으로 구분된다. Young Generation 영역은 다시 Eden, Survivor 영역으로 구분된다. 힙 메모리 영역을 관리할 목적으로 모든 오브젝트(Object)는 일정한 기준을 가지고 각 영역들을 영위한다. Eden 영역은 오브젝트가 힙 영역에 최초로 할당되는 공간이다. Eden 영역의 공간이 부족하게 되면 오브젝트의 참조 여부를 확인 후, 참조되고 있다면 Survivor 영역으로 이동되며 더 이상 참조되지 않는다면 Eden 영역에서 청소된다. Survivor 영역은 0, 1 두 개로 구분되는데, Eden 영역에서 Survivor 영역으로 오브젝트가 이동될 때는 한 개의 Survivor 영역만 사용된다. 자세한 내용은 별도의 포스팅으로 다룰 예정이지만 간략히 설명하면, Survivor0으로 옮겨질 때는 Survivor1 영역이 초기화되며 Survivor1으로 옮겨질 때는 Survivor0 영역이 초기화되는 구조다. 이러한 과정을 Minor GC라고 한다.
특정 기준치 이상 Survivor 영역에서 살아남은 오브젝트는 Old Generation 영역인 Tenured 영역으로 이전하게 된다. 특정 기준치라 함은 오브젝트의 참조 회수 및 생존 시간 등을 의미한다. Tenured 영역은 비교적 오랫동안 참조되었으며 앞으로도 사용될 가능성이 높은 오브젝트들이 보관되는 영역이다. Tenured 영역도 공간이 부족하게 되면 우선순위 알고리즘에 따라 메모리 공간을 확보하는 과정이 발생된다. 이러한 과정을 Full GC라고 한다. GC 과정에 대한 자세한 내용은 별도의 포스팅에 다룰 예정이다.
Permanent, Metaspace 영역의 비교
Permanent 영역은 클래스나 메서드의 메타(Meta) 정보를 저장하거나 정적(Static) 변수 또는 상수(Constant)가 저장되는 공간으로써 또 다른 명칭으로 메타데이터 영역으로 불렸다. 하지만 Java8 버전부터 Permanent 영역이 Metaspace 영역으로 이름을 바꾸면서 네이티브 영역으로 편입되었다. 기존의 힙 영역의 소속일 때는 JVM에 의해 관리되는 영역이었지만, 네이티브 영역으로 변경되었기 때문에 운영체제 레벨에서 관리하는 영역으로 탈 바꿈 되었음을 의미한다. Metaspace가 네이티브 메모리를 활용함으로써, 운용자는 JVM 최대 사이즈를 크게 의식할 부담이 줄어든 셈이다. 하지만, 기존의 Permanent 영역의 모든 역할이 고스란히 Metaspace 영역이라는 이름과 소속만 변경된 것은 아니다. Permanent 영역에 속해있던 정적 변수와 상수는 힙 영역을 벗어나지 못하고 그대로 머물게 되었다. 다른 의미로는 힙 영역에서 관리되기 때문에 GC의 대상이 되도록 현행을 유지한다는 의미다. Java7 버전과 Java8 버전의 힙 메모리 구조 변화는 아래와 같이 간략히 정리할 수 있다.
Java 7 이전 Permanent 영역 | Java 8 Metaspace 영역 | |
클래스 및 메서드의 메타데이터 저장 여부 | O | X |
정적 변수 및 상수의 저장 여부 | O | X |
GC 수행 대상 | Full GC 대상 | Full GC 대상 |
메모리 튜닝 포인트 | 힙(Young+Old) 영역 튜닝과 Permanent 영역 튜닝 별도 수행 | 네이티브 영역의 사이즈를 별도 옵션으로 조정 가능 |
메모리 옵션 포인트 | -XX:PermSize -XX:MaxPermSize |
-XX:MetaspaceSize -XX:MaxMetaspaceSize |
Permanent 영역과 Metaspace 영역의 사이즈 기본값은 시스템 별로 크게 다를 수 있다. 아래 사진과 같은 명령어로 각 영역의 최대치를 확인할 수 있다. Java7 버전의 Permanent 영역은 약 82MB, Java8 버전의 Metaspace 영역은 약 16EB 정도로 큰 차이를 보인다. 16EB는 64bit 운영체제가 취급할 수 있는 메모리 최대치라 할 수 있는데, 네이티브 영역으로 분류되어 운영체제의 자원을 최대한 활용할 수 있다는 의미다. Metaspace 영역은 별도의 옵션을 통해 최대치를 지정할 수 있다고 하지만, 결국 메타데이터는 프로그램을 수행하기 위한 필수 정보이기 때문에 자동적으로 증가하는 특징이 있다.
/usr/local/jdk/jdk1.7.0_80/bin/java -XX:+PrintFlagsFinal -version -server | grep PermSize
/usr/local/jdk/jdk1.8.0_351/bin/java -XX:+PrintFlagsFinal -version -server | grep MetaspaceSize
꼬리말
수행 데이터 영역 중 가장 익숙한 영역이 힙 영역일 것이다. 그 이유로는 자바가 메모리 이슈에 집중적이었기 때문으로 생각된다. 이는 메모리 할당 및 회수의 역할을 담당하는 GC와 관련이 깊다. 기회가 된다면 다음 포스팅에서는 GC에 대해서 자세히 다루어 볼 예정이다. 본 포스팅에서 JVM 힙 메모리 영역에 대해서 최대한 자세히 다루고 싶은 욕심이 있었다. 하지만, 한 페이지의 포스팅에 그 내용을 전부 담아내는 것이 쉽지 않았다. 결국 내용을 쪼개어 여러 번의 포스팅으로 다루어볼 계획이다. 본 포스팅의 연관된 내용이 작성된다면 Helpfult 영역에 추천 포스팅을 지속적으로 업데이트할 예정이다. JVM Heap 메모리의 구조에 대해 소개한 본 포스팅은 이로써 마무리를 짓도록 한다.
소중한 댓글 (0)