JVM 심층 분석: 아키텍처, 메모리 및 가비지 컬렉션

1. JVM 아키텍처 개요

자바 가상 머신(JVM)은 자바 애플리케이션의 핵심 실행 환경입니다. 운영체제 위에서 동작하며 하드웨어와 직접 상호작용하지 않습니다. JVM은 클래스 로더, 런타임 데이터 영역, 실행 엔진 등 여러 서브시스템으로 구성되어 자바 바이트코드를 실행합니다.

1.1. 클래스 로더 (ClassLoader)

클래스 로더는 .class 파일을 JVM 메모리 영역으로 로드하는 역할을 담당합니다. .class 파일은 특정 파일 시그니처를 가지고 있으며, 클래스 로더는 이 바이트코드를 메모리로 가져와 메서드 영역에 런타임 데이터 구조로 변환합니다. 클래스 파일 로딩만을 담당하며, 실제 실행 가능 여부는 실행 엔진이 결정합니다.

  • JVM 내장 로더
    • 부트스트랩 클래스 로더 (Bootstrap ClassLoader): C++로 구현되며, JVM 시작 시 rt.jar와 같은 핵심 라이브러리를 로드합니다. 최상위 로더입니다.
    • 확장 클래스 로더 (Extension ClassLoader): jre/lib/ext 디렉터리에 있는 확장 라이브러리를 로드합니다.
    • 애플리케이션 클래스 로더 (Application ClassLoader): 자바 애플리케이션의 클래스패스에 있는 모든 클래스를 로드합니다. 시스템 클래스 로더라고도 불립니다.
  • 사용자 정의 로더: java.lang.ClassLoader를 상속받아 개발자가 직접 클래스 로딩 방식을 제어할 수 있습니다.

1.2. 이중 부모 위임 모델 (Parent Delegation Model)

클래스 로딩 요청을 받으면, 클래스 로더는 스스로 클래스를 로드하기 전에 이 요청을 먼저 부모 클래스 로더에게 위임합니다. 이 과정은 최상위 부트스트랩 클래스 로더까지 반복됩니다. 부모 로더가 해당 클래스를 찾지 못하거나 로드할 수 없을 때만 자식 로더가 로드를 시도합니다.

이 모델의 장점은 java.lang.Object와 같이 핵심 라이브러리에 속하는 클래스들이 항상 최상위 부트스트랩 로더에 의해 로드되어 일관성을 유지하고 보안을 강화한다는 점입니다. 이는 동일한 클래스가 여러 번 로드되어 발생하는 혼란을 방지합니다.

1.3. 네이티브 인터페이스 및 네이티브 메서드 스택

네이티브 인터페이스 (Native Interface)는 자바 코드가 C/C++과 같은 다른 언어로 작성된 코드(네이티브 라이브러리)를 호출할 수 있도록 하는 다리 역할을 합니다. 이는 JVM 초기에는 C/C++ 프로그램과의 상호운용성을 위해 중요했으나, 현대에는 소켓 통신이나 웹 서비스 등 이기종 시스템 간 통신 기술의 발전으로 사용 빈도가 줄어들고 있습니다.

네이티브 메서드 스택 (Native Method Stack)은 네이티브 메서드 호출을 처리하는 데 사용됩니다. 실행 엔진이 네이티브 메서드를 실행할 때 이 스택에 해당 메서드를 등록하고, 필요에 따라 네이티브 라이브러리를 로드합니다.

1.4. 프로그램 카운터 (PC Register)

각 스레드마다 독립적으로 존재하는 PC 레지스터는 현재 스레드가 실행할 다음 바이트코드 명령어의 주소를 저장하는 작은 메모리 공간입니다. 실행 엔진은 이 레지스터 값을 읽어 다음 명령어를 실행합니다. 네이티브 메서드를 실행할 때는 이 값이 비어 있습니다. PC 레지스터는 메모리 오버플로우(OutOfMemoryError)가 발생하지 않는 유일한 런타임 영역입니다.

1.5. 메서드 영역 (Method Area)

메서드 영역은 모든 스레드가 공유하는 런타임 메모리 영역입니다. 이 공간에는 클래스의 구조 정보, 런타임 상수 풀, 필드 및 메서드 데이터, 생성자와 일반 메서드의 바이트코드 내용 등이 저장됩니다. JVM 구현에 따라 '영구 영역(PermGen space)' 또는 '메타스페이스(Metaspace)'로 구현됩니다. 인스턴스 변수는 힙 메모리에 저장되며, 메서드 영역과는 무관합니다.

1.6. 스택 (Stack)

스택 메모리는 자바 프로그램의 메서드 실행을 관리하며, 스레드 생성 시 함께 생성되어 스레드의 생명주기와 동기화됩니다. 스레드가 종료되면 스택 메모리도 해제되므로, 스택에는 가비지 컬렉션(GC) 문제가 발생하지 않습니다. 각 스택은 스택 프레임(Stack Frame)으로 구성되며, 기본형 변수, 객체 참조 변수, 인스턴스 메서드가 스택 프레임에 할당됩니다.

스택 프레임 구성

  • 지역 변수 테이블 (Local Variables): 메서드의 매개변수 및 내부에서 선언된 변수들을 저장합니다.
  • 피연산자 스택 (Operand Stack): 메서드 내의 연산 처리를 위한 임시 공간으로, 데이터를 푸시하고 팝하는 데 사용됩니다.
  • 프레임 데이터 (Frame Data): 클래스 파일, 메서드 등 스택 프레임과 관련된 부가 정보를 포함합니다.

메서드가 호출될 때마다 새로운 스택 프레임이 생성되어 스택에 푸시되고, 메서드 실행이 완료되면 해당 프레임은 스택에서 팝됩니다. 이러한 '선입후출(LIFO)' 원칙에 따라 동작합니다.

1.7. 스택, 힙, 메서드 영역의 상호작용

HotSpot JVM은 객체에 접근할 때 포인터를 사용합니다. 자바 힙에는 객체의 실제 데이터가 저장되며, 이 객체의 클래스 메타데이터 주소는 메서드 영역에 존재합니다. 참조 변수는 힙에 있는 객체의 실제 주소를 가리킵니다.

2. 힙 메모리 구조

JVM 인스턴스당 하나의 힙 메모리만 존재하며, 그 크기는 조절 가능합니다. 클래스 로더가 클래스 파일을 로드하면 클래스, 메서드, 상수, 변수 등의 정보를 힙에 저장하여 실행 엔진이 참조할 수 있도록 합니다. 힙은 논리적으로 세 부분으로 나뉩니다.

2.1. 힙 영역 구성 (JDK 7 이전)

  • Young Generation (신생 영역): 새로운 객체가 생성되고 소멸하는 영역입니다.
  • Old Generation (오래된 영역 / 테뉴어 영역): Young Generation에서 오랫동안 살아남은 객체들이 이동하는 영역입니다.
  • Permanent Generation (영구 영역): JDK 자체에서 사용하는 클래스 메타데이터 및 상수 풀 등을 저장하는 영역입니다. (JDK 8부터 메타스페이스로 대체)

2.2. 신생 영역 (Young Generation)

신생 영역은 객체가 처음 생성되어 성장하고 소멸하는 곳입니다. 대부분의 객체는 이곳의 에덴(Eden) 공간에서 생성됩니다. 에덴 공간이 가득 차면 마이너 GC(Minor GC)가 발생하여 더 이상 참조되지 않는 객체를 제거합니다. 살아남은 객체들은 서바이버(Survivor) 공간(0구역과 1구역)으로 이동합니다. 서바이버 공간도 가득 차면 다시 GC가 발생하고, 일정 횟수(기본 15회) 이상 GC에서 살아남은 객체들은 오래된 영역(Old Generation)으로 이동합니다. 오래된 영역마저 가득 차서 Full GC 이후에도 객체를 저장할 공간이 없으면 OutOfMemoryError: Java heap space 예외가 발생합니다.

java.lang.OutOfMemoryError: Java heap space는 일반적으로 다음 두 가지 원인 중 하나로 발생합니다:

  • JVM의 힙 메모리 설정이 너무 작을 경우: -Xms, -Xmx 옵션으로 조절 가능합니다.
  • 코드에서 과도하게 많은 객체를 생성하거나, 불필요한 객체가 오랫동안 참조되어 GC가 수거하지 못하는 경우입니다.

마이너 GC(Minor GC) 과정 (복사 -> 비우기 -> 교환)

  1. 에덴, SurvivorFrom 영역에서 SurvivorTo 영역으로 객체 복사 및 나이 증가: 에덴 영역이 가득 차면 첫 번째 마이너 GC가 발생합니다. 살아있는 객체들은 SurvivorFrom 영역으로 복사되고 나이가 1 증가합니다. 다음 GC 때는 에덴과 SurvivorFrom 영역을 스캔하여 살아있는 객체를 SurvivorTo 영역으로 복사합니다. 이때, 일정 나이(-XX:MaxTenuringThreshold)에 도달한 객체는 오래된 영역으로 이동합니다.
  2. 에덴, SurvivorFrom 영역 비우기: 복사가 완료되면 에덴과 SurvivorFrom 영역은 깨끗하게 비워집니다.
  3. SurvivorTo와 SurvivorFrom 영역 교환: 이전 SurvivorTo 영역이 다음 GC의 SurvivorFrom 영역이 되고, 이전 SurvivorFrom 영역은 SurvivorTo 영역이 됩니다. 이렇게 Survivor 영역 간의 객체 복사가 반복되면서 객체는 나이를 먹고, 최종적으로 일정 나이에 도달하면 오래된 영역으로 이동합니다.

메서드 영역은 힙과 마찬가지로 스레드 공유 영역이지만, '비-힙(Non-Heap)'이라는 별칭에서 알 수 있듯이 힙과는 논리적으로 분리된 공간입니다. HotSpot JVM에서는 JDK 7까지 메서드 영역을 구현하기 위해 영구 영역(PermGen)을 사용했으나, 이는 엄밀히 말해 메서드 영역의 구현체일 뿐 동일한 개념은 아닙니다. JDK 7부터는 문자열 상수 풀이 영구 영역에서 힙으로 이동했습니다.

2.3. 영구 영역 (Permanent Generation, JDK 7 이전)

영구 영역은 JDK 자체에 포함된 클래스, 인터페이스의 메타데이터와 같은 런타임 환경에 필수적인 클래스 정보를 저장하는 상주 메모리 영역이었습니다. 이 영역의 데이터는 JVM이 종료될 때까지 가비지 컬렉터에 의해 회수되지 않았습니다.

3. 힙 파라미터 튜닝

3.1. JDK 8 이후의 변화: 메타스페이스 (Metaspace)

Java 8부터 영구 영역(PermGen)이 제거되고 메타스페이스(Metaspace)로 대체되었습니다. 메타스페이스는 영구 영역과 유사하지만, 가장 큰 차이점은 영구 영역이 JVM 힙 메모리의 일부를 사용했던 것과 달리, 메타스페이스는 네이티브 운영체제 메모리를 사용한다는 점입니다. 이는 클래스 메타데이터가 JVM 힙 크기 제한에 구애받지 않고 시스템의 실제 가용 메모리만큼 로드될 수 있음을 의미합니다. 문자열 상수 풀과 클래스의 정적 변수는 다시 자바 힙으로 이동했습니다.

3.2. 힙 메모리 튜닝 예시

기본적으로 JVM은 시스템 메모리의 일부를 힙으로 할당합니다. 다음 코드를 통해 현재 JVM의 최대 및 전체 메모리량을 확인할 수 있습니다.

public class JvmMemoryInfo {
    public static void main(String[] args) {
        long maxHeapMemory = Runtime.getRuntime().maxMemory(); // JVM이 사용할 수 있는 최대 힙 메모리 (바이트)
        long currentTotalMemory = Runtime.getRuntime().totalMemory(); // JVM이 현재 확보한 힙 메모리 (바이트)

        System.out.println("최대 힙 메모리: " + maxHeapMemory + " 바이트, " + (maxHeapMemory / (double) 1024 / 1024) + " MB");
        System.out.println("현재 총 힙 메모리: " + currentTotalMemory + " 바이트, " + (currentTotalMemory / (double) 1024 / 1024) + " MB");
    }
}

기본 설정으로 실행하면 일반적으로 최대 메모리는 전체 물리 메모리의 1/4, 초기 할당 메모리는 1/64 수준인 것을 확인할 수 있습니다. 힙 메모리 크기는 다음과 같은 JVM 인자를 통해 명시적으로 설정할 수 있습니다.

-Xms1024m -Xmx1024m -XX:+PrintGCDetails
  • -Xms: JVM 시작 시 할당되는 초기 힙 메모리 크기입니다.
  • -Xmx: JVM이 사용할 수 있는 최대 힙 메모리 크기입니다.
  • -XX:+PrintGCDetails: 상세한 GC 로그를 출력하여 GC 동작을 분석하는 데 도움을 줍니다.

4. 가비지 컬렉션 (GC)

4.1. GC의 역할 및 세대별 수집 알고리즘

가비지 컬렉션은 JVM의 메모리 관리 메커니즘으로, 더 이상 사용되지 않는 객체를 자동으로 회수하여 메모리를 확보합니다. JVM은 '세대별 수집(Generational Collection)' 알고리즘을 사용하여 메모리 영역을 효율적으로 관리합니다.

  • Young Generation (신생 영역): 객체의 생존율이 낮으므로 자주 수집됩니다.
  • Old Generation (오래된 영역): 객체의 생존율이 높으므로 비교적 적게 수집됩니다.
  • Metaspace (메타스페이스): 거의 수집되지 않으며, 클래스 언로딩이 필요할 때만 GC가 발생합니다.

4.2. GC 유형: Minor GC vs. Full GC

JVM은 모든 메모리 영역을 동시에 수집하지 않습니다. GC는 수집 대상 영역에 따라 두 가지 주요 유형으로 나뉩니다.

  • 마이너 GC (Minor GC): 신생 영역에서 발생하는 가비지 컬렉션입니다. 대부분의 자바 객체는 수명이 짧기 때문에 마이너 GC는 매우 빈번하게 발생하며, 일반적으로 처리 속도가 빠릅니다.
  • 메이저 GC (Major GC) / 풀 GC (Full GC): 오래된 영역에서 발생하는 가비지 컬렉션입니다. 메이저 GC는 종종 (항상은 아니지만) 한 번 이상의 마이너 GC를 동반합니다. 마이너 GC보다 훨씬 느리며, 애플리케이션에 더 큰 지연(Stop-The-World)을 초래할 수 있습니다.

4.3. 주요 GC 알고리즘

4.3.1. 참조 카운팅 (Reference Counting)

객체에 대한 참조 횟수를 세어 참조 횟수가 0이 되면 가비지로 간주하여 회수하는 방식입니다. 구현이 간단하지만, 순환 참조(circular dependency) 문제를 해결하기 어렵고 추가적인 오버헤드가 발생하여 HotSpot JVM에서는 사용하지 않습니다.

4.3.2. 복사 알고리즘 (Copying)

적용 영역: 주로 신생 영역(Young Generation)의 마이너 GC에 사용됩니다.

원리: 메모리 영역을 두 개의 동일한 크기로 나누고, 한쪽 영역만 사용합니다. 해당 영역이 가득 차면 살아있는 객체를 다른 영역으로 모두 복사한 후, 기존 영역을 완전히 비웁니다. 이 과정에서 객체는 조각화 없이 연속적으로 배치됩니다.

장점: 메모리 단편화가 발생하지 않아 새로운 객체 할당 속도가 빠릅니다.

단점: 절반의 메모리 공간을 항상 비워두므로 메모리 효율이 낮습니다. 객체 생존율이 높은 경우 복사 비용이 커질 수 있습니다.

4.3.3. 마크-스윕 알고리즘 (Mark-Sweep)

적용 영역: 주로 오래된 영역(Old Generation)에 사용됩니다.

원리:

  1. 마크(Mark) 단계: GC Roots(GC 대상이 되는 객체를 참조하는 기준점)에서부터 시작하여 도달 가능한 모든 객체를 '살아있는 객체'로 표시합니다.
  2. 스윕(Sweep) 단계: 힙 전체를 스캔하여 마크되지 않은 객체(즉, 도달 불가능한 객체)들을 제거합니다.

단점: GC 실행 중 애플리케이션이 일시 정지(Stop-The-World)되어 사용자 경험에 영향을 줄 수 있습니다. 또한, 제거 후 메모리 공간이 단편화되어 연속적인 큰 메모리 블록 할당이 어려워질 수 있습니다.

4.3.4. 마크-컴팩트 알고리즘 (Mark-Compact)

적용 영역: 주로 오래된 영역(Old Generation)에 사용되며, 마크-스윕의 단편화 문제를 보완합니다.

원리:

  1. 마크(Mark) 단계: 마크-스윕과 동일하게 GC Roots에서부터 도달 가능한 모든 객체를 표시합니다.
  2. 컴팩트(Compact) 단계: 표시된 살아있는 객체들을 메모리의 한쪽 끝으로 이동시켜 재배치합니다. 이후, 이동된 객체들의 경계선 바깥쪽 메모리를 모두 비웁니다.

장점: 메모리 단편화를 해결하여 연속적인 메모리 공간을 확보할 수 있습니다.

단점: 살아있는 객체를 이동시키는 추가적인 작업 때문에 마크-스윕보다 효율이 낮을 수 있으며, Stop-The-World 시간이 길어질 수 있습니다.

마크-스윕-컴팩트 (Mark-Sweep-Compact): 마크-스윕과 마크-컴팩트를 혼합하여 단편화 문제를 완화하면서 효율성을 높이려는 알고리즘입니다.

4.4. GC 알고리즘 종합

GC 알고리즘은 각각의 장단점이 명확하므로, "최고의 알고리즘"은 존재하지 않습니다. 대신 "가장 적합한 알고리즘"을 선택하는 것이 중요하며, 이것이 바로 JVM의 세대별 수집(Generational Collection)이 탄생한 배경입니다.

  • 신생 영역(Young Generation): 객체의 생존율이 매우 낮으므로, 복사 알고리즘이 가장 효율적입니다. 복사 알고리즘의 메모리 낭비 문제는 두 개의 Survivor 영역(8:1:1 비율)을 사용하여 완화됩니다.
  • 오래된 영역(Old Generation): 객체의 생존율이 높으므로 복사 알고리즘은 비효율적입니다. 따라서 마크-스윕 또는 마크-컴팩트 알고리즘이 주로 사용됩니다.

예를 들어, HotSpot JVM의 CMS(Concurrent Mark-Sweep) GC는 마크-스윕 기반으로 높은 효율을 보이지만, 단편화 문제가 발생할 수 있습니다. 이를 보완하기 위해 CMS는 메모리 단편화가 심해질 경우 Serial Old GC(마크-컴팩트 기반)를 사용하여 오래된 영역의 메모리를 정리하는 보상 메커니즘을 사용합니다.

결론적으로, JVM은 객체의 특성(수명)에 따라 가장 적합한 GC 알고리즘을 조합하여 사용하여 전반적인 성능과 효율성을 극대화합니다.

태그: JVM java Garbage Collection Heap Metaspace

7월 30일 20:15에 게시됨