서론
지난 포스팅에서 Class와 Struct의 차이점을 다루며 Reference 타입과 Value 타입에 대해 다루었습니다.
Reference 타입과 Value 타입의 차이 중 하나가 저장되는 메모리 공간인데요.
Reference 타입은 Heap, Value 타입은 Stack에 저장된다고 설명했고, 그렇게 알고 있는 분이 많으실 겁니다.
하지만 타입에 따른 저장 공간이 상황에 따라 달라질 수 있다는 거 아시나요?
오늘은 Value 타입인데도 Heap에 저장되는 상황에 대해 알아보도록 합시다.
Value 타입이 Stack에 저장되는지 확인해보기
먼저 Value 타입이 정말 Stack에 저장되는지 확인해보겠습니다.
Stack의 메모리 범위를 구해서 변수가 그 범위 안에 있는지 확인하면 됩니다.
Stack 범위 확인하기
Stack 범위는 아래 코드를 이용해 구할 수 있습니다.
vmmap <PID> | grep Stack
PID는 Xcode에서 볼 수 있습니다. 지금 동작 중인 것은 69659네요.
프로그램을 break point를 건 후 실행시키면 break point에서 멈춥니다.
이때 PID를 확인하고 명령어를 실행하면 돼요!
PID를 구했다면 터미널에 "vmmap 69659 | grep Stack"을 입력하면 됩니다.
grep Stack은 Stack이 적혀있는 메시지만 출력한다는 의미입니다.
저걸 안 쓰면 정~~~~말 많이 출력 돼요 ㅎㅎ;;
제 컴퓨터는 16f60400 ~ 16fe0000이 스택 범위로 나오네요.
정확히는 0x0000000016f60400 ~ 0x0000000016fe0000 일텐데 간략하게 앞의 0은 생략하고 나오네요. 첨 알았어요 ㅎ..
이제 이 범위 안에 변수가 존재하면 그 변수는 스택에 위치한 것입니다.
일반적인 Value 타입은 정말 Stack에 저장될까?
Value 타입 중 가장 대표적인 Int형 변수를 생성해서 확인해봅시다.
참고로 이제부터 자주 보일 address 메서드는 아래 코드입니다.
func address(of object: UnsafeRawPointer) -> String {
let address = Int(bitPattern: object)
return String(format: "%p", address)
}
아무튼,
Int 변수의 메모리가 스택에 위치하면 되는거겠죠?
func dumpInt() {
print("\n----- dumpInt() -----")
var intValue: Int = 10
print("Int(\(MemoryLayout.size(ofValue: intValue)))")
print("\(address(of: &intValue))")
var copyInt: Int = intValue
print("\(address(of: ©Int))")
print("--------------------")
}
확인하는 김에 Value 타입의 복사까지 확인해보겠습니다.
intValue 변수는 16fdff208로 스택 범위인 16f60400 ~ 16fe0000 사이에 존재합니다.
따라서 스택에 저장되었다는 것을 알 수 있습니다.
intValue를 복사한 copyInt는 저장된 메모리 주소가 다르므로 다른 인스턴스로 복사된 것을 알 수 있고
이 값도 스택 안에 위치하네요
그럼 이제 Value 타입이지만 스택이 아닌 사례를 알아보겠습니다.
Collection
Array같은 Collection은 Struct이지만 Heap에 저장되는 대표적인 사례입니다.
Array, Dictionary, Set 같은 가변 길이 Collection은 내부 데이터를 Heap에 저장해서 사용합니다.
컴파일 타임에 사이즈를 정확히 알기 어렵기 때문에 Heap에 할당 후 적절히 저장 공간을 조절하는 것입니다.
Array를 먼저 확인해보겠습니다.
func dumpIntArray() {
print("\n----- dumpIntArray() -----")
var array: [Int] = [1, 2, 3]
var array2 = array
print("Array(\(MemoryLayout.size(ofValue: array)))")
print("\(address(of: &array))")
print("\(address(of: &array2))")
...
}
Int형 Array를 하나 생성하고 다른 배열에 array를 복사합니다.
Array의 크기는 8이 나왔네요. 이상하죠?
이미 배열에 1, 2, 3 총 3개의 아이템이 존재하는데 8이라고 나오니...
위에서 Int형이 8이었는데 3개면 3 * 8 = 24가 출력 돼야 하는게 아닐까요?
일단 다음 출력도 살펴봅시다.
array의 주소는 1dd6ab0c0으로 스택 범위 밖입니다.
따라서 스택이 아닌 Heap에 저장된다는 것을 알 수 있습니다.
array2는 COW 기법에 의해 array와 같은 메모리 주소를 출력합니다.
변화가 있기 전에는 같은 메모리를 참조하기 때문입니다.
func dumpIntArray() {
...
array2.append(1)
print("----- After Append -----")
print("\(address(of: &array))")
print("\(address(of: &array2))")
print("--------------------")
}
array2의 값을 변경한 뒤에 주소를 확인해보면,
다른 주소값을 출력합니다.
결론은,
Array는 Struct로 Value 타입이지만 컴파일 타임에 사이즈를 정확히 알기 어렵기 때문에 스택이 아닌 Heap에 저장됩니다.
이 Heap 영역 주소가 8 바이트로 스택에 저장되면서 참조가 1개만 있는 reference 타입처럼 동작합니다.
동시에 Value 타입이기 때문에 COW 기법에 의해 복사가 최적화됩니다.
이어서 다른 Collection인 Dictonary와 Set을 간단히 살펴보겠습니다.
func dumpCollection() {
var dict: Dictionary = [0: 0]
var setValue: Set = [1, 2, 3]
print("Dictionary: \(address(of: &dict))")
print("Set: \(address(of: &setValue))")
}
Dictonary와 Set 변수를 생성해서 저장된 메모리 주소를 확인해 봅시다.
두 개 모두 스택 범위가 아니므로 Heap에 저장된다는 것을 확인했네요.
String
String도 struct로 Value 타입이고, Collection 중 하나지만 동작이 특이해서 따로 뺐습니다.
String은 Character의 Collection 입니다.
마치 C언어에서 char형 배열을 String으로 사용하는 것과 같은 느낌입니다.
String은 좀 희안하게 동작합니다 ㅎㅎ;
func dumpString() {
var str: String = ""
print("String(\(MemoryLayout.size(ofValue: str)))")
print("\(address(of: &str))")
}
String 변수를 생성하여 크기와 주소값을 확인해보았습니다.
크기가 16이 나오면서 뭔가 싸하죠? ㅎ
길이가 달라지면 크기가 커질까요?
var str: String = String(repeating: "1", count: 1000)
//String(16)
똑같이 16으로 출력이 됩니다.
이에 대한 내용은 size(ofValue:)에서 확인할 수 있었습니다.
The result does not include any dynamically allocated or out of line storage. In particular, pointers and class instances all have the same contiguous memory footprint, regardless of the size of the referenced data.
동적으로 할당되는 사이즈는 출력되지 않으며,
포인터와 클래스 인스턴스는 참조된 데이터의 크기에 상관 없이 모두 동일한 메모리 공간을 갖는다고 나와 있네요.
이제 String의 길이에 상관 없이 동일한 크기로 출력되는 이유에 대해서 알았습니다.
하지만 이러면 이해가 안 가는 부분이 생기는데요.
String은 struct 타입이고 주소 공간도 16fdff200으로 스택에 저장이 되었습니다.
근데 String의 크기는 어떠한 reference data를 가리키는 것처럼 출력이 되었죠.
왜 이러는걸까요?
String은 Stack과 Heap을 동시에
String은 스택에 16 바이트가 할당되고
Heap 영역인 MALLOC_TINY 영역에 내부 문자열 공간이 또 할당됩니다.
위 공식 문서에서 말한 "참조된 데이터의 크기"가 바로 이 Heap의 내부 문자열 공간을 의미하는 것이죠.
만약 str에 다른 문자열을 할당하면 스택 영역은 변경이 되지 않고 Heap 영역만 변경이 됩니다.
이는 reference 타입인 클래스 레퍼런스 포인터가 동작하는 구조와 비슷합니다.
func dumpString() {
var str: String = "Hello"
var str2: String = str
print("str: \(address(of: &str))")
print("str2: \(address(of: &str2))")
}
//str: 0x16fdff200
//str2: 0x16fdff1f0
위처럼 str2에 str을 할당하는 경우에는,
다른 스택 공간을 사용하지만 동일한 Heap 영역 주소를 가집니다.
여기서 str2에 변화가 생기면 새로운 Heap 영역 주소를 가지게 됩니다.
이는 COW에 의한 동작입니다.
String 타입의 MemoryLayout 크기가 16바이트라서 15글자까지만 스택 영역에 직접 저장이 된다고 하네요.
그래서 "Hello"처럼 짧은 문자열은 스택에 바로 저장되고 Heap에 저장되지는 않는다고 합니다.
왜 굳이 struct로..?
조사하면서 느낀건데 왜 String을 struct로 했는지는 모르겠네요.
이럴거면 class로 하는게 덜 복잡하지 않나? 하는 생각..
16글자 미만인 내용을 스택에 담아서 빠르게 하기 위해 복잡하게 struct로 했다고 하기에는
배보다 배꼽이 더 큰 최적화 같기도 하고...
이런 작은 최적화가 모여 큰 성능 향상으로 이어지는 거 같기도 하고... ㅎ;;
오직 COW만 보고 struct로 한 것인지...
또 다른 이유가 있는 것인지... 아리송합니다.
마무리
이번 포스팅은 다른 분의 포스팅을 참고하면서 확인하는 과정만 해본...
정말 공부한 내용을 담은 포스팅입니다.
그래서 참고 링크의 첫 번째는 반드시 확인해보세요.
String에 대한 메모리 내용이 더 자세히 나와 있습니다.
Swift는 참 어려운 친구라는 것을 다시 한 번 느끼네요.
어려운만큼 최적화를 위해 이렇게 복잡한거겠죠? ㅎㅎ;;;
감사합니다!
참고
https://babbab2.tistory.com/35?category=828998
https://developer.apple.com/documentation/swift/memorylayout/size(ofvalue:)
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.