[Java] JDK, JRE, JVM, JIT

6 minute read

Published:

In this post, basic comcept of JDK, JRE, JVM is introduced.

JDK, JRE, JVM

  • JDK (Java Development Kit) 은 Java 개발 도구 모음으로, 컴파일러(javac), 디버거, 각종 툴 + JRE를 포함한다. 개발자가 코드를 작성하고 컴파일할 때 필수로 필요하다.

  • **JRE (Java Runtime Environment **) 은 Java 실행 환경으로, JVM + Java 표준 라이브러리(API)를 제공한다. 단순히 프로그램을 실행만 하고 싶을 때 JRE 만 있어도 된다.

    • Java API : Java에서 표준으로 제공하는 클래스/메소드 라이브러리 모음. java.lang, java.util, javax.swing, java.io 등 수많은 클래스/메서드 들이 존재한다. 이것들은 자바 코드로 구현되어 있고 미리 컴파일 되어 바이트코드(.class) 형태로 JRE에 존재한다. 프로그램 실행 시 JVM의 클래스 로더(ClassLoader) 가 이 .class 파일을 로딩해 메모리에 올린다.

    • 즉, JRE에는 ArrayList 같은 표준 클래스들이 이미 바이트코드 형태(.class)로 들어있다고 생각할 수 있다.

  • JVM (Java Virtual Machine) 은 Java 가상 머신으로, 바이트코드(.class)를 읽어서 OS에 맞게 실행한다.

    • 클래스 로더(Class Loader) → .class 불러오기

    • 바이트코드 검증(Verifier) → 안전성 검사

    • 실행 엔진(Execution Engine) → 해석기(Interpreter) + JIT 컴파일러(Just-In-Time)

      • Interpreter : 바이트코드를 한 줄씩 해석하면서 실행한다. CPU 입장에서 보자면 인터프리터 자체도 결국 기계어로 번역된 하나의 프로그램이고, 이 프로그램이 다른 코드(소스나 바이트코드)를 읽어서 “대신 실행”해주는 구조이다. 따라서 인터프리터 프로그램 자체는 기계어로 이미 번역되어 실행 중이다. (예: Python 인터프리터 = python.exe, Java JVM 인터프리터 = java.exe) 인터프리터는 실행 중에 소스 코드나 바이트 코드 파일을 입력으로 읽어서, 한 줄씩 분석(파싱)하고 명령어를 해석해서 대응되는 CPU 명령어를 대신 실행한다.

        예를 들어, python에서 print(1+2) 라는 구문을 보면 인터프리터가 이를 해석하여 인터프리터 내부에 덧셈 기능을 수행하는 C 함수 (add_object)를 호출한다. 그리고 인터프리터 내부에 출력 함수 (write syscall 호출 코드) 를 실행한다.

      • JIT : 처음 실행할 때 (인터프리터 모드)는 인터프리터 방식으로 바이트코드를 한 줄씩 해석하게 된다. JIT 컴파일러가 실행 중에 계속 반복되는 핫 스팟 코드를 감지한다. 그러면 그 부분의 바이트코드를 CPU가 바로 실행할 수 있는 네이티브 기계어로 변환하여 변환된 기계어는 메모리에 캥싱해둔다. 이제 인터프리터가 그 코드를 다시 만나면, 굳이 해석하지 않고 “이 부분은 JIT 컴파일된 기계어 블록을 바로 실행하라” 고 점프한다.

      • Garbage Collector : 사용하지 않는 객체 메모리 회수

    • 런타임 데이터 영역(Runtime Data Area) → 힙, 스택, 메소드 영역 등 메모리 공간

우리가 코드를 작성하고 실행할 때의 순서는 다음과 같다.

  • 컴파일 (javac) ** : javac Hello.java 을 하면 자바 소스코드 (.java) 가 **바이트코드 (.class) 로 컴파일 됨.

    • 바이트코드 : 바이트코드는 JVM 만이 이해할 수 있는 기계어이다. 실제 CPU 명령어가 아니라, JVM 명령어 집합으로 이루어져 있다.

      Javap -c Hello 로 디컴파일하면 다음과 같이 바이트코드의 모습을 볼 수 있다.

      public static void main(java.lang.String[]);
        Code:
           0: getstatic     #2   // Field java/lang/System.out:Ljava/io/PrintStream;
           3: ldc           #3   // String Hello, Bytecode!
           5: invokevirtual #4   // Method java/io/PrintStream.println:(Ljava/lang/String;)V
           8: return
      

      import java.util.ArrayList; 와 같이 Java API 를 사용하면, ArrayList 클래스의 선언부(시그니처)를 보고 컴파일 하여 .class 파일에는 “ArrayList 클래스의 add() 메소드 호출” 과 같은 바이트코드 명령이 기록된다.

  • 실행 : java Hello 로 실행하면 JVM이 Hello.class를 로드 -> 바이트코드 해석/컴파일 -> CPU가 실행

    • 클래스 로더가 .class 읽어들임

      • 클래스 로더가 Array.class 를 JRE의 라이브러리에서 찾아서 메모리에 적재.

      • 바이트코드에서 list.add(“Hello”) 같은 명령을 만나면, JVM은 이미 로딩된 ArrayList 클래스 안에서 add() 메소드를 실행한다.

        이때, 위의 Interpreter 와 JIT에서 설명했듯이 두 가지 방법이 있다.

        인터프리터 모드: 바이트코드 → JVM 내부의 C로 작성된 switch-case 루프 같은 실행기에서 해석

        JIT 컴파일 모드: 자주 쓰이는 바이트코드를 → 네이티브 기계어(x86, ARM 등)로 즉석 번역 후 CPU가 직접 실행

    • 바이트코드 검증 → 안전한지 체크

    • 실행 엔진이 바이트코드를 해석

    • 자주 실행되는 코드는 JIT 컴파일러가 네이티브 코드로 변환 → 속도 향상

    • 가비지 컬렉터(GC)가 메모리 자동 관리

Why Using JVM?

왜 Java는 C처럼 컴파일을 하여 곧바로 머신코드를 만들지 않고, 바이트코드 → JVM → JIT 같은 방식을 사용할까.

C 프로그램은 윈도우 OS와 리눅스 OS 에서 컴파일할 때 각각 따로 해야 한다. 그 이유는 첫째로, OS가 취급하는 실행파일의 형식이 다르다. 윈도우 실행 파일은 .exe 포맷(PE/COFF), 리눅스 실행 파일은 ELF 포맷을 사용한다. 또한, CPU의 차이로 인해 x86 CPU와 ARM CPU 가 취급하는 명령어가 다르기 때문이다.(윈도우 OS 이면 CPU가 반드시 x86 ISA 인 것은 아니다. Window ARM 버전도 존재한다)

반면 Java의 .class (바이트코드)는 플랫폼 독립적 이다. 어느 OS든 JVM만 있으면 그대로 실행이 가능하다(즉, 새로 컴파일 할 필요가 없다). 당연한 말이지만, JVM 자체는 OS(설치된 프로그램 실행파일의 형식이 다름)/CPU(실행파일에 들어있는 명령어가 다름) 에 따라서 다르게 설치되어야 한다.

C vs Java

C와 Java 에서의 실행 과정을 비교해보면 다음과 같다.

C 소스(.c)
   ↓ 컴파일러(gcc -S)
어셈블리(.s)
   ↓ 어셈블러(as)
오브젝트 파일(.o, 기계어 일부 + 심볼 테이블)
   ↓ 링커(ld)
실행 파일(a.out, ELF/PE 등)
   ↓ 로더(OS)
메모리에 적재 후 실행
  • 오브젝트 파일(.o): 아직 독립적으로 실행 불가, 심볼(함수, 전역변수) 해결 전 상태

  • 링킹(Linking): 여러 .o + 라이브러리 묶어 실행 파일 생성

  • 로딩(Loading): OS가 실행 파일을 메모리에 적재하고 시작

Java 소스(.java)
   ↓ javac
바이트코드(.class, JVM 명령어)
   ↓ JVM(Class Loader)
클래스 로딩 & 링크
   ↓ 실행 엔진(Interpreter/JIT)
실제 기계어 실행
  • Java에는 전통적 의미의 .o 오브젝트 파일이 없다. 대신 .class 파일이 중간 산출물 역할을 하여 심볼 정보(메서드명, 필드명)도 포함한다.

  • C는 컴파일 시점에 정적 링킹을 한다 (외부 함수 주소까지 결정). 그러나 Java는 run-time에 동적 링킹 을 한다.
    • run-time 은 프로그램이 실제로 메모리에 올라가서 실행되는 동안을 의미한다. 이 때 동적으로 필요한 클래스(.class 파일)나 메서드, 라이브러리를 찾아서 연결하는 게 동적 링킹 이다.
  • C에서 OS 로더가 실행 파일을 메모리에 올리는 역할을, Java는 JVM이 대신한다.
    • JVM의 클래스 로더(Class Loader)가 .class 파일을 메모리에 불러온다.

Install JDK

Java로 프로그램을 개발하기 위해선 JDK 가 필요하고, 바이트코드(.class) 를 단순 실행만 할 때에는 JRE 만 있어도 된다.

JDK 에는 다음이 포함된다.

  • JRE (실행 환경: JVM + 표준 라이브러리)

  • javac (컴파일러)

  • java (실행기) : JVM을 띄우고 .class 파일을 실행하는 명령어(프로그램)

  • 기타 개발용 도구 (javadoc, jar, javap, jdp 등)

    • javadoc : 소스 주석에서 API 문서 자동 생성
    • jar : 여러 .class 파일을 묶어서 JAR(Java Archive) 파일 생성
    • javap : 디스어셈블러 (바이트코드(.class 파일)를 사람이 읽을 수 있게 풀어 보여줌)
    • jdp : 디버거

다음과 같은 명령어도 JDK를 설치할 수 있다. openjdk 는 JDK의 여러 배포판 중 하나이다.

sudo apt update
sudo apt install openjdk-17-jdk -y

JDK 설치가 끝나고 Hello.java 라는 소스 코드를 입력했다고 가정하자. 이제 다음의 과정으로 컴파일 후, 프로그램을 실행할 수 있다.

javac Hello.java # compile
java Hello # run

다음의 명령어로 설치 버전을 확인할 수 있다.

java -version
javac -version

Jar

여러 개의 .class파일이 있고, 그중 Main.class가 진입점(실행 시작)이라고 하자.

project/
 ├─ com/
 │   └─ example/
 │       ├─ Main.class
 │       ├─ Service.class
 │       └─ util/
 │            └─ Helper.class
 └─ lib/
     └─ external-lib.jar

Java에서 JAR (Java ARchive) 파일은 자바 프로그램을 배포하고 실행하기 위해 .class 파일들과 관련 리소스(이미지, 설정 파일 등)를 하나로 묶어 압축한 파일 형식이다. 쉽게 말하면, 여러 .class 파일과 자바 프로젝트에 필요한 리소스를 ZIP 형태로 묶고, 실행 정보를 담은 메타데이터(MANIFEST.MF)를 포함한 패키지이다.

Leave a Comment