본문 바로가기
Java

JVM 과 스택 프레임 (Stack Frame)

by 밀러 (miller) 2022. 1. 19.

JVM

JVM (Java Virtual Machine) 은 자바 코드에 있는 메인 메서드를 실제로 호출하여, 자바 어플리케이션을 실행하는 런타임 엔진 역할을 한다. 자바는 코드를 작성한 프로그래머가 개발과 배포에 사용되는 시스템에 관계없이, 실행 환경인 JRE (Java Runtime Environment) 갖춰지면 어플리케이션을 실행할 수 있으므로 WORA (Write Once Run Anywhere) 라고 불린다. 이는 JRE 의 일부인 JVM 위에서 자바 어플리케이션이 실행되기 때문에 가능한 일이다.

 

우리가 작성한 소스코드인 .java 파일을 컴파일하면, 자바 컴파일러에 의해 .java 파일과 같은 이름의 바이트 코드로 구성된.class 파일이 생성된다. 그리고 생성된 .class 파일을 java *.class 와 같은 명령어로 실행하면, JVM 에서 main() 함수를 실행하고 클래스를 로드하며 프로그램이 실행된다.

 

 

클래스를 로드하고 프로그램이 실행되기까지는 여러 단계를 거쳐야 하는데, 이 단계들은 JVM 이 어떤 원리로 동작하는지 설명해준다.

 

 

Class Loader

JVM 메모리에 프로그램 실행을 위한 코드 데이터가 올라가기 전에, 먼저 .class 파일을 불러와 클래스로 로드해야한다. 이 역할을 하는 JVM 의 하위 시스템인 Class Loader 는 3개의 일을 담당한다.

  • Loading
  • Linking
  • Initialization

Loading

Class Loader 는 .class 파일을 읽어 이진 데이터를 생성한 뒤, Method Area 에 저장한다. 각 .class 파일에 대해, JVM 은 아래의 정보들을 Method Area 로 저장한다.

 

  • 로드된 .class 파일의 FQN (Fully Qualified Name) 과 부모 클래스의 정보
  • 로드된 .class 파일이 클래스, 인터페이스 혹은 열거형과 관련되어 있는지에 대한 정보
  • 제어자 (modifier), 변수, 혹은 메서드의 정보 등

.class 파일을 로드한 후, JVM 은 힙 (heap) 메모리에 이 .class 파일이 나타내는 Class 타입의 객체를 생성한다. 이 객체는 java.lang.Class 의 객체로, 이 객체를 통해 해당 클래스 이름, 상위 클래스 이름, 메서드 및 멤버변수의 정보 등과 같은 클래스 수준 정보를 얻을 수 있다. 이 객체 참조Object 클래스의 getClass() 메서드를 사용하여 얻을 수 있다.

 

Student.java

class Student {
    private String name;

    private String getName() {
        return name;
    }

    private void setName() {
        this.name = name;
    }
}

 

Test.java

import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Test {
    public static void main(String[] args)
    {
        Student s1 = new Student();

        // JVM 에 의해 생성된 클래스 객체 참조 획득
        Class c1 = s1.getClass();

        // c1 의 객체 타입 출력
        System.out.println(c1.getName());

        // c1 에 선언된 모든 메서드를 배열로 출력
        Method m[] = c1.getDeclaredMethods();
        for (Method method : m)
            System.out.println(method.getName());

        // c1 에 선언된 모든 필드를 배열로 출력
        Field f[] = c1.getDeclaredFields();
        for (Field field : f)
            System.out.println(field.getName());
    }
}

소스코드인[Student.java](http://Student.java) 로부터 컴파일되어 Student.class 파일이 생성된 후에, Class Loader 는 Student.class 파일을 읽어들여 java.lang.Class 의 객체를 생성한다. 해당 객체의 참조는 .getClass() 메서드로 얻을 수 있으며, 이 객체를 통해 Method Area 에 저장한 정보인 클래스 이름, 메서드 및 멤버 변수의 정보를 얻을 수 있다.

 

컴파일로 생성된 모든 .class 파일에 대하여, Class Loader 가 각 파일을 로드하면 .class 파일 당 한개의 java.lang.Class 객체가 생성된다. (클래스이지만 java.lang.Class 의 객체다!)

 

Linking

Linking 에서는 Verifcation, Preparation, Resoution 3가지 일을 담당한다.

 

  • Verification
    .class 파일의 정확성를 보장한다. 즉, 이 파일의 형식이 올바른지, 유효한 컴파일러에 의해 새성되었는지 여부를 확인한다. 만약 검증에 실패한다면, 런타임 예외인 java.lang.VerifyError 를 받게 된다. 이 과정은 ByteCodeVerifier 에 의해 수행되며, 이 작업이 완료되면 클래스 파일에 대해 compliation 을 준비한다.
  • Preparation
    JVM 은 클래스 변수에 메모리를 할당하고, 해당 메모리를 디폴트 값으로 초기화한다.
  • Resoultion
    타입의 symoblic reference 를 direct reference 로 변환하는 과정이다. Method Area 에 위치하는 참조된 엔티티를 찾기위해, Method Area 를 탐색한다.

Initialization

Initialization 에서는 모든 static 변수들이 코드와 static 블록에 정의된 값에 따라 초기화된다. 이 작업은 클래스 내에서 Top-down 방식으로 진행되고, 클래스 계층 간에는 부모 클래스로부터 자식 클래스로 진행된다.

 

Class Loader 의 종류

일반적으로 3가지 종류의 Class Loader 가 있다.

 

Bootstrap Class Loader

모든 JVM 실행은 Boostrap Class Loader 를 포함하는데, Bootstrap Class Loader 는 $JAVA_HOME/jre/lib 디렉토리에 있는 core java API 클래스들을 로드한다. 이 경로는 bootstrap path 라고도 알려져 있으며, C, C++ 과 같은 native language 로 실행된다.

 

Extension Class Loader

Bootstrap Class Loader 의 자식으로, extensions directory 인 $JAVA_HOME/jre/lib/ext 이나 java.ext.dirs 시스템 속성이 명시되어있는 다른 디렉토리에서 클래스들을 로드한다. 이 디렉토리 경로는 sum.misc.Launcher$ExtClassLoader 로 실행된다.

 

System/Application Class Loader

Extension Class Loader 의 자식으로, application classpath 에 있는 클래스들을 로드한다. 내부적으로 java.class.path 환경 변수에 매핑된 path 를 이용하며, 이 경로는 sun.misc.Launcher$AppClassLoader 로 실행된다.

 

아래와 같이 각 클래스 객체에 대하여 .getClassLoader() 메서드를 통해, 해당 클래스를 로드하는데 어떤 Class Loader 가 사용되었는지 확인할 수 있다.

 

public class Test {
    public static void main(String[] args)
    {
        // String 클래스는 Bootstrap Loader 에 의해 로드
        // Bootstrap Loader 는 native language 에 의해 실행되므로 null 값 출력
        System.out.println(String.class.getClassLoader());

        // 테스트 클래스는 Application Loader 에 의해 로드
        System.out.println(Test.class.getClassLoader());
    }
} 

 

JVM 은 클래스들을 로드하기 위해 Delegation-Hierarchy 원리를 따른다. System Class Loader 는 Extension Class Loader 에 로드 요청을 위임하고, Extension Class Loader 는 Bootstrap Class Loader 에 로드 요청을 위임한다. 만약 클래스를 bootstrap 경로에서 찾으면 클래스는 로드되지만, 그렇지 않을 경우엔 Extension Class Loader 와 System Class Loader 에 로드 요청이 전송된다. 만약 System Class Loader 까지 클래스를 불러오는데 실패하면, java.lang.ClassNotFoundException 이 발생된다.

 

 

JVM Memory

JVM 메모리에는 아래의 구성 요소들이 존재한다.

 

 

Method Area

프로그램 실행 중 어떤 클래스가 사용되면, JVM 은 해당 클래스의 클래스 파일 (.class) 을 읽어서 분석하여 메서드 코드와 클래스 변수 등을 메서드 영역에 저장한다. JVM 마다 오직 1개의 Method Area 가 존재하며, 모든 프로세스/스레드가 공유한다.

→ 클래스/정적변수 저장

 

Heap

프로그램 실행 중 생성되는 인스턴스는 모두 이곳에 생성되며, 인스턴스변수들이 생성되는 공간이다. JVM 마다 오직 1개의 Heap Area 가 존재하며, 모든 프로세스/스레드가 공유한다.

→ 인스턴스 저장

 

Call Stack

호출 스택은 메서드의 작업에 필요한 메모리 공간을 제공한다. 메서드가 호출되면, 호출 스택에 호출된 메서드를 위한 메모리가 할당되며, 이 메모리는 메서드가 작업을 수행하는 동안 매개변수를 포함한 지역변수들과 연산의 중간결과를 저장하는데 사용된다. 그리고 메서드가 작업을 마치면 메모리공간은 반환되어 비워진다. 각 스레드마다 고유한 Call Stack 을 가지며, 스레드 생성 시 함께 생성된다.

→ 메서드 내 매개변수/지역변수 저장

 

혹은 아래 그림과 같이 JVM 메모리에는 Method Area, Heap, Call Stack 외에 다른 구성요소가 포함된다.

 

 

Program Counter Register (PC Register)

스레드의 현재 실행중인 명령의 주소를 저장한다. 당연히, 각 스레드마다 별도의 PC Register 가 존재한다.

 

Native Method Stacks

각 스레드마다, 별도의 Native Stack 이 존재한다. Native Method 는 C/C++ 메서드를 자바에서 이용할 수 있는 방법으로, 여기에는 Native Method 에 관한 정보가 저장된다.

 

Execute Engine

Execute Engine 은 바이트 코드로 구성된 .class 를 실행한다. 바이트 코드를 줄 단위로 읽고, 다양한 메모리 영역에 존재하는 데이터오하 영역을 사용하며, 명령어를 실행한다. 여기서의 역할을 3가지로 분류할 수 있다.

 

  • Interpreter
    Interpreter 는 바이트 코드를 줄 단위로 읽고 실행한다. 여러 번 호출할 때 마다 매번 해석이 필요하다는 단점이 있다.
  • Just-In-Time Compiler (JIT)
    JIT 는 Interpreter 의 효율성 증가를 위해 사용한다. JIT 는 전체 바이트 코드를 컴파일하고 네이티브 코드로 변경해, Interpreter 가 여러번 메서드 호출을 반복해도 JIT 가 해당 부분에 대한 네이티브 코드를 제공하므로 매번 해석이 필요하지 않다.
  • Garbage Collector
    Garbage Collector 는 참조가 없는 객체를 할당 해제한다.

 

Stack Frame

이번에는 Stack 영역에 조금 더 깊게 들어가보자. 메서드 코드는 JVM 메모리에서 Method Area 에 위치하는데, 그럼 메서드 단위로 어떻게 Call Stack 에서 상태 정보가 관리되는 걸까?

 

Call Stack

Call Stack 에서는 메서드마다 스택 프레임 (Stack Frame) 단위로 내부에 매개 변수와 지역 변수를 저장하며, 메서드 호출과 반환의 역할을 한다. 예를 들면, 아래와 같은 코드를 실행했을 때를 살펴보자.

 

class Data {
        int x;
}

class ReferenceParamEx {
        public static void main(String[] args) {
                Data d = new Data();
                d.x = 10;

                change(d);
        }

        public static void change(Data d) {
                d.x = 1000;
                System.out.println("change() : x = " + x);
        }
}

 

ReferenceParamEx 클래스의 main 메서드가 실행되어 Call Stack 에 main 스택 프레임이 생성된다. 그리고 내부에서 Data d = new Data() 명령어가 실행되어 Call Stack 의 main 스택 프레임에서 지역 변수 d 가 생성되고, Heap 에 Data 인스턴스가 생성된다. 다음으로 d.x = 10 의 명령어가 실행되는데 지역 변수 d 는 Heap 의 Data 인스턴스를 참조하기 때문에, 해당 인스턴스의 멤버 변수을 변경하게 된다.

 

 

그렇다면 Call Stack 에서 스택 프레임은 어떻게 추가되고 삭제되는걸까?

 

먼저 스레드가 생성되었음을 가정하고, Call Stack 에서 스택 프레임 연산이 어떻게 이루어지는지 알아보자. 자바에서 새로운 스레드가 생성되었을 때, JVM 은 스레드를 위해 새로운 Call Stack 을 생성한다. Call Stack 은 스택 프레임에 스레드의 상태를 저장하는데, 이렇게 Call Stack 에 저장된 프레임에 대하여 JVM 은 오로지 2가지 종류의 오퍼레이션, pushpop 만 수행한다.

 

즉 현재 실행중인 메서드에서 다른 메서드를 호출하면 스택 프레임을 push 하고, 현재 실행 중인 메서드가 종료되면 스택 프레임을 pop 하게 된다. 따라서 현재 실행 중인 메서드의 스택 프레임은 항상 Call Stack 에서 가장 상단에 존재 한다. 그럼 pop 연산을 하게 되면 기존에 메서드를 호출했던 주소로 돌아가야 할텐데, 어떻게 이전에 호출한 메서드의 주소를 알 수 있을까? 스택 프레임의 내부를 알면 해법을 알 수 있을지도 모른다.

 

Stack Frame

스택 프레임은 3가지 구성 요소를 갖는다.

 

  • Local Variable
  • Operand Stack
  • Frame Data - Constant Pool Resolution, Instruction Pointer

프레임에서는 Local Variable 배열을 갖고, 메서드 내 계산을 위한 작업 공간인 Operand Stack 을 갖는다. 여기서 특이한 점은 스택 프레임의 크기가 Local Variable 과 Operand stack 에 따라 달라진다고 한다. Local Variable 과 Operand Stack 에 관한 자세한 내용은 아래 링크를 참고하자.

 

https://johngrib.github.io/wiki/jvm-stack/

 

JVM stack과 frame

 

johngrib.github.io

 

위 문서보다 조금 더 깊게 다룰 부분은 Frame Data 이다. Frame Data 는 Constant Pool Resolution, Method 가 정상 종료했을 때의 정보, Exception 으로 종료했을 때의 정보들을 저장하고 있다. 혹시 Constant Pool Resolution 에 호출한 메서드의 주소를 알 수 있을지 모르니 자세히 알아보자.

 

Constant Pool Resolution

Constant Pool 은 .class 파일의 한 부분으로 클래스의 코드를 실행할 때 필요한 상수 (constant) 를 포함한다. (이름 그대로 “Pool” 이다.) 여기서 각 스택 프레임은 현재 메서드 유형의 런타임 Constant Pool 에 대한 참조가 포함되어 메서드 코드의 Dynamic Link 를 포함한다. Dynamic Link 에 대한 설명은 아래와 같다. 근데, 무슨 뜻일까?

 

Dynamic Linking

Each frame contains a reference to the run-time constant pool for the type of the current method to support dynamic linking of the method code. The class file code for a method refers to methods to be invoked and variables to be accessed via symbolic references. Dynamic linking translates these symbolic method references into concrete method references, loading classes as necessary to resolve as-yet-undefined symbols, and translates variable accesses into appropriate offsets in storage structures associated with the run-time location of these variables.

 

 

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.6.3

 

Chapter 2. The Structure of the Java Virtual Machine

Conditional branch: ifeq, ifne, iflt, ifle, ifgt, ifge, ifnull, ifnonnull, if_icmpeq, if_icmpne, if_icmplt, if_icmple, if_icmpgt if_icmpge, if_acmpeq, if_acmpne.

docs.oracle.com

 

 

.class 파일 코드는 Symbolic Reference 를 통해 호출되는 메서드와 액세스할 변수를 참조한다. 그러니까 .class 파일은 메서드를 실행하거나 변수를 참조할 때 실제 물리주소와 link 할 수 있도록 Symbolic Reference 만을 가진다는 뜻이다. 그리고 Dynamic Link 는 이러한 Symbolic Reference 를 런타임에 메모리 상에서 실제로 존재하는 물리적인 주소로 대체되는 작업을 수행한다. 따라서 Symbolic Method Reference 는 Concrete Method Reference 로 변환되고, 아직 정의되지 않은 symbol 을 해결하기 위해 필요한 경우 클래스를 로드하고, 변수 접근 시에는 변수의 런타임 위치와 관련된 스토리지 구조의 적절한 offset (물리적인 위치를 말하는 듯하다.) 으로 변환한다는 것이다.

 

요약하자면, 스택 프레임에서 다른 메서드를 참조할 때 해당 클래스의 .class 파일 코드로부터 참조하여, Symbolic Method Reference 를 실제 물리 주소인 Concrete Method Reference 로 변환한다. 그런데 지금 마주친 문제는 메서드를 반환했을 때, 해당 메서드를 호출한 메서드의 주소로 어떻게 이동하냐는 것이다. 아무래도 Constant Pool Resolution 은 그것과는 관련이 없어보인다. 하지만 Frame Data 에는 Method 가 정상 종료했을 때 정보와 Exception 으로 종료했을 때 정보도 가지고 있다.

 

Instruction Pointer

스택 프레임의 Frame Data 에는 메서드 자신을 호출한 Stack Frame 의 Instruction Pointer 가 존재한다. 메서드가 정상적으로 종료되면 JVM 은 이 정보를 JVM 메모리의 PC Register 에 설정하고 Stack Frame 은 Call Stack 에서 pop 된다. 만약 반환값이 있다면 다음 Frame 에 푸시한다. 그리고 Frame Data 에는 Exception 이 발생하는 경우를 위한 Exception 정보도 가지고 있다. Exception 이 발생하면, jvm 은 catch 절에 해당하는 바이트코드로 점프한다.

 

따라서, 만약에 Call Stack 을 직접 구현할 경우, 메서드는 스택 프레임이라는 단위로 함수에 대한 정보를 Call Stack (혹은 Stack) 에 저장해야한다. 그리고 스택 프레임 내부에 메서드에서 선언한 매개변수/지역변수 등을 배열로 저장하고 (Local Variable Array), 함수에 대한 정보를 스택 프레임의 Frame Data 에 저장해야 한다. 그리고 Frame Data 에는 함수 종료 후 리턴을 위한 Instruction Pointer 를 저장해야 한다.

 

출처

https://www.geeksforgeeks.org/differences-jdk-jre-jvm/?ref=lbp

https://www.geeksforgeeks.org/jvm-works-jvm-architecture/?ref=lbp

https://alvinalexander.com/scala/fp-book/recursion-jvm-stacks-stack-frames/

https://stackoverflow.com/questions/10209952/what-is-the-purpose-of-the-java-constant-pool/20357685

https://blog.embian.com/57

https://naruu098.tistory.com/76

댓글