JVM 기본

1. JVM 아키텍처

png

JVM(Java Virtual Machine, 자바 가상 머신)은 크게 클래스 로더, 런타임 데이터 영역 및 실행 엔진 세 개의 영역으로 나뉜다.

  1. 클래스 로더가 바이트코드로 컴파일 된 자바 클래스 파일을 런타임 데이터 영역에 로드

  2. 실행 엔진이 런타임 데이터 영역에 올라간 자바 바이트 코드를 실행

2. 클래스 로더

png

클래스 로더(Class Loader)는 자바의 동적 로드를 담당한다. 동적 로드란 컴파일 시점이 아닌 런타임에 처음 참조하는 클래스를 로드하고 링크하는 것을 말한다. 클래스 로더의 특징은 다음과 같다.

  • 계층 구조: 클래스 로더끼리 부모-자식 관계를 이루는 계층 구조로 생성

    • 최상위 클래스 로더는 부트스트랩 클래스 로더(Bootstrap Class Loader)

  • 위임(Delegation): 계층 구조를 바탕으로 클래스 로더끼리 로드를 위임하는 구조로 동작

    • 클래스를 로드할 때 먼저 상위 클래스 로더를 확인

      • 상위 클래스 로더에 있다면 해당 클래스를 사용

      • 없다면 로드를 요청받은 클래스 로더가 클래스를 로드

  • 가시성(visibility) 제한: 클래스 로더는 하위 -> 상위로만 검색 가능

  • 유일성(Uniqueness): 클래스 로더는 클래스를 로드할 수는 있지만 언로드할 수는 없음

    • 언로드 대신, 현재 클래스 로더를 삭제하고 아예 새로운 클래스 로더를 생성하는 방법을 사용

각 클래스 로더는 로드된 클래스들을 보관하기 위해 네임스페이스를 가진다. 네임스페이스 내부에는 FQCN(Fully Qualified Class Name)이 보관되어 있다. 클래스 로더는 클래스를 로드할 때 FQCN을 기준으로 클래스를 찾는다. 만약 FQCN이 동일한 경우 네임 스페이스를 확인한다. 그리고 네임스페이스가 다른 경우(= 다른 클래스 로더가 로드한 클래스인 경우) 다른 클래스로 간주한다.

2.1. 로딩

클래스 또는 인터페이스 생성을 요청하면 클래스 로더는 로딩 작업을 수행한다. JVM이 클래스 또는 인터페이스의 이진 표현을 찾도록 클래스 로더에 요청하면 클래스 로더는 해당 이름의 클래스 또는 인터페이스에 대한 이진 표현을 로드한다. 클래스 로더 위임으로 인해 JVM의 요청에 따라 로딩을 시작하는 로더 L1과 클래스 또는 인터페이스를 정의하는 로더 L2가 동일하지 않을 수 있다. 클래스 로더는 클래스 로더 캐시, 상위 클래스 로더, 자기 자신의 순서로 클래스 또는 인터페이스를 찾는데 만일 부트스트랩 클래스 로더까지 확인해도 없으면 요청받는 클래스 로더가 파일 시스템에서 해당 클래스를 찾는다.

  • 부트스트랩 클래스 로더(Bootstrap Class Loader): Java API 핵심 클래스와 인터페이스를 로드

    • 부트스트랩 클래스 로더는 C나 C++과 같은 네이티브 언어로 구현

  • 확장 클래스 로더 (Extension Class Loader)는 기본 자바 API를 제외한 확장 클래스와 인터페이스를 로드

    • 일반적으로 Java 플랫폼의 라이브러리 클래스와 인터페이스를 로드

  • 시스템 클래스 로더(Application Class Loader)는 애플리케이션에서 생성한 클래스와 인터페이스를 로드

2.2. 연결

아직 로드되지 않은 클래스 또는 인터페이스를 찾은 경우 클래스 로더는 연결 작업을 시작한다. 연결(Linking)은 클래스 또는 인터페이스가 올바른지 확인하고 메모리에 할당 및 초기화 등의 작업을 하는 걸 말한다. 클래스 또는 인터페이스를 연결하려면 많은 검증과 준비가 필요하다. 연결에는 심볼릭 참조 해결도 포함되지만, 반드시 검증 및 준비 작업과 동시에 이루어지지는 않는다. 연결에는 새로운 데이터 구조 할당이 포함되므로 OutOfMemoryError가 발생해서 실패할 수 있다.

2.2.1. 검증

연결이 끝나면 클래스 로더는 검증 작업을 시작한다. 검증(Verification)은 클래스 또는 인터페이스의 바이너리 표현이 구조적으로 올바른지 확인한다. 검증으로 추가 클래스 및 인터페이스를 로드할 수 있지만, 반드시 검증하거나 준비할 필요는 없다. 클래스 또는 인터페이스의 바이너리 표현이 정적 또는 구조적 제약 조건을 충족하지 않으면 VerifyError가 발생한다. JVM이 클래스 또는 인터페이스를 확인하려는 시도가 실패하면 LinkageError 발생한다.

2.2.2. 준비

준비(Preparation)는 클래스 또는 인터페이스에 대한 static 필드를 생성하고 해당 필드를 기본값으로 초기화한다. static 필드에 대한 명시적 초기화는 클래스 로더의 초기화(initialization) 단계에서 실행된다. 준비 작업은 생성 후 언제든지 수행할 수 있지만, 초기화 전에 완료해야 한다.

2.2.3. 해결

해결(Resolution)은 런타임 상수 풀의 심볼릭 참조에서 하나 이상의 구체적인 값을 동적으로 결정하는 작업이다. 초기에는 런타임 상수 풀의 모든 심볼릭 참조가 해결되지 않은 상태이다. 심볼릭 참조는 (1) 클래스나 인터페이스, (2) 필드, (3) 메서드, (4) 메서드 타입, (5) 메서드 핸들(Method handle), (6) 동적으로 계산된 상수 순서로 해결한다.

2.3. 초기화

클래스 로더는 연결이 끝난 클래스 또는 인터페이스의 초기화 작업을 실행한다. 클래스 또는 인터페이스의 초기화는 해당 클래스 또는 인터페이스의 초기화 메서드를 실행하는 것으로 구성된다.

3. 런타임 데이터 영역

png

JVM은 프로그램 실행 중에 사용되는 다양한 런타임 데이터 영역(Runtime Data Areas)을 정의한다. 런타임 데이터 영역은 5개 영역으로 나눌 수 있다. 힙과 메서드 영역은 Java 가상머신이 시작될 때 생성되고 종료될 때 소멸한다. PC 레지스터, JVM 스택, 네이티브 메서드 영역은 스레드 단위로 생성되고 소멸된다.

3.1. 메서드 영역

메서드 영역(Method Area)은 JVM이 시작될 때 생성되고 종료될 때 소멸하며 모든 JVM 스레드가 공유한다. 메서드 영역은 런타임 상수 풀(Runtime Constant Pool), 필드 및 메서드 데이터, 클래스와 인터페이스의 초기화 및 인스턴스 초기화에 사용하는 특별한 메서드를 포함하는 메서드 및 생성자 코드 등 클래스 별 구조를 저장한다. 메서드 영역은 논리적으로 힙의 일부지만 구현에 따라 GC나 압축을 제공하지 않을 수 있다. 메서드 영역은 고정된 크기일 수도 있고 계산에 따라 필요한 만큼 확장 혹은 축소될 수 있다. 메서드 영역의 메모리는 연속적일 필요가 없다. JVM 벤더에 따라 메서드 영역의 구현은 달라진다. 만일 메서드 영역의 메모리가 가득 찼을 때 할당 요청이 충족되지 않으면 OutOfMemoryError가 발생한다.

3.1.1. 런타임 상수 풀

런타임 상수 풀(Runtime Constant Pool)은 각각의 클래스(혹은 인터페이스)가 JVM에 의해 로드될 때, 클래스(혹은 인터페이스) 마다 존재하는 constant_pool 테이블을 참조하여 생성되는 고유한 상수 풀을 말한다. 런타임 상수 풀은 캄피일 시 알려진 숫자 리터럴부터 런타임에 확인해야 하는 메서드 및 필드 참조에 이르기까지 여러 종류의 상수를 포함한다. 상수, 필드 그리고 메서드에 대한 모든 레퍼런스를 가지고 있으므로 JVM은 런타임 상수 풀을 통해 참조한다. 만일 런타임 상수 풀을 생성하는 데 필요한 메모리가 메서드 영역에서 사용할 수 있는 메모리보다 큰 경우 OutOfMemoryError가 발생한다.

3.2. 힙

힙(Heap)은 JVM이 시작될 때 생성되고 종료될 때 소멸하며 모든 JVM 스레드가 공유한다. 힙에는 모든 클래스 인스턴스(객체) 및 배열이 할당된다. 힙에 할당된 객체는 GC(Garbage Collector)에 의해 회수되고 빈 공간은 재활용된다. 힙은 고정된 크기이거나 계산에 의해 필요한 만큼 확장되거나 축소될 수 있다. 힙의 메모리는 연속적일 필요가 없다. JVM 벤더에 따라 GC의 구현은 달라진다. 만일 객체를 할당하기 위해 필요한 메모리가 GC가 사용할 수 있는 메모리보다 큰 경우 OutOfMemoryError가 발생한다.

3.3. PC 레지스터

PC 레지스터(Program Counter Register)는 스레드가 시작될 때 생성되고 종료될 때 소멸하며 스레드 단위로 존재한다. PC 레지스터는 현재 실행 중인 스레드가 네이티브 메서드가 아닌 경우 수행 중인 JVM 명령의 주소를 저장한다. 현재 실행 중인 메서드가 네이티브 메서드인 경우 PC 레지스터는 아무 것도 저장하지 않는다. JVM의 PC 레지스터는 특정 플랫폼에서 반환되는 주소 혹은 네이티브 포인터를 보유할 수 있을 만큼 충분히 넓다.

3.4. JVM 스택

JVM 스택(Java Virtual Machine Stack)은 스레드가 시작될 때 생성되고 종료될 때 소멸하며 스레드 단위로 존재한다. JVM 스택은 스택 프레임(Stack Frame)을 저장한다. JVM은 스택 프레임 push와 pop을 제외하고는 JVM 스택을 직접 조작하지 않는다. 스택 프레임은 힙에 할당될 수도 있다. JVM 스택 메모리는 연속적일 필요가 없다. JVM 스택은 동적으로 확장할 수 있으며, 확장을 시도했으나 필요한 메모리가 충분하지 않은 경우 혹은 JVM 스택에 허용된 것보다 많은 메모리가 필요한 경우 StackOverflowError가 발생한다.

3.4.1. 스택 프레임

스택 프레임(Stack Frame)은 데이터와 부분 결과(partial result)를 저장하고 동적 연결(dynamic linking), 메서드의 반환 값 및 예외 전달을 수행한다. 스택 프레임은 메서드가 호출될 때마다 생성되며 메서드 호출이 완료되면 소멸한다. 스택 프레임은 자체 지역 변수 배열, 자체 피연산자 스택(operand stacks), 현재 실행 중인 메서드 클래스의 런타임 상수 풀을 가진다. 지역 변수 배열과 피연산자 스택의 크기는 컴파일 시점에 결정되며 프레임과 연결된 메서드의 코드와 함께 제공된다. 따라서 스택 프레임의 크기는 JVM 구현에 의존하고 메서드 호출 시 동시에 할당된다. 주어진 제어 스레드의 어느 시점에서든 즉 실행 중인 메서드에 대한 하나의 스택 프레임만 활성화된다. 실행 중인 메서드를 현재 메서드라고 하면 실행 중인 스택 프레임은 현재 스택 프레임이라고 할 수 있다. 로컬 변수와 피연산자 스택에 대한 연산은 일반적으로 현재 스택 프레임을 참조한다. 실행 중인 메서드가 다른 메서드를 호출하면 해당 메서드로 제어가 이전되고, 새 스택 프레임이 생성되어 현재 스택 프레임이 된다. 현재 스택 프레임은 메서드 결과 값이 있는 경우 이전 스택 프레임으로 값을 전달하고 삭제된다. 스레드에 의해 생성된 스택 프레임은 해당 스레드의 지역(local) 프레임으로, 다른 스레드에서 참조할 수 없다.

3.4.2. 지역 변수 배열

지역 변수 배열(array of local variables)은 메서드의 정보를 저장하는 배열이다. 지역 변수 배열의 길이는 컴파일 시점에 결정되며 프레임과 연결된 메서드 코드와 함께 클래스 또는 인터페이스의 이진 표현으로 제공된다. 지역 변수는 boolean, byte, char, short, int, float, reference 혹은 returnAddress 유형의 값을 가질 수 있다. 한 쌍의 지역 변수는 long 또는 double 타입의 값을 가질 수 있다. 로컬 변수는 인덱싱을 통해 주소를 지정하며, 첫 번째 지역 변수의 인덱스는 0이다. 0에는 인스턴스 메서드가 호출되는 객체에 대한 참조(this)를 저장한다. 1부터는 메서드에 전달된 매개변수를 저장한다. 메서드 매개변수가 저장된 이후에는 메서드의 지역 변수들을 저장한다. integer는 0보다 크고 지역 변수 배열의 크기보다 작은 경우에만 지역 변수 배열의 인덱스로 간주된다. long 또는 double 유형의 값은 두 개의 연속된 지역 변수를 차지한다. 이러한 유형의 값은 차지하는 공간 중 더 작은 인덱스를 참조해야 한다. 예를 들어, 인덱스 n에 저장된 double 유형의 값은 실제로 인덱스 n과 n+1의 지역 변수를 차지하지만, 인덱스 n+1의 지역 변수에서는 로드할 수 없다. n+1의 지역 변수에 값을 저장할 수 있지만, 그렇게 하면 지역 변수 n의 데이터가 무효화된다. JVM은 n의 값을 짝수로 강제하지 않는다. JVM 벤더는 값을 표현하는 방법을 자유롭게 결정할 수 있다.

3.4.3. 피연산자 스택

피연산자 스택(Operand Stacks)은 메서드의 실제 작업 공간이다. 피연산자 스택의 최대 깊이는 컴파일 시점에 결정되며 프레임과 관련된 메서드 코드와 함께 제공된다. 피연산자 스택은 피연산자를 포함하는 스택 프레임이 생성될 때 빈 공간으로 같이 생성된다. JVM은 지역 변수 또는 필드에서 상수 또는 값을 피연산자 스택으로 push하거나, 피연산자 스택에서 피연산자를 pop하고 연산한 결과를 다시 push하는 등 계산에 활용한다. 또한 메서드에 전달한 매개변수를 준비하고 결과를 전달받는데도 사용한다. 예를 들어, iadd 명령어는 두 개의 int 값을 더한다. 두 정수 값은 피연산자 스택에서 pop하고, 값을 더한 후 다시 피연산자 스택으로 push한다. 피연산자 스택은 모든 JVM 타입의 값을 저장할 수 있다. 피연산자 스택의 값은 데이터 타입에 적합하게 연산되어야 한다. 두 개의 int 값을 push한 후 long으로 처리하는 것은 불가능하다. 피연산자 스택 조작에 대한 제한은 클래스 파일 검증을 통해 실행한다.

3.4.4. 동적 연결

동적 연결(Dynamic Linking)은 컴파일 시점이 아닌 런타임 시점에 메서드 코드를 연결하는 것을 말한다. 각 스택 프레임은 동적 연결을 위해 런타임 상수 풀에 대한 참조가 포함된다. 메서드의 클래스 파일 코드는 호출할 메서드와 심볼릭 참조를 통해 액세스할 변수를 나타낸다. 동적 연결은 심볼릭 메서드 참조를 구체적이 메서드 참조로 변환하고 아직 정의되지 않은 심볼을 해결하기 위해 필요에 따라 클래스를 로드하며 변수 액세스를 해당 변수의 런타임 위치와 관련된 스토리지 구조의 적절한 오프셋으로 변환한다. 이렇게 메서드와 변수를 늦게 연결하면 메서드를 사용하는 다른 클래스에서 변경해도 코드가 손상될 가능성이 줄어든다.

3.5. 네이티브 메서드 스택

네이티브 메서드 스택(Native Method Stack)은 스레드가 시작될 때 생성되고 종료될 때 소멸하며 스레드 단위로 존재한다. 네이티브 메서드 스택은 네이티브 메서드(Java 외의 언어로 작성된 메서드)를 지원하는 스택이다. 네이티브 메서드 스택은 고정된 크기일 수도 있고 계산에 따라 필요한 만큼 확장 혹은 축소될 수 있다. 네이티브 메서드 스택이 고정된 크기인 경우 각 스택의 크기는 스택이 생성될 때 독립적으로 선택될 수 있다. 만일 네이티브 메서드 스택에 허용된 것보다 많은 메모리가 필요한 경우 StackOverflowError가 발생한다.

4. 실행 엔진

실행 엔진(Execution Engine)은 JVM 내의 런타임 데이터 영역에 배치된 바이트 코드를 실행한다. 실행 엔진은 자바 바이트 코드를 명령어 단위로 읽어서 실행한다. 바이트 코드의 각 명령어는 OpCode와 추가 피연산자로 이루어져 있다. 실행 엔진은 OpCode를 가져와서 피연산자와 함께 작업을 수행한다.

4.1. 인터프리터

인터프리터(Interpreter)는 바이트코드 명령어를 하나씩 읽어서 해석하고 실행한다. 하나씩 해석하고 실행하므로 바이트 코드 하나의 해석은 빠르지만 인터프리팅 결과의 실행은 느리다. 바이트코드 언어는 인터프리터 방식으로 동작한다.

4.2. JIT 컴파일러

png

JIT 컴파일러(Just-In-Time Compiler)는 런타임 시점에 바이트 코드를 기계어로 변환하는 컴파일러이다. 인터프리터의 단점을 보완하기 위해 도입되었으며 인터프리터 방식으로 실행하다가 적절한 시점에 바이트 코드 전체를 컴파일해서 네이티브 코드로 변경하고 실행한다. 네이티브 코드는 캐시에 보관되고 매 번 바이트 코드를 해석할 필요가 없으므로 인터프리터보다 빠르게 동작한다. 그러나 JIT 컴파일러의 컴파일 과정은 명령어 인터프리팅보다 오래 걸리기 때문에 주의가 필요하다. JIT 컴파일러를 사용하는 JVM은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고 일정 정도를 넘길 때만 컴파일을 수행한다.

대부분의 JIT 컴파일러는 다음 그림과 같은 형태로 동작한다.

JIT 컴파일러는 바이트코드를 일단 중간 단계의 표현인 IR(Intermediate Representation)로 변환하여 최적화를 수행하고 그 다음에 네이티브 코드를 생성한다.

오라클 핫스팟 VM은 핫스팟 컴파일러라고 불리는 JIT 컴파일러를 사용한다. 핫스팟이라 불리는 이유는 내부적으로 프로파일링을 통해 가장 컴파일이 필요한 부분, 즉 '핫스팟'을 찾아낸 다음, 이 핫스팟을 네이티브 코드로 컴파일하기 때문이다. 핫스팟 VM은 한번 컴파일된 바이트코드라도 해당 메서드가 더 이상 자주 불리지 않는다면, 즉 핫스팟이 아니게 된다면 캐시에서 네이티브 코드를 덜어내고 다시 인터프리터 모드로 동작한다. 핫스팟 VM은 서버 VM과 클라이언트 VM으로 나뉘어 있고, 각각 다른 JIT 컴파일러를 사용한다.

참고 자료

Last updated