포인터와 객체 메모리 · C++ & Java
한 줄 소개 — "변수·객체가 메모리에 어떻게 올라가는가"를 두 언어로 비교한 노트다. C++ 로 포인터(&·*·포인터 산술·배열)를 직접 들여다본 뒤, 같은 개념이 Java 에선 스택/힙 + 참조 + GC 로 어떻게 추상화됐는지 이어 본다. 알고리즘을 Java 로 풀기 전에 "객체가 메모리에 어떻게 저장되는지"를 이해하면 자료구조 동작이 훨씬 선명해진다.
1 · 변수는 메모리에 어떻게 저장되나 (C++)
변수를 선언하면 그 타입 크기 만큼 메모리(셀, 한 칸 = 1byte)를 차지한다. 할당된 메모리의 맨 앞 셀 주소 가 그 변수의 대표 주소이고, &(ampersand) 로 그 주소를 얻는다.
char lang = 'c'; // 1 byte
int year = 2022; // 4 byte
double pi = 3.14592; // 8 byte2 · Call by value — 깊은 복사
변수를 함수에 값으로 넘기면(call by value), 매개변수를 위한 메모리가 따로 생기고 값이 복사(깊은 복사)된다. 큰 데이터라면 그만큼 메모리가 중복으로 낭비된다.
void func1(int parameter) { /* parameter = argument 의 복사본 (새 메모리) */ }
int argument = 10;
func1(argument); // argument 값이 parameter 로 깊은 복사됨포인터의 당위성 — 값 전체를 복사하지 말고, 그 변수가 있는 메모리 주소만 넘겨 거기에 접근하게 하면 복사 비용이 사라진다. 이것이 포인터를 쓰는 이유다(메모리 절약).
3 · 포인터 — & 와 * (C++)
포인터는 주소값을 저장하는 변수 다. &변수 로 주소를 얻고, 그 주소는 일반 변수가 아니라 * 로 선언한 포인터 변수 에 담는다. 선언은 int *p, 사용할 땐 p(주소), *p(가리키는 곳의 값, 역참조)다.
int a = 231;
int *b = &a; // b = a 의 주소를 담는 포인터
cout << sizeof(a); // 4 (int)
cout << &a; // 0x70fd88 (a 의 주소)
cout << b; // 0x70fd88 (b 가 담은 주소 = &a)
cout << *b; // 231 (역참조 → a 의 값)
cout << sizeof(b); // 8 (포인터는 64bit 시스템에서 8byte)
cout << &b; // 0x70fd80 (포인터 자신의 주소)왜 int *p 처럼 타입을 붙일까? 포인터는 데이터가 어디서 시작하는지(주소)는 알지만 어디서 끝나는지 모른다. 앞의 타입이 "이 주소에서 몇 byte 를 읽어야 하는지"를 알려준다 — 그래서 포인터 산술에서도 이 타입이 기준이 된다.
4 · 포인터 산술 & 배열 (C++)
p + 1 은 주소에 1 byte 가 아니라 가리키는 타입 크기만큼 더해진다(int 면 +4, double 면 +8). 그래서 배열 과 잘 맞는다 — 배열 이름은 사실상 첫 원소의 주소(포인터) 이고, arr[i] 는 *(arr + i) 와 같다.
int a = 231; int *b = &a;
cout << *b + 1; // 232 (값 231 에 +1)
cout << b + 1; // 0x70fd7c (주소가 +4 = int 크기만큼 이동)
// 배열 = 첫 원소의 주소
void array_func(int *arr, int size) {
for (int i = 0; i < size; i++)
cout << *(arr + i); // == arr[i]
}
int numArr[4] = {1,5,2,3};
int size = sizeof(numArr) / sizeof(numArr[0]); // 전체크기/한칸크기 = 개수
array_func(numArr, size); // numArr == &numArr[0]배열 decay 함정 — 배열을 함수 매개변수로 넘기면 첫 주소(포인터)만 전달되고 "어디까지가 배열인지" 정보는 사라진다. 그래서 함수 안에서 sizeof(arr)/sizeof(arr[0]) 를 하면 배열 개수가 아니라 포인터 크기(8)/원소크기 가 나와 엉뚱한 값이 된다. → size 를 따로 같이 넘겨야 한다.
5 · Java — 스택 vs 힙, 값 vs 참조
Java 엔 포인터 문법(*·&)이 없지만, 개념은 그대로 살아 있다 — 참조(reference) 가 사실상 "안전하게 감춰진 포인터"다. 메모리는 크게 둘로 나뉜다.
- 스택(Stack) — 메서드의 지역변수. 기본형(primitive)(int·double·boolean…)은 값 자체 가 여기 올라간다. 메서드가 끝나면 사라진다.
- 힙(Heap) —
new로 만든 객체 가 여기 생성된다. 스택의 참조 변수 는 그 객체의 주소(참조) 만 들고 있다 — C++ 포인터와 정확히 대응.
int a = 10; // 기본형 → 스택에 값 10 자체
Person p = new Person("Lee"); // 객체는 힙에 생성, p(스택)는 그 주소(참조)만 보관
Person q = p; // 참조 복사 — q 와 p 는 "같은 힙 객체"를 가리킴 (C++ 의 두 포인터처럼)
q.setName("Kim");
System.out.println(p.getName()); // "Kim" ← 같은 객체이므로6 · Java — == vs equals, 전달, GC
참조가 곧 주소라는 걸 알면 다음이 자연스럽다.
==vs.equals()—==는 참조(주소) 가 같은지,.equals()는 내용(값) 이 같은지 비교한다. 그래서 값 비교엔 반드시equals.- 인자 전달 — Java 는 항상 값 전달(pass by value) 인데, 객체의 경우 그 "값"이 참조(주소)의 복사본 이다. 그래서 메서드 안에서 객체 필드를 바꾸면 원본에 반영되지만, 참조 자체를 다른 객체로 바꿔치기 해도 호출부엔 영향이 없다.
- GC(Garbage Collection) — C++ 은
delete로 직접 해제하지만, Java 는 더 이상 아무 참조도 가리키지 않는 힙 객체를 가비지 컬렉터가 자동 회수 한다.
String s1 = new String("Lee");
String s2 = new String("Lee");
System.out.println(s1 == s2); // false — 서로 다른 객체(주소)
System.out.println(s1.equals(s2)); // true — 내용은 같음
Person p = new Person("Lee");
p = null; // 이제 이 Person 객체는 아무도 안 가리킴 → GC 대상정리 — C++ 포인터에서 직접 보던 "값 vs 주소"가 Java 에선 "기본형 vs 참조", "스택 vs 힙"으로 추상화됐을 뿐 본질은 같다. 이 그림이 머리에 있으면 ==/equals, 얕은/깊은 복사, 컬렉션이 객체를 어떻게 담는지가 한 번에 이해된다.
📓 2022년 제가 알고리즘을 공부하며 C++ 포인터와 Java 객체 메모리를 직접 정리한 노트입니다 · 원본 Notion · C++ Pointer ↗