Language/Java

JVM 구조와 메모리 영역

olsohee 2023. 12. 19. 12:53

JVM(Java Virtual Machine)

JVM은 자바 바이트코드(.class)를 해석하고 실행한다. JVM의 역할은 자바 애플리케이션을 클래스 로더를 통해 읽어 들여서 자바 API와 함께 실행하는 것이다.

JVM 덕분에 자바 코드는 OS에 종속적이지 않을 수 있다. 하나의 자바 프로그램의 바이트 코드만으로도 각 OS에 맞는 JVM이 해석하고 실행하기 때문에 OS에 상관없이 실행이 가능하다.

 

JVM의 특징은 다음과 같다.

  • 스택 기반의 가상 머신
  • 심볼릭 레퍼런스: 기본 자료형을 제외한 모든 타입(클래스, 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 참조한다. 즉 참고하는 클래스의 특정 메모리 주소를 참조 값으로 갖지 않고, 이름만 갖고 있다.
  • 가비지 컬렉션: 클래스 인스턴스는 사용자 코드에 의해 명시적으로 생성되고 가비지 컬렉션에 의해 자동으로 파괴된다.

여기서 스택 기반이라는 특징에 대해 좀 더 알아보자. 자바 가상머신은 가상머신이 필요로하는 기능 명세서를 구현한 것이다. 따라서 가상머신의 구현체는 기능 명세서를 어떻게 구현하느냐에 따라 여러 종류가 있을 수 있다.

 

일반적으로 가상머신은 어떤 기능을 가져야 할까? 가상머신이라면 구현해야 할 컨셉은 다음과 같다.

  • 소스 코드를 가상머신이 실행할 수 있는 바이트 코드로 변환
  • 명령어와 피연산자를 포함하는 데이터 구조
  • 함수를 실행하기 위한 콜 스택
  • IP(Instruction Pointer): 다음 실행할 곳을 지정하는 포인터
  • 가상 CPU: 다음 명령어를 패치 & 명령어를 해석 & 명령 실행

위와 같은 명세를 만족하여 가상머신을 구현할 수 있는 방법은 Stack 기반과 Register 기반으로 두 가지가 있다. 두 방법의 차이는 피연산자를 저장하고 다시 가져오는 방법이 다르다.

 

[Stack 기반의 가상머신]

  • 피연산자와 연산 결과가 스택에 저장된다.
  • 예를 들어 위와 같이 20과 7을 더할 경우, 스택 구조이기 때문에 POP과 PUSH가 필요하여 총 4단계의 명령이 필요하다.
  • 장점
    • 다음 피연산자의 메모리 위치를 기억할 필요가 없다. SP(Stack Pointer)가 다음 피연산자의 위치를 나타내기 때문이다. 즉 스택에서 POP을 하면 다음 피연산자가 나오기 때문에 피연산자의 메모리 위치를 기억할 필요가 없다.

[Register 기반의 가상머신]

  • 피연산자와 연산 결과가 CPU의 레지스터에 저장된다.
  • 명령어가 피연산자의 위치인 레지스터 주소를 기억해야 한다.
  • 장점
    • 위와 같이 PUSH&POP 과정이 없기 때문에 같은 덧셈이더라도 하나의 명령으로 충분하다. 따라서 스택 기반보다 더 빠르다.
    • 스택 기반에서는 할 수 없는 명령어 최적화를 적용할 수 있다. 예를 들어, 어떤 연산이 나중에 또 필요할 때 레지스터에 저장하여 다시 계산하지 않고도 연산 결과를 활용할 수 있다.
  • 단점
    • 피연산자의 주소를 명시해줘야 하므로 스택 기반보다 명령어의 길이가 길다.

자바 바이트코드

WORA(Write Once Run Anywhere)를 구현하기 위해 JVM은 사용자 언어인 자바와 기계어 사이의 중간 언어인 자바 바이트 코드를 사용한다. 컴파일러는 자바 코드를 바로 기계어, 즉 직접적인 CPU 명령으로 변환하는 것이 아니라, JVM이 이해할 수 있는 자바 바이트코드로 변환한다. 따라서 자바 바이트코드는 특정 OS에 의존적이지 않으며, JVM이 설치된 장비라면 CPU나 OS가 다르더라도 해당 바이트코드를 실행할 수 있다. 

 

바이트코드는 OpCode(Operation Code)와 피연산자인 Operand로 구성된 명령어의 집합이다. 그리고 이때 하나의 OpCode가 1바이트로 표현되기 때문에 바이트코드라고 한다. 따라서 OpCode는 2^8 = 256가지이고 하나의 OpCode는 0~255까지의 범위를 갖는다. 

 

다음은 자바 코드를 바이트코드로 컴파일한 결과이다.

// UserService.java
…
public void add(String userName) {  
admin.addUser(userName);  
}
public void add(java.lang.String);  
Code:  
0: aload_0  
1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin;  
4: aload_1  
5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;  
8: pop  
9: return

여기서 addUser() 메소드를 호출하는 부분은 "5: invokevirtual #23;"이다. 이는 23번 인덱스에 해당하는 메소드를 호출하라는 의미이며, 23번 인덱스에 해당하는 메소드가 무엇인지 javap 프로그램이 주석으로 달아주었다.

 

invokevirtual은 자바 바이트코드에서 메소드를 호출하는 가장 기본적인 명령어 OpCode(Opertation Code) 중 하나이다. 자바 바이트코드의 명령어는 OpCode와 피연산자(Operation)로 나뉜다. OpCode(Opertation Code)는 다음과 같다. 이때 invokevirtual과 같은 OpCode는 2바이트의 피연산자를 필요로 한다.

  • invokeinterface: 인터페이스 메서드 호출
  • invokespecial: 생성자, private 메서드, 슈퍼 클래스의 메서드 호출
  • invokestatic: static 메서드 호출
  • invokevirtual: 인스턴스 메서드 호출

JVM 구조

자바로 작성된 코드는 다음 과정으로 실행된다.

  1. 자바 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당받는다.
  2. 자바 소스 파일(.java)를 자바 컴파일러(javac)가 바이트 코드의 클래스 파일로 변환시킨다.
  3. Class Loader가 동적 로딩을 통해 필요한 클래스들을 로딩 및 링크하여 바이트 코드를 JVM 내의 Runtime Data Area로 적재시킨다.
  4. Runtime Data Area에 로딩된 바이트 코드는 Execution Engine을 통해 해석되고 실행된다.

간단히 말하면 클래스 로더가 컴파일된 자바 바이트 코드를 런타임 데이터 영역에 로드하고, 실행 엔진이 자바 바이트 코드를 실행한다.

클래스 로더(Class Loader)

특정 메소드가 호출되려면 그 메소드를 가진 클래스 파일이 먼저 메모리에 로딩되어 있어야 한다. 따라서 특정 클래스가 필요한 시점에 클래스 파일을 메모리에 로딩하는 것을 클래스 로딩이라고 한다. 클래스 로더는 바로 이 클래스 로딩을 하는 공간이다. 클래스 로더는 컴파일 타임이 아니라 런타임 중 클래스를 처음으로 참조할 때 해당 클래스의 클래스 파일을 JVM 내로 로딩하고(동적 로딩), 링크 과정을 통해 관련된 클래스들을 연결시키고 초기화 작업을 하는 공간이다. 그리고 JVM 내의 Runtime Data Area에 바이트 코드를 적재시킨다.

클래스 로더의 특징

  • 계층 구조: 클래스 로더끼리 부모-자식 관계의 계층 구조로 이루어져 있다. 최상위 클래스 로더는 부트스트랩 클래스 로더(Bootstrap Class Loader)이다.
  • 위임 모델: 계층 구조를 바탕으로 클래스 로더끼리 로드를 위임하는 구조로 동작한다. 클래스를 로드할 때 먼저 상위 클래스 로더를 확인하여 상위 클래스 로더에 있다면 해당 클래스를 사용하고, 없다면 로드를 요청받은 클래스 로더가 클래스를 로드한다.
  • 가시성 제한: 하위 클래스 로더는 상위 클래스 로더의 클래스를 찾을 수 있지만, 상위 클래스 로더는 하위 클래스 로더의 클래스를 찾을 수 없다.
  • 언로드 불가: 클래스 로더는 클래스를 로드할 수 있지만 반대로 언로드할 수는 없다. 언로드 대신, 현재 클래스 로더를 삭제하고 아예 새로운 클래스 로더를 생성하는 방법은 가능하다.
  • 네임스페이스: 각 클래스 로더는 로드된 클래스들을 보관하는 네임스페이스를 갖는다. 클래스를 로드할 때 이미 로드된 클래스인지 확인하기 위해서 네임스페이스에 보관된 FQCN(Full Qualified Class Name)을 기준으로 클래스를 찾는다. 비록 FQCN이 같더라도 네임스페이스가 다르면, 즉 해당 클래스를 로드한 클래스 로더가 다르면 다른 클래스로 간주된다.

클래스 로드 순서

  1. Loading(로드): 클래스 파일을 가져와서 JVM의 메모리에 로드한다.
  2. Linking(링크): 클래스 파일을 사용하기 위해 검증하는 과정이다.
    1. Verifying(검증): 읽어들인 클래스가 자바 언어 명세 및 JVM 명세에 명시된 대로 잘 구성되어 있는지 검사한다. 클래스 로드의 전 과정 중에서 가장 까다로운 검사를 수행하는 과정으로서 가장 복잡하고 시간이 오래 걸린다.
    2. Preparing(준비): 클래스가 필요로 하는 메모리를 할당하고, 클래스에서 정의된 필드, 메소드, 인터페이스들을 나타내는 데이터 구조를 준비한다.
    3. Resolving(분석): 클래스의 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.
  3. Initialization(초기화): 클래스 변수들을 적절한 값으로 초기화한다. 즉 static 필드들을 설정된 값으로 초기화한다.

런타임 데이터 영역(Runtime Data Area)

런타임 데이터 영억은 JVM이라는 프로그램이 OS 위에서 실행될 때 OS로부터 할당받는 메모리 공간이다. 런타임 데이터 영역은 다음과 같이 5가지 영역으로 나뉜다. 이때 Method, Heap Area는 모든 스레드가 공유하는 영역이고 나머지는 각 스레드마다 생성되는 영역이다.

  • PC 레지스터
  • JVM 스택
  • 네이티브 메소드 스택
  • 힙 영역
  • 메소드 영역

PC 레지스터(Program Counter Register)

PC 레지스터는 각 스레드마다 존재하며 스레드가 시작될 때 생성된다. PC 레지스터는 현재 수행 중인 JVM 명령의 주소 값을 갖는다.

JVM 스택(JVM Stack)

JVM 스택은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 스택 프레임(Stack Frame)이라는 구조체를 저장하는 스택으로, LIFO(Last In First Out)의 구조로 저장되고, JVM은 오직 JVM 스택에 스택 프레임을 추가(push)하고 제거(pop)하는 동작만 수행한다. 스택 메모리가 가득차면 자바에서는 java.lang.StackOverFlowError를 발생시킨다.

 

[스택 프레임]

하나의 메소드에 필요한 메모리 덩어리를 묶어서 스택 프레임이라고 한다. 하나의 메소드당 하나의 스택 프레임이 필요한데, JVM 내에서 메소드가 수행되면 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고, 메소드가 종료되면 스택 프레임이 제거된다. 스택 프레임에는 메소드의 매개변수, 지역변수, 리턴값 등이 저장된다.

 

[스택에 저장되는 데이터의 형태]

스택 영역에는 지역변수, 매개변수 등이 저장된다. 이때 데이터 타입에 따라 저장되는 데이터가 다르다. 기본 타입 변수는 스택 영역에 직접 값이 저장되고, 참조 타입 변수는 힙 영역이나 메소드 영역에 있는 객체의 주소가 저장된다.

[스택 사이즈]

스택 사이즈는 프로세스가 메모리에 로드될 때 고정되어, 런타임시에 스택 사이즈를 바꿀 수 없다. 만약 고정된 크기의 JVM 스택에서 프로그램 실행 중 메모리 크기가 충분하지 않다면 StackOverFlowError가 발생한다. 

네이티브 메소드 스택(Native Method Stacks )

자바 외의 언어로 작성된 네이티브 코드를 위한 스택이다. 즉 JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 수행하기 위한 스택으로, 언어에 맞게 C 스택이나 C++ 스택이 생성된다.

메소드 영역(Method Area)

Class Area, Static Area라고도 불리는 메소드 영역은 모든 스레드가 공유하는 영역으로, 런타임 상수풀, 멤버 변수(필드), static 변수, 상수(final), 생성자, 메소드 등이 저장된다. 메소드 영역의 데이터는 프로그램의 시작부터 종료까지 메모리에 존재하기 때문에 이 데이터들은 프로그램 실행 중 어디서든 사용이 가능하다.

 

[런타임 상수풀(Runtime Constant Pool)이란?]

각 클래스와 인터페이스마다 별도의 상수풀 테이블이 존재한다. 이는 클래스와 인터페이스에 존재한 상수뿐만 아니라 메소드와 필드에 대한 모든 주소 값을 담고 있는 테이블이다. 즉 어떤 메소드나 필드를 참조할 때 JVM은 이 상수풀을 참고하여 해당 메소드나 필드의 실제 메모리상 주소를 찾아서 참조한다.

힙 영역(Heap Area)

힙 영역은 메소드 영역과 마찬가지로 모든 스레드가 공유하는 영역으로, 인스턴스 또는 객체를 저장하는 공간이다. 런타임시 동적으로 할당되고 가비지 컬렉션을 통해 제거된다. 힙 메모리가 가득차면 자바에서는 java.lang.OutOfMemoryError를 발생시킨다.

 

[가비지 컬렉션]

힙 영역에 생성된 객체나 배열은 스택 영역의 변수에 의해 참조된다. 즉, 힙의 참조 주소는 스택이 갖고 있고 해당 객체를 통해서만 힙 영역의 인스턴스에 접근할 수 있다.

따라서 만약 힙 영역에 생성된 객체가 어디서도 참조되지 않으면, 필요 없는 객체가 되기 때문이 이는 가비지 컬렉션의 대상이 된다. 이처럼 힙 영역은 가비지 컬렉션의 대상이 되는 공간으로, 효율적인 가비지 컬렉션을 수행하기 위해 힙 영역은 다음과 같이 5가지 세부적인 영역으로 나뉘게 된다.

실행 엔진(Execution Engine)

실행 엔진은 클래스 로더를 통해 런타임 데이터 영역에 배치된 바이트 코드를 실행 가능한 형태로 변경하고 명령어 단위로 읽어서 실행한다. 바이트 코드의 각 명령어는 1바이트의 OpCode와 추가 피연산자로 이루어져 있으며, 실행 엔진은 하나의 OpCode를 가져와서 피연산자와 함께 작업을 수행한 후 다음 OpCode를 수행하는 방식으로 동작한다.

 

[바이트코드를 기계어로 변경하는 방법]

그런데 자바 바이트코드는 JVM이 읽을 수 있는 형태이지 기계가 바로 수행할 수 있는 기계어는 아니다. 그래서 실행 엔진은 이러한 바이트 코드를 실제 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식은 다음 두 가지가 있다.

  • 인터프리터
    • 인터프리터는 자바 바이트코드를 한 줄씩 읽어서 기계어로 해석하고 실행한다. JVM 안에서 바이트코드는 기본적으로 인터프리터 방식으로 동작한다. 
    • 하나씩 해석하고 실행하기 때문에 바이트코드 하나하나의 해석은 빠르나, 전반적인 실행은 느리다는 단점이 있다. 그리고 같은 메소드가 여러 번 호출될 때도 매번 해석한다는 단점이 있다.
    • 즉 자바 소스 코드는 바이트 코드로 컴파일 되는 과정과 바이트 코드가 인터프리터를 통해 기계어로 변환되는 과정, 2과정이 이뤄지며 그래서 자바가 느린 것이다.
  • JIT(Just-In-Time) 컴파일러
    • 속도가 느린 인터프리터의 단점을 보완하기 위해 도입된 방식이다. JIT 컴파일러는 바이트 코드 전체를 기계어로 변경하고 실행한다. 
    • JVM은 인터프리터와 JIT 컴파일러를 함께 사용한다. 인터프리터로 바이트코드를 하나씩 해석하고 실행하다가, 적절한 시점에 JIT 컴파일러를 통해 바이트코드 전체를 해석하고 실행한다. 그리고 해석된 기계어를 캐시에 보관하여, 나중에 해당 코드가 반복되어 실행되면 다시 해석하지 않고 캐싱된 코드를 사용한다. 
    • JIT 컴파일러가 바이트코드 전체를 컴파일하는 과정은 하나씩 인터프리팅 하는 방식보다 오래 걸린다. 따라서 만약 한 번만 실행되는 코드라면 인터프리팅하는 것이 훨씬 빠르다. 그러나 여러 번 실행되는 코드는 JIT 컴파일러를 사용하는 것이 효율적이다. 따라서 JVM은 내부적으로 해당 메소드가 얼마나 자주 실행되는지 체크하고, 일정 정도를 넘으면 JIT 컴파일러를 통한 컴파일을 수행한다.

자바 성능 개선의 많은 부분은 바로 이 실행 엔진을 개선하여 이뤄지고 있다. JIT 컴파일러는 물론이고, 다양한 최적화 기법을 도입하여 JVM의 성능은 계속해서 향상되고 있다. 

가비지 컬렉터(Garbage Collector, GC)

JVM은 가비지 컬렉터를 이용하여 Heap 메모리 영역에서 더이상 사용하지 않는 메모리를 자동으로 회수한다. C언어와 달리 자바는 가비지 컬렉터가 개발자 대신 메모리를 관리해주기 때문에 편리하다.


Reference