#jvm

JVM에 관하여 - Part 3, Run-Time Data Area

Java 로 작성된 코드는 어떻게 돌아가는 걸까? 해당 물음에 답을 찾기 위한 JVM 시리즈 3편, JVM 의 구성요소 중 Run-Time Data Area 에 관한 글입니다. 이번 글에서는 JVM 의 메모리 영역에서는 어떤 일이 일어나는지에 대해서 알아봅시다.

클래스 파일이 JVM 에 탑재된 이후

지난 글에서는 클래스 파일들을 어떻게 JVM 에 탑재하고 초기화가 되는지에 대해서 알아보았습니다. 이렇게 탑재하는 클래스 파일들은 JVM 에서 어떤 영역을 차지하고 있는 것일까요? JVMRun-Time Data Area 에는 크게 Method Area , Heap , Java Stacks , PC registers 그리고 Native Method Stacks 가 존재합니다. 각각의 영역이 어떤 역할을 하는지에 대해서 알아봅시다.

JVM Run-Time Data Area 구조

Method Area

Method Area 에는 인스턴스 생성을 위한 객체 구조, 생성자, 필드 등이 저장됩니다. Runtime Constant Poolstatic 변수, 그리고 메소드 데이터와 같은 Class 데이터들도 이곳에서 관리가 됩니다. 이 영역은 JVM 당 하나만 생성이 됩니다. 인스턴스 생성에 필요한 정보도 존재하기 때문에 JVM 의 모든 Thread 들이 Method Area 을 공유하게 됩니다. JVM 의 다른 메모리 영역에서 해당 정보에 대한 요청이 오면, 실제 물리 메모리 주소로 변환해서 전달해줍니다. 기초 역할을 하므로 JVM 구동 시작 시에 생성이 되며, 종료 시까지 유지되는 공통 영역입니다.

Heap

Heap 영역은 코드 실행을 위한 Java 로 구성된 객체 및 JRE 클래스들이 탑재됩니다. 이곳에서는 문자열에 대한 정보를 가진 String Pool 뿐만이 아니라 실제 데이터를 가진 인스턴스, 배열 등이 저장이 됩니다. JVM 당 역시 하나만 생성이 되고, 해당 영역이 가진 데이터는 모든 Java Stack 영역에서 참조되어, Thread 간 공유가 됩니다. Heap 영역이 가득 차게 되면 OutOfMemoryError 를 발생시키게 됩니다. 다음은 인스턴스의 영역을 가득 차게 만들어서 해당 Heap 영역에서의 Error 발생시키는 코드입니다.

public class Heap {
    public static void main(String[] args) {
        System.out.println("Heap 메모리 오류");
        int num = 1;
        List<Integer> nums = new LinkedList<>();
        try {
            while (true) {
                nums.add(num);
                num = num + 1;
                if (num < 1) {
                    break;
                }
            }
        } catch (Exception e) {
            System.out.println(e);
        }
    }
}
Heap 메모리 오류
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at Heap.main(Heap.java:16)

Heap 에서는 참조되지 않는 인스턴스와 배열에 대한 정보 또한 얻을 수 있기 때문에 GC 의 주 대상이기도 합니다. 이때, 인스턴스가 생성된 후 시간에 따라서 다음과 같이 5가지 부분으로 나눌 수가 있습니다.

JVM Heap Structure 구조

Eden, Survivor0, Survivor1, Old , Perm 으로 나누어지게 됩니다. Young Gen 이라고 불리는 비교적 신생 데이터 부분은 Eden , Survivor0 , Survivor1 입니다. Eden 에는 new 를 통해 새롭게 생성된 인스턴스가 위치하며, 이후에는 Survivor 로 이동하게 됩니다. 이곳에서도 참조되지 않는 인스턴스와 배열 대상으로 Minor GC 가 일어납니다. 하지만 가장 주요하게 GC 가 일어나는 부분은 그 이후의 부분인 Old 부분입니다. Perm 의 경우에는 클래스의 메타 정보 및 static 변수를 저장하고 있었습니다. Java 8 버전 이후로 Native 영역에 존재하는 Metaspace 라는 영역으로 대체되었습니다. 정확한 내용은 후에 GC 파트에서 다루도록 하겠습니다.

또한, 각 Thread 별로 메모리를 할당받는 Java Stack 영역과 달리 조금은 속도가 느린 점이 있습니다. 그리고 앞서 언급했듯이 모든 Thread 들이 해당 영역인 Heap 을 공유하여 Java 의 동시성 문제가 발생하게 됩니다. 각각의 Thread 메모리가 따로 관리되는 것과 달리 이 부분은 Thread 에 의해서 공유가 되기 때문에 Thread Safe 하지 않습니다. 이 때문에 해당 영역에 있는 객체나 인스턴스를 사용하게 되면, synchronized 블록을 사용하는 방법 등을 비롯하여 동시성을 지켜주는 방법을 사용해야 합니다.

Java Stacks

Thread 별로 따로 할당되는 영역입니다. Heap 메모리 영역보다 비교적 빠르다는 장점이 있습니다. 또한, 각각의 Thread 별로 메모리를 따로 할당하기 때문에 동시성 문제에서 자유롭다는 점도 있습니다. 각 Thread 들은 메소드를 호출할 때마다 Frame 이라는 단위를 추가(push)하게 됩니다. 메소드가 마무리되며 결과를 반환하면 해당 FrameStack 으로부터 제거(pop)가 됩니다. Frame 은 메소드에 대한 정보를 가지고 있는 Local Variable, Operand Stack 그리고 Constant Pool Reference 로 구성이 되어 있습니다. Local Variable 은 메소드 안의 지역 변수들을 가지고 있습니다. Operand Stack 은 메소드 내 연산을 위해서, 바이트 코드 명령문들이 들어있는 공간입니다. Constant Pool ReferenceConstant Pool 참조를 위한 공간입니다. 이렇게 구성된 Java Stack 은 메소드가 호출될 때마다 Frame 이 쌓이게 됩니다.

다음은 Frame 가 쌓인 구조 및 Heap 영역과의 참조를 보여주는 코드 및 그림입니다.

class Person {
    private int id;
    private String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

public class Main {
    public static void main(String[] args) {
        int id = 1;
        String name = "whybe";
        Person person = null;
        person = buildPerson(id, name);
    }

    private static Person buildPerson(int id, String name) {
        return new Person(id, name);
    }
}

JVM Reference Structure 구조

위 그림에서 알 수 있듯이 지역 변수에서 값으로 가지는 부분, 그리고 참조로 가지는 부분이 나누어져 있습니다. 이후 각 Frame 의 연산이 끝나게 되면, 결과 값을 호출한 상위 Frame 에 반환해주게 됩니다.

Java Stack 영역이 가득 차게 되면 StackOverflowError 를 발생시키게 됩니다. 다만 JVM 의 전체적인 메모리가 부족하면 OutOfMemoryError 가 발생하기도 합니다.
아래 코드에서 재귀 함수를 무한으로 호출시켜 Stack 영역에서의 Error 를 일으켜보겠습니다.

public class Stack {
    
    public static void main(String[] args) {
        System.out.println("Stack 메모리 오류");
        try{
            int num = func(0);
        } catch (Error e) {
            System.out.println(e);
        }
    }

    private static int func(int num) {
        num = num+1;
        return func(num);
    }
}
Stack 메모리 오류
java.lang.StackOverflowError

Native Method Stacks

Java 로 작성된 프로그램을 실행하면서, 순수하게 Java 로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API 가 존재합니다. 다른 프로그래밍 언어로 작성된 메소드들을 Native Method 라고 합니다. Native Method StacksJava 로 작성되지 않은 메소드를 다루는 영역입니다. C Stacks 라고 불리기도 합니다. 앞의 Java Stacks 영역과 비슷하게 Native Method 가 실행될 경우 Stack 에 해당 메서드가 쌓이게 됩니다. 각각의 Thread 들이 생성되면 Native Method Stacks 도 동일하게 생성이 됩니다.

PC(Program Counter) Registers

Java 에서 Thread 는 각자의 메소드를 실행하게 됩니다. 이때, Thread 별로 동시에 실행하는 환경이 보장되어야 하므로 최근에 실행 중인 JVM 에서는 명령어 주소값을 저장할 공간이 필요합니다. 이 부분을 PC Registers 영역이 관리하여 추적해주게 됩니다. Thread 들은 각각 자신만의 PC Registers 를 가지고 있습니다. 만약 실행했던 메소드가 네이티브하다면 undefined 가 기록이 됩니다. 실행했던 메소드가 네이티브하지 않다면, PC RegistersJVM 에서 사용된 명령의 주소 값을 저장하게 됩니다.

Reference