JVM의 메모리 구조 - 231206

팝콘·2023년 12월 12일

Java

목록 보기
16/17

왜 JVM을 알아야 할까?

과제를 받고 왜 JVM에 대해서 알아야 하는지 궁금해졌다. "메모리를 효율적으로 쓰려면 JVM의 구조를 알아야 한다." 는 하지만 솔직히 와닿지는 않는다. 그래서 일단 JVM에 대해서 알아보기로 했다.


JDK와 JRE

JDK (Java Development Kit)

자바를 개발하기 위해 사용되는 SDK(소프트웨어 개발 키드)다. 라이브러리, JRE, 컴파일러, 디버거 등 개발도구를 포함하고 있다.

JRE (Java Runtime Environment)

자바를 실행하기 위한 최소 환경이다. 클래스 라이브러리, JVM 및 배포 도구가 포함되어 있다. JVM을 이용하여 Byte code를 실행시킨다.


JVM이란?

Java Virtual Machine의 줄임말로 직역하면 자바 가상 기계라는 뜻이며 간단하게 말하면 '자바를 실행시키기 위한 가상 컴퓨터'라고 생각하면 된다.

실행 시에 하드웨어에 맞게 해석되기 때문에 속도가 느리다는 단점이 있다. 또한 자바가 OS에 독립적인 대신 JVM은 OS에 종속적이라 다른 Window에서 작성한 자바 애플리케이션을 Linux에서 실행시키려면 Linux용 JVM이 필요하다.

자바로 작성된 모든 애플리케이션의 소스코드는 우선적으로 Java 컴파일러를 통해 JVM이 이해할 수 있는 Java Byte code로 번역된다.

Java Byte code

자바를 컴파일 하면 JDK나 JRE에 포함되어 있는 javac.exe 실행파일이 실행되는데 이때 자바의 소스코드가 JVM이 이해할 수 있는 java Byte code 로 번역이 되는 것이다. 그리고 클래스 파일을 생성하게 된다. 클래스 파일 안에 존재하는 데이터가 바로 Byte code다.

그리고 JVM은 Java Byte code를 OS가 이해할 수 있는 기계어로 번역해준다.

  • 기계어란 0과 1로 이루어진 바이너리 코드다.
  • 기계어는 특정 언어가 아니라 CPU가 이해하는 명령어 집합이다. 바이너리 코드는 이진 코드라고도 부르는데 기계어 = 이진 코드는 아니다.
  • 기계어는 캐시에 보관되기 때문에 한 번 컴파일 되고 다시 실행할 경우 빠르게 실행된다.

JVM의 메모리 구조

Class Loader

런타임, 즉 Byte code가 실행될 때 동적로딩 방식으로 클래스를 로드하고 Runtime Data Area에 클래스를 적재하는 역할을 맡고 있다. 클래스 로더는 Loading, Linking, Initialization 세가지 과정을 거친다.

한 번 로드된 클래스는 메소드 영역에 저장되기 때문에 매번 다시 로드할 필요가 없다.

동적 로딩

  • 프로세스가 시작될 때 프로세스의 주소 공간 전체를 한 번에 메모리에 올리는 것이 아니라 필요한 코드와 데이터만 메모리에 적재시키는 방식을 말한다. 이런 방식을 사용하면 메모리를 좀 더 효율적으로 사용할 수 있다.

Loading

컴파일된 클래스를 메모리에 로드하는 역할을 한다. 이때 클래스 로더가 지켜야할 4가지의 원칙이 존재한다.

  1. 위임 계층 원칙

    • 클래스 로드 요청을 받았을 때 상위 클래스 로더에게 책임을 위임한다.
    • 우선적으로 런타임 데이터 영역(Runtime Data Area) 내의 존재하는 메소드 영역(Method Area)에 클래스가 로드되어 있는지 확인한다. 로드되어 있으면 해당 클래스를 사용하고 로드되어 있지 않으면 애플리케이션 클래스 로더에게 로드를 요청한다. 애플리케이션 클래스 로더는 확장 클래스 로더에게 로드를 요청하고 확장 클래스 로더는 부트스트랩 클래스 로더에게 로드를 요청한다.
    • 클래스 로더가 세 개로 나눠져 있는 이유는 간단하게 말하자면 보안을 위해서다. 만약 클래스 로더가 나눠져 있지않고 합쳐져 있다고 생각해보자. 만약 내가 악의적인 이유로 Integer 클래스를 생성하고 운이 아주 좋다면 자바의 기본 Integer 클래스보다 내가 만든 Integer가 먼저 실행될 수도 있다. 그런 이유로 클래스 로더의 종류가 나눠져 있는 것이다.
    • 아래의 그림은 클래스 로더의 계층 구조다.
    • Bootstrap Class Loader (부트스트랩 클래스 로더)
      • JVM이 기동될 때 생성되며 Object 클래스를 비롯해 Java의 API들을 로드한다. 다른 클래스 로더와는 다르게 기계어로 구현되어 있다.
    • Extension Class Loader (확장 클래스 로더)
      • Java 9부터는 Platform Class Loader라고 부른다.
      • 기본 Java API를 제외한 확장 클래스를 로드한다.
      • 예를 들면 보안 확장 프로그램 등이 있다.
    • Application Class Loader (애플리케이션 클래스 로더)
      • Java 9부터는 System Class Loader라고 부른다.
      • 애플리케이션 내의 클래스를 로드한다.
      • 사용자가 지정한 classpath 내의 클래스를 로드한다.
  2. 가시성 원칙

    • 하위 클래스 로더는 상위 클래스 로더가 로딩한 클래스를 알 수 있지만 반대는 허용되지 않는다는 원칙이다.
  3. 유일성 원칙

    • 상위 클래스 로더에 의해 로드된 클래스를 하위 클래스 로더가 다시 로드하지 않는 것을 말한다. 이를 지키기 위해서는 Class Binary name, 즉 FQCN (Fully Qualified Class Name)를 이용해야 한다.

      FQCN (Fully Qualified Class Name)
      = 패키지명 + 클래스명

  4. 언로드 금지 원칙

    • 로드된 클래스를 취소할 수 없다. 권장되는 방법은 아니지만 로드된 클래스를 취소하고 싶다면 해당 클래스가 로드된 클래스 로더를 삭제하고 다시 생성하는 수밖에 없다. 하지만 이마저도 JVM이 실행될 때는 사용할 수 없다.

Linking

로드된 클래스 파일들을 검증하고 사용할 수 있게 준비하는 과정이다.

  1. 검증 (Verification)

    • 바이트 코드 검증기가 읽어온 Byte code가 자바와 JVM에 맞게 잘 구성되어 있는지 확인한다.
    • 파일의 정확성을 판단하는 과정으로 클래스를 로드하는 과정 중 가장 복잡하기 때문에 시간이 많이 걸린다.
    • 바이트 코드 검증기는 검증에 실패하면 런타임 에러를 발생시킨다.
  2. 준비 (Preparation)

    • 클래스에 필요한 정적 변수 (static field)에 메모리를 할당하고 기본값으로 초기화 한다.
  3. 분석 (Resolution)

    • 메소드 영역(Method Area) 내의 런타임 상수 풀 (Runtime Constant Pool)에 존재하는 심볼릭 참조(symbolic reference)를 직접 참조(direct reference)로 대체한다.

      심볼릭 참조 (symbolic reference)

      • 사용자가 작성한 class, field, method의 이름을 지칭한다.
      • 심볼릭 참조를 직접 참조로 대체한다는 것은 사용자가 정의한 class, field, method의 이름을 실제 메모리 주소로 대체한다는 것을 의미한다.
      • 소스코드가 Byte code로 변경될 때 이미 로드된 자바의 기본 클래스 파일의 이름이 그대로 올라가기 때문에 심볼릭 참조를 이용해서 클래스 이름의 실제 메모리 값을 찾아주는 것이다.

Initialization

클래스 파일의 코드를 읽고 코드에 명시된 원래 값을 정적 변수에 할당되며 정적 초기화 블럭이 실행된다.


Runtime Data Area

JVM의 메모리 영역으로 프로그램 실행에 사용되는 메모리를 관리하는 영역이다. Runtime Data Area 내부에서도 몇가지 영역으로 나뉘어있다.

JVM 단위

JVM이 시작할 때 생성되고 종료할 때 소멸된다. JVM의 모든 스레드가 힙과 메소드 영역을 공유한다.

1. Method Area (= Class Area = Static area)
클래스, 인터페이스, 메소드, 변수, 정적 변수의 Byte code를 저장한다. 클래스에 대한 정보가 이곳에 저장되기 때문에 인스턴스를 생성하려면 Method Area를 참조해야 한다.

해당 영역에 저장되는 데이터의 종류는 아래와 같다.

  • Type Information : 클래스와 인터페이스 정보

    • 패키지명을 포함한 Type의 이름 저장
    • 클래스인지 인터페이스인지 Type의 종류 저장
    • Type의 접근제어자(public, private 등) 및 기타 제어자(final, abstract 등) 저장
    • 클래스가 사용하는 인터페이스 저장
  • Runtime Constant Pool

    • 메소드 영역에 존재하지만 별도의 관리 영역이다.
    • 클래스의 생성/소멸과 함께 하며 클래스당 하나씩 생성된다.
    • 상수의 자료형을 저장하여 참조하고 중복을 막는다.
    • 인덱스를 통해 접근이 가능하다.
    • 클래스와 인터페이스 상수, 메소드와 변수에 대한 모든 참조를 저장한다.
  • Field Information : 클래스 변수의 정보를 저장한다.

    • 클래스 변수명을 저장
    • 클래스 변수의 접근제어자와 기타 제어자 저장
  • Method Infeomation : 메소드의 정보를 저장한다.

    • 메소드 이름
    • 메소드의 반환 타입
    • 메소드의 매개변수 정보(매개변수 개수, 타입)
  • Class Variable : 정적 변수의 정보를 저장한다.

2. Heap
new 키워드를 통해 생성되는 객체와 배열이 저장된다. 메소드 영역에 로드된 클래스만 객체로 생성이 가능하다. 또한 힙의 데이터는 스택에 참조된다. 만약 힙 영역이 가득 차게 되면 OutOfMemoryError가 발생한다.

Garbage Collection의 대상이 되는 영역이며 참조되지 않은 메모리는 GC에 의해 제거된다.

힙 또한 여러개의 영역으로 구분되어 있다.

  • Young(New) Generation

    • Eden
      • 객체가 생성되면 최초로 주어지는 메모리 영역이다. Eden 영역이 가득 차게 되면 Miner GC가 실행되어 참조 여부를 확인하고 잠조가 되어 있으면 Survivor 영역으로 넘기고, 참조가 끊어져 있으면 제거된다.

        Miner GC

        • Young 영역에서 일어나는 GC를 호칭하는 단어다.
        • Survivor 공간을 검사하는 역할도 맡고 있다.
    • Survivor
      • Eden에서 살아남은 객체들이 저장되는 영역이다. Survivor에 저장되면 다른 영역에 저장되어 있는 객체를 삭제한다.
  • Tenured(Old) Generation

    • Young 영역에서 오래된 객체가 저장되는 영역이다. 즉, Miner GC가 여러번 실행됐음에도 살아남은 객체가 저장되는 곳이다.
    • Old 영역이 가득 차게 되면 Major GC가 실행된다. Major GC는 Old 영역에 존재하는 모든 객체의 참조 여부를 확인하고 참조가 끊긴 객체를 제거한다.

      Major GC

      • Old 영역에서 일어나는 GC를 호칭하는 단어다.
      • Major GC가 실행되면 GC를 제외한 모든 스레드가 정지하는데 이 이벤트를 "Stop the world"라고 부른다. 때문에 Miner GC보다 속도가 느리며 Major GC가 실행되는 횟수를 최소화해야 한다.

Thread 단위

스레드의 생성/소멸과 함께 하며 스레드마다 하나씩 주어진다.

1. JVM Stack
지역 변수, 메서드의 매개변수, 반환값 등 임시적으로 사용되는 값을 저장하는 영역이다.

프로그램이 실행 후 메서드가 실행되면 JVM은 Stack에 해당 메소드의 프레임을 추가(push)하고 임시적으로 데이터를 저장한다. 메소드가 종료되면 해당 메소드의 프레임을 삭제(pop)한다.

  • Local Variables Array : 메소드 내의 지역변수를 담고 있는 배열

    • 첫번째 인덱스에 현재 객체에 대한 참조값(This)를 저장한다.
    • 두번째 인덱스부터 매개변수, 지역변수 순서대로 저장한다.
  • Operand Stack

    • 연산을 위한 작업공간이며 Byte code 명령문들이 들어있다.
    • 피연산값과 연산과정에서 생기는 중간값을 저장한다.
  • Constant Pool Reference : 메소드 영역의 Runtime Constant Pool에 대한 참조

    • 해당 메소드가 속한 클래스의 상수값을 사용하기 위해 Runtime Constatnt Pool에 대한 참조값이 저장된다.

2. Native Method Stack
프로그램을 실행하면서 순수하게 자바로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API가 존재한다. 자바가 아닌 다른 언어로 작성된 메소드를 Native Method라고 하며 그런 메소드를 관리하는 영역이 Native Stack이다. C Stack이라고도 불린다.

3. PC Register
멀티스레드 프로그래밍 환경에서 한 스레드가 작업을 하다가 다른 스레드에게 CPU 자원을 넘겨주고 다시 받았을 때, 이어서 작업하기 위해서는 실행하던 명령어의 주소값을 저장할 공간이 필요하다. 그 공간이 바로 PC Resister다.

실행했던 메소드가 Native Method라면 undefined가 기록되고 아니라면 사용된 명령어의 주소값이 저장된다.


Execution Engine

실행 엔진(Execution Engine)은 Runtime Data Area에 할당된 Byte code를 실행한다. 하지만 Byte code는 기계 바로 수행할 수 없는 형태이기 때문에 상황에 따라 두 가지 방법을 사용하면서 Byte code를 기계에 익숙한 형태(= 네이티브 코드)로 변경해야 한다.

Interpreter

Byte code 명령어를 하나씩 읽어서 해석하고 실행한다. 하나하나의 해석은 빠르나 전체 실행은 느리다. 기본적으로 인터프리터 방식으로 Byte code가 실행된다.

JIT Compiler

인터프리터의 단점을 보안하기 위해 도입되었다. 인터프리터 방식으로 실행하다가 해당 코드가 자주 실행되면 Byte code를 전체를 컴파일 해서 네이티브 코드로 변경하고 이후에는 해당 메소드를 네이티브 코드로 실행하는 방식이다. 네이티브 코드는 캐시에 저장되기 때문에 한 번 컴파일된 코드는 계속 더 빨리 실행할 수 있다.

그러나 JIT 컴파일러가 컴파일 하는 과정은 인터프리터가 하나씩 해석하는 것보다 훨씬 오래 걸리기 때문에 한 번만 실행되는 코드라면 인터프리터를 사용하는 게 유리하다.

JIT의 동작 과정
1. 우선 Byte code를 중간 단계의 표현인 IR(Intermediate Representation)로 변환한다.
2. 중간 코드를 최적화 한다.
3. 네이티브 코드를 생성한다.

Garbage Collection

더 이상 참조되지 않는 객체를 찾아서 제거한다.


예제

Q. 아래 코드를 실행했을 때 JVM 메모리엔 어떤 변화가 생길까?

Class A {
    // static 변수, 메소드
    
    // member 변수, 메소드
    
    public static void main(...) {
    	A a = new A();
    }
}
  1. Java 컴파일러에 의해 소스코드가 Byte code로 변환된다.

  2. 변환된 Byte code는 JVM의 클래스 로더에 의해 로드된다.

    • 로딩
      • 클래스를 로드하기 전에 우선적으로 Runtime Data Area의 메소드 영역에 해당 클래스가 로드되어 있는지 확인한다.
      • 로드되지 않았다면 Class A를 로드하는데 클래스 로더가 지켜야할 4가지 원칙 중 위임 계층 원칙에 따라 상위 계층 클래스 로더에 로드 요청을 위임한다. 애플리케이션 클래스 로더는 확장 클래스 로더에게 로드 요청을 위임하고 확장 클래스 로더는 부트스트랩 클래스 로더에게 로드 요청을 위임한다. Class A는 애플리케이션 내의 클래스이기 때문에 부트스트랩부터 순서대로 내려오며 애플리케이션 클래스 로더에 의해 로드된다.
    • 링크
      • 바이트 코드기를 사용하여 컴파일 된 Byte code가 알맞은 구성으로 작성되었는지 확인한다.
      • 확인이 끝나면 Class A에 정의된 static 변수가 메소드 영역에 적재되고 기본값으로 초기화 된다. 그리고 메모리 할당이 이루어진다.
    • 분석
      • static 변수명을 초기화된 기본값으로 대체한다.
  3. Class A의 클래스 파일을 읽고 원래 코드에 명시된 실제 값을 static 변수에 할당하고 main 메소드를 포함한 static 메소드의 초기화 블럭을 실행한다.

  4. 실행엔진이 main 메소드를 시작한다. 처음 실행하는 거라면 인터프리터 방식으로 Byte code를 해석해서 기계어로 변경할 것이다.

  5. main 메소드가 JVM Stack에 추가되고 new 키워드로 인해 A 객체가 생성되어 Heap 영역에 저장된다.

  6. main 메소드가 끝나면 JVM Stack에서 제거된다.


class A {
	// static 변수들, 메소드들...
  	private static final int i = 1; // 원시(primitive) 타입
    // 변수명 i와 실제 값 1은 메서드 영역에 저장이 된다.
    
  	private static final Integer ii = Integer.parseInt(2); // 참조(Reference) 타입
    // 변수명 ii는 메소드 영역에 저장되고 실제 값은 Runtime COnstant Pool에 저장된다.
    
  	private static A a = new A();
    // a는 메소드 영역에 저장되고 Class A의 객체는 Heap 영역에 저장이 된다.
    // 그리고 a에는 객체 A가 저장된 메모리의 주소값이 저장된다.
    
  	// Runtime Constant Pool
  	// -> 상수의 값
	// https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/Integer.java
   	// https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/Long.java
  
	// member 변수들, 메소드들...
	private final A aa = new A();
	private final String str = "STRING";
	private volatile B b;
  
	public static void main(...) {
        // 아래의 코드를 실행했을 때, 
        // JVM의 메모리에는 어떤 변화가 생기나요?
        A a = new A();
        // Heap에 A객체가 저장됨
        // aa, str, b
        // Class A의 생성자가 실행이 됨
    
    	a.print(...);
    }
  
    public void print(...) {
    	System.out.println(i + ", " + ii + ", " + str);
    
	}
}

참고자료
차근차근 알아보는 Java 프로그램 실행 과정

profile
터짐

0개의 댓글