[CS] JVM 메모리 구조

U·2025년 9월 22일

CS

목록 보기
12/23
post-thumbnail

📚 JVM

자바의 바이트코드는 JRE(Java Runtime Environment) 위에서 동작하며, JRE는 자바 클래스 라이브러리와 JVM으로 구성된다.

JVM은 Java Virtual Machine, 즉 자바 가상 머신으로 Java 프로그램을 실행 시키는 소프트웨어다.

💡 가상 머신이란?
프로그램을 실행하기 위해 물리적 머신(컴퓨터)과 유사한 머신을 소프트웨어로 구현한 것

C언어 같은 프로그램은 Window용, Mac용, Linux용으로 따로 만들어야 하지만, 자바는 개발자가 *.java 코드를 작성하면 JVM이 설치된 모든 운영체제에서 똑같이 실행될 수 있도록 번역기 역할을 해준다.

자바의 유명한 슬로건인 Write Once, Run Anywhere(한 번 작성하면, 어디서든 실행된다)를 구현하기 위해 물리적인 머신과 별개의 가상 머신을 기반으로 동작하도록 설계됐다.

따라서 모든 하드웨어에 JVM을 동작시킴 = 자바 실행 코드를 변경하지 않고도 모든 종류의 하드웨어에서 동작되게 한다.

다른 부분에 대해서는 다음주차 스터디에서 진행하게 될 것 같아 오늘은 JVM의 메모리 구조에 대해 알아보겠다.

JVM 메모리 영역

메모리 영역에서는 자바 프로그램을 실행하는 동안에 동적으로 할당하고 사용되는 데이터와 정보를 저장하며, 프로그램의 실행 시간(runtime) 동안 필요한 데이터를 보관하고 관리하는 역할까지 수행한다.

따라서 실행 데이터 영역들을 보관하고 관리하는 영역으로 Runtime Data Area라고도 부른다.

자바 변수의 종류

먼저 자바의 변수 종류에 대해 알아보자.

자바에서는 선언 위치에 따라서 클래스 변수, 인스턴스 변수, 지역 변수, 매개변수로 나뉜다.

public class Main {
  public static void main(String[] args) { // 매개변수
  	int num = 1000; // 지역 변수
  }
}

public class Counter {
  private int state = 0; // 인스턴스 변수
  
  public static int page = 100; // 클래스 변수
  
  public int get() {
  	return state;
  }
}
변수명선언 위치설명
클래스 변수
(=static 변수)
클래스 영역- 클래스 영역에서 타입 앞에 static이 붙는 변수
- 객체를 공유하는 변수여러 객체에서 공통으로 사용하고 싶을 때 정의
- new로 객체를 만들지 않아도 클래스명.변수명 또는 클래스명.메서드명()으로 즉시 사용 가능(메모리에 이미 올라가 있기 때문)
인스턴스 변수클래스 영역- 클래스 영역에서 static이 아닌 변수
- 개별적인 저장 공간으로 객체/인스턴스마다 다른 값 저장 가능
- 객체/인스턴스 생성만 하고 참조 변수가 없는 경우 가비지 컬렉터에 의해 자동 제거됨
지역 변수메서드 영역- 메서드 내에서 선언되고 메서드 수행이 끝나면 소멸되는 변수
- 초기값을 지정한 후 사용할 수 있음
매개변수메서드 영역- 메서드 호출 시 전달하는 값을 가지고 있는 인수
(지역 변수처럼 선언된 곳부터 수행이 끝날 때까지 유효함)

각 변수의 생성 시기는 다음과 같다.

  • 클래스 변수 : 클래스가 메모리에 올라갈 때
  • 인스턴스 변수 : 인스턴스가 생성되었을 때
  • 지역 변수 / 매개변수 : 위치하고 있는 메서드가 수행되었을 때

PC 레지스터(PC Register)

  • 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됨
  • CPU는 자바 바이트코드를 한 줄씩 실행하는데, PC 레지스터는 다음 명령어의 위치를 정확하게 알려주는 역할을 함
    => CPU가 여러 스레드의 작업을 번갈아 가며 수행하더라도, 독립적인 작업 흐름을 보장하기 위함

JVM 스택 = Java 스택

  • 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됨
  • 메서드를 위한 임시 작업 공간으로, 스택 프레임이라는 구조체를 저장하는 스택

💡 스택 프레임(Stack Frame)이란?
어떤 메서드가 호출되면 JVM은 그 메서드가 작업하는 동안 필요한 모든 정보를 담을 공간을 JVM 스택에 만든다. 이 공간이 스택 프레임이다.

📌 주요 구성 요소

1️⃣ 지역 변수 배열 (Local Variable Array)

  • 메서드 안에서 선언된 지역 변수나 매개변수(파라미터)가 저장되는 곳
public class Calculator {
  public int multiply(int a, int b) { // 인스턴스 메서드
    int result = a + b;
    return result;
  }
}

multiply() 메서드가 실행될 때 생성되는 지역 변수 배열은 다음과 같다.

  1. Index 0 (this) : Calculator 객체의 메모리 주소가 담긴다. 이를 통해 메서드 안에서 클래스의 다른 필드에 접근할 수 있다. (인스턴스 메서드일 때만 자동으로 할당됨)
  2. Index 1 (a) : 매개변수로 들어온 첫 번째 정수값이 저장된다.
  3. Index 2 (b) : 매개변수로 들어온 두 번째 정수값이 저장된다.
  4. Index 3 (result) : 메서드 내부에서 계산된 결과값을 담는 지역 변수 공간이다.

💡 알아두면 좋은 사실!
기본적으로 1개의 슬롯은 32비트(4바이트) 크기이다.

  • int, float, reference(참조) 등은 1개의 슬롯을 차지함
  • longdouble은 크기가 크기 때문에 2개의 연속된 슬롯을 차지함
    소스 코드가 .class 파일로 컴파일될 때, 컴파일러는 이 메서드가 최대 몇 개의 슬롯을 사용할지 미리 계산하여 바이트코드에 기록한다. 따라서 런타임에 배열의 크기가 변하지 않는다.

정적 메서드의 경우에는 this가 필요 없기 때문에 인덱스 0번부터 바로 매개변수가 저장된다는 것이 차이점이다.

2️⃣ 피연산자 스택 (Operand Stack)

  • 메서드의 실제 작업이 일어나는 공간
  • 덧셈, 뺄셈 같은 연산을 할 때 값을 잠시 꺼내어 계산하고 다시 넣는 역할
  • 모든 연산은 반드시 이 스택을 거쳐야 하며, 스택의 최대 깊이는 컴파일 시점에 결정

int c = a + b;라는 코드가 실행될 때의 과정은 다음과 같다.

  1. 지역 변수 배열 1번에 있는 a를 피연산자 스택에 넣는다 (push)
  2. 지역 변수 배열 2번에 있는 b를 피연산자 스택에 넣는다 (push)
  3. 스택 상단의 두 값 a, b을 꺼내(pop) 더한 뒤, 다시 스택에 넣는다 (push)
  4. 스택에 있는 결과값을 꺼내 지역 변수 배열 3번 c에 저장한다

3️⃣ 런타임 상수 풀 참조 (Refrence to Runtime Constant Pool)

  • 현재 실행 중인 클래스와 메서드에 대한 참조 정보가 저장됨
  • 스택 프레임 내의 상수 풀 참조는 해당 클래스의 상수 풀(Constant Pool) 주소를 가리킴
  • 메서드가 실행되는 동안 필요한 외부 클래스, 메서드, 변수 정보를 동적으로 연결하는 역할을 함

4️⃣ 프레임 데이터 (Frame Data)

  • 메서드가 정상 종료되었을 때 돌아갈 복귀 주소나, 예외 발생 시 처리해야 할 예외 테이블 정보 등을 포함
try {
  int result = 10 / 0;
} catch (ArithmeticException e) {
  // 처리
}

프레임 데이터 안에는 "0번~5번 줄 사이에서 에러가 나면 10번 줄로 이동하라"는 정보가 테이블 형태로 들어 있다. 에러 발생 시 JVM은 이 테이블을 보고 적절한 catch 블록을 찾아간다.

📌 동작 방식

  • 새로운 메서드(예: methodB)가 methodA로부터 호출되면, 그 메서드를 위한 새로운 스택 프레임이 생성되어 JVM 스택의 맨 위에 쌓임 (push)
  • methodB의 실행이 끝나면, 맨 위에 있던 스택 프레임이 스택에서 제거됨 (pop) 제어권은 자신을 호출했던 이전 메서드(예: methodA)로 돌아감

2025-09-21T18:20:53.389Z ERROR 1 --- [nio-8080-exec-9] c.h.h.common.advice.FailResponseAdvice : Unexpected Exception: Bad credentials
org.springframework.security.authentication.BadCredentialsException: Bad credentials at org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider.authenticate(AbstractUserDetailsAuthenticationProvider.java:144) ~[spring-security-core-6.5.2.jar!/:6.5.2] at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182) ~[spring-security-core-6.5.2.jar!/:6.5.2] at com.heybro.heybro.user.service.UserServiceImpl.login(UserServiceImpl.java:97) ~[!/:0.0.1-SNAPSHOT] at
(중략)

위와 같이 printStackTrace()를 호출했을 때 보이는 에러 로그의 각 줄이 스택 프레임 하나하나를 의미한다.

가장 위에 있는 줄이 현재 실행 중이던 메서드이자 에러가 발생한 직접적인 위치이고, 아래로 갈수록 호출 순서의 역순, 즉 메서드 호출 경로를 보여주는 것이다.

네이티브 메서드 스택(Native Method Stack)

위에서 설명한 것처럼 자바는 모든 운영체제에서 동일하게 돌아가지만, 반대로 말하자면 특정 운영체제나 하드웨어에만 있는 고유하고 세세한 기능까지는 직접 제어할 수 없다. 이런 경우 C나 C++ 같은 네이티브 언어의 힘을 빌리는 것이다.

예를 들어 System.currentTimeMillis()를 사용해서 현재 시간을 밀리초 단위로 정밀하게 가져오거나, 파일을 디스크에 직접 읽고 쓰는 일들은 JVM 내부에서 네이티브 코드를 통해 운영체제에서 처리하는 것이다.

또한 이미 존재하는 C/C++ 라이브러리를 JNI(Java Natvie Interface)를 통해 자바에서 바로 불러다 쓸 수 있도록 한다.

JVM 스택이 자바 메서드만을 위한 작업 공간이었다면, 네이티브 메서드 스택은 C/C++ 같은 네이티브 메서드만을 위한 전용 작업 공간이다. 자바와 C/C++은 데이터를 다루고 메서드를 호출하는 방식이 다르기 때문에, JVM은 네이티브 코드를 실행할 때 C/C++의 규칙을 따르는 별도의 스택 공간을 만들어주는 것이다.

📌 동작 과정

1️⃣ 자바 코드에서 JNI를 통해 네이티브 메서드 호출
2️⃣ JVM은 실행 흐름을 네이티브 코드로 넘김
3️⃣ 이때 네이티브 메서드 스택에 해당 네이티브 메서드를 위한 프레임이 생성됨
4️⃣ 네이티브 코드 실행이 끝나면 결과 값을 자바로 반환하고, 네이티브 메서드 스택의 프레임은 사라짐

PC 레지스터, JVM 스택, 네이티브 메서드 스택은 모두 스레드 별로 생성되며 공유되지 않는다.

힙(Heap)

  • 자바 프로그램에서 new 키워드를 통해 생성된 모든 객체(인스턴스)와 배열이 저장되는 공간
  • 더 이상 사용되지 않는 객체들은 가비지 컬렉터에 의해 자동으로 정리됨
  • JVM 성능 문제를 이야기할 때 힙 영역GC 때문에 발생함

그럼 GC는 어떤 객체를 보고 사용되는지, 더 이상 사용되지 않는지 어떻게 알까? 🤔

JVM은 Root Set(루트 집합)이라고 불리는 지점부터 시작해서 참조를 따라간다.

  • Reachable : 루트에서부터 닿을 수 있는 객체 = 사용 중
  • Unreachable : 루트에서 닿을 수 없는 객체 = 가비지

GC Root가 되는 시점은 주로 세 가지다.

  1. JVM 스택의 지역 변수 및 매개변수 : 현재 실행 중인 스택 프레임에 있는 참조 변수가 힙의 객체를 가리키고 있다면, 그 객체는 사용 중인 것
  2. 메서드 영역의 정적 변수 : 프로그램 종료 전까지 유지되는 static 변수가 가리키는 객체도 사용 중인 것
  3. JNI 참조 : 자바 외부에서 생성된 객체 참조
public void run() {
  Counter c = new Counter(); // 1. c는 스택에, Counter 객체는 힙에 생성 (Reachable)
  c = null; // 2. 스택의 c가 객체와의 연결을 끊음 (Unreachable)
} // 3. 메서드 종료 시 스택 프레임이 날아가며 c 자체가 사라짐 (Unreachable)
  1. c라는 지역 변수가 힙의 Counter 객체를 가리키는 동안에는 Reachable하므로 GC가 건드리지 않는다.
  2. c = null을 하는 순간, 힙에 있는 객체를 가리키는 변수가 사라지고 이 객체는 Unreachable 상태가 된다.
  3. 가비지 컬렉터가 힙을 훑으면서 루트로부터 연결되지 않았다고 판단하면 메모리에서 제거한다.

힙은 효율적인 가비지 컬렉션을 위해 Young, Old Generation으로 나뉘어 관리된다.

1️⃣ Young Generation (젊은 세대)

새로 생성된 객체가 대부분 위치하는 영역으로, 이 영역에서 발생하는 GC를 마이너(Minor) GC라고 한다. 내부적으로는 Eden 영역과 2개의 Survivor 영역으로 나뉜다.

2️⃣ Old Generation (늙은 세대)

Young Generation에서 여러 번의 GC 후에도 살아남은 객체들이 이동하는 영역으로, 이 영역에서 발생하는 GC를 메이저(Major) GC 또는 풀(Full) GC라고 한다.

📌 JVM 스택 & 힙 비교

구분JVM 스택 (Stack)힙 (Heap)
저장 데이터기본 타입 변수, 객체의 참조(주소)값new로 생성된 모든 객체 인스턴스, 배열
공유 범위스레드마다 개별 소유 (다른 스레드 접근 불가)모든 스레드가 공유 (여러 스레드가 동일 객체 접근 가능)
생명 주기메서드 호출 시 생성, 종료 시 소멸 (매우 짧음)GC에 의해 제거될 때까지 유지 (상대적으로 김)
관리 주체JVM이 자동으로 관리 (Push & Pop)가비지 컬렉터가 관리
	Person person = new Person("김유나", 26);

예를 들어 위의 코드가 실행된다면, JVM 스택에는 person이라는 참조 변수가 저장된다. 이 변수 안에는 실제 데이터가 아닌 힙 영역에 생성된 Person 객체의 메모리 주소값만 들어간다.

힙에는 new Person()에 의해 생성된 실제 Person 객체 인스턴스가 저장된다. 이름, 나이 등 객체가 가진 모든 데이터는 힙에 보관된다.

메서드 영역(Method Area) = 정적 영역(Static Area)

메서드 영역은 모든 스레드가 공유하는 영역으로, JVM이 시작될 때 생성되어 클래스 로더에 의해 로드된 클래스 정보를 저장한다. 이 영역에는 각 클래스의 바이트 코드, 상수(public static final), 필드, 메서드 등 클래스 관련 정보가 저장된다.

메서드 영역은 프로그램 시작부터 종료까지 메모리에 적재된 상태를 유지하며, 해당 영역에서 더 이상 데이터를 저장할 공간이 없을 경우 OutOfMemoryError가 발생한다. (Java 8부터 개선됨)

메서드 영역은 JVM 벤더마다 다르게 구현되어 있다. 그중 Oracle Hotspot JVM JDK 7까지는 메서드 영역을 Permanent Generation(PermGen)이라고 불렀으며, JDK 8부터는 Metaspace로 완전히 대체되었다.

Java 7 메모리 구조

Java 8 메모리 구조

구분Java 7 이전 (PermGen)Java 8 이후 (Metaspace)
관리 위치JVM Heap 내부에 고정 크기로 존재Native Memory (OS가 관리하는 메모리)
크기 결정제한된 고정 크기 (기본값 존재)제한 없음 (OS의 가용 메모리만큼 자동 확장)
주요 에러OutOfMemoryError: PermGenspaceOutOfMemoryError: Metaspace (발생 빈도 낮음)
핵심 변화Static Object, String Pool 등이 포함됨클래스 메타데이터만 저장, 상수는 Heap 이동

런타임 상수 풀(Runtime Constant Pool)

런타임 상수 풀은 클래스 로더가 메서드 영역에 클래스를 로딩할 때, 같이 메서드 영역에 적재된다. 즉, 클래스 별로 각각 따로 런타임 상수 풀을 가지고 해당 클래스의 상수들이 이 풀에 저장된다. 런타임 상수 풀에는 클래스 및 인터페이스의 상수뿐 아니라 메서드와 필드에 대한 모든 레퍼런스 정보를 갖고 있다.

Java 8 이전에는 Perm 영역에 저장되었고, Java 8 출시 이후부터는 JVM - Metaspace 영역에 저장된다.

참고로 힙 영역의 String Constant Pool과 Runtime Constant Pool은 다른 개념이다.

	String str = "hello";
    String str = new String("hello");

new 생성자를 사용하지 않고 생성한 String 객체는 String Constant Pool에 생성된다. Java 7부터 String Constant Pool이 힙 영역으로 이동함에 따라, 리터럴로 생성된 문자열도 참조가 모두 사라지면 GC의 대상이 된다. 또한 동일한 문자열에 대해 재사용하여 메모리를 절약하는 역할을 한다.

바인딩과 메모리의 관계? 🤔

메서드 호출 시 JVM이 어떤 메서드 코드를 실행할지 결정하는 과정을 바인딩이라고 한다.

  • 정적 바인딩(Static Binding) : 객체 생성 전 클래스 로딩 시점에 메서드 영역에 실행할 주소가 이미 결정된다. 호출 대상이 실행 중에 변하지 않으므로 오버라이딩 규칙이 적용되지 않으며, 자식 클래스에서 동일한 이름으로 정의할 경우 하이딩(Hiding) 현상이 발생한다.
  • 동적 바인딩(Dynamic Binding) : 런타임에 힙 영역의 실제 객체 정보를 확인하고 메서드를 결정한다. 이를 통해 상속과 오버라이딩이 가능해진다.

💡 하이딩 현상이란?
상속 관계에서 부모 클래스와 자식 클래스가 동일한 이름의 정적 멤버를 선언했을 때, 자식의 멤버가 부모의 멤버를 가리는 현상을 말한다.
정적 바인딩에서 static 메서드는 클래스 로딩 시점에 메서드 영역에 이미 주소가 고정된다. 호출 시 힙 영역의 객체를 확인하지 않으므로, 코드를 짤 때 선언한 참조 변수의 타입에 정의된 메서드만 실행한다.

class Parent {
  public static void display() { System.out.println("부모 정적 메서드"); }
}

class Child extends Parent {
  public static void display() { System.out.println("자식 정적 메서드"); } // Hiding
}

public class Main {
  public static void main(String[] args) {
  	Parent p = new Child(); // 부모 타입 변수로 자식 객체 참조
    p.display(); // 결과 : 부모 정적 메서드
    
    Child c = new Child(); //
    c.display(); // 결과 : 자식 정적 메서드
  }
}

위 코드에서 p.display()를 호출하면 실제 객체는 Child임에도 불구하고 부모의 메서드가 호출된다. 이는 static 메서드가 객체 정보와 상관없이 참조 변수 p의 타입인 Parent를 보고 호출 대상을 결정했기 때문이다.

구분정적 바인딩동적 바인딩
대상정적(static), final, private 메서드일반 인스턴스 메서드
결정 시점컴파일 타임 및 클래스 로딩 시점런타임
판단 근거참조 변수의 타입실제 생성된 객체
메모리 영역메서드 영역힙 영역

📌 출처

profile
백엔드 개발자 연습생

0개의 댓글