이 글은 Naver D2의 JVM Internal을 읽고 정리한 글 입니다. 내용은 같으며, 자세한 내용은 위 링크에서 읽으시면 됩니다. 만약 해당 글에 저작권과 관련된 문제가 있다면 dev.kimji1@gmail.com 으로 연락부탁드립니다.
자바 바이트 코드
사용자 언어인 자바와 기계어 사이의 중간언어로 자바 코드를 배포하는 가장 작은 단위가 된다.
- JVM은 자바 바이트코드를 실행하는 실행기이다.
- 자바 컴파일러는 직접적인 CPU 명령으로 변환하는게 아닌 JVM이 이해하는 자바 바이트코드로 번역
- 자바 바이트코드는 플랫폼 의존성이 없기 때문에 JVM이 설치된 장비라면 CPU나 OS가 달라도 실행할 수 있고, 컴파일 결과물의 크기가 소스코드의 크기와 크게 다르지 않으므로 네트워크로 전송하여 실행하기 쉽다.
JVM 구조
클래스 로더가 컴파일된 자바 바이트 코드를 런타임 데이터 영역에 로드하고, 실행 엔진이 자바 바이트코드를 실행한다.
클래스로더
- 자바는 컴파일 타임이 아닌 런타임에 클래스를 처음 참조할 때 클래스를 로딩하는 동적 로딩 방식으로, 이 동적 로딩을 담당하는 부분이
JVM의 클래스로더 - 클래스 로드 시 이미 로드된 클래스인지 확인하기 위해 네임스페이스에 보관된 FQCN을 기준으로 클래스를 찾음, FQCN이 같더라도 네임스페이스가 다르면 (다른 클래스 로더가 로드한 클래스이면) 다른 클래스로 간주
- 클래스 로더가 클래스 로드를 요청받으면 클래스 로더 캐시, 상위 클래스 로더, 자기 자신 순으로 해당 클래스가 있는지 확인한다. 없는 경우 상위 클래스 로더를 올라가며 확인한다. 부트스트랩 클래스 로더까지 확인해도 없으면 요청받은 클래스 로더가 파일 시스템에서 해당 클래스를 찾는다.
특징
- 계층구조: 클래스 로더끼리 부모-자식 관계를 이루어 계층 구조로 생성.
최상위 클래스 로더는 부트스트랩 클래스 로더 - 위임 모델: 계층 구조를 바탕으로 클래스 로더끼리 로드를 위임
클래스 로드 시 먼저 상위 클래스 로더를 확인하여 상위 클래스 로더에 있다면 해당 클래스를 사용하고, 없다면 로드를 요청받은 클래스 로더가 클래스를 로드한다. - 가시성 제한: 하위 클래스 로더는 상위 클래스 로더의 클래스를 찾을 수 있지만, 상위 클래스 로더는 하위 클래스 로더의 클래스를 찾을 수 없다.
- 언로드 불가: 클래스 로더는 클래스를 로드 할 수는 있지만 언로드 할 수는 없다. 언로드 대신 현재 클래스 로더를 삭제하고 새로운 클래스 로더를 생성하는 방법을 사용할 수 있다.
런타임 데이터 영역
- 프로그램이 운영체제 위에서 실행되면서 할당받는 메모리 영역
- 런타임 데이터 영역은 6개의 영역으로 이루어 진다.
- PC 레지스터, JVM 스택, 네이티브 메서드 스택은 스레드마다 하나씩 생성되며 힙, 메서드 영역, 런타임 상수풀은 모든 스레드가 공유해서 사용한다.
PC 레지스터
- 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 현재 수행중인 JVM 명령의 주소를 가진다.
JVM 스택
- 각 스레드 마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 스택 프레임이라는 구조체를 저장하는 스택으로 스택 프레임의 추가(push), 제거(pop) 하는 동작만 수행한다.
- 예외 발생 시 printStcakTrace() 등의 메서드로 보여주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현한다.
- 스택프레임: JVM 내에서 메서드가 수행될 때마다 하나의 스택프리임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택 프레임이 제거된다.
: 각 스택 프레임은 지역변수 영역, 피연산자 스택, 현재 실행중인 메서드가 속한 클래스의 런타임 상수 풀에 대한 레퍼런스를 가진다.
: 지역 변수 배열, 피연산자 스택의 크기는 컴파일 시 결정되므로 스택 프레임의 크기도 메서드에 따라 크기가 고정된다.
— 지역 변수 배열: 0부터 시작하는 인덱스를 가진 배열, 0은 메서드가 속한 클래스의 인스턴스 this레퍼런스이고, 1부터는 메서드에 전달된 파라미터들이 저장되며, 메서드 파라미터 이후에는 메서드 지역변수들이 저장된다.
— 피연산자 스택: 메서드의 실제 작업 공간
: 각 메서드는 피연산자 스택과 지역 변수 배열 사이에서 데이터를 교환하고, 다른 메서드의 호출 결과를 추가(push)하거나 꺼냄(pop)
:피연산자 스택 공간이 얼마나 필요한지는 컴파일 시 결정할 수 있으므로 연산자 스택의 크기도 컴파일 시 결정된다.
네이티브 메서드 스택
- 자바 외의 언어로 작성된 네이티브 코드를 위한 스택으로 JNI를 통해 호출하는 C/C++ 코드를 수행하기 위한 스택으로 언어에 맞게 C스택이나 C++스택이 됨
메서드 영역
- 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성된다. JVM이 읽어들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메서드 정보, static 변수, 메서드 바이트코드 등을 보관한다.
- 메서드 영역은 JVM 벤더마다 다양한 형태로 구현되며, Permenent Area 혹은 Permenent Generation으로 불린다. 이 영역의 GC는 JVM 벤더의 선택이다.
런타임 상수 풀
- 클래스 파일 포맷에서 constant_pool 테이블에 해당하는 영역이다.
- 메서드 영역에 ㅍ함되는 영역이긴 하지만, JVM 동작에서 가장 핵심적인 역할을 수행하는 곳이기 때문에 JVM 명세에서도 따로 중요하게 기술한다.
- 각 클래스와 인터페이스의 상수 뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스를 담고 있는 테이블이다.
- 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조한다.
힙
- 인스턴스 또는 객체를 저장하는 공간으로 가비지 컬렉션의 대상이 된다.
- JVM 성능 등의 이슈에서 가장 많이 언급되는 공간이다.
실행엔진
- 클래스 로더를 통해 JVM 내의 런타임 데이터 영역에 배치된 바이트 코드는 실행엔진에 의해 실행된다.
- 실행 엔진은 자바 바이트 코드를 명령어 단위로 읽어서 실행한다.
- 바이트 코드의 각 명령어는 1바이트 짜리 OpCode와 추가 피연산자로 이루어져 있으며, 실행 엔진은 하나의 OpCode를 가져와서 피연산자와 작업 실행 후 다음 OpCode를 수행한다.
- 실행엔진에는 두 가지 방식이 있다.
인터프리터
- 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행한다.
- 하나씩 해석하고 실행하기 때문에 하나 하나의 해석은 빠르지만 인터프리팅 결과의 실행은 느리다.
- 바이트 코드라는 ‘언어'는 기본적으로 인터프리터 방식으로 동작한다.
JIT(Just-In-Time) 컴파일러
- 인터프리터의 단점을 보완하기 위해 도입된 것으로 인터프리터 방식으로 실행하다가 적절한 시점에 바이트 코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅 하지 않고 네이티브 코드로 직접 실행하는 방식
- 네이티브 코드를 실행하는 것이 하나씩 인터프리팅 하는 것 보다 빠르고, 네이티브 코드는 캐시에 보관하기 때문에 한 번 컴파일된 코드는 계속 빠르게 수행된다.
- JIT 컴파일러가 컴파일하는 과정은 바이트 코드를 하나씩 인터프리팅 하는 것보다 훨씬 오래걸리므로, 만약 한 번만 실행되는 코드라면 컴파일 하지 않고 인터프리팅 하는 것이 훨씬 유리하다.
- JIT 컴파이러를 사용하는 JVM은 내부적으로 해당 메서드가 얼마나 자주 실행되는지 체크하고, 일정 정도를 넘을 때만 컴파일한다.
이 글을 쓰실 때 많이 참고한 책이라고 하나, 절판되었다고 해서 책의 저자분이 공유한 게시글이라도 찾아봤다.
함께 보면 좋을 것 같은 글, JRE 에 대해서 설명하고 있다.