[iOS] Heap 그리고 Stack (feat. struct, class)

 

[iOS] 메모리 구조

이번 글에서는 개발자가 작성하는 코드가 메모리에서 어떤 부분에 저장되는지 알아보려고 한다. 메모리 구조프로그램이 실행되면 운영체제(OS)는 메모리(RAM)에 프로그램을 위한 공간을 할당해

d0ngurrrrrrr.tistory.com

 

앞서 메모리 구조(코드, 데이터, 힙, 스택)에 대해서 공부를 하였는데, 이 중 힙, 스택의 장/단점을 보면서 이 둘에 대해 더 알아보겠다고 했는데, 이번 글에서는 힙, 스택과 더불어 struct, class까지 자세하게 알아보자.

 

 

힙, 스택 언제 쓰면 될까?

 

 앞선 글에서 스택은 메모리가 한정되어 있기에

너무 큰 메모리는 할당할 수 없다고 하였다. 

따라서 테이터의 크기를 모르거나,

스택에 저장하기엔 큰 데이터에 할당하고 그 외엔 스택에 할당하면 된다. 

 

 

앞선 설명에서 스택은 메모리가 한정되어 있다고 했다.

(물론 힙도 메모리가 무한정은 아니지만, 스택에 비해 상대적으로 크다)

그렇다면 너무 많은 메모리를 스택에 할당하게 되면 어떻게 될까?

 

스택 오버 플로우

 

스택 오버 플로우가 발생하게 된다. 
즉, 자신의 스택 영역을 초과해 버리게 되는 것이다.

이 경우엔 iOS에서 앱이 죽어버리게 되니 주의해야 한다.
(힙 역시 자신의 영역을 초과하면 힙 오버 플로우가 발생한다고 한다.)

 

 

 

힙과 스택의 관계

메모리 구조

 

이전에 메모리를 설명하면서 보여주었던 그림을 다시 봐보자.

이 중에서도 힙과 스택 부분을 자세하게 보자.

힙과 스택 영역을 분리하여 보여주었고, 또 나누어 설명했지만

이미지가 맞물려 있게 표현을 해놓았다.

 

 

 

사실 스택같은 메모리 영역을 공유한다!!

 

같은 메모리 영역이니 메모리를 할당하는데 문제가 생길 수 있지 않냐고 묻는다면?

 

같은 메모리 공간이지만

힙 영역낮은 메모리 주소부터 할당받는 것이고,

스택 영역높은 메모리 주소부터 할당받는 것이다.

 

하지만 앞서 설명했듯

스택 영역이 힙 영역을 침범하는 경우 스택 오버 플로우가 발생하며

역으로 힙 영역이 스택 영역을 침범해도 힙 오버 플로우가 발생하게 된다.

 

 

 

Struct와 Class

 

그렇다면 메모리 영역이 struct, class와 무슨 연관이 있을까?

 

이 부분을 알아보기 앞서

간단하게 struct와 class의 특징을 정리해 보면

 

class

- 참조 타입

- 다중 상속을 지원

-부모 클래스에서 특성을 상속받을 수 있음

- 참조 계수에 의한 메모리 관리(ARC)

- 클래스 인스턴스가 힙 메모리에 저장됨

- 여러 변수가 동일한 인스턴스를 참조할 수 있음

 

struct

- 타입

- 상속을 지원하지 않음

- 참조 계수 사용 x(값이 복사되기 때문에 자동으로 메모리 관리됨)

- 구조체 인스턴스가 스택에 직접 저장되거나, 힙에 저장되더라도 복사본 전달

- 각 변수는 독립적인 값을 가지며 수정이 다른 인스턴스에 영향 x

 

라고 정리할 수 있다.

 

 

 

struct, class 각각 어디에 저장될까?

 

위의 struct와 class 정리 부분에서

눈여겨봐야 할 부분이 바로 타입이다.

 

보통 공부를 하다 보면 참조 타입Heap

값 타입Stack에 저장된다고 설명을 한다.

 

하지만 이것은 반만 맞는 설명이다.

그 이유는 Heap 영역에 참조타입이 저장된다라는 것이 잘못된 정보기 때문이다.

 

응?? 이게 무슨 소리야?라고 생각이 드는 사람도 있을 것이다.

정확하게 말하자면 상황에 따라

Heap 영역에 참조 타입, 값 타입 모두 할당될 수 있다.

 

 

코드를 통해 이해를 해보자.

struct User {
    let name: String
    let age: Int
    let company: String
}

class Test {
    let testValue = 10
    let text = "반갑습니다."
    
    init() {
        hello()
    }
    
    func hello(){
        let hello = "안녕하세요."
    }
}

class MemoryTest {
    let nickname = "greed"
    let count = 10
    let cheolSuUser = User(name: "철수", age: 15, company: "구글")
    let test = Test()
    
    init() {
        run()
    }
    
    func run() {
        let youngHeeUser = User(name: "영희", age: 30, company: "애플")
        let copyCheolSuUser = cheolSuUser
        let copyTest = test
    }
}

func main() {
    let memoryTest = MemoryTest()
}

main()

 

위와 같은 코드가 있을 때,

처음에는 main()이 실행된다.

 

그럼 그 내부에 있는 지역변수인 memoryTest가 할당되고

그로 인해 MemoryTest Class가 호출된다. 

이때 상황은 위와 같다.

Stack 영역에 메서드와 지역변수가 뒤에서부터 할당이 되고,

MemoryTest Class가 호출되면서 Class 크기만큼 즉, 6개의 영역만큼 할당된다.

이후 코드가 한 줄씩 실행되면서 값이 할당이 된다. (처음에는 공간만 잡아준다)

nickname으로 인해 1개 영역,

변수 count로 인해 1개의 영역, 

변수 cheolSuUser로 인해 3개의 영역,

변수 test로 인해 1개의 영역을 할당하게 된다.

 

여기서 왜 cheolSuUser는 3개의 영역에 할당될까?

그 이유는 해당 인스턴스가 Struct User를 담는 변수이고, 

내부적으로 name, age, company 변수를 가지고 있기 때문이다.

 

여기서 Struct값 타입이지만 Heap에 할당된 것을 볼 수 있다.

 

 

이제 이후에는 test에

testClass가 할당되는데

Test Class는 새롭게 영역을 할당해 주게 된다.

Test Class까지 할당이 되면 위와 같은 형태가 된다.

 

여기서 Test.init(), Test.hello()는 왜 Stack인지 궁금해할 수 있는데

이전 글에서 설명하였듯 지역변수, 매개변수, 리턴값 등이 함수가 Call 될 때,

Stack에 할당되게 된다. 

 

그다음엔 Test.hello() 함수 내부에 있던 let hello = "안녕하세요"까지

호출되면서 Test Class 호출이 종료된다.

그럼 Stack에 할당되어 있던 Test.hello()를 비롯해 Test.init()까지 pop이 된다.
(자료구조에서 보았던 LIFO의 pop이 맞다)

 

그리고 Test Class 호출이 종료되면서 MemoryTest의 test 변수에는 

Test Class의 주소가 저장되고, 

test 변수는 Heap에 할당된 Test 영역바라보게(retain) 된다.

 

 

init() {
    run()
}
    
func run() {
    let youngHeeUser = User(name: "영희", age: 30, company: "애플")
    let copyCheolSuUser = cheolSuUser
    let copyTest = test
}

 

이제 클래스에서 남은 부분을 마저 호출해 보자면

init() 함수가 처음으로 호출되고, 그 후에는 run() 함수가 호출되면서

내부 변수도 같이 메모리에 할당된다.

아까 설명했듯, 초기값이 바로 세팅되는 것이 아니라 먼저 영역만 할당이 되고

하나씩 실행되면서 초기값이 세팅이 된다.

 

그럼 여기서 copyCheolSuUser는 메모리에 어떻게 할당이 될까?

1. 영역이 1개의 크기로 할당되고, cheolSuUser를 바라본다

2. cheolSuUser과 동일한 크기 메모리할당된다.

 

 

 

 

 

정답은??

 2번이다!!

 

struct는 값 타입이기 때문에 복사하면 그 영역이 copy가 된다.

이와 다르게 Class는 동일한 Class를 복사하면 해당 영역을 같이 바라보게 된다.

(그래서 copyTest는 Heap에 할당된 Test 영역을 바라보게 된다)

 

 

위와 같이 영역을 먼저 할당하고

run() 함수 안에 코딩된 순서대로

영희가 먼저 초기값 세팅이 되고,

철수가 된 후

copyTest가 Test 영역을 바라보게 된다.

 

이제 모든 함수가 호출되었기 때문에 pop을 해주게 된다.

Stack에서는 LIFO나중에 들어온 게 먼저 pop 되기 때문에

run() 함수부터 pop을 해주고, 

init() 함수 순서대로 pop을 하게 된다.

이때, copyTest가 pop 되면서 바라보던 게 끊어지게(release) 된다.

그리고 MemoryTest Class 호출이 종료되면서

memoryTest 변수에는 할당된 MemoryTest의 주소가 저장된다.

이후 main() 함수 호출이 종료되면서 pop 되고,

memoryTest가 바라보던 게 끊어지게 되면서

MemoryTest Class 역시 해제된다.
(ARC에 의해 retain count가 0이 되면서 해제되는 것) 

 

마찬가지로

MemoryTest가 pop 되면서, test 역시 pop 되고

test가 바라보고 있는 것도 끊어지게 된다.(release)

이때, Test Class를 바라보는 게 없으므로 

ARC에 의해 Test도 자동으로 해제된다.

 

 

그리고 모든 호출이 종료되면서 처음과 같이 메모리에는 아무것도 존재하지 않게 된다.

 

 

 

 

 

 

 

참고