자바 메모리 구조 완벽 정리
자바 프로그램이 실행되면 메모리에서 무슨 일이?
자바 프로그램을 실행하면 JVM(자바 가상 머신)이 운영체제로부터 메모리를 할당받는다. 이 메모리를 JVM은 용도에 따라 여러 영역으로 나누어 관리하는데, 이것이 바로 Runtime Data Area다.
컴퓨터의 메모리는 한정되어 있기 때문에 어떻게 관리하느냐에 따라 프로그램의 성능이 크게 달라진다. 메모리 관리가 잘 안 된 프로그램은 느려지거나 튕기는 현상이 자주 발생한다. 따라서 자바로 효율적인 프로그램을 만들려면 메모리 구조를 이해하는 것이 필수다.
JVM의 메모리 공간은 크게 Method 영역, Stack 영역, Heap 영역 3가지로 구분된다. 데이터 타입에 따라 각 영역에 나눠서 할당되는 구조다.
먼저 알아야 할 것: 자바 변수의 종류
메모리 영역을 이해하기 전에 자바 변수의 종류를 먼저 알아야 한다. 변수는 선언된 위치에 따라 4가지로 분류된다.
| 변수 종류 | 선언 위치 | 생성 시기 | 소멸 시기 | 저장 위치 |
|---|---|---|---|---|
| 클래스 변수 (static 변수) | 클래스 내부, static 키워드 사용 | 클래스가 메모리에 로드될 때 | 프로그램 종료 시 | Method 영역 |
| 인스턴스 변수 | 클래스 내부, static 없이 선언 | 객체(인스턴스) 생성 시 | 객체가 GC에 회수될 때 | Heap 영역 |
| 지역 변수 | 메서드 내부 | 메서드 실행 시 | 메서드 종료 시 | Stack 영역 |
| 매개 변수 | 메서드의 파라미터 | 메서드 호출 시 | 메서드 종료 시 | Stack 영역 |
public class Student {
static int studentCount; // 클래스 변수
String name; // 인스턴스 변수
public void setScore(int score) { // score는 매개변수
int bonus = 5; // 지역 변수
}
}
이 구분을 이해하면 각 변수가 어느 메모리 영역에 저장되는지 자연스럽게 알 수 있다.
Method 영역 (Static 영역)
Method 영역은 JVM이 시작될 때 생성되고, 클래스 정보를 저장하는 공간이다. 모든 스레드가 공유하는 영역이다.
저장되는 것들:
- 클래스의 정보 (이름, 부모 클래스, 인터페이스 등)
- 메서드의 바이트코드
- static 변수 (클래스 변수)
- static 메서드
- final 클래스 변수 (상수)
특징:
- 프로그램 시작부터 종료까지 메모리에 계속 남아있다
- 어디서든 접근 가능하다
- 무분별하게 static을 많이 사용하면 메모리 부족이 발생할 수 있다
코드 예제로 이해하기
public class GameApplication {
public static int totalPlayers = 0; // Method 영역
public static void main(String[] args) {
System.out.println("현재 플레이어: " + totalPlayers);
}
public static void resetGame() { // Method 영역
totalPlayers = 0;
}
}
class Character {
public final String DEFAULT_WEAPON = "맨손"; // Heap 영역 (인스턴스 변수)
public static final int MAX_LEVEL = 99; // Method 영역 (static final)
}
위 코드에서 Method 영역에 저장되는 것은:
GameApplication클래스 정보Character클래스 정보totalPlayers변수 (static 변수)resetGame()메서드 (static 메서드)MAX_LEVEL상수 (static final)
주의할 점은 DEFAULT_WEAPON은 final이지만 static이 아니므로 인스턴스 변수다. 따라서 객체가 생성될 때 Heap 영역에 할당된다.
Stack 영역
Stack 영역은 메서드가 호출될 때마다 생성되는 영역이다. 각 스레드마다 독립적으로 생성된다.
저장되는 것들:
- 지역 변수 (기본 타입의 실제 값)
- 매개 변수
- 참조 변수 (참조 타입의 주소값)
- 메서드 호출 정보
특징:
- LIFO (Last In First Out) 구조로 동작한다
- 메서드가 호출되면 Stack에 프레임이 쌓인다
- 메서드가 종료되면 해당 프레임이 자동으로 제거된다
- 각 스레드마다 독립적이므로 스레드 간 간섭이 없다
기본 타입과 참조 타입의 차이
int score = 95; // Stack에 95라는 값이 직접 저장됨
String message = "합격"; // Stack에는 주소값만, 실제 "합격"은 Heap에 저장됨
기본 타입(int, double, boolean 등)은 Stack에 실제 값이 저장되지만, 참조 타입(객체, 배열 등)은 Stack에 주소값만 저장되고 실제 데이터는 Heap에 저장된다.
코드 실행 흐름으로 이해하기
public class Calculator {
public static void main(String[] args) {
int price = 10000;
int quantity = 3;
int total = calculateTotal(price, quantity);
System.out.println("총 가격: " + total);
}
public static int calculateTotal(int unitPrice, int count) {
int discount = 500;
int result = (unitPrice * count) - discount;
return result;
}
}
1단계: main() 실행
- Stack에 main() 프레임 생성
- 지역 변수 price = 10000 저장
- 지역 변수 quantity = 3 저장
2단계: calculateTotal() 호출
- Stack에 calculateTotal() 프레임 생성 (main 위에 쌓임)
- 매개 변수 unitPrice = 10000 저장
- 매개 변수 count = 3 저장
- 지역 변수 discount = 500 저장
- 지역 변수 result = 29500 저장
3단계: calculateTotal() 종료
- calculateTotal() 프레임이 Stack에서 제거됨
- return 값 29500이 main()의 total에 저장
4단계: main() 종료
- main() 프레임이 Stack에서 제거됨
- 프로그램 종료
이처럼 Stack은 메서드 호출마다 프레임이 쌓이고, 종료되면 자동으로 제거되는 구조다.
Heap 영역
Heap 영역은 객체와 배열이 생성되는 공간이다. new 키워드로 생성되는 모든 것이 여기에 저장된다.
저장되는 것들:
- 객체 (인스턴스)
- 배열
- 인스턴스 변수
특징:
- 모든 스레드가 공유하는 영역이다
- 메서드가 종료되어도 객체는 사라지지 않는다
- Garbage Collector가 관리하는 영역이다
- 참조되지 않는 객체는 GC가 자동으로 제거한다
코드 예제로 이해하기
public class ShoppingMall {
public static void main(String[] args) {
int itemCount = 5; // Stack
String productName = "노트북"; // Stack(참조), Heap(객체)
Product product = new Product(); // Stack(참조), Heap(객체)
}
}
class Product {
String name; // 인스턴스 변수 (Heap)
int price; // 인스턴스 변수 (Heap)
}
메모리 구조:
Stack:
- itemCount = 5 (실제 값)
- productName = 0x100 (Heap의 "노트북" 주소)
- product = 0x200 (Heap의 Product 객체 주소)
Heap:
- 주소 0x100: "노트북" 문자열 객체
- 주소 0x200: Product 객체 (name, price 필드 포함)
실전 예제: 메모리 구조 완벽 분석
이제 좀 더 복잡한 코드를 단계별로 분석해보자.
public class BankSystem {
public static int totalAccounts = 0;
public static void main(String[] args) {
int transferAmount = 50000;
Account acc1 = new Account("123-456", 100000);
Account acc2 = new Account("789-012", 200000);
acc1.withdraw(transferAmount);
}
}
class Account {
String accountNumber;
int balance;
public Account(String accountNumber, int balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
public void withdraw(int amount) {
String status = "출금완료";
this.balance -= amount;
System.out.println(status + ": " + amount + "원");
}
}
메모리 배치 분석
Method 영역:
- BankSystem 클래스 정보
- Account 클래스 정보
- totalAccounts = 0
- main(), withdraw() 메서드 바이트코드
Stack 영역 (main 실행 시):
- transferAmount = 50000
- acc1 = 0x100 (Heap 주소)
- acc2 = 0x200 (Heap 주소)
Stack 영역 (withdraw 실행 시):
- main() 프레임
- withdraw() 프레임
- this = 0x100 (acc1 주소)
- amount = 50000
- status = 0x300 (Heap 주소)
Heap 영역:
- 주소 0x100: Account 객체1 (accountNumber="123-456", balance=50000)
- 주소 0x200: Account 객체2 (accountNumber="789-012", balance=200000)
- 주소 0x300: "출금완료" 문자열 객체
withdraw() 메서드가 종료되면 Stack의 withdraw() 프레임은 사라지지만, Heap의 Account 객체들은 main()에서 여전히 참조하고 있으므로 남아있다.
Stack과 Heap의 관계
Stack과 Heap은 독립적이면서도 긴밀하게 연결되어 있다.
public void processScores() {
int[] scores = new int[5];
scores[0] = 90;
scores[1] = 85;
}
메모리 구조:
- Stack: scores 변수 (배열 주소를 담고 있음)
- Heap: 실제 int[5] 배열 객체 (90, 85, 0, 0, 0)
processScores() 메서드가 끝나면 Stack의 scores 변수는 사라진다. 하지만 Heap의 배열 객체는 즉시 사라지지 않고, GC가 "더 이상 참조되지 않는다"고 판단할 때 제거된다.
Garbage Collection (가비지 컬렉션)
Heap 영역에 생성된 객체는 개발자가 직접 삭제할 수 없다. 대신 JVM의 Garbage Collector가 자동으로 관리한다.
GC의 동작 원리:
- Heap을 Young Generation과 Old Generation으로 구분
- 새로 생성된 객체는 Young Generation에 할당
- Young Generation이 가득 차면 Minor GC 발생
- 살아남은 객체는 Old Generation으로 이동
- Old Generation이 가득 차면 Major GC 발생
public void processBatch() {
for (int i = 0; i < 1000; i++) {
String log = new String("처리완료: " + i); // 1000개 객체 생성
System.out.println(log);
// log는 지역 변수이므로 반복문 다음 사이클에서 참조 끊김
}
// 반복문 종료 시 1000개 String 객체는 모두 참조가 끊겨서 GC 대상이 됨
}
이처럼 참조가 끊긴 객체는 GC가 자동으로 회수한다. 개발자가 메모리를 직접 해제할 필요가 없다는 것이 자바의 큰 장점이다.
메모리 에러의 종류
메모리 영역별로 발생하는 에러가 다르다.
1. StackOverflowError
Stack 영역이 가득 찼을 때 발생한다.
public int factorial(int n) {
return n * factorial(n - 1); // 종료 조건이 없는 무한 재귀
}
재귀 호출이 너무 깊어지면 Stack에 프레임이 계속 쌓여서 결국 Stack 메모리가 부족해진다.
2. OutOfMemoryError: Java heap space
Heap 영역이 가득 찼을 때 발생한다.
List<String> dataList = new ArrayList<>();
while (true) {
dataList.add(new String("데이터" + System.currentTimeMillis()));
}
객체를 무한정 생성하거나, 메모리 누수로 GC가 회수하지 못하면 Heap이 가득 차서 에러가 발생한다.
메모리 구조를 알아야 하는 이유
- 성능 최적화: 불필요한 객체 생성을 줄여 Heap 메모리와 GC 부담을 줄일 수 있다
- 메모리 누수 방지: 참조가 끊기지 않아 GC가 회수하지 못하는 상황을 이해하고 예방할 수 있다
- 멀티스레드 이해: Stack은 스레드별로 독립적이지만 Heap은 공유된다는 점을 알면 동시성 문제를 파악할 수 있다
- 에러 대응: StackOverflowError와 OutOfMemoryError가 왜 발생하는지 정확히 알고 해결할 수 있다
자바 코드를 작성할 때 변수를 선언하고 객체를 생성하는 모든 순간에 이 메모리 구조가 작동하고 있다. 겉으로는 간단해 보이는 new Account() 한 줄도 내부적으로는 Heap에 메모리를 할당하고, Stack에 주소를 저장하는 복잡한 과정이 일어나는 것이다.