Post

[JAVA] 자바 메모리 구조와 static 알아보기

자바 메모리 구조와 static 알아보기

김영한의 실전 자바 기본편을 보며 정리해보는 자바의 메모리 구조와 static

1
public static void main(String[] args) { }

위와 같은 메인 메서드는 자바 프로그램을 실행할 때 반드시 필요하다. 그런데 생각해보자. 우리는 지금까지 메서드를 사용하기 위해 클래스의 인스턴스를 생성하고, 해당 인스턴스에 접근해 메서드를 불러와 사용했다. 반면에 main 메서드는 따로 객체를 생성하지도, main 메서드를 사용하겠다고 입력하지도 않았는데 가장 먼저 실행되고 있다.

이런 일이 가능한 것은 static이라는 키워드와 깊은 관계가 있다.

오늘은 static에 대해 알아보고 메인 메서드의 비밀을 함께 풀어보도록 하자.

📦 자바 메모리 구조

static의 작동 방식에 대해 이해하기 위해서는 자바의 메모리 구조가 어떻게 구성되어 있는지 알아야 한다.

자바 메모리 구조는 크게 메서드 영역, 스택 영역, 힙 영역으로 나눌 수 있다. 자바 프로그램이 샐행되면 아래와 같은 메모리 구조를 가지고 JVM이 동작하게 된다.

자바 메모리 구조

각 영역의 역할은 다음과 같다.

메서드 영역(Method Area)

프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 시스템의 모든 영역에서 공유한다.

  • 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드, 생성자 코드등 모든 실행 코드가 존재한다.
  • static 영역: static 변수들을 보관한다.
  • 런타임 상수 풀: 프로그램을 실행하는데 필요한 공통 리터럴 상수를 보관한다. 문자열을 다루는 문자열 풀은 자바 7부터 힙 영역으로 이동했다.

왜 메서드 코드는 메서드 영역에 들어갈까❔ 클래스 한 개로 인스턴스는 무한정 생성할 수 있다. 이 때 각각의 인스턴스는 내부에 변수와 메서드를 가지는데, 인스턴스 내부의 변수 값은 서로 다를 수 있지만 메서드는 공통된 코드를 공유한다.

예를 들어 Data 클래스가 존재한다고 하자.

1
2
3
4
5
6
7
public class Data {
    private int value;
    public void method(int value) {
        this.value = value;
        System.out.println("method 호출, value: " + value);
    }
}

이 클래스를 이용해 우리는 무수히 많은 Data 인스턴스를 만들어 낼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DataMain {
    public static void main(String[] args) {
        Data data1 = new Data();
        data1.method(10);

        Data data2 = new Data();
        data2.method(20);

        Data data3 = new Data();
        data3.method(30);

        Data data4 = new Data();
        data4.method(40);
    }
}

위 코드를 실행하면 우리는 각각 value 값으로 10, 20, 30, 40을 가지고 있는 인스턴스를 만들 수 있다.

그러나 method(int value)의 동작을 생각해보자. 메서드는 어떤 인스턴스 안에서도 똑같은 동작을 수행한다.

value를 매개변수로 받아 해당 인스턴스 변수 value에 값을 저장하고 콘솔에 출력하는 동작 말이다.

따라서 굳이 인스턴스를 생성할 때마다 메모리에 할당할 필요 없이 메서드 영역에 저장해 두었다가 필요할 때 꺼내 사용할 수 있도록 만들어졌다.

alt text

결론적으로 객체가 생성될 때 인스턴스 변수에는 메모리가 할당되지만 메서드에 대한 새로운 메모리 할당은 없다.

메서드는 메서드 영역에서 공통으로 관리되고 실행됨으로써 메모리를 절약하고 코드의 재사용성을 높인다.

즉, 인스턴스의 메서드를 호출하면 실제로는 메서드 영역에 있는 코드를 불러서 수행한다.

static 영역

static으로 선언한 변수나 메서드들은 모두 이 곳에 저장되게 된다. static 변수의 특징은 다음과 같다.

  • static으로 선언된 변수는 클래스 변수, 정적 변수, static 변수 등 여러 이름으로 불린다.
  • static이 붙은 변수나 메서드는 인스턴스와 무관하게 클래스에 바로 접근해서 사용할 수 있고, 클래스 자체에 소속되어 있다.
  • 프로그램 실행 시점에 만들어지고, 프로그램 종료 시점에 제거된다.

이론은 알겠는데, static이 필요해졌지? 에 대한 궁금증이 든다.

의문을 풀기 위해 다음 예제를 보자. 우리는 생성된 인스턴스의 개수를 세는 프로그램을 만들 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Data1 {
    public String name;
    public int count;

    public Data1(String name) {
        this.name = name;
        count++;
    }
}

public class DataCountMain1 {
    public static void main(String[] args) {
        Data1 data1 = new Data1("A");
        System.out.println("A count = " + data1.count);

        Data1 data2 = new Data1("B");
        System.out.println("B count = " + data2.count);

        Data1 data3 = new Data1("C");
        System.out.println("C count = " + data3.count);
    }
}
1
2
3
A count = 1
B count = 1
C count = 1

Data1의 인스턴스를 만들어 실행했을 때 3의 결과가 나올 것을 기대했지만 1이 나온다.

인스턴스가 생성될 때 마다 각 인스턴스 변수도 새로 만들어지기 때문이다.

물론, 이런 점을 보완하기 위해 인스턴스 개수를 저장하기 위한 클래스를 만들고 주소값을 넘겨줄 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Counter {
    public int count;
}

public Data2(String name, Counter counter) {
        this.name = name;
        counter.count++;
}

public class DataCountMain2 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Data2 data1 = new Data2("A", counter);
        System.out.println("A count = " + counter.count);

        Data2 data2 = new Data2("B", counter);
        System.out.println("B count = " + counter.count);

        Data2 data3 = new Data2("C", counter);
        System.out.println("C count = " + counter.count);
    }
}

이렇게 하면 해당 주소에 존재하는 count 변수의 값을 증가시키므로 의도한 동작을 수행할 것이다.

그러나, 이런 과정은 꽤 번거롭다. 우리는 인스턴스 숫자를 가지고 있는 공유 변수를 만들고 싶을 뿐이다.

이 때 static 변수가 필요해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Data3 {
    public String name;
    public static int count;

    public Data3(String name) {
        this.name = name;
        count++;
    }
}

public class DataCountMain3 {
    public static void main(String[] args) {
        Data3 data1 = new Data3("A");
        System.out.println("A count = " + Data3.count);

        Data3 data2 = new Data3("B");
        System.out.println("B count = " + Data3.count);

        Data3 data3 = new Data3("C");
        System.out.println("C count = " + Data3.count);

        // 인스턴스를 통한 접근, 권장되지 않음
        Data3 data4 = new Data3("D");
        System.out.println(data4.count);

        // 클래스를 통한 접근
        System.out.println(Data3.count);
    }
}

static을 사용하면 프로그램 실행 시점에 메서드 영역에 count 변수가 만들어진다.

이후에는 굳이 주소값을 넘겨주지 않고 클래스명.변수명 형태로 count 변수를 사용할 수 있게 된다.

모든 인스턴스가 공유하는 변수로 사용할 수 있게 된 것이다.

비슷한 예로 static 메서드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class MathUtils {
    public int sum(int n1, int n2) {
        return n1 + n2;
    }
}

public class MathUtilsMain {
    public static void main(String[] args) {
        MathUtils utils = new MathUtils();
        utils.sum(10, 20);
    }
}

아무런 인스턴스 변수가 없고 기능만 존재하는 클래스 MathUtils를 만들었다.

이런 경우 MathUtils의 인스턴스를 100개 만든다고 해도, 모든 인스턴스가 같은 동작과 같은 코드만을 가지고 있다. 이런 경우는 굳이 인스턴스를 만들 필요가 없다. 우리는 그냥 MathUtils 클래스에 만든 기능들을 사용하고 싶을 뿐이다.

1
2
3
4
5
6
7
8
9
10
11
public class MathUtils {
    public static int sum(int n1, int n2) {
        return n1 + n2;
    }
}

public class MathUtilsMain {
    public static void main(String[] args) {
        MathUtils.sum(10, 20);
    }
}

static 메서드는 이렇게 객체 생성이 필요 없이 메서드의 호출만으로 필요한 기능을 수행할 때 주로 사용한다. 예를 들어 간단한 메서드 하나로 끝나는 유틸리티성(수학 관련 기능과 같은) 메서드에 자주 사용하게 된다.

단, static 메서드는 사용 할 때 주의사항이 있다.

정적 메서드 사용법

  • static 메서드는 static만 사용할 수 있다.
    • 클래스 내부의 기능을 사용할 때, 정적 메서드는 static이 붙은 정적 메서드나 정적 변수만 사용할 수 있다.
    • 클래스 내부의 기능을 사용할 때, 정적 메서드는 인스턴스 변수나 인스턴스 메서드를 사용할 수 없다.

정적 메서드는 클래스의 이름을 통해 바로 호출할 수 있다. 그래서 인스턴스처럼 참조값의 개념이 없다.

특정 인스턴스의 기능을 사용하려면 참조값을 알아야 하는데 정적 메서드는 참조값 없이 호출한다. 따라서 정적 메서드 내부에서 인스턴스 변수나 인스턴스 메서드를 사용할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DecoData {
    private int instanceValue;
    private static int staticValue;

    public static void staticCall() {
        // 인스턴스 변수 접근, 컴파일 에러
        // instanceValue++;
        // 인스턴스 메서드 접근, 컴파일 에러
        // instanceMethod();
        staticValue++; // 정적 변수 접근
        staticMethod(); // 정적 메서드 접근
    }
}

alt text

물론, static 메서드에 매개변수로 주소값을 받도록 한다면 정적 메서드도 인스턴스의 변수나 메서드를 호출할 수 있다.

  • 반대로 모든 곳에서 static을 호출할 수 있다.
    • 정적 메서드는 공용 기능이다. 따라서 접근 제어자만 허락한다면 클래스를 통해 모든 곳에서 static을 호출할 수 있다.

스택 영역(Stack Area)

자바 실행 시 하나의 실행 스택이 생성된다(멀티 스레드 환경의 경우 스레드 수 만큼 스택 영역이 생성). 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다. 메서드를 실행할 때마다 하나씩 쌓인다.

  • 스택 프레임: 스택 영역에 쌓이는 박스가 하나의 스택 프레임이다. 메서드를 호출할 때마다 하나의 스택 프레임이 쌓이고, 메서드가 종료되면 해당 스택 프레임이 제거된다.

alt text

처음 프로그램을 실행하면 main 메서드가 실행되고, 차례로 method1, method2를 실행한 스택 영역의 모습이다. method2는 지역 변수로 Data data1을 가지고 있다. 이 지역 변수로 스택 프레임에 포함된다.

method2new Data(10)을 사용해서 힙 영역에 Data 인스턴스를 생성하고 참조값을 data1에 보관하고 있다.

alt text

method2가 종료되면 method2의 스택 프레임이 제거되면서 지역 변수 data1도 함께 제거된다.

x001 참조값을 가진 Data 인스턴스를 참조하는 곳이 없어지므로 해당 인스턴스는 GC의 대상이 된다.

alt text

method1이 종료되면 다음과 같은 상태가 된다.

스택 영역은 이름 그대로 스택 자료구조 형태로 되어있어 나중에 실행한 메서드가 먼저 제거되게 된다.

힙 영역(Head Area)

객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다. new 명령어를 사용하면 이 영역을 사용한다.

변수의 생명주기

자바 메모리 구조를 살펴보면 변수의 생명주기에 대해서도 이해할 수 있다.

  • 지역 변수(매개변수 포함): 지역 변수는 스택 영역에 있는 스택 프레임 안에 보관된다. 메서드가 종료되면 스택 프레임도 제거되는데 이때 해당 스택 프레임에 포함된 지역 변수도 함께 제거된다. 따라서 지역 변수는 생존 주기가 짧다.
  • 인스턴스 변수: 인스턴스에 있는 멤버 변수를 인스턴스 변수라 한다. 인스턴스 변수는 힙 영역을 사용한다. 힙 영역은 GC가 발생하기 전까지는 생존하기 때문에 보통 지역 변수보다 생존 주기가 길다.
  • 클래스 변수: 클래스 변수는 메서드 영역의 static 영역에 보관되는 변수이다. 메서드 영역은 프로그램 전체에서 사용하는 공용 공간이다. 클래스 변수는 해당 클래스가 JVM에 로딩 되는 순간 생성된다. 그리고 JVM이 종료될 때 까지 생명주기가 이어민다. 따라서 가장 긴 생명주기를 가진다.

정리

서론에 메인 함수의 비밀이라고 썼지만, 사실 별 게 아니다. 자바에서 프로그램을 실행하면 가장 먼저 main 함수를 찾아 실행한다. 이 main 함수는 static이기 때문에 클래스 영역에 저장되어 있어 객체 생성 없이도 main() 메서드가 작동할 수 있었다는 것!

This post is licensed under CC BY 4.0 by the author.