7 분 소요

자바 메모리 모델

자바에서는 자바 파일 실행 시 필요한 메모리에 대해, 메모리 공간을 최대한 효율적으로 활용하고자 메모리 공간을 크게 3개의 공간(메서드 영역, 스택 영역, 힙 영역)으로 나누고, 그 중 일부를 또 다른 세부 영역으로 나누기도 한다.

그림 1-1. 자바에서의 메모리 공간을 시각화한 모습. HTML, CSS로 직접 그림.

그림 1-1. 자바에서의 메모리 공간을 시각화한 모습. HTML, CSS로 직접 그림.

메서드 영역 (Method area)

메서드 영역 내부에는 프로그램 실행을 위한 코드, static 변수와 메서드를 위한 영역, 상수 풀 영역이 나뉘어 생성된다. 메서드 영역 내부에 저장된 데이터들은 프로그램 시작 전에 미리 로드되고, 프로그램 종료 후에는 모두 삭제된다.

static 영역

자바 파일 실행 시 먼저 JVM이 실행되며, 그 후 코드가 메서드 영역에 로딩된다. 해당 코드 영역 내에서 static 변수 및 메서드를 찾아 static 영역으로 옮긴다. 이 때 main 메서드에는 static 키워드가 붙어있기에 이 메서드도 static 영역으로 이동된다. 여기까지는 코드가 본격적으로 실행되기 전의 과정이다. 이후 JVM은 static 영역 내에서도 main 메서드를 찾아 맨 먼저 스택 영역에 올림으로써 제일 먼저 main 메서드를 실행시킨다. 만약 static 영역에 main 메서드를 발견하지 못하면 프로그램이 실행되지 않는다. 이 때, main 메서드는 static 뿐만 아니라 public으로도 지정되어야만 실행된다.

static 영역 내 변수 및 메서드는 외부의 모든 객체에서 접근하여 사용할 수 있다. 따라서 static 변수를 전역 변수라고도 한다.

스택 영역 (Stack area)

메서드 호출 시 스택 영역 안에서는 메서드 단위로 frame이 형성되며, 그 안에는 코드 상에서 해당 메서드 내부에서 사용되는 매개변수와, 해당 메서드 내부에서 선언, 할당 및 사용되는 지역 변수들의 데이터가 기록된다. 이렇게 프레임 별로 스택 영역에 변수들을 저장하여 코드 상에서 필요할 때마다 읽어들여 사용하는 원리이다. 이러한 메서드 프레임들은 코드 상에서 먼저 호출된 메서드가 먼저 스택에 들어오고, 나중에 호출되어 스택 영역에 쌓인 프레임이 가장 먼저 삭제되는 LIFO 구조를 띈다. 어떤 메서드가 호출되고 그 메서드 내부의 코드에 따른 명령 처리를 모두 다 하여 반환값을 반환하고 나면 그 메서드는 종료되며, 스택 영역 상에서 삭제된다.

자바에서는 main 메서드가 먼저 실행되므로, main 메서드의 프레임이 항상 스택 영역에 먼저 생성된다.

프레임들은 서로 독립적이라서 한 프레임이 다른 프레임의 변수를 참조할 수 없다. 즉, 한 메서드의 지역 변수를 다른 메서드가 직접 참조하여 사용할 수 없다는 뜻이다.

힙 영역 (Heap area)

클래스로부터 생성된 객체를 힙 영역에 동적으로 저장한다. 숫자, 문자 등의 기본 자료형들은 차지하는 메모리 용량이 항상 정해져 있고, 이로 인해 스택 영역에 들어갈 수 있지만, 객체의 경우, 클래스를 어떻게 정의하느냐에 따라 그로부터 생성되는 객체의 크기가 제각기 달라지므로, 객체는 힙 영역이라는 곳에 따로 동적으로 할당되는 것이다.

힙 영역에 객체가 저장될 때에는 각 객체에 다른 객체들과 구분할 수 있는 고유한 정수 id값이 부여된다. (id와 메모리 주소는 서로 다른 개념이다)

new 키워드를 통해 클래스로부터 객체를 생성하여 변수에 할당하고, 나중에 이 변수를 사용할 때의 메커니즘은 기본 자료형의 값이 변수에 할당될 때와 그 메커니즘이 다르다. 기본 자료형의 값이 변수에 할당될 때, 해당 변수가 해당 값이 저장되어 있는 메모리 시작 위치의 주소값과 바인딩되며, 해당 변수가 호출될 때에는 그 변수에 저장된 메모리 주소를 따라 해당 메모리에서 값을 읽어들여온다. byte, long 등의 기본 자료형들은 그들이 차지하는 메모리 용량이 정해져 있으므로, 해당 값이 저장되어 있는 메모리 시작 위치만 알면 그로부터 해당 자료형의 크기만큼 메모리를 읽으면 해당 값을 읽어올 수 있다. 그러나 객체의 경우 앞서 말했듯 각 객체마다 그 크기가 달라서 이러한 방법으로는 메모리로부터 객체를 읽어들여오기 어렵다. 따라서 객체의 경우, 힙 영역에 저장되고 고유한 id값을 부여받으며, 변수는 그 id값을 할당받는다. 그 후, 나중에 해당 변수가 호출되면 그 변수에 저장된 id값을 따라 힙 영역에서 해당 객체를 읽어들여온다. id값 자체는 객체와 달리 정해진 크기를 가지므로, 변수에 할당하여 스택 영역에 저장될 수 있다. 즉, 스택 영역에 저장된 변수를 토대로 힙 영역의 객체를 참조하는 형태이다. 이렇듯 위의 그림에서는 메모리 공간을 구분해놨지만, 각 공간은 서로 상호작용할 수 있는 것이다.

한 편, 객체 자체를 변수에 할당하는 것이 아닌, 객체에 부여된 고유한 id값을 변수에 할당받고, 이 id값을 “참조”하여 객체를 읽어오므로, 해당 변수를 “참조 변수”라고 부르기도 한다.

int num = 12;  // 참조 변수 X
SiteUser me = new SiteUser();  참조 변수 O;

가비지 컬렉션(Garbage collection)과 객체

자바는 가비지 컬랙터(Garbage collector)라는 자동 메모리 관리 시스템을 통해 사용되지 않는 객체를 메모리 상에서 자동으로 제거해준다. 즉, 프로그래머가 수동으로 메모리 관리를 하지 않아도 되는 언어이다.

가비지 컬렉터 및 자동 메모리 관리에 대한 자세한 내용은 메모리 관리와 garbage collector 페이지 참고.

다음은 객체를 생성하여 참조 변수에 할당한 후, 해당 참조 변수에 null 값을 대입함으로써 해당 객체에 대한 참조를 끊는 예이다.

public class ObjectReference {
    public static void main(String[] args) {
        User me = new User();
        me.name = "가나다";
        me.age = 23;
        me = null;
    }
}

public class User {
    String name;
    int age;
}

처음에 me라는 변수에 new User() 코드를 통해 User() 객체를 생성한 후 이 객체의 id값을 me 변수에 할당하였다(앞으로는 편의상 id값에 대한 언급 없이 “객체를 변수에 할당하였다”와 같이 표현하겠다). 그 후 해당 객체의 name, age라는 멤버 변수에 값을 할당한 후, me 변수 자체에 null 대입함으로써 해당 객체의 참조를 끊어버렸다. 메모리 상에서는, 처음에는 스택 영역에 있는 me 변수가 힙 영역에 존재하는 User 객체를 참조하였다가, me 변수에 null을 대입함으로써 더 이상 해당 변수가 힙 영역을 참조하고 있지 않는 상황이다.

그러나 참조를 끊었다고 해서, 그래서 해당 객체를 참조하는 변수가 하나도 없다고 해서 해당 객체가 힙 영역에서 그 즉시 삭제되는 것은 아니다. 그저 가비지 컬렉터는 힙 영역을 탐색하며 각 객체를 참조하는 스택 영역 내 변수의 개수를 조사하고, 만약 0개임을 확인하면 해당 객체는 더 이상 참조되지 않는다는 “표시”를 한다. 이렇게 참조되지 않다고 표시된 객체들은 이후 메모리 상에 남은 상태로 프로그램이 종료되더라도 운영체제에서 이를 삭제한다. 즉, 참조되지 않는 객체에 대한 소멸은 이후에 몰아서 하는 것으로 추측된다.

가비지 컬렉션은 기본적으로 자동으로 실행된다. 만약 개발자가 가비지 컬렉션을 직접 조작, 통제하고자 한다면 다음의 메서드들을 사용할 수 있다.

  • System.gc() : 가비지 컬렉션을 강제로 실행시킨다.
  • System.runFinalization() : 객체를 소멸시키는 finalize() 메서드 호출을 위해 그 전에 미리 호출해야하는 메서드.

그러나 가비지 컬렉션을 개발자가 수동으로 다루는 작업은 프로그램 실행에 예기치 않은 에러를 일으킬 수 있어 조심해야한다고 한다.

스택 영역 살펴보기

여기서는 vscode 에디터를 통해 자바 코드를 직접 살펴보면서 스택 영역에 대해 살펴보겠다.

먼저, 다음의 코드를 준비하였다.

사진 2-1.

사진 2-1.

위 코드의 2, 10번째 라인 왼쪽에 마우스 좌클릭을 하여 break point를 설정해두었다. 그 후, 한 줄씩 디버깅하기 시작했다. 위 상황에서는 3번째 라인이 노란색으로 칠해져 있는데, 3번째 라인은 아직 실행 전이고, 그 이전 라인들까지만 실행되었다는 의미이다. 왼쪽 창의 “호출 스택” 창을 보면 해당 자바 파일의 main 메서드 이름이 떠 있는 것을 확인할 수 있고, 해당 메서드의 매개변수인 args가 “변수” → “Local”에 작성되어 있다. “호출 스택”창이 스택 영역을 살펴볼 수 있는 곳이고, “변수” 창이 스택 영역 내 현재 프레임 내부의 변수 현황들을 살펴볼 수 있는 곳이다.

사진 2-2.

사진 2-2.

다른 메서드인 getSum 메서드 호출 라인 전까지 실행한 모습이다. 왼쪽 창을 보면 main이라는 프레임 안에 args, a, b라는 매개변수 및 지역 변수가 기록됨을 알 수 있다. 이렇게 vscode에서는 디버깅을 통해 호출 스택 및 호출 스택 내 프레임들의 현황을 살펴볼 수 있다.

사진 2-3

사진 2-3

5번째 라인을 실행할 때, getSum이라는 메서드 호출부와 마주친다. 이 때는 해당 메서드의 정의부로 건너뛰어 해당 메서드부터 실행하게 된다. 메모리 상에서는 호출 스택에 getSum 메서드 프레임이 새로 삽입되었으며, 해당 프레임 내부에 n1, n2 매개변수와 sum 지역 변수의 값이 존재함을 알 수 있다.

사진 2-4

사진 2-4

해당 메서드의 실행이 끝나면 스택 영역에서 사라진다. 이는 위 사진의 “호출스택” 부분을 보면 알 수 있다. 그 후 해당 메서드의 반환값이 main 메서드 내 지역변수인 sumResult에 할당된다.

main 메서드의 실행도 끝나면 스택 영역 상에서도 main 프레임이 삭제되며 스택 영역이 텅 비게 된다.

힙 영역 살펴보기 : 객체 참조와 얕은 복사

vscode 에디터에서는 디버깅 시 호출 스택을 통해 스택 영역 내부를 시각적으로 볼 수 있으나, 힙 영역은 볼 수 없다. 대신 객체들의 id와 함께 객체가 어떤 변수에 참조되었는지를 확인할 수는 있다.

다음은 객체 참조에 대해 살펴보기 위해 마련한 예제 코드이다.

public class User {
    String name;
    int age;
}

public class ObjectReference2 {
    public static void main(String[] args) {
        User me = new User();
        me.name = "가나다";
        me.age = 24;

        User myClone = me;
        System.out.println("myClone name: " + myClone.name);
        System.out.println("myClone age: " + myClone.age);

        myClone.name = "다라마";
        myClone.age = 29;
        System.out.println("=== me ===");
        System.out.println(me.name);
        System.out.println(me.age);
    }
}

myClone name: 가나다
myClone age: 24
=== me ===
다라마
29

디버깅은 ObjectReference2.java 파일에서 진행한다.

사진 3-1

사진 3-1

위 사진에서 3번째 라인이 실행되었다. 해당 라인에는 User() 객체를 생성하여 이 객체를 me 변수에 할당하고 있다. 메모리 상에서는, 힙 영역에 User 객체가 생성되었고, id가 11인 값을 부여받았다. 위 사진의 왼쪽 창의 “변수”창에서, me 변수 옆에 User 객체명과 함께 그 뒤에 @11이라고 덧붙여져 있는데, 해당 숫자가 해당 객체에 부여된 id값이다. 그리고 이러한 id값이 스택 영역에 생성된 me 변수에 할당되었다. 해당 객체의 멤버 변수는 아직 사용자가 초기화하지 않았으므로 각각 0, null로 지정되었다.

사진 3-2

사진 3-2

5번째 라인까지 실행되었다. 해당 객체의 멤버 변수에 각각 age=24, name=”가나다”라고 할당한 상황이다.

사진 3-3

사진 3-3

7번째 라인에서 myClone 변수에 me 변수값을 할당하였다. 이는 myClone 변수에 me 변수값인 User 객체의 id(=11)값을 대입하는 것이다. 즉, myClone 변수는 me 변수의 “참조값”을 대입받은 것이다. 즉, 힙 영역에 또 다른 User 객체가 하나 더 생성된 것이 아니라, 그저 같은 User 객체를 가리키는 변수 하나가 더 늘어난 것 뿐이다. 위 사진의 “변수”창에서도 이를 확인할 수 있는데, myClone에 연결된 객체의 id값도 똑같이 11임을 알 수 있다.

사진 3-4

사진 3-4

myClone 에 할당된 객체의 멤버 변수값을 새로 할당한 후의 모습이다. 그런데 왼쪽의 “변수”창을 자세히 보면 분명 myClone 변수에 연결된 객체의 멤버 변수를 변경한 것인데 me 변수에 연결된 객체의 멤버 변수까지 똑같이 변한 것을 알 수 있다. 사실 당연한 것이, 두 변수는 앞서 언급했듯, 같은 하나의 객체를 가리키고 있기 때문이다. myClone 변수를 통해 User 객체 내 멤버 변수들의 값을 변경하면, me 변수를 통해서도 동일한 객체에 접근하기에 두 변수에 기록된 멤버 변수 모두 똑같을 수밖에 없다.

이렇듯 객체를 다룰 때에는 특히 객체 복사 시에 주의해야 한다. 만약 방금의 사실을 몰랐다면, “myClone 변수에 me 변수에 할당된 객체와 똑같이 생긴 또 다른 객체를 복제하여 할당해야지” 라는 목적으로 User myClone = me; 코드를 사용해선 안되는 것이다. 해당 코드는 정말로 me 변수가 참조하는 객체와 똑같이 생긴 다른 복제품을 복제하여 myClone에 연결시키는 것이 아니다. 동일한 객체의 id값만을 복사해오는 것이기에 두 변수는 각각의 객체가 아닌 하나의 동일한 객체를 가리키게 되는 것이다. 이렇게, 참조 변수에 또 다른 참조 변수에 할당된 id값을 복사하여 할당하는 것을 “얕은 복사(shallow copy)”라 한다. 얕은 복사를 할 경우, 두 변수 중 하나의 변수를 통해 객체의 멤버 변수를 변경하면 다른 변수에도 그 변경점이 업데이트된다는 것에 유의해야 한다.


References

[1] 이재환, “이재환의 자바 프로그래밍 입문”, (골든래빗, 2021)

This content is licensed under CC BY-NC 4.0

댓글남기기