본문 바로가기

iOS/Deep dive!!!

[iOS] ARC의 개념, Class vs Struct 및 Memory Leak발생 이유 탐구하기 | ARC 진짜 뿌수기 #2

 
안녕하세요.
 
이번 포스트에서는 ARC의 기본 개념 및 class와 struct의 차이, GC's Mark and Sweep은 왜 메모리 릭을 예방하는지 등의 ARC와의 차이점, memory leack의 발생을 Debug Memory Graph를 통해 탐구하고 해결하는 과정까지 소개하려고 합니다: ]

Class vs Struct

ARC를 탐구하기 위해서 Value type, Reference type의 개념을 알아야 합니다. 대표적으로 Struct, Class가 있습니다.

Value Type의 Struct 특징!!

 
Potato 구조체가 있습니다.

그리고 thread에 의해 execute()가 호출되어 해당 함수의 scope에 들어왔을 때의 상황입니다. Memory(memory layout link)의 stack 영역에 두 구조체의 인스턴스 크기가 지정됩니다.
 

cf. 함수를 호출하면 해당 scope로 jump합니다. 그리고 함수 내에서 사용될 스택 프레임(jump 전에 원래 실행중인 위치 저장, 지역변수 등등..)이 설정됩니다. 간단하게, 함수가 호출될 때 stack 영역에 로컬 변수 및 함수에 필요한 데이터의 크기가 할당됩니다.
이후 해당 scope가 끝나 함수가 종료될때 Stack을 관리하는 스택 포인터가 pop되어 이와 할당된 메모리들도 stack 영역에서 해제됩니다.

 

 
흐름에 따라 thread가 코드를 실행할 때 17라인을 실행한 후에 이미 할당된 지역에 값을 넣습니다.
 
 


potato2를 초기화하는 시점에도 마찬가지입니다.
 
여기서 특징은 스택 영역에 서로 다른 두 개의 프로퍼티의 데이터가 할당 되었다는 것입니다. 즉 서로 다른 메모리 주소를 참조하고 있음을 알 수 있습니다. 따라서 20라인이 실행된 이후에도 여전히 서로 다른 2개의 메모리 주소는 존재하고 potato1의 rating을 변경해도 potato2의 rating은 영향을 받지 않습니다.
 
 

 
그리고 execute()의 scope가 끝난 후 함수가 종료됬을 때 해당 스택 프레임이 제거되고, stack memory에 할당된 데이터들도 제거됩니다. 
 
여기서 알 수 있는 value type의 일반적인 특징은 각 인스턴스는 stack 영역에 고유한 메모리 주소 위치를 갖고, 크기를 갖습니다. 그러기에 각각의 인스턴스를 할당할 때 복사를 통해 값이 전달되어 인스턴스들은 독립적으로 관리하게 됩니다. 그리고 특정 scope에 따라 할당되고, 해제됩니다.
 


 

 

Reference Type의 Class 특징!!

 
이번엔 타입을 Class로 변경했습니다.
 

 
이번엔 execute()호출로 해당 함수의 scope로 진입했을 때의 경우입니다. Stack에는 클래스의 프로퍼티들 크기는 할당되지 않지만, heap 영역을 참조하기 위한 공간이 할당됩니다.
 

 
런타임 시점에 a thread가 19라인을 호출했을 때 memory의 heap Potato 크기만큼의 특정 block을 찾은 후 해당 block에 rating값을 초기화 합니다. 그리고 memory heap 영역의 객체의 메모리 주소를 Stack의 potato1에 reference로 초기화 합니다.
 

 
이는 potato2를 초기화해도 마찬가지입니다.
 
여기서 앞에 설명했던 struct와의 차이점은 무엇일까요?

  • 힙 영역에 데이터를 저장한다!
  • 스택 영역에선 힙 영역의 특정 객체의 메모리 주소를 참조한다.

이 두 특징을 제외하고는 별 차이가 없어 보입니다.
 

 
class(reference)와 struct(value) 차이는 특정한 인스턴스를 다른 인스턴스에 치환할때! 두드러지게 차이가 발생하게 됩니다. 22라인의 코드를 실행한 후에 기존에 heap 영역에 할당되었던 potato2가 참조하던 인스턴스는 메모리에서 할당 해제가 됩니다. 그리고 potato1, potato2 두 인스턴스는 힙 영역의 한 인스턴스 메모리 주소를 같이 참조하게 됩니다. 그래서 potato1을 통해 rating을 변경해도 potato2도 변경이 되는 것입니다.
 
이 시점을 다시 살펴 봐야 합니다.


Swift's ARC(Automatic Reference Counting)

대부분의 컴퓨터 언어는 메모리 관리를 하는 메커니즘이 있습니다. alloc, dealloc을 직접 제어 해줘야 하기도 하고, Java는 GC(Garbage Collection)로 메모리를 관리합니다.
 
Swift는 ARC를 통해 메모리를 관리합니다. Class instances의 Reference를 Count하며 관리하고 더 이상 필요하지 않을 때 free(할당 해제)합니다. 사실 매번 객체를 생성해서 인스턴스에 할당할 때마다 힙 영역에서 해당 인스턴스에 stored properties를 컴파일러가 할당하고 ARC로 해당 reference의 수명주기를 관리합니다.
이 인스턴스를 Strong refer 할 때는 reference count가 증가하지만, weak, unowned refer할 때는 reference count가 증가되지 않습니다.
 

class Potato {
  var rating: Int
  var refCount: Int 
  
  init(rating: Int) { self.rating = rating }
  
  deinit { print(self,"will be deinitd") }
}

 
 
다시 돌아와서 Swift의 ARC가 어떻게 동작되는지를 살펴보겠습니다. 이번엔 refCount를 추가했습니다. 그리고 deinit을 구현했는데, refCount가 0이 된 인스턴스가 메모리 해제되기 전 호출되는 지의 여부를 빠르게 파악할 수 있게 하기 위해서 입니다.
 
cf.
deinit은 class에서 작성할 수 있습니다. 해당 객체의 reference count가 0인 경우, 아무도 그 힙 영역의 데이터를 retain하지 않는 경우 힙 영역의 메모리를 반환하기 전에 호출됩니다.
 

 
다시 이어서.. a thread가 execute()'s scope에 진입해 potato1 인스턴스를 할당합니다. 이때 Stack의 photo1은 Heap에 할당된 메모리를 강력하게 참조합니다. 강력한 참조일때 만 reference count가 증가합니다. 
 
cf.

weak var potato1: AnyObjct? = Potato(rating: 10)

 

Question) 만약 potato1을 weak하게 참조한다면 어떻게 될까요?!!!

Potato의 함수를 호출하긴 했어서, initializer는 호출되고 Photo 인스턴스는 생성될 것입니다. 그러나 이 인스턴스를 할당하는 과정에서 photo1은 weak하게 refer함으로 힙 영역의 인스턴스 Reference Count는 증가되지 않습니다. 더 이상 참조가 없음으로 바로 메모리에서 (deallocate)해제되고, 23라인이 실행될 때 potato1은 nil이 될 것입니다.

 

 
다시 돌아와서 23라인을 실행한다면 potato2 또한 자신만의 refCount 1로 갖게 될 것입니다. 그리고 위 사진과 같이 25라인을 호출했을때의 결과로 potato1도 potato2의 메모리를 참조하고, refCount는 2가 됩니다.
 
여기서 retain(), release() 두개의 코드를 추가해봤습니다🤩
 
LLVM 컴파일러는 자동으로 retain, release와 같은 reference count 증가, 감소로 memory management 하는 코드들을 삽입합니다.  컴파일 시점에 삽입해서 컴파일합니다. 물론 memory management한 코드들의 실행은 런타임 때 이루어지지만요. 그리고 Reference 할당은 dynamic하게 이루어지기 때문에 런타임 때 Reference Count가 0이 되는 코드들은 위에 언급한 weak경우처럼 바로 deallocate 됩니다.
 

cf. 컴파일이란

열심히 작성한 소스코드가 기계어로 변환되는 과정입니다. (컴퓨터가 이해할 수 있는 형태로 변환하는 과정입니다.)
(흔히 빌드 build 할 때 xcode's build system은 소스코드들은 컴파일되고, third party와 같은 라이브러리들이 링크되며, 리소스  copy, build configuration등이 적용됩니다.)

 

https://developer.apple.com/library/archive/releasenotes/ObjectiveC/RN-TransitioningToARC/Introduction/Introduction.html

Swift's ARC(Automatic Reference Counting) vs Java's GC(Garbage Collection)

대표적으로 여러 언어중 자바의 GC과 Swift's ARC를 비교해보려고 합니다. (예전에 공부했었는데.. 까먹다니 ㅠㅠ😅)
 
위에 언급했듯, Swift의 ARC는 컴파일 시점에 retain/release관련 코드들을 삽입합니다. + 컴파일 시점에 미리 rc가 예상 가능하다는 것입니다. 런타임 시점때 그저 class's instances의 Integer가 0이 되는지 체크를 하면 되기에 GC보다 상대적으로 메모리를 더 확보한다는 장점이 있습니다.

Swift's ARC 장 단점

[장점]

  • Swift의 ARC는 컴파일 시점에 retain/release관련 코드들을 삽입 -> 컴파일 시점에 미리 rc가 예상 가능하다는 것입니다.
  • 런타임 시점때는 그저 class's instances의 rc 관련 Integer가 0이 되는지 체크를 하면 됨. GC보다 상대적으로 메모리를 더 확보한다. (크으)

[단점]

  • 컴파일 시점에 언제 참조되고 해제되는지 결정되는 것이어서 순환 참조 발생이 있습니다. 코드 작성할 때 정신 똑바로 차려야 합니다. 금방 익숙해집니다: ] (weak 사용!)


그럼 자바의 GC는 어떻게 동작될까요?

가비지 컬랙션이란?

힙 영역에서 동적으로 할당했던 메모리 중 필요 없게된 메모리 객체(garbage)를 모아 주기적으로 제거하는 프로세스입니다. 여기서 Garbage란 어느 변수도 더 이상 참조하지 않게된 메모리 영역을 의미합니다. reference를 count하는 Reference Counting 기법과 다르게 컴파일이 아닌 런타임때 메모리들을 관리합니다. 
 
[장점]

  • Memory leack 발생을 예방해준다

[단점]

  • 런타임 때 포인터를 주기적으로 가비지 컬랙터가 포인터 추적하는 알고리즘(ex. Mark and Sweep)하면서 관리하기에 Reference Counting에 비교하면 오버헤드가 더 든다.
  • 이 Mark and Sweep이 언제 일어나는지 알기 어려우니까 메모리 해제 발생 시점을 개발자가 제어하기 힘들다고 합니다. ( 점유 시간 예측 )(하드)
  • 실행시간이 늘어나고 object가 늘어나면 추적해야 할 대상들이 많아집니다

 
가비지 컬랙션이 동작하는 방식 중 Mark ans Sweep을 소개하려고 합니다.

https://stackoverflow.com/questions/20993746/how-does-java-solve-retain-cycles-in-garbage-collection#:~:text=Add%20a%20comment-,5%20Answers,-Sorted%20by%3A

 
 
Java의 가비지 컬랙터는 런타임 때 주기마다 GC's root로부터 힙 영역에 할당된 reachable한 obejct를 찾습니다. 그리고 도달한 obejct는 mark표시를 합니다. 도달하지 못한 references는 Sweep! 힙 영역에서 제거됩니다(쓸어버립니다). 그렇다는 건 내부에 특정 object들이 순환 참조를 형성하더라도 "reachable"할 수 없게 되는 것 이라면 할당 해제합니다.
 
Reference Counting의 개념과 다릅니다. Reference Counting은 참조하는 strong reference가 있다면(화살표로 strong하게 가리키는 객체) 그 객체의 Reference Count를 증가시킵니다. 그리고 런타임때 상황에 따라서 Reference Count가 0이되는 객체들을 힙 영역에서 해제하는 것입니다.

순환 참조는 무엇이고 Swift에서 Memory leak은 왜 발생되는지 탐구!!

지난 1편에서는 제가 프로젝트 개발하면서 실제로 마주했었던 메모리 릭 발생원인과 해결 과정을 작성했는데요.(이전 포스트 링크) 지금은 더 간단한 예시로 순환참조의 예시를 탐구하려고 합니다. 순환 참조는 서로 다른 reference 타입의 인스가 서로를 참조함으로 발생됩니다.

[예시 1]

class JJangu {
  var friend: AnyObject?
  
  deinit { print(self,"will be deinitd") }
}

class Hooni {
  var friend: AnyObject?
  
  deinit { print(self,"will be deinitd") }
}

 
 
짱구 class와 훈이 class가 있습니다. 이들은 내부 프로퍼티로 Class의 최상위 타입인 AnyObject을 상속받는 모든 reference 인스턴스들을 Strong하게 참조합니다.
 

var jjangu: JJangu? = JJangu() // 전역 변수
var hooni: Hooni? = Hooni() // 전역 변수
  
jjangu?.friend = hooni
  
print("전역 변수 scope 끝!")

 
 
그리고 이 코드를 실행하게 된다면 어떻게 될까요?
 

 
이렇게 25번 라인을 실행했다면 reference를 아래와 같이 알 수 있습니다.
 

Debug Memory Graph

 
여기서 알 수 있는 것은 memroy의 __DATA segment가 짱구 인스턴스를 참조하고, 후니 인스턴스도 참조한다는 것입니다. 그리고 JJangu의 프로퍼티인 friend가 Hooni 인스턴스를 참조한다는 것입니다.
 
즉 Hooni의 reference count는 2가 됩니다.
 

 
 
그리고 짱구 인스턴스에 nil을 주는 코드 27번 라인이 호출됬을 때, jjangu의 Deinit은 호출됩니다.


그래서 힙 영역에서 제거되고, 이와 같이 후니 인스턴스만 남게 됩니다. 이때 후니를 가르키는 화살표가 하나임으로 hooni = nil을 한다면 후니도 메모리에서 할당 해제가 됩니다.
 
여기서 알 수 있는 점은 특정한 reference 인스턴스가 Stong한 참조를 하나 이상 받더라도, 참조 받는 객체가 참조하는 객체를 소유하지 않는 이상 순환 참조가 발생되지는 않습니다.
 


[예시2]

 

var jjangu: JJangu? = JJangu()
var hooni: Hooni? = Hooni()

jjangu?.friend = hooni
hooni?.friend = jjangu

 
그렇다면 이번엔 서로가 서로를 참조하게 된다면 어떻게 될까요?
 

 
왼쪽 주황색 구간을 통해 힙 영역에 두 인스턴스가 할당 되었다는 것을 알 수 있습니다.
파란색 영역을 보면 VM: XXX __DATA는 짱구 인스턴스를 강력 참조합니다. 그리고 위에 보이는 hooni라는 인스턴스의 친구가 짱구 인스턴스를 강력하게 참조하고 있습니다.
 
즉 짱구 인스턴스의 reference count는 2가 됩니다.
 

 
Hooni 인스턴스는 VM: __DATA에서 강력하게 참조중입니다. 그리고 JJangu의 friend 프로퍼티도 마찬가지로 후니를 강력 참조하고 있습니다. 그리고 후니, 짱구의 펼쳐보기 화살표를 누르면?


친구니까!! 짱구의 친구는 후니를, 후니의 친구는 짱구를 가리키고 있습니다.
 

 
이렇게 표현할 수 있습니다.
 

jjangu = nil

 
그리고 짱구 인스턴스에 nil을 하게 된다면!!! 이 코드를 호출한 직후의 시점은
 
 
이렇게!!
 

확대해서 봐주세요!!

 
짱구의 인스턴스는 nil이 됩니다. 아직 후니는 전역변수로 남아있는데, 그 후니의 친구가 짱구를 강력하게 참조해서  hooni.firend를 통해서 여전히  짱구의 인스턴스에 접근할 수 있습니다. 그러나 stored property인 jjangu의 인스턴스는 nil이 됩니다. 그러나 hooni.friend를 통해 힙 영역에 할당된 jjangu 인스턴스는 여전히 힙 영역에 메모리를 점유하고 있습니다. 
 

...

jjangu = nil
hooni = nil

 
이제 연이어 hooni 인스턴스 = nil을 하는 코드를 main thread에서 실행한 직후의 메모리 graph 시점을 보겠습니다.

 
여전히 heap영역에서 두 인스턴스의 메모리는 할당되어있지만 hooni, jjangu의 인스턴스는 nil 입니다.
 

 
당연히 이 시점에 hooni 인스턴스나 jjangu 인스턴스를 사용한다한들 nil 이 되어서 짱구가 참조하는 주소값 출력을 할 수 없습니다.(훈이도 마찬가지..)
 
그렇게 위 scope를 벗어나도 영원히 JJangu, Hooni 인스턴스에서 deinit이 호출되지 않습니다. 서로가 강력 참조중이므로 여전히 힙 영역에 메모리를 점유하고 있기 때문입니다.
 
이렇게 지금 시점 기준으로 제가 할당한 인스턴스는 두 개인데.. 할당한 인스턴스를 손수!! 직접!!!! nil처리함으로 프로퍼티들을 해제시켰음에도 메모리에서 여전히 점유중인 상태를 memory leak이라고 합니다.
 

 
아까 jjangu = nil을 했을때 왜 friend 프로퍼티가 hooni를 가리키는걸 release 하지 않을까요?
 
왜!!!!!
 
이유는 간단합니다. Reference count가 1로 여전히 유효하기 때문입니다. 비록 jjangu인스턴스는 nil이 됬지만 deinit은 호출되지 않습니다. deinit이 호출되는 시점은 reference count가 0인 시점입니다. 여전히 hooni.friend가 짱구를 참조하고 있기 때문입니다 XD

Reference count가 0이라는 것은 힙 영역에 할당되어 있던 인스턴스의 프로퍼티들이 힙 영역에서 점유중인 데이터를 할당 해제하거나 인스턴스의 내부 멤버 객체들이 참조중인 다른 클래스 instance의 rc를 감소합니다.


만약 이때 내부 프로퍼티가 다른 참조 객체를 강력 참조중이라면 이 내부 프로퍼티는 사라짐으로 참조중인 다른 객체의 rc가 감소하게 됩니다. 위에 [예시1]이 바로 그 상황입니다.
 

var jjangu: JJangu? = JJangu() // 전역 변수
var hooni: Hooni? = Hooni() // 전역 변수
  
jjangu?.friend = hooni // 중요 중요

jjangu = nil // 중요
hooni = nil

 
jjangu = nil이 호출되기 전, 후의 시점은 아래와 같습니다.
 

 
Nil 호출 전 상황을 보면 힙 영역에서 짱구를 Strong 참조하는 인스턴스는 존재하지 않습니다. 그렇다는 것은 짱구의 rc = 1이라는 것을 알 수 있고 jjangu = nil을 했을 때 짱구의 rc가 감소되어 0이 됩니다. 그래서 deinit을 호출하는데, deinit을 호출한다는 위에서 언급했듯,, 내부 프로퍼티들의 할당을 취소하거나 내부 프로퍼티가 참조중인 다른 객체의 rc를 감소시키는 것입니다.


 
다시 예제2로 돌아와서 jjangu, hooni = nil이 되어도 여전히 힙 영역에 존재하는 것입니다. 그래서 두개의 deinit이 불리지 않습니다. 이걸 어떻게 확인할 수 있을까요? 몇 방법이 있지만 정말 간단하게 weak 하게 참조하는 것입니다. 
 

 
이렇게 짱구를 약하게 참조를 한 후에 jjangu, hooni의 인스턴스를 nil을 통해 해제했을때 직후의 시점을 memory graph를 보겠습니다.
 

 
jjangu, hooni는 nil이 됬지만, weakJJangu는 여전히 메모리에 할당된 주소를 참조하고 있습니다. 

 
서로는 서로를 참조하고 있는게 분명히 보입니다. 접근도 할 수 있습니다.
 

 
어차피 weakJJangu는 힙 영역의 짱구 인스턴스를 약하게 참조함으로 weakJJangu = nil을 한다고 달라지는건 없습니다. 그러나 이렇게 짱구의 친구를 nil로한다면? 그친구의 친구도 nil이 되고 결국 deinit이 호출되게 됩니다.

 


서로가 Strong하게 참조하지 않도록 하는것이 해결방책입니다.
JJangu의 friend나 Hooni의 friend 멤버 프로퍼티를 weak로 선언하거나 둘다하면 해결됩니다. weak는 reference count가 증가되지 않기 때문입니다.
 

 
짱구, 훈이 friend 둘다 Weak 선언할 경우 이렇게 훈이는 친구를 weak하게 바라보고 훈이가 바라보는 그 대상은 짱구가 약하게 참조중입니다.


프로그래밍 할 때는 지금 겪어봤던게 주로 델리게이트나 클로저(관련문제 해결 상황 링크)! 체이닝으로 클로저를 활용하는 컴바인, 옵저버를 사용하는 노티피케이션! 등의 경우가 있었습니다😅
 
cf. 보통 노티피케이션 사용할 때 self- target 패턴을 사용하는데 이때 자시자신을 참조로 넘겨 observe 하기에 더이상 사용하지 않거나 사용하지 않게될 경우 해당 observe를 release 해야 합니다.
 
 
짧은 요약 네 줄: [클래스] 힙 영역에 저장됨. 이는 memory에서 점유가능한 block을 찾고, 런타임시 동적으로 할당되는 인스턴스의 reference를 관리하기위해 rc를 수시로 보면서 rc가 0일때 deinit호출하며 해당 힙 영역 할당해제 -> 점유중이었던 block반납. 동시성을 고려한다면 concurrent problem을 보장하는 알고리즘도 적용되야 함. [구조체] stack에만 할당하면됨. 그러나 struct 멤버 프로퍼티가 Strong 참조타입인 경우 그 멤버 프로퍼티는 역시 당연하게 힙 영역의 인스턴스를 참조하게 됨.
 
 
이번 포스트는 클래스를 중점으로 ARC가 동작되는 과정을 살펴봤습니다. 다음 편엔 클로저!!에서 발생되는 ARC를 탐구해보려고 합니다.
 
긴 글 읽어주셔서 감사합니다.
 
 

References:

소들이님 메모리 관리(1/3)탄 - ARC ( 링크 바로가기 )
공식 문서 ARC ( 링크 바로가기 )
제가 작성한 뷰컨트롤러 강한참조.....( 링크 바로가기 )
StackOverflow- When ARC working? Compilation or runtime ( 링크 바로가기 )
공식문서 - ARC release notes - object-c에서 더이상 오토릴리즈풀, release를 쓰지마세요! ( 링크 바로가기
wwdc Understanding Swift Performance ( 링크 바로가기 )

[OS]

yks-study님 스택 프레임 ( 링크 바로가기 )
wiki - Computer program memory ( 링크 바로가기 )
net-infromations. Stack vs Heap Memory Allcation ( 링크 바로가기 )

[Xcode]

canapio님 Xcode 메모리 그래프 디버거... ( 링크 바로가기 )