[Java] 10. 메모리 할당(stack 메모리와 heap 메모리 그리고 new)과 Call by reference(포인터에 의한 참조)


Study/Java  2020. 5. 8. 04:18

안녕하세요. 명월입니다.


이 글은 Java에서 메모리 할당(stack 메모리와 heap 메모리 그리고 new)과 Call by reference(포인터에 의한 참조)에 대한 글입니다.


이전에 제가 클래스를 설명한 적이 있습니다.

링크 - [Java] 07. 클래스를 생성하는 방법 (생성자를 사용하는 방법)


Java에서 변수를 사용할 때, 원시 데이터 타입(Primitive type)이 아닌 이상 모든 데이터 타입은 new로 할당이 됩니다.

예를 들어, 문자열의 타입 String을 사용할 때 우리는 String a = "hello world" 라고 사용하기는 하지만 내부적으로는 String a = new String("hello world");와 같습니다.

즉, Java에서는 원시 데이터 타입을 제외하고는 모든 객체가 클래스 타입입니다.


클래스를 사용할 때는 우리가 new라는 객체를 사용합니다.

// 클래스
public class Example {
  // 맴버 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 데이터
    this.data = data;
  }
  // 출력 함수
  public void print() {
    // 콘솔 출력
    System.out.println("data - "+ this.data);
  }
  // 실행 함수
  public static void main(String... args) {
    // 클래스 선언
    Example ex = new Example(10);
    // 출력
    ex.print();
  }
}

위 예제는 당연한 것입니다만, 우리가 봐야 할 부분은 Example ex = new Example(10); 입니다.

먼저 앞의 Example ex은 변수 선언입니다. 여기서 우리가 Example ex = 10;이라고 하면 당연히 에러가 발생합니다. 왜냐하면 Example ex에는 Example의 클래스만 할당이 되어야 하기 때문입니다.

즉, Java 내부에서는 Example ex = new Example(10);가 어떤 형태로 생성이 되냐하면 아래의 그림과 같이 생성이 됩니다.

여기서 Stack 메모리와 Heap 메모리에 대한 구조가 나왔습니다.

잠깐 이 Stack 메모리와 Heap 메모리에 대해 이해할 필요가 있습니다.

Stack 메모리는 우리가 프로그램에서 함수를 작성할 때, 실행하는 영역을 중괄호({ })로 설정합니다. 이 중괄호의 영역을 우리는 Stack 영역이라고 이야기합니다. 이 Stack 영역에서 선언된 변수의 값은 우리가 Stack 메모리에 보관 된다고 합니다.

위 이미지를 보시면 main 함수 안에 임의의 중괄호를 사용해서 새로운 스택 영역을 만들었습니다. 그 새로운 스택 영역에서 선언된 data는 스택 영역을 벗어나서는 사용할 수 없습니다.

다시 돌아와서 Example ex는 스택 영역에서 선언된 것입니다.


new Example(10)는 Heap 영역에서 할당된 것인데, Heap은 프로그램의 영역입니다. 즉, 프로그램이 실행되면서 Heap 메모리가 생성이 되고 그 안에서 자유롭게 선언하고 해지를 할 수 있습니다.

그러나 이 Heap은 딱히 어디에서 선언되고 메모리에 어디에 박혀있는지 알 수 없습니다. 그래서 Heap에서 선언된 new Example의 주소 값을 Stack메모리에 선언된 Example ex에 넣는 것입니다.


정리하면 Stack은 정적인 메모리 영역이며 데이터를 찾기가 쉽지만(Stack 알고리즘의 push pop으로 데이터를 찾는다.), Heap은 동적인 메모리 영역이며 new키워드로 클래스를 할당하면 데이터를 주소 값으로만 찾을 수 있습니다.

그리고 이 둘을 연결한 게 Example ex = new Example(10);의 형태입니다.


그럼 왜 Java에서는 이렇게 복잡하게 Stack = Heap의 구조로 데이터를 취급하는 것일까?

이 개념은 사실 C/C++에서 온 개념이긴 합니다. C/C++에서는 new를 사용하지 않고 클래스를 stack 영역에 선언할 수 있습니다. 원시 데이터 타입(Primitive type)처럼 말입니다.

그러나 이 Stack 메모리가 무한정 정해져 있는게 아니고 stack의 메모리가 정해져 있습니다. 보통은 이 크기가 한 1MB정도? 2MB정도 사용합니다.

이 메모리 크기는 int가 4byte정도 되는데 약 25만개 정도 선언할 수 있는 크기입니다. 25만개라고 하면 큰거 같지만, 이게 사실 금방입니다.

그리고 Stack 메모리에서는 꼭 이 변수 할당만 쓰는게 아니고 함수 상태(Interrupt)등 사용하는 데이터가 많이 있습니다. 그러면 이 Stack 메모리라는게 많은 양이 아닙니다.

그리고 최근 프로그램 실행하면 기본 몇 GB도 우습게 사용하는데 1MB는 엄청 적은 것입니다. 그렇게 이 Stack에 할당되어 있는 메모리를 전부 사용하면 그 유명한 StackOverflow 에러가 발생합니다.


그럼 이 Stack 메모리 설정을 높이면 되지 않을까 생각하는데.. 맞습니다. Stack 메모리를 높이면 StackOverflow는 해결됩니다. 그러나 이 Stack 메모리는 구조가 Stack 알고리즘 구조로 push와 pop으로 데이터를 찾는 건데, 당연히 메모리가 커지면 느려집니다. 꼭, Stack이 아니더라도 탐색 알고리즘으로 되어 있는 자료 구조는 커지면 느려집니다. 즉, Stack 메모리가 많아지면 프로그램은 느려집니다.


Heap은 이렇게 Stack처럼 정형화 된 자료 구조가 아니기 때문에 용량이 큽니다. 그러나 이를 참조하기 위해선 반드시 메모리 주소 값이 있어야 하는데, 이 메모리 주소 값을 Stack 영역에 보관을 하는 것입니다.

자바에서는 이 주소 값을 확인할 수 있는 함수가 hashcode입니다.


Java에서는 모든 클래스가 일단 Object 클래스를 자동으로 상속받게 되어 있습니다.

이 Object 함수에는 9개의 함수가 기본적으로 선언이 되어 있는데 이 뜻은 Java에서 사용하는 모든 데이터(원시 데이터 제외)는 이 9가지 함수를 가지고 있다는 뜻입니다.

링크 - https://docs.oracle.com/javase/7/docs/api/java/lang/Object.html

메소드 설명
protected Object clone() 객체를 복사한다.(다른 클래스이다. 단순히 포인터 복사가 아니다.)
boolean equals(Object obj) 해당 객체와 동일한 지 비교 한 후, 같으면 true, 다르면 false를 반환한다
protected void finalize() 소멸자와 비슷한 것으로 가비지 컬렉션(GC)에서 더이상 참조 없음을 확인할 때 호출된다.
int hashCode() 객체의 해시 코드를 반환한다.
String toString() 객체의 문자열을 반환한다
Class getClass() 객체의 클래스 타입을 반환한다.
void notify() 모니터에서 대기 중인 단일 스레드를 재개한다.
void notifyAll() 모니터에서 대기 중인 모든 스레드를 재개한다.
void wait() notify와 notifyAll가 호출될 때까지 쓰레드 대기
void wait(long timeout) notify와 notifyAll가 호출되거나 지정된 시간까지 쓰레드 대기
void wait(long timeout, int nanos) notify와 notifyAll가 호출되거나 다른 쓰레드가 인터럽트하거나 지정된 시간까지 쓰레드 대기

여기서 notiry와 wait은 쓰레드 영역에서 설명하고, getClass와 clone은 Reflection 또는 디자인 패턴에서 따로 설명하겠습니다.

그리고 hashCode, toString, equals에 관해서도 설명이 무척 많으니 따로 설명하겠습니다.


일단은 객체를 구분 하기 위해서 hashCode를 확인하겠습니다.

이 해쉬코드는 클래스의 주소값을 해시 알고리즘으로 나타낸 것입니다. 해시 알고리즘은 일련의 데이터를 구분하기 위해 짧게 압축해서 나타낸 것이라고 생각하면 됩니다. (뭐하나 설명하려고 해도 알고리즘 이야기가 많이 나오네요.)


다시 위의 Example 클래스를 선언해서 다른 변수에 옮겨 담아보겠습니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    setData(data);
  }
  // 변수 설정
  public void setData(int data) {
    this.data = data;
  }
  // 출력 함수
  public void print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
  }
  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(10);
    // ex1 변수를 ex2로 넣는다.
    Example ex2 = ex1;
    // hashcode 출력
    System.out.println("ex1 hashcode = " + ex1.hashCode());
    System.out.println("ex2 hashcode = " + ex2.hashCode());
    // ex2의 클래스의 data를 수정하면
    ex2.setData(20);
    // ex1의 data 콘솔 출력
    System.out.println("ex1 data");
    ex1.print();
    // ex2의 data 콘솔 출력
    System.out.println("ex2 data");
    ex2.print();
  }
}

위 소스를 보시면 ex1에는 new로 클래스를 할당했습니다. 그리고 ex2의 변수에 ex1를 넣었습니다.

hashCode를 보니 값은 값이 나오네요. 이는 같은 클래스임을 설명하는 것입니다.

그리고 ex2의 data를 수정했습니다.

결과는 ex1과 ex2이 같은 값이 나옵니다.

즉, 위와 같은 형태로 메모리가 묶여 있는 것입니다. ex1변수와 ex2의 변수는 heap의 같은 클래스를 가르키고 있기 때문에 ex2의 값을 수정해도 ex1에 영향이 가는 것입니다.

Stack과 Heap의 구조를 알았으니 이것까진 쉽게 이해하실 것입니다.


그럼 다음 소스들은 실무에서 초심자들이 정말 많이 실수하는 소스입니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    setData(data);
  }
  // 변수 설정
  public void setData(int data) {
    this.data = data;
  }
  // 출력 함수
  public void print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
  }
  // 클래스를 초기화하는 함수  
  public static Example initClass(Example ex) {
    // 값을 초기화
    ex.setData(0);
    // 파라미터를 리턴
    return ex;
  }
  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(10);
    // ex2 변수에 ex1 클래스를 초기화해서 넣는다.
    Example ex2 = initClass(ex1);
    // hashcode 출력
    System.out.println("ex1 hashcode = " + ex1.hashCode());
    System.out.println("ex2 hashcode = " + ex2.hashCode());
    // ex1의 data 콘솔 출력
    System.out.println("ex1 data");
    ex1.print();
    // ex2의 data 콘솔 출력
    System.out.println("ex2 data");
    ex2.print();
  }
}

위 소스를 보면 Example ex1 클래스를 initClass 함수에 넘겨서 값을 0으로 설정한 후 ex2 변수로 받았습니다.

여기서는 ex1와 ex2는 다른 클래스일까요? hashCode를 보면 같은 클래스입니다. 즉, ex1의 data값도 0으로 초기화가 되어 있습니다.

여기까지는 파라미터 값을 그대로 return했으니 같은 클래스이거니 예상할 수 있습니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    setData(data);
  }
  // 변수 설정
  public void setData(int data) {
    this.data = data;
  }
  // 출력 함수
  public void print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
  }
  // 클래스를 초기화하는 함수  
  public static void initClass(Example ex) {
    // 값을 초기화
    ex.setData(0);
  }
  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(10);
    // ex1의 값을 초기화
    initClass(ex1);
    // ex1의 값을 출력
    ex1.print();
  }
}

여기서 이제 함정이 있습니다. initClass함수는 분명 return 값이 없습니다. 파라미터로 Example 클래스를 넘겨서 setData를 설정했는데, main의 ex1의 변수의 값은 10이 아닌 0으로 설정이 되어 있습니다.

즉, 파라미터로 Example의 주소 값을 넘겨서 initClass클래스 안에서는 주소 값으로 heap영역의 클래스를 타고 가서 data를 수정한 것입니다.

그러므로 return을 하지 않아도 Example의 값이 바뀌어 있는 것입니다.


이게 초심자들이 많이 놓치는 부분인데... 분명 클래스의 값은 return을 하지 않고 파라미터로 데이터를 넘겨도 분명히 클래스의 값은 바뀔 수 있습니다.

(예전에 간혹 후배들이.. Java 버그 있어요.. 디버깅 했는데 이 함수를 지나가면 값이 변해요..... 진짜 선배들한테 쌍욕 먹습니다.)

이것이 Java의 Call by reference(포인터에 의한 참조)입니다.


참고로 Java에서는 원시 데이터 타입과 그의 관한 클래스와 String에서는 call by value(값의 의한 참조)가 발생합니다.

// 클래스
public class Example {
  // 문자열 초기화
  public static void initString(String ex) {
    // 초기화
    ex = "";
  }

  // 실행 함수
  public static void main(String... args) {
    // 문자열 선언
    String ex = "Hello world";
    // 초기화
    initString(ex);
    // 콘솔 출력
    System.out.println("ex - " + ex);
  }
}

여기서는 initString를 보내도 값이 변하지 않습니다. 사실 initString에서 이꼴(=)로 다시 재할당 한 것이니 main의 ex와 initString는 다른 포인터를 참조하고 있는게 맞겠네요..


참고로 이 Java의 Call by reference을 확실히 이해하게 되면 chain 메소드 패턴은 그냥 가져가게 됩니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 변수 값 설정
    this.data = data;
  }
  // 더하기 함수
  public Example sum(int data) {
    // 변수에 값을 가산
    this.data += data;
    // 콘솔 출력
    return print();
  }
  // 출력 함수
  public Example print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
    // 자기 자신 클래스를 리턴
    return this;
  }

  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex = new Example(1);
    // sum 함수에는 print를 호출하는데. print함수는 자기 자신의 참조 값을 리턴한다.
    // 즉 ex.sum(2), ex.sum(3)과 같은 효과의 chain 메소드 패턴이 구현된다.
    ex.sum(2).sum(3).sum(4).sum(5).sum(6).sum(7).sum(8).sum(9).sum(10);
    // 콘솔 출력
    System.out.println("ex data return");
    // 결과 출력
    ex.print();
  }
}

최근에 개발 트랜드가 객체 지향 프로그래밍에서 함수 지향 프로그래밍으로 많이 옮겨져 왔습니다. 이 때, callback이던가 이런 chain 메소드 패턴 형식의 코딩이 많이 보이는데 이걸 이해하고 있으면 많은 부분에 응용도 가능할꺼라 생각되네요.


여기까지 Java에서 메모리 할당(stack 메모리와 heap 메모리 그리고 new)과 Call by reference(포인터에 의한 참조)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.