자바 메모리 영역 - JVM 끝까지 파헤치기

이상윤·2026년 2월 27일

런타임 데이터 영역

책을 다 읽었지만, 2장~3장의 내용이 특히 중요한 것 같아 한번 글을 써 보고자 한다.

자바 개발자는 가상 머신이 제공하는 자동 메모리 관리 메커니즘 덕에 메모리 할당과 해제를 짝지어 코딩하지 않아도 메모리 누수나 오버플로 문제를 거의 겪지 않는다. 메모리 문제를 가상 머신이 해 주기 때문이다. 하지만 통제권이 개발자에게 없기 때문에 문제가 한번 터지면 가상 머신의 메모리 관리 방식을 이해하지 못하는 한 해결하기 어렵다는 문제가 있다. 이러한 문제를 방지하기 위해 메모리 영역에 관해 한번 알아보자.

가장 먼저, 런타임 데이터 영역의 전체 구조를 한번 보고, 그 후에 각 영역들에 관해 알아보겠다.

PC Register

프로그램 카운터 레지스터는 작은 메모리 영역으로, 현재 실행중인 바이트코드 명령의 주소값이다.
자바 가상 머신의 개념 모형에서 바이트코드 인터프리터는 이 카운터의 값을 바꿔 다음에 실행할 바이트코드 명령어를 선택하는 식으로 동작한다.

자바 가상 머신에서의 멀티스레딩은 CPU 코어를 여러 스레드가 교대로 사용하는 방식으로 구현되기 때문에 특정 시각에 각 코어는 한 스레드의 명령어만 실행하게 된다. 따라서 스레드 전환 후, 이전에 실행하다 멈춘 부분부터 다시 실행해야 하므로 멈춘 부분을 기록해둔 데이터가 필요하기 때문에 각각의 스레드에 프로그램 카운터(이하 PC) 값을 저장한다.
각 PC는 서로 영향을 주면 안 되기 때문에, 서로 영향을 주지 않는 독립된 영역에 저장되고 이 영역을 스레드 프라이빗 메모리라고 한다. 이 영역은 자바 가상 머신 명세에서 OutOfMemoryMemoryError 조건이 명시되지 않은 유일한 영역이기도 하다.

자바 가상 머신 스택

프로그램 카운터처럼 자바 가상 머신 스택도 스레드 프라이밋하며, 연결된 스레드와 운명을 같이 한다. 자바 가상 머신 스택은 각 메서드가 호출될 때 마다 스택 프레임을 만들어 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환값 등의 정보를 저장하며, 이는 push, pop 동작으로 이루어진다.

지역 변수 테이블에는 자바 가상 머신이 컴파일타임에 알 수 있는 다양한 기본 데이터타입, 객체 참조, 반환 주소 타입을 저장한다. 이들을 저장하는 공간을 지역 변수 슬롯이라 하며, 일반적으로 슬롯 하나의 크기는 32비트다.

지역 변수 테이블을 구성하는 데 필요한 데이터 공간은 컴파일 과정에서 할당된다. 자바 메서드는 스택 프레임에서 지역 변수용으로 할당받아야 할 공간의 크기(슬롯의 개수)가 이미 결정되어 있어, 메서드 실행 중에는 절대 변하지 않는다.

자바 가상 머신 명세 에서는 스택 메모리 영역에서 두 가지 오류가 발생할 수 있도록 하였다.
첫 번째는 스레드가 요청한 스택 깊이가 가상 머신이 허용하는 깊이보다 클 때 StackOverFlowError 에러를 리턴하고, 두 번째는 스택 용량을 동적으로 확장할 수 있는 가상 머신에서 스택을 확장하려는 시점에 여유 메모리가 부족하다면 OutOfMemoryError를 던지는 것이다.

네이티브 메서드 스택

네이티브 메서드 스택은 가상 머신 스택과 매우 비슷한 역할을 한다. 차이점이라면 가상 머신 스택은 자바 메서드를 실행할 때 사용하고, 네이티브 메서드 스택은 네이티브 메서드를 실행할 때 사용하는 것이다.

자바 힙

  • 자바 애플리케이션이 사용할 수 있는 가장 큰 메모리

모든 스레드가 공유하며, 가상 머신이 구동될 때 만들어진다. 객체 인스턴스를 저장한다.
힙은 사용하는 GC에 따라 보통 여러 단계로 나누어져 있는데, 이 단계들은 다음 포스팅에서 GC별 정리를 할 때 정리해 보겠다.

자바 힙은 모든 스레드가 공유하기 때문에 스레드 로컬 할당 버퍼 힙 여러개로 나뉘며, 이는 메모리 할당과 회수를 더 빠르게 해 준다.

자바 가상 머신 명세에 따르면 자바 힙은 물리적으로 떨어진 메모리에 위치해 있어도 상관없지만, 큰 객체 할당시엔 물리적으로도 연속된 메모리 공간을 사용하도록 구현하며 이는 저장 효율을 높이고 구현 로직을 단순하게 유지하기 위함이다.

메서드 영역

가상 머신이 읽어들인 타입 정보, 상수, 정적 변수 그리고 JIT 컴파일러가 컴파일한 코드 캐시 등을 저장한다.

모든 스레드가 공유하며, 힙의 한 부분이지만 자바 힙과 구분하기 위해 논힙이라고 부르기도 한다.
이전에는 영구 세대가 메서드 영역에 구현되어 있었지만, JDK 8 이후로는 메타스페이스라는 개념으로 네이티브 영역으로 옮겨졌다.

메서드 영역에 저장되는 데이터는 대부분 상수 풀과 타입이라서 회수해도 효과가 미미하고, 특히 타입은 회수하기 매우 까다롭다. 그래서 가비지 컬렉션이 실행될 일이 별로 없다.
메서드 영역이 꽉 차서 더 이상 메모리를 할당할 수 없다면 OutOfMemoryError를 발생시킨다.

런타임 상수 풀

메서드 영역의 일부이며, 클래스 버전, 필드, 메서드, 인터페이스 등 클래스 파일에 포함된 설명 정보에 더해 컴파일타임에 생성한 다양한 정보를 메서드 영역의 런타임 상수 풀에 저장한다.

클래스 파일에 대해서는 정리해 놓은 포스팅이 있으니 궁금하다면 보고 오자.

이 영역은 동적으로 변경되며, 상수 풀의 모든 내용이 클래스 파일에 미리 완벽하게 기술되어 있지 않기 때문이다. 메서드 영역에 포함되어 있기 때문에 이를 초과하면 OutOfMemoryError를 발생시킨다.

다이렉트 메모리

핫스팟 가상 머신에서의 객체 들여다보기

다음으로는 메모리 모델을 알아보겠다. 가상 머신 메모리에 들어갈 내용(만들어지는 시기, 저장되는 구조, 접근 방식 등)을 알아 볼 텐데, 가장 대중적인 가상 머신인 핫스팟과 자바 힙으로 정리 해 보겠다.

객체 생성

언어 수준에서는 그냥 new 키워드를 쓰면 객체가 생성되지만, 가상 머신 수준에서는 이야기가 달라진다. 가상 머신이 new에 해당하는 바이트코드를 만나면, 다음 절차를 거친다.

  1. 명령의 매개 변수가 상수 풀 안의 클래스를 가리키는 심벌 참조인지 확인한다.
  2. 심벌 참조라면, 이 심벌 참조가 뜻하는 클래스가 로딩, 해석, 초기화 되었는지 확인한다.
    ㄴ 아니라면, 클래스를 로딩한다.
  3. 새 객체를 담을 메모리를 할당하며, 크기는 메모리 로딩 이후에 알 수 있다.
  4. GC에 맞는 방법으로 힙의 적절한 위치에 있는 메모리를 객체에 할당하며, 포인터를 이동한다.
    여기서 포인터는 힙 내의 사용 가능한 메모리를 알려주는 역할을 한다.

하지만 이때, 여러 스레드가 동시에 객체를 할당하려고 하면 어떻게 될까?
포인터의 위치가 꼬여 이상한 위치에 객체를 할당 해 버리게 될 것이다.
이에 대한 해법은 크게 두 가지가 있다.

  1. 메모리 할당을 동기화한다.
    ㄴ CAS연산 또는 실패시 재활용 방법을 사용하여 메모리 할당 과정을 원자화 한다.
  2. 스레드마다 다른 메모리 공간을 할당한다.
    ㄴ 전부 다른 공간을 사용하면 겹칠 일이 없다. 이러한 방식을 TLAB(Thread Local Allocation Buffer)라고 한다.

메모리 할당이 끝났으면 가상 머신은 할당받은 공간을 0으로 초기화 하고, 각 객체에 필요한 설정들을 찾아 객체 헤더에 저장해 준다. 이 설정들의 예는 다음과 같다.

  • 어느 클래스의 인스턴스인지
  • 클래스의 메타 정보는 어떻게 찾는지
  • 이 객체의 해시 코드는 무엇인지
  • GC 세대 나이는 얼마인지 등

이상의 과정이 끝났다면 가상 머신 관점에서는 새로운 객체가 다 만들어졌다. 하지만 자바 프로그램 관점에서는 아직 완료되지 않았다. 생성자가 실행되어야 하고, 필드가 아직 기본값이 0이기 때문이다. 일반적으로 new 명령어에 이어서 init 메서드까지 실행되어야 비로소 사용가능한 객체가 되는 것이다.

객체 메모리 레이아웃

핫스팟 가상 머신은 객체를 세 부분으로 나눠서 힙에 저장하는데, 바로 객체 헤더, 인스턴스 데이터, 길이 맞추기용 정렬 헤더이다. 이제 이 부분에 대해 알아보자.

객체 헤더

핫스팟 가상 머신은 여기 두 유형의 데이터를 담는다.

  • 객체 자체의 런타임 데이터
  • 객체 자체의 클래스 관련 메타데이터를 가리키는 클래스 포인터
  • 객체 자체의 런타임 데이터
    마크 워드라고 하며, 여기엔 런타임에 필요한 아주 많은 데이터가 들어가야 하므로 데이터 구조는 동적으로 의미가 달라진다.이는 대표적으로 락 플래그에 따라 달라지는데, 이 차이점도 다른 포스팅에 정리 해 두었으니 궁금하면 한번 보고 오자.
  • 객체 자체의 클래스 관련 메타데이터를 가리키는 클래스 포인터
    클래스 워드라고 하며, 자바 가상 머신은 이 데이터를 보고 이 객체가 어느 클래스의 객체인지 알 수 있다. 하지만 모든 가상 머신이 클래스 포인터를 객체 헤더에 저장하지는 않으며, 이는 객체의 메타데이터 정보를 반드시 객체 자체에서 찾을 필요는 없다는 말이다.

인스턴스 데이터

객체가 실제로 담고 있는 정보이다.

프로그램 코드에서 정의한 다양한 타입의 필드 관련 내용, 부모 클래스 유무, 부모 클래스에서 정의한 모든 필드가 이 부분에 기록된다. 정보의 저장 순서는 옵션 설정(-XX:FieldsAllocationStyle)과 자바 소스 코드에서 필드를 정의한 순서에 따라 달라진다.

+XX:CompactFields매개 변수를 true로 설정하면(기본값임) 하위 클래스의 필드 중 길이가 짧은 것들은 상위 클래스의 변수 사이사이에 끼워 넣어져서 공간이 조금이나마 절약된다.

정렬 패딩

모든 객체의 크기가 8바이트의 정수배가 될 수 있도록 맞춰주는 역할을 한다.

객체 헤더는 8바이트의 정수배가 되도록 잘 설계되어 있어, 인스턴스 데이터만 채워주면 된다.

객체에 접근하기

대다수 객체는 다른 객체 여러 개를 조합해 만들어진다. 그리고 자바 프로그램은 스택에 있는 참조 데이터를 통해 힙에 들어 있는 객체들에 접근해 이를 조작한다.
하지만 자바 가상 머신 명세에는 이 방법에 대해 규정하지 않았기 때문에, 이는 가상 머신이 구현하기 나름이며, 주로 핸들이나 다이렉트 포인터를 사용해 구현된다.

  • 핸들
    핸들 방식에서는 자바 힙에 핸들 저장용 풀이 객체로 존재할 것이며, 참조에는 객체의 핸들 주소가 저장되고 핸들에는 다시 해당 객체의 인스턴스 데이터, 타입 데이터, 구조 등의 정확한 주소 정보가 담길것이다. 아래 그림으로 보자.

    이 방식의 장점은 참조에 안정적인 핸들의 주소가 저장된다는 점이다.

  • 다이렉트 포인터
    이 방식은 자바 힙에 위치한 객체에서 인스턴스 데이터뿐 아니라 타입 데이터에 접근하는 길도 제공해야 하며, 스택의 참조에는 객체의 실제 주소가 바로 저장되어 있다.

이 방법은 속도가 빠르다는 장점이 있다.

이제 다음 포스팅에서는 GC에 대해 간단히 살펴보고, G1GC와 ZGC를 비교해 보겠다.

0개의 댓글