차근차근 알아보는 Java 프로그램 실행 과정

Ariul·2023년 2월 2일
4

KEYWORD 뽑아먹기🥢

목록 보기
2/4

네이버 기술 블로그의 JVM Internal 을 읽던 중 그만 정신을 잃고 말았습니다.
아아.. 선생님 무슨 말인지 모르겠어요
.
.
.

수많은 레퍼런스를 찾아본 끝에,
이제 조금은 정신이 드는 것 같아 정리해 보는 Java 프로그램 실행 과정!


🔍 한눈에 알아보기

Compile Time 환경

자바 소스 코드 파일(.java)을 생성합니다.
② 이 파일을 자바 컴파일러(javac.exe)를 통해 자바 바이트 코드 파일(.class)로 컴파일합니다.

Runtime 환경

③ 컴파일된 자바 바이트 코드 파일(.class)을 Runtime으로 가져가는 시점에 클래스 로더(Class Loader)가 동작합니다.
④ 클래스 로더(Class Loader)가 동적 로딩(Dynamic Loading)을 통해 자바 바이트 코드를 런타임 데이터 영역(Runtime Data Areas), 즉 JVM의 메모리에 로드합니다.
실행 엔진(Execution Engine)은 JVM 메모리에 적재된 바이트 코드들을 명령어 단위로 읽어서 실행합니다.

💡 Compile Time? Runtime?

컴파일 타임(Compile Time)
컴퓨터는 0과 1로 이루어진 기계어만 이해할 수 있기 때문에 개발자가 작성한 소스 코드를 기계어로 변환해 주어야 합니다. 이 역할을 하는 것이 컴파일러이며, 이를 통해 실행 가능한 프로그램이 되는 과정을 컴파일 타임이라고 합니다.

[주의📌] Java의 경우, C나 C++이 컴파일하면 생성되는 기계어와 동일하게 생각하면 안 됩니다. 자바 언어를 컴파일 하면 바이트 코드(중간 코드)가 생성되는데, 이는 컴퓨터가 이해할 수 없기 때문입니다. 자바가 컴파일한 언어를 이해할 수 있는 기계는 컴퓨터가 아니라 JVM(Java Virtual Machine)입니다.

런타임(Runtime)
컴파일 과정을 마친 프로그램이 사용자에 의해 실행되는 때를 의미합니다.


How is Java platform-independent?

💡 플랫폼(Platform)이란?

운영체제(OS) + CPU 아키텍처

C/C++ 등의 컴파일러는 고수준 언어를 기계어, 즉 직접적인 CPU 명령으로 변환합니다. 따라서 컴파일러가 생성한 .exe 파일은 플랫폼마다 다르고, 다른 플랫폼과 호환되지 않습니다. 즉, 컴파일 플랫폼과 타겟 플랫폼이 다르면 프로그램이 동작하지 않으며, 이를 플랫폼에 종속적이라고 합니다.

이와 달리, Java 컴파일러(javac)는 개발자가 이해하는 자바 언어를 직접적인 CPU 명령이 아니라 JVM(Java Virtual Machine)이 이해하는 자바 바이트 코드(.class)로 변환합니다. 자바 바이트 코드는 플랫폼에 의존적인 코드가 없기 때문에 JVM이 설치된 장비라면 CPU나 운영체제가 다르더라도 실행될 수 있으며, 이를 플랫폼에 독립적이라고 합니다.

JVM(Java Virtual Machine)

JVM은 자바 바이트 코드 파일(.class)을 읽어 실행할 수 있는 Virtual Machine을 의미합니다.

Virtual Machine을 더 풀어 설명해 보겠습니다.

Virtual : 물리적인 형태가 아닌 소프트웨어로서 하나의 개념으로 존재하며,
Machine : 독자적으로 작동할 수 있는 메커니즘과 구조를 가지고 있어 하나의 축약된 컴퓨터와 같은 의미인 것

따라서, JVM 명세(The Java Virtual Machine Specification)를 따르기만 하면 어떤 벤더든 JVM을 개발하여 제공할 수 있습니다. 대표적인 Oracle의 Hotspot JVM 외에도 IBM JVM을 비롯한 다양한 JVM이 존재합니다.

💡 JVM 개념에 대한 나의 오해

처음에 JVM에 대한 레퍼런스를 읽으면서 혼란스러웠던 기억이 납니다. 똑같이 JVM의 구성 요소에 대해 설명하고 있는데, 어떤 레퍼런스에는 없는 내용이 다른 레퍼런스에는 있고, 같은 기능을 다른 이름으로 표기하는 등 레퍼런스마다 제공하는 정보가 달랐기 때문입니다. 다 같은 JVM 아닌가? 왜 설명이 다 다르지? 라고 생각했는데 정말 다 다른 JVM이었습니다.😅


Compile Time

자, 이제 🔍 한눈에 알아보기의 내용들을 하나씩 설명해 보겠습니다.

먼저, 개발자가 자바 소스 코드 파일(.java)을 생성하고, 자바 컴파일러(javac.exe)가 이 소스 코드 파일을 자바 바이트 코드 파일(.class)로 컴파일한다고 했습니다.

그리고 이 모든 일은 JDK(Java Development Kit)에서 이루어집니다.

JDK(Java Development Kit)

JDK는 자바 개발 키트(Java Development Kit)의 약자로 개발자들이 자바로 개발하는 데 사용되는 SDK 키트라 생각하면 됩니다.

그래서 JDK 안에는 자바를 개발할 때 필요한 라이브러리들과 컴파일러, 디버거 등의 개발 도구들이 포함되어 있고, 개발을 하려면 자바 프로그램을 실행도 시켜줘야 하기 때문에 뒤에서 배울 JRE(Java Runtime Environment)도 함께 포함되어 있습니다.

정리하면, JDK로 소스 코드를 작성하고 이를 컴파일하며, 그 결과물인 바이트 코드(.class)를 JRE에게 전달합니다.

💡 JDK와 JRE이 차이는?

JRE(Java Runtime Environment)
Java 응용 프로그램을 실행하는 데 필요한 최소 환경입니다. 여기에는 클래스 라이브러리, JVM(Java Virtual Machine) 및 배포 도구가 포함됩니다. 이러한 소프트웨어 구성 요소를 사용하여 모든 디바이스에서 바이트 코드를 실행합니다.

JDK(Java Development Kit)
Java 애플리케이션을 개발하고 실행하는 데 사용되는 완전한 개발 환경입니다. JRE와 개발 도구를 모두 포함합니다.

정리하면, 자바 개발 도구인 JDK를 이용해 개발된 프로그램은 JRE에 의해 가상의 컴퓨터인 JVM 상에서 구동됩니다.


Runtime

앞서, 컴파일된 자바 바이트 코드 파일(.class)을 Runtime으로 가져가는 시점에 클래스 로더(Class Loader)가 동작한다고 했습니다.

컴파일된 자바 바이트 코드 파일(.class)을 Runtime으로 가져가는 시점, 즉 자바 애플리케이션을 실행하는 시점은 java 명령어를 입력할 때입니다.

java 명령어는 먼저 JRE(Java Runtime Environment)를 시작하고,
인자로 지정된 클래스(static main() 메서드를 포함하고 있는 클래스)를 로딩하며, main() 메서드를 호출합니다.

이 과정을 알아보겠습니다.

Class Loader

자바 컴파일러를 통해 .class 확장자를 가지게 된 클래스 파일들은 각 디렉터리에 흩어져 있습니다. 또한, 기본적인 라이브러리의 클래스 파일들도 $JAVAHOME_ 내부 경로에 존재합니다. 이렇게 흩어져 있는 각각의 클래스 파일들을 찾아서 동적 로딩(Dynamic Loading) 방식으로 런타임 데이터 영역(Runtime Data Areas), 즉 JVM의 메모리에 탑재해 주는 것이 바로 클래스 로더(Class Loader)의 역할입니다.

💡 동적 로딩(Dynamic Loading)이란?

프로세스가 시작될 때 그 프로세스의 주소 공간 전체를 메모리에 올려놓는 것이 아니라, 필요한 루틴이 호출될 때 해당 루틴을 메모리에 적재하는 방식을 말합니다. 즉, 필요한 시점에만 올리니까 메모리를 좀 더 효율적으로 사용할 수 있습니다.

클래스 로더는 크게 Loading, Linking, 그리고 Initialization 3가지 역할을 맡습니다.
Loading은 클래스 파일을 탑재하는 과정이고, Linking은 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정입니다. Initialization은 정적 필드(static field)의 값들을 코드 상에서 정의한 값으로 초기화하는 과정입니다.

❶ Loading

컴파일된 클래스(.class)를 메모리에 로드하는 것은 클래스 로더의 주요 작업입니다. 일반적으로 클래스 로드 프로세스는 메인 클래스(즉, static main() 메서드 선언이 있는 클래스)를 로드하는 것부터 시작합니다. 이때, 클래스 로더들은 아래 4가지 원칙들을 지키며 클래스를 로드합니다.

① Delegation Hierarchy Principle(위임 계층 원칙)

클래스 로더는 아래 그림과 같이 계층 구조로 이루어져 있습니다.

  • Bootstrap Class Loader
    JVM을 기동할 때 생성되며, Object 클래스들을 비롯하여 자바 API들을 로드합니다. 다른 클래스 로더와 달리 자바가 아니라 네이티브 코드로 구현되어 있습니다.

  • Extension Class Loader(Java 9부터는 Platform Class Loader)
    기본 자바 API를 제외한 확장 클래스들을 로드합니다. 다양한 보안 확장 기능 등을 여기에서 로드하게 됩니다.

  • Application Class Loader(Java 9부터 System Class Loader)
    부트스트랩 클래스 로더와 익스텐션 클래스 로더가 JVM 자체의 구성 요소들을 로드하는 것이라 한다면, 애플리케이션 클래스 로더는 애플리케이션의 클래스들을 로드한다고 할 수 있습니다. 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드합니다.

    💡 Java 9 이후 변화

    Java 9 에서도 기본 클래스 로더의 3계층 구조와 3가지 원칙은 유효합니다. 다만 모듈 시스템 도입에 맞춰 이름과 범위, 구현 내용 등이 바뀌었습니다.

클래스 로더가 클래스 로드를 요청받으면, 클래스 로더 캐시, 상위 클래스 로더, 자기 자신의 순서로 해당 클래스가 있는지 확인합니다. 즉, 이전에 로드된 클래스인지 클래스 로더 캐시를 확인하고, 없으면 상위 클래스 로더를 거슬러 올라가며 확인합니다. 부트스트랩 클래스 로더까지 확인해도 없으면 요청받은 클래스 로더가 파일 시스템에서 해당 클래스를 찾습니다.

② Visibility Principle(가시성 원칙)

가시성 원칙은 하위 클래스 로더는 상위 클래스 로더가 로딩한 클래스를 볼 수 있지만, 상위 클래스 로더는 하위 클래스 로더가 로딩한 클래스를 볼 수 없다는 원칙입니다.

③ Uniqueness Principle(유일성 원칙)

유일성 원칙은 상위 클래스 로더에 의해 로드된 클래스가 하위 클래스 로더에 의해 다시 로드되지 않게 하여 유일성을 보장(중복된 클래스 로드 X)하는 원착압니다.

이러한 유일성을 지키기 위해서 Visibility Principle 이외에도 Class Binary name을 이용하는데, 이를 FQCN, Fully Qualified Class Name이라고 합니다. 이미 로드된 클래스인지 확인하기 위해서는 네임스페이스(Namespace)에 보관된 FQCN을 기준으로 클래스를 찾아보고, 없다면 위임 모델을 통해서 클래스를 로드합니다.

💡 Namespace? FQCN?

FQCN
패키지명 + 클래스명

네임스페이스(Namespace)
각 클래스 로더마다 가지고 있으며, 로드된 클래스를 보관하는 공간입니다. 클래스를 로드할 때 위임 모델을 통해서 상위 클래스 로더들을 확인하는데, 그 때 확인하는 공간이 네임스페이스입니다.

클래스 로더마다 각자 네임스페이스를 가지고 있기 때문에, FQCN이 같은 클래스라도 네임스페이스가 다르면 다른 클래스로 간주합니다.

④ No Unloading Principle(언로드 금지 원칙)

클래스 로더는 클래스를 로드할 수 있지만 로드된 클래스를 언로드할 수 없습니다. 언로드하는 대신 현재 클래스 로더를 삭제하고 새 클래스 로더를 만들 수 있습니다.

❷ Linking

Linking은 로드된 클래스 파일들을 검증하고, 사용할 수 있게 준비하는 과정을 의미합니다. Linking 또한 Verification, Preparation, 그리고 Resolution이라는 세 가지 단계로 이루어져 있습니다.

① Verification

클래스 로더가 .class 파일의 바이트 코드를 자바 언어 명세(Java Language Specification)에 따라서 제대로 잘 작성했는지, JVM 규격에 따라 검증된 컴파일러에서 .class 파일이 생성되는지 등을 확인하여 .class 파일의 정확성을 확인하는 단계입니다. 내부적으로 바이트 코드 검증기(Bytecode verifier)가 이 과정을 담당합니다. 이 과정은 클래스를 로드하는 과정 중 가장 복잡한 테스트 과정이며, 가장 오랜 시간이 걸립니다. 링크로 인해 클래스를 로드하는 과정이 느려지지만 바이트 코드를 실행할 때 이런 검사를 여러 번 수행할 필요가 없기 때문에 전반적으로 효율적이며 효과적입니다. 바이트코드 검증기는 검증이 실패하면 런타임 에러(java.lang.VerifyError)를 발생시킵니다.

② Preparation

이 단계에서는 메서드 테이블 같이 JVM에서 쓰이는 자료구조나 static storage을 위해 메모리를 할당합니다. 또한, 이 단계에서 정적 필드(static field)가 만들어지고 기본값으로 초기화됩니다. 코드에 작성한 원래 값은 Initialization(초기화) 단계에서 할당되므로 아직은 초기화 블록이나 초기화 코드가 실행되지 않습니다.

③ Resolution

이 단계에서는 JVM 메모리 구성 요소인 Method Area 내의 런타임 상수 풀(run-time constant pool)에 있는 심볼릭 참조(symbolic reference)직접 참조(direct reference)로 대체합니다. 다시 말해서, 추상적인 기호를 구체적인 값으로 동적으로 결정하는 과정이라고 할 수 있습니다. JVM 명령인 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, putstatic은 런타임 상수 풀에 있는 심볼릭 참조를 사용합니다.

💡 심볼릭 참조(symbolic reference)와 직접 참조(direct reference)

심볼릭 참조(symbolic reference)란 우리가 코드를 작성하면서 사용한 class, field, method의 이름을 지칭합니다. Resolution 단계는 class, field, method 그리고 constant pool의 symbolic references를 실제 메모리 주소로 변환합니다.

❸ Initialization

Linking 과정을 거치면 Initialization 단계에서 클래스 파일의 코드를 읽게 됩니다. 이 단계는 코드에 명시된 원래 값이 정적 변수에 할당되고, 정적 초기화 블록이 실행되는 클래스 로딩의 마지막 과정입니다. 이 작업은 클래스의 위에서 아래로 실행되며, 클래스 계층 구조에서는 부모에서 자식까지 한 줄씩 실행됩니다. 클래스 로더를 통한 클래스 탑재 과정이 끝나면 본격적으로 JVM에서 클래스 파일을 구동시킬 준비를 마치게 됩니다.


Runtime Data Areas

JVM은 프로그램의 실행에 사용되는 메모리를 런타임 데이터 영역(Runtime Data Areas)이라고 부르는 몇 가지 영역으로 나눠서 관리합니다.

JVM 단위에 속하는 힙과 메서드 영역은 JVM이 시작될 때 생성되고, JVM이 종료될 때 소멸되며, JVM 하나에 힙과 메서드 영역이 하나씩가 생성됩니다. 즉, 모든 JVM 스레드는 동일한 힙 영역과 메서드 영역을 공유합니다.

마찬가지로 클래스 단위에 속하는 런타임 상수 풀은 클래스가 생성/소멸될 때 함께 생성/소멸되며, 클래스 하나에 런타임 상수 풀도 하나가 생성 됩니다.

스레드 단위에 속하는 PC 레지스터, JVM 스택, 네이티브 메서드 스택도 스레드가 생성/소멸될 때 함께 생성/소멸되며, PC 레지스터, JVM 스택, 네이티브 메서드 스택은 스레드 당 각각 하나씩 생성됩니다.

Method Area(스레드 간 공유)

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

Heap(스레드 간 공유)

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

JVM Stacks(스레드 별 할당)

스레드가 시작되면 메서드 호출을 저장하기 위해 별도의 런타임 스택이 생성됩니다. 모든 메서드 호출에 대해 하나의 엔트리가 생성되고 런타임 스택의 맨 위에 추가(push)되는데, 이러한 엔트리를 스택 프레임(Stack Frame)이라고 합니다.

각 스택 프레임은 실행 중인 메서드가 속한 클래스의 로컬 변수 배열(Local Variable Array), 피연산자 스택(Operand Stack) 및 런타임 상수 풀에 대한 참조(Reference to Constant Pool)를 가지고 있습니다. 로컬 변수 배열은 메서드 안의 지역 변수들을 가지고 있습니다. 피연산자 스택은 메서드 내 연산을 위해서 바이트 코드 명령문들이 들어있는 공간입니다. 상수 풀에 대한 참조는 Constant Pool 참조를 위한 공간입니다. 이렇게 구성된 JVM Stack에는 메서드가 호출될 때마다 프레임(Frame)이 쌓이게 됩니다. 그리고 메서드가 정상적으로 반환되거나 메서드 호출 중에 예외가 발생하면 프레임이 제거(pop)됩니다.

PC(Program Counter) Registers(스레드 별 할당)

자바에서 스레드는 각자의 메서드를 실행합니다. 이때, 스레드 별로 동시에 실행하는 환경이 보장되어야 하므로 현재 실행 중인 JVM에서는 명령어 주소 값(Method Area의 메모리 주소)을 저장할 공간이 필요합니다. 이 부분을 PC Registers 영역이 관리하여 추적하며, 스레드들은 각각 자신만의 PC Registers를 가지고 있습니다.
만약 실행했던 메서드가 네이티브하다면 undefined가 기록됩니다. 실행했던 메서드가 네이티브하지 않다면, PC Registers는 JVM에서 사용된 명령의 주소 값을 저장하게 됩니다. 실행이 완료되면 PC 레지스터는 다음 명령의 주소로 업데이트됩니다.

Native Method Stacks(스레드 별 할당)

자바로 작성된 프로그램을 실행할 때, 순수하게 자바로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API가 존재합니다. 다른 프로그래밍 언어로 작성된 메서드들을 Native Method라고 하며, Native Method Stacks는 자바로 작성되지 않은 메서드 정보를 저장하는 영역입니다. 각각의 스레드들이 생성되면 Native Method Stacks도 스레드 별로 생성됩니다. 또한 앞의 JVM Stacks 영역처럼, Native Method가 실행되면 Stack에 해당 메서드가 쌓이게 됩니다.


Execution Engine

실행 엔진(Execution Engine)은 위의 런타임 데이터 영역에 할당된 데이터를 읽어 바이트 코드의 명령을 한 줄씩 실행합니다. 그런데 자바 바이트 코드는 기계가 바로 수행할 수 없기 때문에, 실행 엔진은 JVM 내부에서 2가지 방식을 통해 자바 바이트 코드를 기계가 실행할 수 있는 형태로 변경합니다.

Interpreter

인터프리터는 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행합니다. 하나씩 해석하고 실행하기 때문에 바이트 코드 하나하나의 해석은 빠르지만, 해석된 결과의 실행은 느리다는 단점을 가지고 있습니다. 이는 흔히 얘기하는 인터프리터 언어의 단점을 그대로 가지는 것입니다. 즉, 바이트 코드라는 '언어'는 기본적으로 인터프리터 방식으로 동작합니다.

JIT(Just-In-Time) 컴파일러

만약 인터프리터만 사용할 수 있는 경우라면, 하나의 메서드가 여러 번 호출될 때마다 인터프리터를 작동해야 합니다.
이런 인터프리터의 단점을 보완하기 위해 도입된 것이 JIT 컴파일러입니다. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식입니다. 네이티브 코드를 실행하는 것이 하나씩 인터프리팅하는 것보다 빠르고, 네이티브 코드는 캐시에 보관하기 때문에 한 번 컴파일된 코드는 계속 더 빨리 실행할 수 있습니다.

그러나 JIT 컴파일러의 경우에도 단점이 있습니다. JIT 컴파일러가 컴파일하는 과정은 인터프리터가 바이트 코드를 하나씩 해석하는 것보다 훨씬 오래 걸리기 때문입니다. 그래서 만약 한 번만 실행되는 코드라면, 컴파일하지 않고 인터프리팅하는 것이 훨씬 좋습니다. 또한 네이티브 코드는 캐시에 저장되는데, 이는 비싼 자원입니다.
따라서, JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고, 일정 정도를 넘을 때에만 컴파일을 수행합니다.

JIT 컴파일러 동작 과정

  • JIT 컴파일러의 중간 코드 생성기(Intermediate Representation Generator)
    일단 바이트 코드를 중간 단계의 표현인 IR(Intermediate Representation)로 변환합니다.
  • Code Optimizer
    위에서 생성된 중간 코드를 최적화하는 역할을 합니다.
  • 타겟 코드 생성기(Target Code Generator)
    네이티브 코드(즉, 기계 코드) 생성을 담당합니다.
  • Profiler
    '핫스팟'(ex. 하나의 메서드를 여러 번 호출하는 인스턴스)과 같은 성능 병목 현상을 찾는 특수 구성 요소입니다.

Runtime 정리

클래스 로더(Class Loader)가 컴파일된 자바 바이트 코드를 런타임 데이터 영역(Runtime Data Areas)에 로드하고, 실행 엔진(Execution Engine)이 자바 바이트 코드를 실행한다.


📌 이 글은 Java 프로그램 실행 과정에 대해 학습한 내용을 정리한 글입니다. 잘못된 내용은 피드백 부탁드립니다!

Reference

다시 읽어야 하는 문서

profile
정성과 진심을 담아 흔적을 기록하자💡

2개의 댓글

comment-user-thumbnail
2024년 2월 13일

너무 알기 쉽게 적어주셔서 이해가 쏙쏙 됩니다. 감사해요!

1개의 답글