현대의 운영체제들은 프로세스를 실행할 때마다 프로세스 메모리 모델을 생성해 관리한다.
메모리 관리 모델에 대한 지식을 학습하고 동작 방식을 이해하면
모든 프로그래밍 언어 동작과 처리 흐름를 이해하는 데 도움이 될 것이다.
프로그램이란 pro(= before)
와 gram(= write)
이 합쳐져 탄생한 단어로,
특정 입력에 대해 어떻게 동작할 것인지 미리 작성해놓은 것을 말한다.
참고할 만한 도서 : Code: The Hidden Language of Computer Hardware and Software
대부분의 언어들은 c언어의 컴파일 구조를 따른다.
test.c(c언어)
-> test.i
test.i
-> test.s(어셈블리어)
test.s(어셈블리어)
-> test.o(기계어)
test.o(기계어)
-> test.out(binary file)
프로세서가 이해하고 처리할 수 있는 2진수로 표현된 명령.
sub esp,0E4h
push ebx
push esi
push edi
lea edi,[ebp+FFFFFF1Ch]
mov ecx,39h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
mov ecx,9AC003h
call 009A1316
mov dword ptr [ebp-8],1
mov dword ptr [ebp-14h],2
mov eax,dword ptr [ebp-8]
add eax,dword ptr [ebp-14h]
mov dword ptr [ebp-20h],eax
mov eax,dword ptr [ebp-20h]
컴파일의 결과로 실행파일이 아닌 .class 파일(byte code)이 만들어진다.
이 byte code는 JVM에서 실행된다.
JS engine은 interpreter라고 많이 알려져 있지만
최근 많이 다루는 V8 engine이나 Safari에서 사용하는 JS core engine 같은 엔진들은 컴파일러에 가깝다.
약간의 차이가 있다면 JIT(Just-In-Time) compiler라고 해서 필요할 때 컴파일 시켜서 실행하는 구조로 되어있다는 점이다.
하지만 나머지 기본적인 부분은 동일하기 때문에 자바스크립트 언어를 컴파일하면 자바 컴파일러와 동일하게 byte code가 나온다.
디스크에 있는 실행파일(프로그램)들을 실행하면 메모리에 로딩이 된다.
이 때 프로세서가 프로세스를 생성하면서 해당 프로세스의 메모리 구조가 결정된다.
실행되어 메모리에 적재된 프로그램으로,
프로세스는 각 메모리 공간을 시스템으로부터 할당 받는다.
Stack overflow,
Heap overflow
라고 한다.Heap overflow
: 지속적으로 메모리를 할당하고 사용 후 해제하지 않으면 Heap 영역의 버퍼가 넘쳐서 다른 버퍼를 침범하게 되고 이때 Heap overflow가 발생한다. Stack overflow
: 종료없이 자신을 재귀적으로 호출하는 함수를 호출하면 각 함수 호출이 새로운 Stack Frame을 생성하고, 결국 스택이 예약된 것보다(스택이 사용할 수 있는 공간보다) 더 많은 메모리를 소비하게 되어 Stack overflow가 발생한다. 참고 : 프로세스 메모리 구조
프로세스 메모리 구조 2
다음과 같은 코드가 실행되었을 때, 프로세스 메모리의 stack과 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 MemoryExam {
let nickname = "shark"
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 memoryExam = MemoryExam()
}
main()
맨 처음에 빈 Stack과 Heap이 있습니다.
그리고, 처음으로 main()이 실행됩니다.
func main() {
let memoryExam = MemoryExam()
}
main()
그러면 실행된 main()함수와 그 내부에 있는 지역변수 memoryExam이 stack에 할당됩니다.
그 후, memoryExam에 의해 MemoryExam 클래스가 호출됩니다.
class MemoryExam {
let nickname = "shark"
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
}
}
MemoryExam 클래스가 호출되면 해당 클래스 크기만큼 Heap에 영역이 잡히는데,
nickname 1칸, count 1칸, cheolSuUser 3칸, test 1칸 으로 총 6칸의 영역이 할당됩니다.
cheolSuUser이 3칸인 이유는 User라는 struct는 내부적으로 name, age, company라는 3개의 변수를 가지고 있기 때문입니다.
여기서 struct 자체는 value type임에도 heap에 할당된 것을 볼 수 있는데,
이 경우 때문에 heap에는 reference type만 할당된다는 말은 잘못됐다는 것을 알 수 있습니다.
MemoryExam 클래스 영역이 할당될 때는 공간만 잡아주고,
MemoryExam 클래스의 코드 한줄 한줄이 실행되면서 값이 할당
이 때, test에는 Test 클래스가 할당되어 새롭게 영역을 할당.
class Test {
let testValue = 10
let text = "반갑습니다."
init() {
hello()
}
func hello(){
let hello = "안녕하세요."
}
}
Test 클래스는 내부적으로 변수 2개가 있으므로 heap 영역에 2칸이 할당 되고,
MemoryExam의 test변수에 의해 Test()가 생성 됩니다.
-> Test클래스의 init() 이 호출 -> hello() 호출 -> hello 변수 호출까지 이어져 메모리는 이런 형태를 띄게 됩니다.
여기서 Test.init()과 Test.hello()는 call 되므로 heap이 아닌 stack에 할당되게 됩니다.
stack에는 함수의 지역변수, 매개변수, 리턴값 등이 함수가 call되면서 할당됩니다.
이 때 하나의 함수에 필요한 메모리 덩어리를 묶어 Stack Frame이라 부르고 이 전체를 통틀어 Call Stack이라 합니다.
이 후, Test.hello() 내부의 hello 변수 까지 호출되면서 Test 클래스의 호출이 종료되게 되고 stack에 할당되어 있던 Test.hello() 영역이 pop되고 Test.init() 영역까지 pop되게 됩니다.
Test 클래스의 호출이 종료되면서 MemoryExam의 test 변수에는 heap에 할당된 Test 영역의 주소가 저장되어 Test 영역을 바라보게(retain) 됩니다.
이제 MemoryExam의 함수를 하나하나 호출해보면
init() {
run()
}
func run() {
let youngHeeUser = User(name: "영희", age: 30, company: "애플")
let copyCheolSuUser = cheolSuUser
let copyTest = test
}
init() 함수가 먼저 호출되고, 그 후에 run() 함수가 호출되면서 내부 변수도 같이 할당됩니다.
물론 초기값이 세팅되는 것이 아닌 영역이 먼저 할당 되고 하나씩 실행되면서 초기값이 세팅 되는데, 여기서 cheolSuUser는 struct 이므로 copyCheolSuUser는 cheolSuUser를 바라보는게 아닌 값이 복사되어 새로 할당되게 됩니다.
여기서 test는 class이므로 값 타입이 아닌 참조 타입이라 copyTest는 heap의 Test영역을 retain하게 됩니다.
이제 모든 함수가 호출되었기 때문에 run() 관련 영역이 먼저 pop 되는데 이 때, copyTest가 pop 되면서 바라보던게 끊어지게(release) 됩니다.
이어 init()이 호출 종료되어 pop 되면서 MemoryExam 클래스의 호출이 종료되어 memoryExam 변수에는 heap의 MemoryExam 영역의 주소가 저장됩니다.
이 후에는 main() 함수 호출이 종료되면서 pop 되고, memoryExam이 release 되어 heap의 MemoryExam 클래스를 바라보는게 없어지게 되므로(retatin count = 0) ARC에 의해 MemoryExam 클래스도 자동으로 해제되게 됩니다.
MemoryExam이 pop되면서 test도 pop되어 release 되게 되므로 Test 클래스도 ARC에 의해 해제 됩니다.
결과적으로 모든 호출이 종료되어 메모리에는 아무것도 남지 않게 됩니다.
heap 메모리에 메모리 할당과 해제를 자주 반복하다보면 fragmentation(단편화)이 발생할 수 있다.
이렇게 되면 메모리 공간이 충분함에도 malloc()이 실패할 수 있는데 이를 해결할 수 있는 방법을 알아본다.
먼저 메모리 단편화는 내부 단편화와 외부 단편화로 구분할 수 있는데
메모리를 할당할 때 프로세스가 필요한 영역보다 더 큰 메모리 영역이 할당되어 내부적으로 메모리 영역이 낭비 되는 상황
ex)
메모리가 할당되고 해제되는게 반복되면 중간에 해제된 작은 메모리 영역이 생기게 된다.
이 때 중간의 메모리 영역이 많이 쌓이게 되면 총 메모리 공간은 충분하지만 실제로 할당은 할 수 없는 상황이 발생하게 된다.
메모리 공간을 재배치하여 단편화로 인해 분산되어 있는 메모리 공간들을 하나로 합치는 기법이다.
단편화로 인해 분산된 메모리 공간들을 인접해 있는 것끼리 통합시켜 큰 메모리 공간으로 합치는 기법이다.
압축은 재배치가 일어나지만 통합은 인접한 공간들끼리 합친다는 차이가 있다.
Page
가상 메모리를 같은 크기의 블록으로 나눈 것
Frame
주 기억장치를 페이지와 같은 크기로 나눈 것
페이징 기법이란 사용하지 않는 프레임을 페이지로 옮기고(swap-out),
필요한 메모미를 페이지 단위로 프레임에 옮기는(swap-int) 기법이다.
외부 단편화는 해결할 수 있지만 내부 단편화는 해결하지 못한다.
페이징 기법과 유사하지만 일정한 크기가 아닌 서로 다른 크기로 나눈 Segmanet를 활용한다.
내부 단편화는 해결할 수 있지만 외부 단편화는 해결하지 못한다.
동적할당과 비슷한 개념이지만 필요한 메모리 공간을 필요한 크기, 갯수만큼 사용자가 직접 지정하여 미리 할당 받아 놓는다는 점에서 차이가 있다.
미리 할당받은 메모리를 memory pool에 모아놓고 필요할 때마다 사용하고 반납하는 방식이다.
메모리 풀 없이 메모리 동적 할당 및 해제를 반복하면 랜덤한 위치에 할당과 해제가 반복되어 단편화를 일으킬 수 있지만 미리 공간을 할당해 놓는다면 할당받았던 메모리 공간을 사용하고 반납하기 때문에 메모리 할당과 해제로 인한 외부 단편화가 발생하지 않는다.
또한 필요한 크기만큼 할당해놓기 때문에 내부 단편화도 생기지 않는다.
하지만 미리 할당해 놓고 사용하지 않는 메모리 누수가 발생할 수 있어 잦은 동적할당과 해제가 일어나는 상황에서 사용하는 것이 효과적이다.
참조 : 메모리 단편화에 대한 해결 방법
iOS에서는 힙 영역에 올라가있는 참조 타입의 메모리를 관리하기 위해 retain count라는 방식을 사용한다.
retain이란 객체에 대한 소유권을 나타내며 malloc이나 new, copy와 같은 명령어를 통해
객체의 소유권을 받을 수 있다.
해당 객체에 대한 메모리는 최초 생성시 힙에 적재되었다가 retain count가 0이 되면 해제 된다.
이러한 방식은 가비지 컬렉팅 없이도 힙 메모리를 관리할 수 있다는 장점이 있다.
ARC란 컴파일 시 코드를 분석해서 자동으로 retain, release 코드를 생성해주고, 참조된 횟수를 추적해 더 이상 참조되지 않는 인스턴스를 메모리에서 해제해주는 Clang 컴파일러의 메모리 관리 기능입니다.
var test = Test() // strong var test = Test(). retain count 1 증가
test = nil // retain count가 1 감소되어 0이 되면서 메모리 해제.
weak var test = Test() // 객체가 생성되지만 weak이기 때문에 바로 해제되어 nil이 된다.
이 때 weak var로 선언하려는 프로퍼티의 자료형은 반드시 Optional 자료형이어야 합니다.
또한 이렇게 Optional 자료형이라는 점에서 let이 아닌 var로만 가능합니다.
unowned let test = Test() // 객체 생성과 동시에 해제되고 댕글링 포인터만 가지고 있다.
이러한 위험성에도 unowned가 존재하는 이유는
weak는 객체를 계속 추적하기 때문에 unowned보다 오버헤드가 커
해제되지 않을게 확실한 객체에는 unowned를 사용하는 것입니다.
ARC가 편하게 메모리를 관리해주지만 잘못하면 순환참조가 발생할 수 있습니다.
여기서 순환참조란 서로가 서로를 소유하고 있어 절대 메모리가 해제 되지 않는 것을 의미합니다.
위 그림과 같이 서로가 서로를 참조하여 reference count가 1에서 줄어들지 않아 ARC에 의해 영원히 해제되지 않는 상황이 발생할 수 있습니다.
이러한 문제는 weak과 unowned를 통해 해결할 수 있는데
먼저 양쪽 모두 nil이 될 수 있는 경우 weak을 사용합니다.
반대로 어느 한쪽이 절대로 nil이 될 수 없는 경우 unowned를 사용합니다.
이처럼 둘 중 하나에 weak이나 unowned를 붙이면 한쪽의 소유관계가 깨어져 순환참조를 방지할 수 있습니다.
참고 : iOS::ARC, strong, weak, unowned
Swift3:메모리 관리
iOS에서 메모리는 크게 클린 메모리, 더티 메모리, 압축된 메모리로 분류된다.
특히 압축된 메모리는 캐시와 깊은 연관이 있다.
캐시 공간을 좀 더 효율적으로 사용하기 위해 사용하지 않은 페이지를 압축하는 것이다.
참조 : WWDC 2018::iOS Memory Deep Dive
WWDC 2018::iOS Memory Deep Dive 정리