본문 바로가기
Java/Java

JVM 메모리 구조

by oneny 2023. 8. 9.

JVM

성능에 관심이 있다면 기본적인 JVM 기술 스택의 구조를 이해해야 할 필요가 있다. JVM 기술을 이해하면 더 좋은 소프트웨어를 개발할 수 있고 성능 이슈를 탐구할 때 필요한 이론적 배경지식을 갖추게 된다. 따라서 JVM이 나오게 된 배경부터 자바 코드를 실행하는 방법에 대해서 알아보자.

 

JVM이 나온 배경

 

JVM 이전 C/C++ 문제점

 

위 그림처럼 리눅스에서 컴파일해서 나온 실행파일을 윈도우에서 돌리게 되면 안돌아간다. C/C++는 컴파일 플랫폼과 타겟 플랫폼(= 운영체제 + CPU 아키텍쳐)이 다를 경우, 프로그램이 동작하지 않는다는 문제가 있었다.

 

이를 해결하기 위해서 크로스 컴파일이라고 하는 것이 나왔다. 이 '크로스 컴파일'은 타겟 플랫폼에 맞춰 컴파일하는 것을 말한다. 이 덕분에 리눅스에서 윈도우를 타겟으로 잡고 컴파일이 가능하다. 당연히 컴파일해서 나온 실행파일은 윈도우에서 동작을 한다. 이러한 방식으로 C/C++에서 플랫폼에 의존적이였던 문제점을 해결하였다. 하지만 컴파일된 파일 자체는 플랫폼에 의존적이라는 단점이 남아있었다고 할 수 있다.

 

JVM으로 문제를 해결

JVM은 위 문제를 근본적으로 해결했다. 소스코드가 컴파일을 거치고 나면 바이트코드가 된다. 이 바이트 코드는 JVM이 설치된 플랫폼이라면 어떤 플랫폼이던지 상관없이 잘 동작한다. 물론 JVM이 플랫폼과 관련된 기계어로 변환하는 지저분한 작업들을 대신해주기 때문에 가능하다. 따라서 Windows면 Windows용 JVM을 설치해도 Linux에서 작업하고 컴파일한 Java 바이트코드를 실행해도 아무런 문제가 없다.

WORA(Write Once, Run Anywhere)처럼 자바 코드를 컴파일해서 배포하면 어떤 플랫폼이든 다시 컴파일할 필요 없이 실행시킬 수 있고 실행하려면 그 플랫폼에 맞는 JVM이 설치되어 있어야 한다.

 

C/C++도 크로스 컴파일해서 배포면 되는데, 굳이 JVM을 사용해야 했을까? Java가 나오던 시절에는 네트워크가 막 발전한 시대였다. 네트워크로 모든 것들이 연결되고, 디바이스마다 실행하기 위한 프로그램이 필요했다. 이때, C/C++는 플랫폼마다 컴파일을 해줘야하는데 많은 기종의 디바이스들에 맞게 모두 컴파일하기에는 한계가 있었다. 그래서 Java는 네트워크에 연결된 모든 디바이스에서 작동하는 것이 목적이었다. 디바이스마다 운영체제나 하드웨어가 다르기 때문에, 자연스럽게 플랫폼에 의존하지 않도록 언어를 설계했다. 그리고 그 결과가 나온 것이 Java Bytecode를 실행하는 JVM이다. 바이트코드를 담은 .class 파일을 네트워크를 통해 전달해주면 해당 플랫폼에 JVM이 설치되어 있는 경우 .class 파일을 통해 실행하기만 하면 된다. 자바스크립트 파일을 웹 브라우저에 넘겨주면 웹 브라우저에 설치되어있는 자바스크립트 런타임이 즉석에서 interpret해서 실행한다는 것과 같다. 따라서 Java를 하이브리드 언어라고 표현하기도 한다.

 

JVM의 내부 구조

 

JVM(Java Virtual Machine)의 동작 방식

Java 소스파일을 Java Byte Code(.class 파일)로 변환하면 기계어 형태로 되어있는데 이는 JVM이 인식할 수 있는 코드이다. 이 바이트코드를 읽어 CPU 아키텍처에 맞게 CPU가 인식할 수 있는 기계어로 변환하여 실제 실행으로 이어진다.

 

출처: 코딩팩토리 - 자바 JVM 내부 구조와 메모리 구조에 대하여

  1. 자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트코드(.class)로 컴파일한다.(javac HelloWorld)
  2. java HelloWorld 명령을 내려 애플리케이션을 실행하면 OS는 가상 머신 프로세스(자바 바이너리)를 구동한다.
  3. JVM이 HelloWorld 클래스 실행하기 전 Class Loader를 통해 JVM Runtime Data Area로 로딩한다.
  4. Runtime Data Area에 로딩된 .class들은 Execution Engine을 통해 해석한다.
  5. 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 수행하며 이 과정에서 Execution Engine에 의해 GC의 작동과 스레드 동기화가 이루어진다.

JVM은 크게 3가지 영역으로 구성된다. 바이트코드를 읽고, 클래스 정보를 메모리의 Heap/Method Area에 저장하는 Class Loader, 실행 중인 프로그램의 정보가 올라가 있는 Runtime Data Area, 바이트 코드를 네이티브 코드로 변환시켜주고, GC를 실행하는 Execution Engine이 있다.

 

Class Loader

 

Class Loader의 기본적인 역할은 1. 클래스를 로딩(Loading)해서 2. 링크(Linking) 절차를 수행한 다음에 3. 초기화(Initialization) 과정을 거쳐 실행을 한다. 초기화에서 연결되는 핵심은 생성자이다.

 

클래스 로딩(Loading)

java HelloWorld 명령을 내려 자바 애플리케이션을 실행하면 OS는 JVM을 구동하고, 자바 가상 환경이 구성되고 스택 머신이 초기화된 다음, 실제로 유저가 작성한 .class 클래스 파일이 실행된다. 그리고 애플리케이션의 진입점(entry point)은 .class 파일에 있는 main() 메서드인데 제어권을 이 클래스로 넘기려면 가상 머신이 실행이 되기 전에 이 클래스를 로드하고 이때 자바 클래스로딩(classloading) 메커니즘이 관여한다.

 

public class Main {

  public static void main(String[] args) throws IOException {
    Class<Main> mainClass = Main.class;
  }
}

자바 프로세스가 새로 초기화되면 사슬처럼 줄지어 연결된 클래스로더가 차례차례 작동한다. 즉, Class Loader가 가장 먼저하는 일은 .java 소스파일을 컴파일해서 얻은 .class 파일을 메모리에 로드한다. 이 때, 로딩할 수 있는 클래스가 여럿 있으면 main() 메서드를 포함하는 클래스를 우선 로드한다. 그리고 클래스를 로딩하는 과정에 아래처럼 여러 개의 클래스들이 부모-자식 관계를 맺고 로딩을 하고, 로딩이 끝나면 해당 클래스 타입의 Class 객체를 생성하여 Heap Area에 저장한다.

위 코드를 보는 것처럼 Main 클래스가 로딩이 되면 Class 타입의 인스턴가 Main.class에 저장되어 static하게 접근을 할 수 있다. 인스턴스 인 경우에는 그 객체의 getClass() 메서드를 사용해서 접근할 수 있다.

 

1. 부트스트랩 클래스 로더

  • java.lang, java.net, java.util, java.io 같은 표준 Java 패키지 로드
  • rt.jar 파일에 들어있는 핵심 라이브러리

부트스트랩 클래스로더의 주임무는, 다른 클래스로더가 나머지 시스템에 필요한 클래스를 로드할 수 있게 최소한의 필수 클래스만 로드하는 것이다.

 

2. 플랫폼 클래스 로더

  • $JAVA_HOME/jre/lib/ext
  • 확장 라이브러리 클래스 로드

부트스트랩 클래스로더를 자기 부모로 설정하고 필요할 클래스로딩 작업을 부모에게 넘긴다. ext 폴더에 확장 라이브러리를 넣어놓았다면 해당 라이브러리도 로딩을 해준다. 그리 널리 쓰이지는 않지만, 확장 클래스로더를 이용하면 특정한 OS나 플랫폼에 네이티브 코드(native code)를 제공하고 기본 환경을 오버라이드할 수 있다.

 

부트스크랩 + 플랫폼 클래스 로더까지 개발자가 개발하는데 필요한 기본이 되는 라이브러리들을 로딩해준다고 봐도 좋다.

 

3. 애플리케이션 클래스 로더

  • (-classpath, -cp) 클래스 경로에 있는 클래스 로드
  • 일반적으로 개발자가 작성한 코드를 포함하는 클래스
  • 로더가 클래스 이름을 찾지 못할 경우 NoClassDefFoundError, ClassNotFoundException 예외 발생

애플리케이션 클래스로더가 생성되고 지정된 클래스패스에 위치한 유저 클래스를 로드한다. 자바는 프로그램 실행 중 처음 보는 새 클래스를 디펜던시(의존체)에 로드하고, 클래스를 찾지 못한 클래스로더는 기본적으로 자신의 부모 클래스로더에게 대신 룩업을 넘긴다. 이렇게 부모의 부모로 거슬러 올라가 결국 부트스트랩도 룩업하지 못하면 ClassNotFoundException 예외가 발생한다. 

 

클래스 로더 확인

public class Main {

  public static void main(String[] args) throws IOException {
    ClassLoader classLoader = Main.class.getClassLoader();
    System.out.println("classLoader = " + classLoader);
    System.out.println("classLoader.getParent() = " + classLoader.getParent());
    System.out.println("classLoader.getParent().getParent() = " + classLoader.getParent().getParent());
  }
}

우리가 사용하는 클래스 로더를 확인해보고 싶으면 getClassLoader() 메서드를 사용하여 출력할 수 있다. 그리고 클래스 로더는 계층형 구조라고 위에서 언급했는데 AppClassLoader -> PlatformClassLoader로 출력된 결과를 보면 확인할 수 있다. PlatformClassLoader의 부모 클래스로더도 있지만 최상위 클래스 로더인 BootstrapClassLoader가 native 코드로 구현되어 있기 때문에 VM마다 다 다르고, 자바에서 참조해서 출력을 할 수 없는 것이지 없는 것이 아니다.

 

클래스 링크(Linking)

클래스 로드 후 링크 절차를 수행하며 Verify, Prepare, Resolve(optional) 세 단계로 나누어져 있다. 링크에서는 클래스간 의존관계를 분석하고 함께 링크한다.

  1. Veify: .class 파일자체에 대한 구조 정합성을 검증한다. 실패 시, VerifyException 에러가 발생한다.
  2. Prepare: 메모리를 준비하는 과정이라고 생각하면 좋다. 클래스에 있는 static 변수와 기본값에 필요한 메모리를 준비해두는 과정으로 생성자 호출 전이기 때문에 일단 0으로 초기화 한다. 예를 들어, static boolean a = true;라 선언해도 처음 메모리에 할당할 때는 기본값으로 0을 초기화하고, 나중에 초기화 과정에서 true로 바뀐다.
  3. Resolve(Optional): 심볼릭 메모리 레퍼런스를 메서드 영역에 있는 실제 레퍼런스로 교체한다.

 

JVM 클래스 초기화(Initialization)

static String name = "oneny";

static int age;

static {
	age = 28;
}

Linking 과정까지 끝나게 되면 초기화 단계에서 클래스 생성자를 호출한다.

위에서 말했던 정적 필드에 0이 아닌 초깃값일 경우 실제 값으로 초기화하고, 멀티스레드 환경을 고려하지 않아 생성자가 호출되어 실행되는 구간에 원자성을 보장하지 않는 경우 클래스 초기화 중 오류가 발생할 가능성이 있다. 즉, 생성자에서 멀티스레드에 관련된 동기화가 요구되는 복잡한 코드를 넣지 말라는 의미이기도 하다.

 

Runtime Data Area

Data Area라는 단어를 보면 알 수 있듯이 Memory 영역이라고 생각하면 좋다. C/C++ 계열에서 생각하는 Memory의 본질은 기본적으로 커널 드라이버를 개발하는 것이 아닌 이상은 OS 수준에서 적용하고 있는 Virtual Memory System을 사용한다. 다시 말해, OS 수준에서 소프트웨어인 JVM 자체가 사용하는 Virtual Memory 체계 기반의 Memory를 Native  Memory라고 부른다. 그래서 Virtual Machine에서 Native라는 용어가 나온다면 OS 수준이라고 생각해도 좋다.

 

JVM은 OS로부터 메모리를 할당받은 후, Java Bytecode를 실행하기 위해 여러 가지 종류의 메모리가 필요한데 그 때 사용하는 메모리 공간이다. 이 때 스레드가 공유하는 것이 있고, 공유하지 않는 것이 있다. Method Area와 Heap Area이 모든 스레드가 공유하는 Data Area이고 Heap Area는 GC의 대상이 된다. 그리고 나머지는 스레드마다 고유하게 생성되고, 스레드 종료 시 소멸된다.

 

Method Area(Static Area)

Method Area는 보통 정적(Static) 영역이라고 부르는 메모리로 상수풀, 필드, 메서드 코드 등이 저장된다. Class Loader가 .class 파일을 읽고 그 내용에 따라 적절한 바이너리 데이터, 즉 메타 데이터를 만들고 Method Area에 저장한다. 메타 데이터는 변수, 메서드, 정적 변수 등을 가리키는데 Method Area에 저장되는 목록에 대해 살펴보자.

 

1. Type Information:  클래스와 인터페이스의 정보

  • Type명: Package name + Class name
  • Type의 종류: Type이 Class인지 Interface인지에 대한 정보
  • Type의 제어자: 접근 제어자(public, private, default 등), 그 외 제어(abstract, final 등)
  • 연관된 Interface 정보: 사용된 Interface의 정보

2. Runtime Constant Pool: Type의 상수 정보를 저장하는 Pool

 

3. Field Information: 인스턴스 변수의 정보를 저장

  • Type명: 인스턴스 변수의 타입
  • 제어자: 접근 제어자(public, private 등), 그 외 제어자(static, final, volatile 등)

4. Method Information: 메서드의 모든 정보 저장

  • Method 명, Method 반환 타입, Method parameter 수와, parameter의 타입 정보
  • 외 필요한 메서드에 대한 정보

5. Class Variable: static 키워드로 선언된 변수 저장

 

Heap Area

Heap 영역 같은 경우에는 동적으로 할당된 메모리 영역이다. 즉, 프로그램을 실행하면서 생성한 모든 객체(오브젝트 타입의 데이터, 클래스 인스턴스)가 저장되는 런타임 데이터 영역으로 GC가 관리한다. 다시 말하자면 new 연산으로 생성된 모든 클래스 인스턴스가 저장되는 영역이다. Heap 영역에 있는 데이터들은 대부분 오브젝트 크기는 크고 서로 다른 코드 블럭에서 참조하다보니 계속 사용하기 때문에 상대적으로 생명주기가 길다고 할 수 있다. 그리고 Heap 영역은 단 하나의 영역만 존재하기 때문에 멀티스레드 환경에서 모든 스레드가 공유하여 동기화가 필수이다.

 

참고: 동적할당
프로그램 실행 도중, 필요한 메모리를 확보하는 방법

 

PC(Program Counter) register

 

일반 CPU(EIP register)처럼 Program Counter를 가지며 현재 수행해야 할 명령어에 대한 인덱스를 저장하는 역할을 수행한다. 즉, 스레드는 항상 어떤 메서드를 실행하고 있고, 각 스레드마다 실행 흐름 즉, 별도 문맥을 가질 수 있도록 개별 PC register를 가져 해당 메서드 안에서 바이트코드 몇 번째 줄을 실행해야 하는지 나타내는 역할을 한다.

 

Stack Area

Stack 같은 경우에는 정적으로 할당된 메모리 영역이다. Stack은 지역변수, 피연산자, 스택 프레임 데이터 등 세 가지 요소로 구성되어 있다. Stack에서는 boolean, char, short, int, long, float, double 같은 원시 타입의 데이터가 값이랑 같이 Stack에 할당이 되고, Heap 영역에서 오브젝트 타입 데이터의 참조값도 Stack에 할당이 된다. 다른 특징으로 새로운 Thread가 생성이 되면 그 해당 Thread에 대한 Stack이 새롭게 생성이 되고, 각 Thread끼리는 Stack 영역을 접근할 수가 없다. 즉, Stack의 메모리는 Thread당 하나씩 할당된다.

그리고 스택 프레임이라는 것이 있는데 이 스택 프레임은 메서드가 호출될 때마다 생성되고, 메서드 실행이 끝나면 스택 프레임은 pop되어 스택에서 제거된다. 따라서 제일 위에 있는 stack frame은 main 메서드이고, 그 밑에 있는 stack frame들은 main 메서드에서부터 계속 호출된 어떤 메서드들이라 할 수 있다.

마지막으로 구성요소 중 피연산자가 있는데 이를 통해 알 수 있는 것은 JVM은 기본적으로 Stack기반 머신 형태로 작동하도록 구성되어 있다. 즉, 연산을 했을때 중간결과가 발생하는데 중간결과도 스택에 저장한다. 이에 대한 설명은 아래 번외에서 자세히 알아보자.

스택의 최대 크기는 컴파일 타임(bytecode로 변환하는 시점)에 결정하고, 메서드에 대한 모든 심볼정보 및 예외처리 관련 catch 블록 정보 등은 프레임 데이터 영역을 사용한다.

 

참고: 정적할당
변수 선언을 통해 필요한 메모리를 확보하는 방법
참고: Stack Frame
스택 프레임은 메서드가 호출될 때마다 새로 생겨 스택에 push된다. 스택 프레임은 Local variables array, Operand stack, Frame Data를 갖는다. Frame Data는 Constant Pool, 이전 스택 프레임에 대한 정보, 현재 메서드가 속한 클래스/객체에 대한 참조 등의 정보를 갖는다. 쉽게 생각하면 바이트코드를 실행하기 위해 당연히 필요한 정보들이라 생각하면 좋다.

 

Native Method Stack

Native Method는 Java Bytecode가 아닌 다른 언어로 작성된 메서드를 의미한다. 위에서 말했듯 Native라는 단어가 나온 것은 운영체제와 직접적으로 관련이 있다는 것을 알 수 있다. JVM이 JNI(Java Native Interface)를 이용해서 성능 향상을 목적으로 C/C++로 작성된 언어로 개발된 메서드를 지원하기 위한 스택으로 스레드마다 별도로 제공한다.

 

참고: JNI(Java Native Interface)
JVM도 소프트웨어인데 소프트웨어는 기본적으로 C/C++로 개발된다. 즉, JVM 자체는 C/C++로 개발되어 있다. 그래서 C/C++로 개발되는 모듈 수준의 라이브러리가 있는 JNI(Native Method Interface)를 통해서 기존 JVM이 갖지 못하는 추가 기능을 갖게 할 수 있다. JNI는 보안과 직결이 된다고도 하는데 자세한 내용은 찾아보자..ㅎㅎ

 

Execution Engine

실행 엔진에는 바이트 코드를 실제 기계어로 바꿔주는 Interpreter, 성능을 향상시키기 위한 JIT Compiler, Heap 영역에서 객체들을 관리해주는 Garbage Collector로 구성되어있다.

 

Interpreter

인터프리터 역할은 말 그대로 컴파일된 바이트 코드를 실제 기계어로 변환하는 것이고, 이를 통해 CPU 연산하여 실행을 할 수 있다. Java를 공부하면서 Hybrid 언어라고 들어본 적이 있다. 왜 하이브리드 언어라고 할까? Java는 컴파일과 인터프리터 두 가지 특성을 모두 다 가지고 있는 언어라 하이브리드 언어라 표현한 것이다.

인터프리터를 통해 같은 메서드를 호출할 때마다 매번 번역하는 것은 매우 번거로운 작업이다. 또한, C/C++과 같은 컴파일 언어에 비해서 기계어로 번환하는 과정이 필요하므로 성능이 느릴 수 밖에 없다. 이를 보완하기 위해 JIT Compiler가 나오게 되었다.

 

JIT Compiler

위 문제를 해결하기 위해 JVM이 JIT Compiler를 통해 반복되는 코드를 발견할 경우 효율을 높일 목적으로 사용하여 해당 코드를 캐싱한다. 다시 말해 한 번 바이트 코드를 실제 기계어로 번역한 다음에 똑같은 바이트 코드를 실행할 일이 있으면 이미 번역한 기계어를 사용하여 효율을 높일 수 있다. 따라서 C/C++ 같은 컴파일 언어 보다 느린 성능을 JIT Compiler를 사용하여 Java를 고성능으로 만드는데 결정적인 역할을 했다.

 

번외

 

인터프리팅

위 Stack Area에 대해 설명할 때 JVM은 스택 기반의 해석머신이라고 설명했다. 물리적 CPU 하드웨어인 레지스터는 없지만 일부 결과를 실행 스택에 보관하며, 이 스택의 맨 위에 쌓인 값(들)을 가져와 계산한다. 왜 레지스터가 아닌 스택을 선택했을까? 이 이유는 디바이스마다 레지스터의 수가 다르기 때문이라고 생각하면 좋다. 만약 어떤 디바이스를 기준으로 레지스터의 수를 정했다면 그 개수보다 적은 디바이스는 실행이 불가할 수도 있다. 따라서 JVM은 레지스터가 아닌 스택을 선택하였다.

JVM 인터프리터의 기본 로직은 평가 스택을 이용해 중간값들을 담아두고 가장 마지막에 실행된 명령어와 독립적으로 프로그램을 구성하는 operation code(명령 코드)를 하나씩 순서대로 처리하는 while 루프 안의 switch 문이다.

 

자바 코드 예시

public class Main {

    public static void main(String[] args) throws IOException {
        double position = 1.0;
        double initial = 1.0;
        double rate = 1.0;
        
        position = initial + rate * 60;
    }
}

위 자바 소스코드를 컴파일하면 다음과 같은 바이트 코드를 변환할 수 있다.

 

0 dconst_1 <-- Operand Stack에 1.0(double constant)을 push
1 dstore_1 <-- Operand Stakc에서 pop한 값(1.0)을 Local Variable Array 1번 인덱스에 저장
2 dconst_1 <-- Operand Stack에 1.0을 push
3 dstore_3  <-- Operand Stakc에서 pop한 값(1.0)을 Local Variable Array 3번 인덱스에 저장
4 dconst_1 <-- Operand Stack에 1.0을 push
5 dstore 5  <-- Operand Stakc에서 pop한 값(1.0)을 Local Variable Array 5번 인덱스에 저장
7 dload_3 <-- Local Variable Array 3번 인덱스에 있는 값(initial, 1.0)을 Operand Stack에 push
8 dload 5  <-- Local Variable Array 5번 인덱스에 있는 값(rate, 1.0)을 Operand Stack에 push
10 ldc2_v #2 <60.0> <-- Constant Pool에서 2번째 값을 가져온다. (double형 리터럴 60.0)
13 dmul <-- Operand Stack에서 두 값을 pop하고, 곱해서 다시 push (*dmul: double multiply)
14 dadd <-- Operand Stack에서 두 값을 pop하고, 더해서 다시 push(*add: double add)
15 dstore_1 <-- Operand Stack에서 pop한 값(61.0)을 Local Array 1번 인덱스(61.0)에 저장
16 return
Local Variable Array(로컬 변수가 들어가 있는 테이블)
index    Name    Descriptor
0           args       java/lang/String*
1           position  61.0
3           initial      1.0
5           rate        1.0


아래 바이트 코드를 실행했을때의 과정을 그림으로 표현하면 다음과 같다(Program Counter 10번부터 시작).

 

 

출처

[10분 테코톡] 무민의 JVM Stack & Heap

자바 JVM 내부 구조와 메모리 구조에 대하여

자바(Java) 메모리 구조 소개 [자바(Java)]

Java 공부 - 07. JVM 구조 

Java 공부 - 08. 클래스 로더

Java 공부 - 09. JVM Runtime data area

Java 공부 - 13. Execution engine

더 자바, 코드를 조작하는 다양한 방법