개요
매운맛은 하단에
2024.06.25 - [Java] - 자바가상머신의 메모리 구조: JVM Runtime Data Area
2024.06.25 - [Java] - JVM스택메모리 구조 이해를 위한 바이트코드 예시
클래스(static) 변수, 인스턴스 변수, 지역 변수의 특성 및 예제를 이전 포스팅에서 살펴 보았는데
왜 메모리 영역에서 어떻게 관리되는지까지 알아야할까요?
크게 두가지인데
1. 나는 성능충이다
- 메모리 관리 원리를 알면 불필요한 자원 낭비를 줄일 수 있다.
- 예를 들어, static 변수는 클래스 로드 시 한 번만 메모리에 올라가는데, 이를 잘못 사용하면 객체마다 중복된 메모리 낭비가 발생한다.
- 알고리즘 설계에서도 메모리 초과를 방지하려면 스택, 힙 사용을 최적화할 수 있어야 한다.
- 한정된 리소스로 최대의 퍼포먼스를 끌어내는 진정한 성능충이라면 메모리 구조는 필수 학습 항목이다.
아래 코드를 봐보자
public class PerformanceExample {
static int sharedCounter = 0; // 클래스 변수 (메모리 1회 할당)
int instanceCounter = 0; // 인스턴스 변수 (객체마다 힙 메모리에 따로 할당)
public void incrementCounters() {
sharedCounter++; // 모든 객체에서 공유
instanceCounter++; // 객체별로 독립적
}
public static void main(String[] args) {
PerformanceExample obj1 = new PerformanceExample();
PerformanceExample obj2 = new PerformanceExample();
obj1.incrementCounters();
obj2.incrementCounters();
System.out.println("Static Counter: " + sharedCounter); // 출력: 2
System.out.println("Instance Counter (obj1): " + obj1.instanceCounter); // 출력: 1
System.out.println("Instance Counter (obj2): " + obj2.instanceCounter); // 출력: 1
}
}
- sharedCounter는 모든 객체가 공유하는 값이므로 힙 메모리 낭비를 줄임.
- 하지만 instanceCounter는 객체마다 따로 생성되므로 힙 메모리를 더 차지한다.
- 이런 차이를 이해하면 더 효율적인 메모리 관리를 할 수 있다. (알고리즘 풀이때 static 해두고 초기화해서 쓰면 굳)
2. 오류를 디버깅하기 쉽다.(결국 원리를 알아야 고장난걸 고침)
- NullPointerException, OutOfMemoryError 같은 오류의 근본 원인을 알기 위해선 메모리 구조를 이해해야 한다.
- 예를 들어, 지역 변수는 스택에 저장되고 메서드 실행이 끝나면 사라지기 때문에, 이를 잘못 참조하면 오류를 뜬다.
public class DebugExample {
static String sharedData = "Shared";
String instanceData;
public void printData() {
String localData = "Local"; // 지역 변수 (스택에 저장됨)
System.out.println("Local Data: " + localData);
System.out.println("Instance Data: " + instanceData); // NullPointerException 가능성
System.out.println("Shared Data: " + sharedData);
}
public static void main(String[] args) {
DebugExample example = new DebugExample();
example.printData(); // instanceData가 초기화되지 않아 NullPointerException 발생 가능
DebugExample.sharedData = "Modified";
DebugExample anotherExample = new DebugExample();
anotherExample.printData(); // sharedData는 모든 객체에서 수정된 값을 공유
}
}
- stanceData는 초기화되지 않았으므로 NullPointerException이 발생 가능.
- sharedData는 static으로 선언되어 모든 객체에서 값이 공유됨.
- 이런 메모리 동작을 이해하면 문제 원인을 빠르게 찾아 고칠 수 있다.
코드 분석
public class Car {
static double speed = 0;
String color;
void drive(String to) {
System.out.println("Driving to " + to);
String msg = "Driving at " + speed + " km/h";
for (int i = 0; i < 3; i++) {
System.out.println(msg);
}
}
}
public class CarDriveTest {
public static void main(String[] args) {
Car myCar = new Car();
myCar.color = "Red";
System.out.println("My car is " + myCar.color);
myCar.drive("New York");
Car yourCar = new Car();
yourCar.color = "Blue";
System.out.println("Your car is " + yourCar.color);
yourCar.drive("Chicago");
Car.speed = 100;
myCar.drive("New York2");
yourCar.drive("Chicago2");
}
}
클래스 변수 (static 키워드)
static double speed = 0;
- 프로그램 실행 시 클래스가 로드될 때 초기화 되어 메모리에 한 번 할당되며, 모든 객체가 공유한다.
- 클래스 변수는 메타스페이스(Metaspace)에 할당된다.
인스턴스 변수
String color;
- 인스턴스 변수는
new Car()
호출 시 힙(Heap) 메모리에 할당됩니다. - 각 객체(
myCar
,yourCar
)가 자신의color
인스턴스 변수가 각 객체 내부의 힙 메모리에 존재한다.
지역 변수
String msg = "Driving at " + speed + " km/h";
int i = 0;
- 지역 변수는 메서드 실행 중 스택(Stack) 메모리에 할당됩니다.
drive()
메서드의 매개변수to
와 반복문 내i
변수도 스택에 저장됩니다.- 메서드 실행이 끝나면 지역 변수는 사라집니다.
클래스로드 - 인스턴스 생성 메모리 동작
클래스 로드 단계
- JVM은
Car
클래스를 메타스페이스에 로드합니다. speed
변수가 메타스페이스에 생성되고 초기값0.0
을 가집니다.
인스턴스 생성
Car myCar = new Car();
new Car()
호출 시 힙에 객체가 생성됩니다.myCar.color
의 초기값은null
로 설정됩니다.
인스턴스 변수 문자열 참조
main.Car myCar = new Car();
myCar.color = "Red";
- "Red" 는 문자열 리터럴로, 클래스로더가 읽을때 String Constant Pool (메타 스페이스에 인접해서 적재하기에 용이하다.)
- myCar 객체의 힙 메모리 내부에 있던 초기값 null 이 "Red" 문자열 참조로 대체된다.
- myCar 은 스택에 존재하는 로컬 변수로서, 힙에 있는 Car 객체 주소를 참조한다.
메서드 호출과 스택 내부 지역변수
메서드 호출과 스택 내 지역 변수
System.out.println("My car is " + myCar.color);
- "My car is "와 myCar.color의 문자열 결합 과정에서 새 String 객체가 힙에 생성될 수 있다
- println 호출 시, 내부적으로 PrintStream 객체(System.out)가 사용된다.
- 스택 프레임에서 임시 지역 변수(결합된 문자열 주소)가 잡힐 수 있고, 메서드 실행이 끝나면 해제된다.
myCar.drive("New York");
- drive("New York") 호출 시 새로운 스택 프레임이 생성된다.
- this → myCar 객체 주소(힙에 있는)를 스택에 참조(주소) 형태로 저장
- 매개변수 to → "New York" 참조
- System.out.println("Driving to " + to);에서 "Driving to " + "New York"에 대한 문자열 처리가 이루어진다(임시 객체 또는 컴파일 최적화).
- msg = "Driving at " + speed 구문에서 "Driving at " 문자열을 String Constant Pool에서 가져오고, speed(현재 0.0)과 결합해 임시 문자열을 힙에 생성할 수 있다.
- for (int i = 0; i < 3; i++) 구문 안에서 i는 스택에 저장된다. 각 반복마다 System.out.println(msg)가 실행되고, msg는 이미 만들어진 임시 문자열을 참조한다.
- drive 메서드가 종료되면 스택 프레임은 해제된다.
정적 필드 변경
정적 필드 변경
Car.speed = 100;
- 이미 메타스페이스에 있던 Car 클래스의 speed 값이 0에서 100으로 변경된다.
- 이는 모든 Car 인스턴스가 공유한다.
글을 읽으면서 따라왔나요? 그럼 또다시 한번 복습을 해봅시다.
코드의 배치/실행 순서에 따라서 흐름 정리를 먼저 하고, 이후에 그래서 각 메모리에는 어떤 정보들이 저장되었는지 또 한번 더 정리합시다.
* 흐름 최종정리
1. 클래스 로딩
- Car와 CarDriveTest 클래스가 JVM에 로드되며 메타스페이스에 클래스 메타데이터 저장.
- 정적 필드 Car.speed는 초기값 0으로 설정(메타스페이스/Method Area).
2. main 메서드 실행
(1) main 메서드 시작
- JVM이 스택 프레임 생성.
- 지역 변수 args 배열은 힙에 저장되고 스택에서 참조.
(2) myCar 객체 생성
- 힙:
- Car 객체(myCar) 생성.
- myCar.color → 초기값 null.
- 스택:
- 지역 변수 myCar는 힙의 Car 객체 참조.
- "Red" 문자열:
- String Pool에 저장.
- myCar.color → "Red" 참조.
(3) myCar.drive("New York") 호출
- 스택:
- 새로운 스택 프레임 생성.
- this → 힙의 myCar 객체 참조.
- to → "New York"(String Pool 참조).
- 출력:
- "Driving to New York".
- "Driving at 0.0 km/h" 3번 출력.
(4) yourCar 객체 생성
- 힙:
- 새 Car 객체(yourCar) 생성.
- yourCar.color → 초기값 null.
- 스택:
- 지역 변수 yourCar는 힙의 Car 객체 참조.
- "Blue" 문자열:
- String Pool에 저장.
- yourCar.color → "Blue" 참조.
(5) yourCar.drive("Chicago") 호출
- 스택:
- 새로운 스택 프레임 생성.
- this → 힙의 yourCar 객체 참조.
- to → "Chicago"(String Pool 참조).
- 출력:
- "Driving to Chicago".
- "Driving at 0.0 km/h" 3번 출력.
3. 정적 필드 업데이트
- 메타스페이스:
- Car.speed 값이 0 → 100으로 변경.
- 모든 Car 객체가 이 값을 공유.
4. 메서드 재호출
(1) myCar.drive("New York2")
- 스택:
- 새로운 스택 프레임 생성.
- this → 힙의 myCar 객체 참조.
- to → "New York2"(String Pool 참조).
- 출력:
- "Driving to New York2".
- "Driving at 100.0 km/h" 3번 출력.
(2) yourCar.drive("Chicago2")
- 스택:
- 새로운 스택 프레임 생성.
- this → 힙의 yourCar 객체 참조.
- to → "Chicago2"(String Pool 참조).
- 출력:
- "Driving to Chicago2".
- "Driving at 100.0 km/h" 3번 출력.
* 메모리 내부 최종정리
1. 메타스페이스
- 클래스 정보:
- Car, CarDriveTest 메타데이터.
- 정적 필드:
- Car.speed → 초기값 0 → 최종값 100.
- String Pool:
- "Red", "Blue", "New York", "Chicago", "New York2", "Chicago2", "Driving at ".
2. 힙
- 두 개의 Car 객체:
- myCar.color → "Red"(String Pool 참조).
- yourCar.color → "Blue"(String Pool 참조).
3. 스택
- main 메서드:
- 지역 변수:
- myCar → 힙의 첫 번째 Car 객체 참조.
- yourCar → 힙의 두 번째 Car 객체 참조.
- 지역 변수:
- drive 메서드 호출:
- this → 힙의 호출된 Car 객체 참조.
- to → String Pool의 문자열 참조.
출력값 결과
My car is Red
Driving to New York
Driving at 0.0 km/h
Driving at 0.0 km/h
Driving at 0.0 km/h
Your car is Blue
Driving to Chicago
Driving at 0.0 km/h
Driving at 0.0 km/h
Driving at 0.0 km/h
Driving to New York2
Driving at 100.0 km/h
Driving at 100.0 km/h
Driving at 100.0 km/h
Driving to Chicago2
Driving at 100.0 km/h
Driving at 100.0 km/h
Driving at 100.0 km/h
마무리
이번 포스팅을 통해 JVM 메모리 구조를 상세히 살펴보았고, 예제인 Car 클래스의 실행 흐름을 통해 메모리 관리가 코드 동작에 어떻게 영향을 미치는지 확인했습니다.
다시 정리하면
- 클래스 변수 (static)는 프로그램 실행 중 한 번만 메모리에 로드되며, 모든 인스턴스에서 공유됩니다.
- 인스턴스 변수는 객체가 생성될 때 힙에 따로 저장되므로, 객체마다 독립적으로 관리됩니다.
- 지역 변수는 메서드 호출 시 스택에 저장되며, 메서드가 끝나면 자동으로 해제됩니다.
- 문자열 리터럴은 String Pool에 저장되어 중복을 줄이고 메모리 사용을 최적화합니다.
마무리 하면서, 만약 main 메서드가 종료된 이후, 힙에 있던 myCar, yourCar 객체는 더 이상 쓰이지 않는데 비효율적(자원낭비) 이지 않을까요?
그래서 자바에서는 이걸 자동으로 치우는 역할을 하는 관리 시스템이 있습니다. 가비지 컬렉터(GC)라는 것이 메모리를 자동으로 관리해주는데 다음에 알아봅시다
매운맛으로 알아보고 싶다면 ⬇️
2024.07.20 - [Java] - 자바 가비지 컬렉터(GC)의 발전; 시리얼 컬렉터부터 ZGC까지
'Java' 카테고리의 다른 글
Java 조각모음 [4] (1) | 2025.01.23 |
---|---|
예제로 배우는 제한자와 자바변수 설계 전략 (1) | 2025.01.22 |
예제로 배우는 자바가 배열을 생성하는 원리 이해하기 (0) | 2025.01.21 |
Java 조각모음 [3] (0) | 2025.01.20 |
예제로 배우는 자바 String Pool 이해하기 (0) | 2025.01.20 |