본문 바로가기
Java

JVM 구조

by nak_honest 2023. 8. 8.

먼저 자바 가상 머신 JVM의 역할은 자바 바이트코드(.class)를 읽어서 실행하는 것이다. JVM을 통해 플랫폼에 상관없이 컴파일된 바이트 코드를 실행할 수 있다.

여기서는 전체적인 내용을 살펴보겠다. 각각에 대한 자세한 내용은 모두 따로 정리하자!
그리고 Java 8 이후에 구조가 바뀐 부분들이 있고, 또한 벤더에 따라 구조에 차이가 있기도 하니 전체적인 개념만 잡고가는 느낌으로 가자.

 

다음은 JVM의 동작 방식을 간단하게 나타낸 것이다.



  1. 먼저 소스파일(.java)를 javac 컴파일러로 컴파일 해서 바이트코드(.class) 파일을 얻는다.
  2. 그후 자바 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당받는다. 프로그램을 실행시키는데 필요한 메모리를 할당받는 것이다.
  3. 그러면 클래스 로더를 통해 이 바이트코드(.class 파일)들을 로드해서 메모리에 올린다. 이때 JVM이 OS로부터 할당 받은 메모리를 Runtime Data Area라고 한다.
  4. 이렇게 Runtime Data Area에 로딩된 클래스 파일들은 Execution Engine을 통해 해석된다. 이때 바이트 코드를 해당 플랫폼에 맞는 기계어로 변경한다.
  5. 이렇게 해석된 바이트코드는 Runtime Data Area의 각 영역에 배치되고, 실행된다.

 

Class Loader

먼저 앞에서 이야기 했듯이 클래스 로더는 클래스 파일(바이트코드)를 동적으로 로드해서 메모리(Runtime Data Area)에 적재하는 역할을 한다. 여기서 동적으로 로드한다는 것은 런타임에 로드한다는 것이다.

이전에 배운 C언어는 프로그램 전체(기계어)가 메모리(텍스트 세그먼트)에 적재되었다. 하지만 자바는 그렇지 않다는 것이다. 자바는 프로그램을 실행할때 필요한 경우에 동적으로 이 클래스를 메모리에 올린다는 것이다.

즉 클래스 로더는 클래스를 메모리에 한번에 올리지 않고 필요한 경우 런타임에 클래스를 메모리에 올린다는 것이다.

클래스 로더에 대해 좀더 자세히 살펴보면 다음과 같다.



먼저 크게 보면 Loading -> Linking -> Initialization 단계로 진행된다.

  1. Loading은 클래스 파일(바이트 코드)을 가져와서 Runtime Data Area 중 메소드 영역(클래스 영역)에 로드한다. 이때 유의할 점은 한번에 모든 바이트 코드를 로드하지 않는다는 것이다!(가끔 구글링 하다보면 모든 바이트 코드가 올라간다고 되어있는데 자바는 필요한 경우 동적으로 이 메소드 영역에 바이트 코드를 로드한다.)
  2. Linking은 클래스 파일이 자바 규칙을 따르는지 검증하고, static 변수와 기본값에 필요한 메모리를 준비한다.
  3. Initialization은 static 변수를 메모리에 할당하고, 초기화한다.

위에서 보면 알겠지만 static 변수는 클래스 로드 시에(정확하게는 Initialization 단계에서) 메모리에 올라간다는 것을 확인할 수 있다.

Execution engine(실행 엔진)

실행 엔진은 메소드 영역에 올라온 바이트 코드를 명령어 단위로 읽어서 실행한다. 바이트 코드를 플랫폼에 맞게 해석해주는 역할을 수행한다. 이때 실행엔진은 인터프리터 또는 JIT 컴파일러 방식 두가지를 혼합하여 사용한다.

인터프리터

바이트 코드 명령어를 하나씩 읽어서 해석하고 실행하는 방식이다. 기본적으로는 이 방식으로 동작하는데, 런타임에 바이트 코드를 해석해서 기계어로 변환하기 때문에 지연이 발생하게 된다.

C/C++이 자바보다 더 빠르다고 하는 이유중에 하나는, C나 C++은 미리 컴파일을 해서 플랫폼에 맞는 기계어로 변환하기 때문에 속도가 빠르다는 것이다.
반면에 자바는 WORA 철학을 지키기 위해 바로 기계어로 변환하는 것이 아니라 바이트 코드로 변환하고, JVM이 해당 바이트 코드를 런타임에 변환하기 때문에 속도가 더 느리다.

JIT(Just In Time) 컴파일러

위의 인터프리터 방식의 속도 문제를 해결하기 위해 나온 방식이다. 예를들어 인터프리터 방식은 반복문을 돌때 매번 같은 코드를 번역하기 때문에 비효율적인데, JIT 컴파일러는 자주 실행되는 코드들을 런타임 중에 기계어로 컴파일 해서 캐싱해 놓는다. 그러면 캐싱되어 있는 기계어를 바로 실행하면 되기 때문에 더 빠르게 실행할 수 있게 된다.

Garbage Collector(GC)

가비지 콜렉터는 Runtime Data Area의 Heap 영역에서 더이상 사용하지 않는 메모리를 자동으로 회수하는 역할을 한다. C같은 언어는 메모리를 할당하면 개발자가 직접 메모리를 해제해 주어야 하지만, 자바는 GC가 이를 자동으로 수행하기 때문에 좀더 쉽게 프로그래밍을 할수 있게 해준다.

 

Runtime Data Area

JVM이 OS로부터 할당 받은 메모리로, 이 메모리를 용도에 따라 여러 영역으로 나누어서 관리한다. OS 독립적으로 JVM이 이 메모리를 관리하기 때문에 플랫폼 독립적으로 실행할 수 있다.



Runtime Data Area는 위처럼 Method Area, Heap Area, Stack Area, PC Register, Native Method Stack 총 5가지 영역으로 나뉜다.

여기서 메소드 영역과 힙 영역은 모든 스레드가 공유하고, 나머지 스택 영역, PC Register, Native Method Stack 은 스레드별로 생성된다.

Method Area

바이트 코드가 올라가는 영역으로, 클래스가 로드될때 적재되어서 프로그램이 종료될때까지 저장된다. 이 메서드 영역에는 클래스의 정보와 메소드의 바이트 코드, static 변수, Runtime Constant Pool 등이 들어있다.

여기서 그냥 바이트 코드들을 적재시키는 것이 아니라, "메소드"의 바이트 코드를 적재시킨다. 실행엔진이 명령어를 실행하기 위해 메소드 영역으로부터 명령어를 읽어와 해석한다고 앞에서 설명했는데, 이를 위해 메소드의 바이트 코드들이 올라오는 것이라 생각하면 된다. 따라서 이 영역의 이름이 method area 인것이다.

 

그리고 나머지 클래스 코드에 대한 정보는 분석해서 저장한다. 해당 클래스(또는 인터페이스)의 패키지까지 포함한 전체 이름, 가지고 있는 필드에 대한 정보(멤버 변수의 이름, 멤버변수의 타입, 멤버변수의 접근제어자 정보 등), 가지고 있는 메소드에 대한 정보들(메소드 이름, 리턴 타입, 매개변수 정보 등) 등을 저장한다.

따라서 이 영역을 Class Area라고 부르기도 한다.

 

또한 메서드 영역에는 클래스 변수(static 변수)도 저장된다. 인스턴스 변수는 객체의 상태를 나타내기 때문에 객체가 생성되면 인스턴스 변수가 생기지만, 클래스 변수는 모든 객체가 서로 공유하고 객체 생성 전에 접근할 수 있다고 배웠었다.

이때 static 변수는 클래스 로더가 클래스를 메소드 영역에 로드할때 같이 저장된다. -> 즉 클래스를 사용하기 이전에 이 변수들이 미리 할당되어 있는 상태가 된다.

즉 static 변수는 인스턴스의 것이 아니라 클래스에 속하는 것이기 때문에 클래스와 함께 저장되는 것이라고 생각하면 된다.

헷갈리면 안되는 부분!
static method만 메소드 영역에 올라간다고 하는데, 위에서 보았듯이 클래스 메소드든 인스턴스 메소드 모두 메소드 영역에 저장된다. 인스턴스 메소드는 객체에 속해있기 때문에 객체를 생성하고 나서 호출할 수 있지만, 인스턴스 메소드에 대한 바이트 코드는 메소드 영역에 저장된다. 즉 인스턴스 메소드가 객체에 속해있긴 해도, 매번 객체가 생성될때마다 같이 생성되는 것이 아니라는 것이 아니라는 것이다. 인스턴스 메소드는 메소드 영역에 한번만 할당되고, 생성된 객체들은 해당 메소드의 위치(메모리 주소)를 담고 있어 해당 주소를 통해 메소드를 호출하는 방식으로 동작한다.

 

이 메소드 영역에 로드되고 나면 프로그램 종료시까지 계속 유지된다. 즉 GC의 관리 영역 대상이 아니라 메모리에 계속 할당되어 있는 상태를 유지하게 된다.

-> Java 8에선 PermGen 영역이 없어지고 MetaSpace 영역이 새로 생겼는데, 정적 변수들은 heap 영역으로 올라간다고 한다.

Runtime Constant Pool

클래스나 인터페이스가 로드될때 JVM에 의해 생성되는 공간으로 메서드 영역 내부에 존재한다.
c언어에서 배운 심볼 테이블과 비슷하지만, 심볼 테이블에 비해 더 넓은 범위의 데이터를 가지고 있다.

클래스 내에서 사용하는 상수를 담은 테이블로, 숫자 리터럴부터 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다.

컴파일 단계에서는 다른 클래스의 실제 주소 값을 알지 못하기 때문에 실제 주소값 대신 symbolic reference로 대체한다.

그리고 JVM이 런타임 상수 풀을 통해 메소드나 필드의 실제 메모리 상 주소를 찾아 참조한다.



Heap Area

힙영역은 new 연산자를 통해 동적으로 생성되는 클래스의 객체(인스턴스 변수 포함)들과 배열을 담는 공간이다. 힙 영역은 GC에 의해 자동으로 관리가 되며, 주기적으로 사용되지 않는 객체를 힙 영역에서 제거한다.

모든 스레드들에 의해 공유되는 영역이다. -> 동기화 이슈가 발생할 수 있어 처리해 주어야 한다.

이렇게 힙 영역에 생성된 객체와 배열은 지역 변수나 다른 객체의 필드에 의해 참조된다. -> 힙의 참조 주소를 저장한다는 것이다. 그리고 이러한 객체들 중에서 참조되지 않고 있는 객체가 있다면 GC가 자동으로 해제하게 된다.

가비지 컬렉션을 효율적으로 수행하기 위해서 힙 영역을 세부적으로 또 나누는데, 이에 대한 내용은 다음을 참고하자.



Stack Area

스택 영역에는 지역변수와 매개변수들이 저장된다. 이름 그대로 스택으로 동작하며(LFIO) 스레드 별로 스택 영역이 생성된다.



가장 처음 JVM 스레드가 생겨날때 해당 스레드를 위한 스택도 같이 만들어지고, 여기에 스택 프레임이 쌓이게 된다. 여기서 스택 프레임은 메소드 실행시마다 생기며, 이 공간을 해당 메서드가 사용하게 된다. -> 매개변수, 지역변수, 리턴 값 등을 저장한다. 그리고 메서드가 종료되면 해당 스택 프레임은 스택에서 pop 된다. stack에서 top에 위치한 스택 프레임이 현재 실행중인 메서드의 프레임이다!

-> 기존 다른 언어의 스택 영역과 비슷한 역할을 한다.

또한 프레임 안에는 연산을 하는데 임시적으로 사용되는 공간인 operand stack도 존재한다.

또한 메소드가 속해있는 클래스의 런타임 상수 풀에 대한 레퍼런스를 가지고 있다. -> 이를 통해 symbolic하게 참조하고 있는 메소드나 필드 등을 참조할 수 있게 된다. 왜냐하면 runtime constant pool에는 참조하고 있는 메소드나 필드의 주소값으로 대체되기 때문!

이때 주의할 점으로 변수가 참조 변수냐, primitive 변수냐에 따라 저장되는 방식이 달라진다.
참조 변수는 힙영역에 존재하는 객체의 주소를 저장한다고 앞에서 이야기 했지만, 기본 타입의 변수는 스택 영역에 직접 값을 저장한다.

PC Register

PC 레지스터 또한 스레드가 시작될 때 생성되며, 현재 수행중인 JVM 명령어의 주소를 저장한다. 컴퓨터 구조 시간에 배우는 그 PC 레지스터!



Native Method Stack

자바 이외의 C나 C++과 같은 언어로 작성된 네이티브 코드를 실행하기 위한 공간이다.
자바에서는 네이티브 코드로 되어 있는 함수를 호출할 수 있는데, 그러한 경우 native method stack에 쌓이게 된다.



출처

'Java' 카테고리의 다른 글

Java enum 활용기(enum에서의 다형성)  (0) 2023.11.10