ComputerScience

Java 컴파일 과정

Albosa2lol 2024. 2. 20. 06:32

Compile?

인간이 작성한 코드를 기계가 읽을 수 있는(실행할 수 있는) 기계어로 변환하는 과정

Java 실행 과정

 
 

Compile Time 환경

 
개발자가 Java 소스코드(.java)를 작성한다.
 
Java Compiler가 Java 소스코드(.java)를 자바 바이트 코드(Java Byte Code)로 컴파일한다.
** .java → .class

Runtime 환경

 
컴파일된 자바 바이트 코드 파일(.class)을 Runtime으로 가져가는 시점에 클래스 로더(Class Loader)가 동작합니다.
 
클래스 로더(Class Loader)가 동적 로딩(Dynamic Loading)을 통해 자바 바이트 코드를 런타임 데이터 영역(Runtime Data Areas), 즉 JVM의 메모리에 로드합니다.
 
실행 엔진(Execute Engine)은 JVM 메모리에 올라온 바이트 코드들을 명령어 단위로 가져와 실행
 
인터프리터 : 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행합니다. 하나하나의 실행은 빠르나, 전체적인 실행 속도가 느리다는 단점을 가집니다.
 
JIT 컴파일러(Just-In-Time Compiler) : 인터프리터의 단점을 보완하기 위해 도입된 방식으로 바이트 코드 전체를 컴파일하여 바이너리 코드로 변경하고 이후에는 해당 메서드를 더이상 인터프리팅 하지 않고, 바이너리 코드로 직접 실행하는 방식입니다. 하나씩 인터프리팅하여 실행하는 것이 아니라 바이트 코드 전체가 컴파일된 바이너리 코드를 실행하는 것이기 때문에 전체적인 실행속도는 인터프리팅 방식보다 빠릅니다.
 
Compile Time? Runtime?
컴파일 타임(Compile Time)
컴퓨터는 0과 1로 이루어진 기계어만 이해할 수 있기 때문에 개발자가 작성한 소스 코드를 기계어로 변환해 주어야 합니다. 이 역할을 하는 것이 컴파일러이며, 이를 통해 실행 가능한 프로그램이 되는 과정을 컴파일 타임이라고 합니다.
 
런타임(Runtime)
컴파일 과정을 마친 프로그램이 사용자에 의해 실행되는 때를 의미합니다.
[주의
] Java의 경우, C나 C++이 컴파일하면 생성되는 기계어와 동일하게 생각하면 안 됩니다. 자바 언어를 컴파일 하면 바이트 코드(중간 코드)가 생성되는데, 이는 컴퓨터가 이해할 수 없기 때문입니다. 자바가 컴파일한 언어를 이해할 수 있는 기계는 컴퓨터가 아니라 JVM(Java Virtual Machine)입니다.
 

Compile Time

 
 
먼저, 개발자가 자바 소스 코드 파일(.java)을 생성하고, 자바 컴파일러(javac.exe)가 이 소스 코드 파일을 자바 바이트 코드 파일(.class)로 컴파일한다고 했습니다.
그리고 이 모든 일은 JDK(Java Development Kit)에서 이루어집니다.
 

자바 바이트 코드

 
WORA(Write Once Run Everywhere)를 구현하기 위해 JVM은 사용자 언어인 자바와 기계어의 중간 언어인 자바 바이트 코드를 사용한다.
 
자바 코드를 배포하는 가장 작은 단위이다.
 
JVM이 바이트 코드를 실행해 동작하기 때문에 CPU, OS에 대한 의존에서 벗어날 수 있다.
 
컴파일 결과물의 크기가 소스코드의 크기와 크게 다르지 않으므로 네트워크로 전송하여 실행하기 쉽다.
 
JVM이 바이트 코드를 사용함을 확인할 수 있는 사례
 
 

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() 메서드를 호출합니다.
 
 

JVM(Java Virtual Machine)

JVM은 자바 바이트 코드 파일(.class)을 읽어 실행할 수 있는 Virtual Machine을 의미합니다.
Virtual : 물리적인 형태가 아닌 소프트웨어로서 하나의 개념으로 존재하며,
Machine : 독자적으로 작동할 수 있는 메커니즘과 구조를 가지고 있어 하나의 축약된 컴퓨터와 같은 의미인 것
따라서, JVM 명세(The Java Virtual Machine Specification)를 따르기만 하면 어떤 벤더든 JVM을 개발하여 제공할 수 있습니다. 대표적인 Oracle의 Hotspot JVM 외에도 IBM JVM을 비롯한 다양한 JVM이 존재합니다.

Class Loader

 
자바는 동적 로드, 즉 컴파일타임이 아니라 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 특징이 있다. 이 동적 로드를 담당하는 부분이 JVM의 클래스 로더이다.
 
 
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 또한 VerificationPreparation, 그리고 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)이 자바 바이트 코드를 실행한다.