본문 바로가기

Computer Science/기타

프로세스의 메모리 영역 (Heap, Stack) 구현하기 (Java)

저번 포스팅에서는 16비트를 기반으로 한 CPU와 메모리의 프로토 타입을 간단하게 구현해보았습니다. 이번 포스팅에서는 운영체제와 연계하여 프로세스가 실행될 때, 가상 메모리 상에서 어떻게 메모리를 관리하는지 간단하게 구현을 통해 알아보도록 하겠습니다.

 

 

프로세스의 메모리


프로세스가 실행되면 운영체제는 프로세스에게 메모리 공간을 할당해주는데요, 물리적인 메모리가 아닌 가상 메모리에서 프로세스 별로 메모리를 관리하는 방식을 프로세스 메모리 구조 모델이라고 합니다.


 

가상 메모리(Virtual Memory)와 물리 메모리


사실, 가상 메모리는 이번 포스팅의 메인 주제는 아니지만, 프로세스의 메모리를 이해하기 위해 필요하므로 간단하게 알아보겠습니다. 

 

[물리 메모리]

- 물리적인 메모리 그 자체를 의미합니다. RAM이 대표적인 주 기억장치로 사용되는 물리 메모리입니다.

- 프로세스를 실행하면 보조기억장치로부터 프로그램을 물리 메모리 상에 Load시켜 CPU가 빠르게 직접 데이터에 접근할 수 있습니다.

- 물리적인 메모리 주소를 통해서 데이터에 접근합니다.

 

[가상 메모리]

- 주 기억장치(RAM 등)은 빠르지만 가격이 비싸고, 보조 기억장치(HDD 등)은 느리지만 가격이 저렴합니다. 

- 위의 물리 메모리의 단점을 보완하기 위해서 가상의 메모리로 메모리를 확장하여 사용합니다.

- 결국 메모리처럼 사용할 기억 장치가 필요하다는 소리인데, 그것이 바로 보조 기억장치입니다.

 

보조 기억장치와 주 기억 장치를 함께 하나처럼 사용하여 거대한 가상 메모리를 구현합니다. 저장 자원의 추상화를 통해서 주 기억장치의 용량 확장이 가능해진 것이죠. 예를 들면, RAM에서 사용하지 않는 데이터를 일시적으로 보조 기억장치로 옮겨두면, 빠르게 접근 가능한 데이터는 물리 메모리 상에서 사용하면서 빠르게 로딩하고, 당장은 필요없는 데이터는 보조 기억장치에 그대로 보존하고 있을 수 있죠? 나중에 보조기억장치에서 주기억장치로 데이터를 swap함으로써 평균적인 속도를 높고 메모리 용량은 크게 사용할 수 있습니다.

 

하지만 가상 메모리의 구현을 위해서는 운영체제와 가상 메모리 구현을 도와주는 하드웨어, 소프트웨어가 필요합니다. 가상 메모리는 OS의 도움을 받아 페이지의 형태로 저장됩니다. 그리고, CPU에서 데이터를 찾을 때, 논리적 주소를 물리적 주소로 매핑(mapping)하는 과정이 필요합니다. 이를 도와주는 하드웨어가 MMU(Memory Management Unit)입니다. 흔히 이야기하는 driver라는 소프트웨어가 하드웨어와 함께 가상의 메모리 주소를 물리적 메모리 주소로 매칭시킵니다. 우리가 앞으로 이야기할 메모리 주소는 모두 이러한 Logical(Virtual) 메모리 주소를 다룰 것입니다. OS와 MMU, 소프트웨어는 실시간으로 메모리 주소의 변환을 계속해서 하고있습니다. 이를 동적 주소 변환(Dynamic Address Translation, ADT)라고 합니다. 그렇다면 페이지는 무슨 단위이며, 언제 무슨 기준으로 페이지 교체가 발생하는가에 대해서는 이후에 알아보겠습니다.


 

 

Heap과 Stack


프로세스의 메모리 구조는 Text(Code), Data, Heap, Stack으로 분류됩니다. Text는 함수 코드, 제어문 등이고 Data는 전역변수와 상수 등을 포함합니다. 언어에 따라서 메모리 구조 모델의 사용이 약간씩 다릅니다. 예를 들면, JVM에서 힙 구조와 JS의 힙 구조가 차이가 있습니다. (JS에서는 String이 스택에 저장되는 반면, JVM에서는 new를 통해 생성 시 heap 영역에 생성됩니다.) 여기서는 메모리 구현에서 가장 핵심적인 힙과 스택에 대해 알아보겠습니다.

 

[stack]

  • Heap 영역에 생성된 Object 타입의 데이터 참조 값(주소)가 할당됩니다.
  • 보통 원시 타입의 데이터는 값과 함께 스택에 할당됩니다.
  • 함수를 호출할 때(call stack) 지역 변수, 매개변수, 리턴 값, 리턴 주소 등이 쌓입니다.
  • 지역 변수는 scope에 따른 visibility를 가집니다.
  • Thread는 자신만의 stack을 가집니다. 즉, multi-thread인 경우 스택이 쓰레드만큼 생성됩니다.

 

[heap]

  • 주로 긴 생명주기를 가지는 오브젝트들 저장
  • 스레드와 상관없이 단 하나의 heap 영역 존재
  • 동적으로 할당되며, 용량이 큰 오브젝트들이 주로 저장
  • 사용자가 관리해야하는 메모리 공간

 

프로세스 메모리 구조 구현해보기


[stack]

스택은 쌓이는 것이 중요한 메모리 구조입니다. call stack을 했을 때, call된 메서드의 실행이 끝나면 다시 리턴 위치로 돌아가야하기 때문입니다. 내부적으로 4Byte 포인터를 int형인 stackPointer로 선언해서 스택의 현재 위치를 저장하도록 했습니다. 그리고, linked list로 스택 구조를 구현했습니다. 스택 내부에 콜스택이나 heap의 객체를 가리키는 pointer 참조변수가 쌓이게 됩니다. 

public class Stack {
    public int stackPointer = 0;
    public LinkedList<Pointer> callstacks = new LinkedList<>();

    public int maxSize;

    public Stack(int size) {
        maxSize = size;
    }

    public void call(Pointer pointer) {
        callstacks.add(pointer);
        stackPointer += pointer.size;
    }

    public int searchHeap(int address) {
        return callstacks.get(address / 4 - 1).variable.addr;
    }

    public int getSpace() {
        int space = 0;
        for (Pointer pointer : callstacks) {
            space += pointer.size;
        }
        return space;
    }

    public LinkedList<Pointer> getCallstacks() {
        return callstacks;
    }


}

 

[heap]

힙 메모리 구조도 마찬가지로 linked list로 구현했습니다. heap은 add/remove가 잦기 때문에 연결 리스트를 사용했고, Variable이라는 클래스 타입을 저장하게 되는데, variable은 아래처럼 타입 이름, 변수의 사이즈, 힙에서의 주소, Garbage Collection 대상인지를 저장할 수 있는 내부 상태를 가지고 있습니다. 

 

public class Variable {

    public Variable(String typeName, int size, int addr) {
        this.typeName = typeName;
        this.size = size;
        this.addr = addr;
    }

    public String typeName;
    public int size;
    public int addr;

    public boolean mark = false;
}

 

public class Heap {

    public LinkedList<Variable> heapMemory = new LinkedList<>();
    public int maxSize;

    public Heap(int size) {
        maxSize = size;
    }

    public int add(String type, int count) {
        int typeSize = UserTypes.getSize(type);
        if (typeSize == 0) {
            UserTypes.add(type, 4);
        }

        int objSize = 0;
        while (count-- > 0) {
            if (typeSize < 8) {
                objSize += 8;
                continue;
            }
            objSize += typeSize;
        }
        heapMemory.add(new Variable(type, objSize, getAddressAtIndex(heapMemory.size())));
        return objSize;
    }

    public int getAddressAtIndex(int index) { //heap list의 index로 주소를 반환받습니다.
        int addr = 0;
        for (int i=0; i< index;i++) {
            addr += heapMemory.get(i).size;
        }
        return addr;
    }

    public void free(int address) {
        for (Variable variable : heapMemory) {
            if (variable.addr == address) {
                heapMemory.remove(variable);
            }
        }
    }
}

위의 두 자료 구조로, 힙에는 객체를 저장하고, 스택에는 그 객체의 참조 변수를 저장할 수 있게 되었습니다. 또한 함수를 호출하면, stack에 call stack을 쌓을 수 있습니다. 이제 자료 구조를 만들었으니, 프로세스 메모리에 접근하여 할당 / 제거를 해주는 OS의 역할을 대신 해줄 프로토타입 프로그램을 구현해보겠습니다.

 

[메모리 관리자]

기초적인 기능만 구현해보겠습니다. 먼저 메모리를 동적할당 하는 기능(malloc), 그리고 메서드의 호출(call), 그리고 스택에서 참조되지 않은 garbage를 제거하는 GC까지 구현해보겠습니다.

  • malloc은 heap에 객체를 생성함과 동시에 stack에 참조변수를 쌓습니다.
  • free는 heap 주소에 할당된 객체를 해제합니다.
  • call은 stack에 새로운 call stack을 쌓습니다.
  • GC는 stack을 조회하면서 heap과 참조 상태를 확인한 후, 참조된 heap의 객체만 mark합니다. 참조되지 않은 heap의 객체는 제거됩니다.
public class Memory {

    private Heap heap; //8Byte 단위로 저장됨
    public Stack stack;

    public int malloc(String type, int count) {
        int heapAddress = heap.getAddressAtIndex(heap.heapMemory.size());
        int objSize = heap.add(type, count);

        stack.call(new Pointer(new Variable(type, objSize, heapAddress)));
        return stack.stackPointer;
    }

    public int free(int pointer) {
        if (pointer > stack.stackPointer) {
            throw new IllegalArgumentException("unthrowable");
        }
        int heapAddress = stack.searchHeap(pointer);
        heap.free(heapAddress);

        return heapAddress;
    }


    public void call(String name, int paramCount) {
        callBool = true;
        for (int i = 0; i < paramCount; i++) {
            stack.call(new Pointer(name));
        }
    }


    public void garbageCollect() {
        for (Pointer pointer : stack.callstacks) {
            for (Variable variable : heap.heapMemory) {
                if (pointer.call == false && pointer.variable.addr == variable.addr) { //stack의 포인터가 가리키는 주소와 힙의 주소가 같을 때
                    variable.mark = true;
                }
            }
        }
        for (Variable variable : heap.heapMemory) {
            if (variable.mark == false) { //stack의 포인터가 가리키는 주소와 힙의 주소가 같을 때
                heap.heapMemory.remove(variable);
            }
        }
    }

}

이상으로 메모리 구조에 대해 알아보았고, 운영체제와 하드웨어가 관리하는 가상 메모리의 heap과 stack의 메모리 구조를 간단하게 구현해보았습니다.