프로세스와 스레드가 메모리를 어떻게 사용하는지 이해하는 것은 실무에서 발생하는 많은 문제를 해결하는 열쇠입니다. "Heap memory 부족" 에러, 멀티스레드 환경의 동시성 문제, 메서드 파라미터 전달 시 원본 변경 여부 등 모든 질문의 답은 메모리 구조에 있습니다.
이 글에서는 Heap과 Stack의 차이, 참조 변수와 객체의 관계, Java의 Call by Value 메커니즘, 스레드 간 메모리 공유 방식을 다룹니다.
Stack은 LIFO(Last In First Out) 구조로 동작하며, 메서드 호출과 생명주기를 함께합니다. 메서드가 호출되면 Stack에 프레임이 생성되고, 메서드가 종료되면 프레임이 pop되어 즉시 소멸합니다.
Stack의 특징
Stack에 저장되는 데이터
Heap은 자유 메모리 영역으로 순차적 구조가 아니며, GC(Garbage Collector)가 관리합니다. Stack과 달리 접근 속도가 상대적으로 느리지만 크기가 크고 설정 가능합니다.
Heap의 특징
Heap에 저장되는 데이터
new로 생성한 모든 객체┌─────────────────────────────────────────────────────────┐
│ Process Memory │
├─────────────────────────────────────────────────────────┤
│ Stack (Thread 1) │ Stack (Thread 2) │
│ ┌──────────────┐ │ ┌──────────────┐ │
│ │ method3() │ │ │ method2() │ │
│ ├──────────────┤ │ ├──────────────┤ │
│ │ method2() │ │ │ method1() │ │
│ ├──────────────┤ │ └──────────────┘ │
│ │ method1() │ │ │
│ └──────────────┘ │ │
│ 독립적 Stack │ 독립적 Stack │
├─────────────────────────────────────────────────────────┤
│ Heap (공유 영역) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Object 1 │ │ Object 2 │ │ Object 3 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ 모든 스레드가 접근 가능 │
└─────────────────────────────────────────────────────────┘
Stack은 LIFO 구조로 동작하므로 가장 나중에 호출된 메서드가 가장 먼저 종료됩니다. 프레임이 pop되면 안의 모든 지역 변수가 즉시 사라집니다.
public class StackLifecycle {
public static void main(String[] args) {
int a = 10;
methodA(a);
System.out.println(a); // 10
}
static void methodA(int num) {
int b = 20;
int result = num + b;
methodB(result);
} // b, result, num 모두 소멸
static void methodB(int value) {
int c = 30;
System.out.println(value + c); // 60
} // c, value 소멸
}
[1] main 시작
Stack: [main: a=10]
[2] methodA 호출
Stack: [main: a=10] [methodA: num=10, b=20, result=30]
[3] methodB 호출
Stack: [main: a=10] [methodA: num=10, b=20, result=30] [methodB: value=30, c=30]
[4] methodB 종료 - POP
Stack: [main: a=10] [methodA: num=10, b=20, result=30]
[5] methodA 종료 - POP
Stack: [main: a=10]
[6] main 종료 - POP
Stack: [ ]
Heap 객체는 참조가 없어진 후 GC가 실행될 때 회수됩니다. Stack과 달리 즉시 소멸하지 않습니다.
public class HeapLifecycle {
public static void main(String[] args) {
Person p1 = new Person("Alice"); // Heap에 객체 생성
Person p2 = p1; // 같은 객체를 가리킴
p1 = null; // p1 참조 끊음
// 하지만 p2가 여전히 참조 중 → 객체 살아있음
p2 = null; // p2도 참조 끊음
// 이제 아무도 참조 안 함 → GC 대상
}
}
[1] Person p1 = new Person("Alice");
Stack: [p1 → 0x1234]
Heap: [0x1234: Person("Alice")]
[2] Person p2 = p1;
Stack: [p1 → 0x1234, p2 → 0x1234]
Heap: [0x1234: Person("Alice")] ← 참조 2개
[3] p1 = null;
Stack: [p1 → null, p2 → 0x1234]
Heap: [0x1234: Person("Alice")] ← 참조 1개 (아직 살아있음)
[4] p2 = null;
Stack: [p1 → null, p2 → null]
Heap: [0x1234: Person("Alice")] ← 참조 0개 (GC 대상)
[5] GC 실행 후
Heap: [ ] ← 회수됨
public class ReferenceLost {
public static void main(String[] args) {
// 방법 1: 스코프를 벗어남
{
Person p1 = new Person("A");
} // p1 사라짐 → 0xAAA 객체는 GC 대상
// 방법 2: null 할당
Person p2 = new Person("B");
p2 = null; // 0xBBB 객체는 GC 대상
// 방법 3: 다른 객체로 재할당
Person p3 = new Person("C");
p3 = new Person("D"); // 0xCCC 객체는 GC 대상
}
}
Person p = new Person("Alice");
이 한 줄은 두 가지 작업을 수행합니다.
[Stack에서]
변수 p 생성 → 주소값 0x1234 저장
[Heap에서]
0x1234 번지에 Person 객체 생성
Stack:
┌──────────────────┐
│ p │
│ [0x1234] │ ← 참조 변수 (주소만 저장)
└──────────────────┘
│
│ 가리킴
↓
Heap:
┌────────────────────────┐
│ 0x1234 번지: │
│ ┌────────────────────┐ │
│ │ Person 객체 │ │
│ │ - name: "Alice" │ │
│ │ - age: 0 │ │
│ └────────────────────┘ │
└────────────────────────┘
p는 객체가 아니라 주소를 담는 상자입니다.
public class ReferenceExample {
public static void main(String[] args) {
Person p1 = new Person("Alice");
Person p2 = p1; // 주소값 복사
p1.name = "Bob";
System.out.println(p2.name); // "Bob" ← 같은 객체
p1 = new Person("Charlie");
System.out.println(p1.name); // "Charlie"
System.out.println(p2.name); // "Bob" ← p2는 원래 객체
}
}
[초기 상태]
Stack: [p1 → 0x1111]
Heap: [0x1111: Person("Alice")]
[Person p2 = p1]
Stack: [p1 → 0x1111, p2 → 0x1111] ← 같은 주소
Heap: [0x1111: Person("Alice")]
[p1.name = "Bob"]
Stack: [p1 → 0x1111, p2 → 0x1111]
Heap: [0x1111: Person("Bob")] ← 둘 다 같은 객체 참조
[p1 = new Person("Charlie")]
Stack: [p1 → 0x2222, p2 → 0x1111] ← p1만 변경
Heap: [0x1111: Person("Bob"), 0x2222: Person("Charlie")]
Java는 항상 Call by Value로 동작합니다. 메서드에 값을 전달할 때 원본 값을 복사해서 전달합니다. 핵심은 무엇을 복사하느냐입니다.
Call by Value란 변수에 저장된 값(value)을 복사한다는 뜻이며, 그 값이 기본형이면 실제 값, 참조형이면 객체를 가리키는 참조값이다.
public class PrimitiveCallByValue {
public static void main(String[] args) {
int x = 10;
System.out.println("Before: " + x); // 10
changeValue(x);
System.out.println("After: " + x); // 10 ← 안 바뀜
}
static void changeValue(int num) {
num = 100;
System.out.println("Inside: " + num); // 100
}
}
메모리 동작 과정
[changeValue(x) 호출]
main Stack: changeValue Stack:
- x = 10 - num = 10 (복사본)
[num = 100 실행]
main Stack: changeValue Stack:
- x = 10 (안 바뀜) - num = 100
→ num을 바꿔도 x는 안 바뀜 (복사본이므로)
public class ReferenceCallByValue {
public static void main(String[] args) {
Person p1 = new Person("Alice");
System.out.println("Before: " + p1.name); // Alice
changeName(p1);
System.out.println("After: " + p1.name); // Bob ← 바뀜
changeReference(p1);
System.out.println("Final: " + p1.name); // Bob ← 안 바뀜
}
static void changeName(Person p) {
p.name = "Bob"; // 객체 속성 수정
}
static void changeReference(Person p) {
p = new Person("Charlie"); // 변수 재할당
}
}
[호출]
main Stack: p1 = 0x1111
changeName Stack: p = 0x1111 (주소값 복사)
Heap: 0x1111: Person("Alice")
→ p1과 p는 같은 객체(0x1111)를 가리킴
[p.name = "Bob" 실행]
Heap: 0x1111: Person("Bob")
→ 같은 객체를 수정했으므로 둘 다 "Bob" 참조
[호출]
main Stack: p1 = 0x1111
changeReference Stack: p = 0x1111 (주소값 복사)
[p = new Person("Charlie") 실행]
main Stack: p1 = 0x1111 (안 바뀜)
changeReference Stack: p = 0x2222 (p만 변경)
Heap: 0x1111: Person("Bob"), 0x2222: Person("Charlie")
→ p와 p1은 별개의 변수이므로 p를 바꿔도 p1은 안 바뀜
public class ProofCallByValue {
public static void main(String[] args) {
Person p1 = new Person("Alice");
tryToChange(p1);
System.out.println(p1.name); // Alice ← 안 바뀜
}
static void tryToChange(Person p) {
p = new Person("Bob");
// p만 변경, p1은 안 바뀜
// 만약 Call by Reference였다면 p1도 변경되어야 함
}
}
핵심 정리
public class ThreadStackExample {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
int x = 10; // t1의 Stack에 저장
method();
});
Thread t2 = new Thread(() -> {
int x = 20; // t2의 Stack에 저장 (독립적)
method();
});
t1.start();
t2.start();
}
static void method() {
int local = 100; // 각 스레드의 Stack에 따로 생성
}
}
메모리 구조
[Main Thread Stack]
- t1 → 0x7777
- t2 → 0x8888
[t1 Thread Stack] - 독립적
- x = 10
- local = 100
[t2 Thread Stack] - 독립적
- x = 20
- local = 100
→ 각 스레드의 지역 변수는 서로 간섭하지 않음
class Counter {
int count = 0; // Heap에 저장
}
public class HeapSharing {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter(); // Heap에 하나만 생성
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.count++; // 같은 객체 수정
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.count++; // 같은 객체 수정
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count); // 2000이 아닐 수 있음
}
}
2000이 안 나올 수 있는 이유
[Main Thread Stack]
- counter → 0x5555
[Heap - 공유]
- 0x5555: Counter { count: 0 }
[t1, t2 동시 실행]
두 스레드가 같은 0x5555 객체를 동시에 수정
→ 동시성 문제 발생 가능
문제 코드
@Service
public class DataProcessor {
private static final Logger log = LoggerFactory.getLogger(DataProcessor.class);
public void processBigData(List<Data> dataList) {
for (Data data : dataList) {
// 나쁜 예: 매번 String 객체 생성
log.info("Processing: " + data.toString()); // Heap 압박
}
}
}
문제점
개선 코드
@Service
public class DataProcessor {
private static final Logger log = LoggerFactory.getLogger(DataProcessor.class);
public void processBigData(List<Data> dataList) {
// 좋은 예: 시작/종료만 로깅
log.info("Starting batch process. Total items: {}", dataList.size());
for (Data data : dataList) {
// 처리 로직
}
log.info("Batch process completed");
// 또는 샘플링
if (processedCount % 1000 == 0) {
log.info("Processed {} items", processedCount);
}
}
}
@Service
public class UserService {
public void updateUser(User user) {
// 가능: 객체 속성 수정
user.setName("Updated Name");
user.setEmail("new@email.com");
// → 원본 객체가 수정됨
}
public void reassignUser(User user) {
// 불가능: 변수 재할당
user = new User("New User");
// → 원본 변수는 안 바뀜
}
// 재할당이 필요하면 반환값 사용
public User createNewUser(User oldUser) {
return new User("New User");
}
}
Heap은 Queue 자료구조가 아니라 자유 메모리 영역입니다. FIFO/LIFO 같은 순차적 구조가 없으며, 필요할 때 동적으로 메모리를 할당하고 해제하는 공간입니다.
주소값으로 가는 경로(참조 변수)가 모두 사라지는 것을 의미합니다.
Person p = new Person("Alice"); // p → 0x1234 p = null; // 0x1234로 가는 길이 끊김 → 참조 없음주소값(0x1234) 자체는 Heap에 여전히 존재하지만, 그곳으로 가는 변수가 없으면 접근 불가능하므로 GC 대상이 됩니다.
Person p = new Person("Alice");
new Person("Alice"): Heap에 객체 생성p참조 변수: Stack에 주소값만 저장객체는 Heap, 참조 변수는 Stack입니다.
정확하지 않습니다.
public static void main(String[] args) { Person obj = new Person(); // 지역 변수 Thread t1 = new Thread(() -> obj.name = "A"); Thread t2 = new Thread(() -> obj.name = "B"); }
obj는 main의 지역 변수입니다- 공유되는 이유는 obj가 전역이어서가 아니라, obj가 가리키는 Heap 객체를 여러 스레드가 접근할 수 있기 때문입니다
- 람다가 obj의 주소값을 캡처해서 같은 Heap 객체에 접근합니다
Java는 100% Call by Value입니다. Reference Type의 경우 주소값을 복사합니다.
void changeName(Person p) { p.name = "Bob"; // p가 가리키는 객체를 수정 → 원본도 "Bob" } void changeReference(Person p) { p = new Person("Charlie"); // p 변수만 재할당 → 원본은 안 바뀜 }
- 객체 속성 수정: 같은 주소를 가리키므로 원본도 변경
- 변수 재할당: p와 원본은 별개 변수라 원본 안 바뀜
Stack은 LIFO 구조이므로:
- 가장 나중에 push된 프레임(메서드)이 가장 먼저 pop
- 프레임이 pop되면 그 안의 모든 지역 변수가 한 번에 사라짐
- 배열에서 마지막 요소를 제거하는 것과 동일한 원리입니다
람다가 외부 변수를 "기억"하는 방식입니다.
Person person = new Person("Alice"); Runnable r = () -> System.out.println(person.name);컴파일러가 내부적으로:
class Lambda { private final Person capturedPerson; Lambda(Person person) { this.capturedPerson = person; // 주소값 복사 } }
- Primitive: 값 복사
- Reference: 주소값 복사
- 여러 람다가 같은 주소를 캡처하면 같은 Heap 객체 공유
Stack
Heap
참조 변수와 객체
Call by Value
Call by Value 면접때 처음 듣고 어버버했던 기억이.. 크흡 잘 읽었습니당