[Live Study 1주차] JVM은 무엇이며 자바 코드는 어떻게 실행하는 것인가.

이호석·2022년 6월 23일
0
post-thumbnail

자바를 사용할것이라면 자바에 대해서 능통해야 한다는 생각이 늘 있었고!
올해 초 백기선님의 Live Study라는 컨텐츠를 보게 됐고!
늦게나마 참여해보고자 이렇게 키보드를 두들깁니다!

1주차의 과제는 JVM은 무엇이며 자바 코드는 어떻게 실행하는 것인가!

세부 내용은 아래와 같다!

목표

  • 자바 소스 파일(.java)을 JVM으로 실행하는 과정 이해하기.

학습할 것

  1. JVM이란 무엇인가
  2. 컴파일 하는 방법
  3. 실행하는 방법
  4. 바이트코드란 무엇인가
  5. JIT 컴파일러란 무엇이며 어떻게 동작하는지
  6. JVM 구성 요소
  7. JDK와 JRE의 차이





1. JVM이란 무엇인가

1-1. JVM의 역할

Java에서 모든 소스 코드는 .java 확장자로 끝나는 플레인 텍스트 파일로 작성된다.
그런 다음 해당 소스 파일은 javac 컴파일러에 의해 .class파일로 컴파일 됩니다.
.class 파일은 프로세서(CPU) 고유의 코드가 아닌 JVM(Java Virtual Machine)의 언어인 바이트 코드가 포함되어 있다.
따라서 자바 런처 툴은 JVM의 인스턴스를 사용하여 애플리케이션을 실행 한다.

1-2.그렇다면 왜 굳이 직접적으로 CPU의 언어가 아닌 바이트 코드로 변환하는 공수를 들일까?

[OS에 구애받지 않는다]
바로 동일한 바이트 코드로 변환해 JVM이 어떤 OS에서도 동작할 수 있게 중개자의 역할을 하기 때문이다. 즉, Java는 OS에 구애받지 않고 재사용을 할 수 있게 해준다.
자바 응용 프로그램은 운영체제, 하드웨어가 아닌 JVM하고만 통신하고, JVM이 해당 운영체제가 이해할 수 있도록 변환하여 전달한다.
자바 응용 프로그램은 운영체제에 독립적이나, JVM은 운영체제에 종속적이라 볼 수 있다. 따라서 각 운영체제에 맞는 서로 다른 버전의 JVM이 존재한다.

[스택 기반의 가상 머신]
가상 머신이라면 물리적인 CPU에 의해 처리되는 동작을 흉내낼 수 있어야 한다. 따라서 아래의 개념을 구현(포함) 해야함

  • 소스 코드를 VM이 실행할 수 있는 바이트 코드로 변환
  • 명령어와 피연산자를 포함하는 데이터 구조
  • 함수를 실행하기 위한 콜스택
  • 다음 실행할 명령어를 가리키는 Instruction Point
  • 가상 CPU
    • Fetch(명령어 가져옴)
    • Decode(명령어 해석)
    • Execution(해석한 명령어 실행)

이를 구현하는 방식으로 스택 기반, 레지스터 기반이 존재하는데 JVM은 스택 기반의 VM이다. 이는 피연산자를 저장하고 가져올 때 스택을 활용한다. 이에대한 장단점은 아래와 같다.

장점

  • 하드웨어에 덜 의존적(레지스터, CPU를 직접 다루지 않음)
  • 명령어의 길이가 짧아짐 (다음 피연산자는 스택의 TOP에 존재하므로 따로 메모리의 주소를 사용할 필요가 없어짐)

단점

  • 명령어의 수가 많아짐
  • 스택 사용 오버헤드 존재
  • 명령어의 최적화를 할 수 없다

[심볼릭 레퍼런스]
기본 자료형(primitive data type)을 제외한 모든 타입(클래스와 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조한다.

심볼릭 레퍼런스: 이는 컴파일까지는 위 타입들을 논리적인 참조를 하지만, Runtime 시점에 실제 물리적인 주소로 대체되는 작업인 Dynamic Linking 작업을 한다.

[가비지 컬렉션]
클래스의 인스턴스는 사용자 코드에 의해 힙 영역에 명시적으로 생성되고 가비지 컬렉션에 의해 자동으로 파괴된다. Java에서는 개발자가 프로그램 코드로 메모리를 명시적으로 해제하지 않는다 때문에 GC가 더 이상 필요없는 객체를 찾아 지우는 작업을 한다.

[명확한 기본 자료형의 정의]
C, C++은 플랫폼에 따라 int형의 크기가 변함, 하지만 JVM은 기본 자료형을 명확하게 정의하여 호환성을 유지하고 플랫폼 독립성을 보장해줌


2. 컴파일 하는 방법

컴파일이란 .java형식의 파일을 JVM이 해석할 수 있는 바이트 코드로 변환하는 작업을 말한다.
즉 HelloWorld.java가 컴파일 되면 HelloWorld.class 파일로 변환되며 .class파일과 JVM을 이용해 해당 파일을 실행하게 된다.

2-1. 동작 과정

실제 동작을 직접 확인해 보자

위와 같은 메모장 파일을 C:\Java\test\HelloWorld.java에 둔다.
이후 cmd를 이용해 해당 경로로 이동하고 아래 명령어를 입력한다.

다음과 같이 아무런 메시지 없이 다음 명령어를 입력할 창이 나오면 컴파일이 완료된 것이다. 해당 폴더를 다시 열어보면 HelloWorld.java가 컴파일되어 생성된 HelloWorld.class라는 바이트 코드의 파일이 보일것이다.

2-2. 컴파일이란?

자바 컴파일러는 JVM이 해석할 수 있는 바이트 코드로 .java 파일을 변환시켜 준다.
이때 .java파일을 소스파일이라고 하고 컴파일된 .class파일을 목적파일이라고 한다.

  • 소스파일: 개발자가 작성하는 고레벨언어인 소스코드로 구성된 파일(.java 파일이 해당됨)
  • 목적파일: 소스파일을 컴파일해 생성된 파일(바이트 코드가 해당)

이러한 특성으로 모든 .java파일은 .class 바이트 코드로 변환되는데 이것 또한 Java가 OS에 관계없이 플랫폼 독립적으로 실행 가능한 환경을 제공하기 위함 입니다.


3. 바이트 코드란?

자바의 컴파일의 결과물로 .java파일을 JVM스펙의 class 파일 구조에 맞는 코드를 바이트 코드라고 한다.

3-1. 생성과정

바이트코드는 아래와 같은 과정을 거쳐야 한다.

  • 로딩(클래스 파일 가져와 JVM메모리에 로드)
  • 링킹(검증, 준비, 분석)
  • 초기화(클래스 변수들 초기화 static 필드들을 설정된 값으로 초기화)
    바이트 코드는 분명히 JVM에서 실행될 수 있는 코드이다. 즉, JVM스펙의 class파일 구조에 맞는 바이트 코드를 만들어 낼 수 있다면 어떤 언어든 JVM에서 실행될 수 있다.(예시로 클로저, 스칼라, 코틀린 등이 존재)

3-2. 실제 바이트 코드 및 구조

바이트코드는 javap 명령어로 확인할 수 있다. 방금 만든 HelloWorld.class파일을 확인해보자

❯ javap -v -l -p HelloWorld.class

Classfile /Users/lhoseok/Java/test/HelloWorld.class
  Last modified 2022. 6. 29.; size 427 bytes
  MD5 checksum 1ce421a2eb9ab6cb795be6e54f7d09a9
  Compiled from "HelloWorld.java"
public class HelloWorld
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // HelloWorld
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // Hello World!!
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // HelloWorld
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               HelloWorld.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               Hello World!!
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               HelloWorld
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  public HelloWorld();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World!!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
}
SourceFile: "HelloWorld.java"

여기서 {, } 사이에 존재하는 코드들을 바이트 코드라 말하고, 다음과 같은 구조로 이루어져 있다.

  • 필드나 메서드 선언부
    • descriptor: 필드의 타입이나 메서드의 파라미터 및 반환 타입
    • flags: 접근 지정자
    • code
      • stack, locals, args_size: 스택 높이, 로컬 변수 갯수, 인자 갯수
      • 실제 구현 코드: 코드 위치, 바이트 코드 명령어(instruction), 오퍼랜드(operand, 피연산자)
      • LineNuberTable: 자바 코드의 행 번호와 바이트코드의 위치 매핑 테이블
      • LocalVariableTable: 로컬 변수 테이블

대부분의 명령어들은 오퍼랜드 스택에 값을 넣고, 빼고, 읽고, 복사하고, 스왑 및 메서드 호출의 내용을 담고 있고, 어셈블리어와 비슷한 양상을 보인다.

또한 사진을 보면 기존에 우리는 생성하지 않았던 default 생성자(public HelloWorld();)가 생성된 것을 볼 수 있다. 자바를 공부해봤다면 생성자를 생략해도 기본 생성자가 생성된다는 것을 바이트 코드로 확인할 수 있다.

바이트 코드는 정말 많은 명령어들이 존재하고 이 블로그에 정말 자세히 설명되어 있으니 궁금하다면 꼭 참고하길 바란다.


4. 실행하는 방법

4-1. 실행 명령어

바이트 코드(HelloWorld.class)파일이 생성된 것을 확인했으니 아래와 같이 명령어를 입력하면 우리가 출력하고자 하는 Hello World!! 문구가 출력될 것이다.

4-2. 실행 내부 동작

오라클 Tools Reference에서 설명하는 Java application의 실행 방법은 다음과 같다.

  • java 명령어로 Java 애플리케이션 실행
  • JRE(Java Runtime Environment)를 시작
  • 지정된 클래스(public static void main(String[] args)를 포함한 클래스)를 로딩하고
  • main() 메서드를 호출한다.

4-3. 실행 내부 동작 분석

  • JRE를 시작?

과연 위의 의미는 무엇일까? JRE의 시작을 알기전에 JDK, JRE, JVM의 관계를 알아보자

위의 사진을 보면 다음과 같이 해석될 수 있다.

  • JDK(Java Development Kit): 컴파일러, 역어셈블러, 디버거, 의존관계분석등 개발에 필요한 도구 제공
  • JRE(Java Runtime Environment): 자바 실행 명려으 클래스로더와 바이트코드의 실행에 필요한 기본 라이브러리 제공
  • JVM(Java Virtual Machine): 바이트코드 인터프리터, JIT 컴파일러, 링커 명령어 세트, GC, 런타임 데이터 영역(메모리) 등 OS에 독립적으로 실행될 수 있게 도와준다.

따라서 JDK를 이용해 바이트 코드를 만들고 -> JRE를 이용해 바이트코드를 실행하면 -> JVM에서 실질적인 바이트코드의 실행이 이루어진다.

자세한 실행과정은 다음 블로그를 참고하자 -> HomoEfficio


5. JDK와 JRE의 차이

위에서 자바를 실행하는 과정을 살펴볼때 JDK와 JRE의 간략적인 차이를 배웠다.
좀 더 자세하게 JDK와 JRE의 차이를 알아보자

5-1. JDK, JRE

JDK 및 JRE의 특징을 알아보자

  • JDK

    • JDK는 Java 프로그램을 작성하는데 필요한 도구 및 이를 실행하는 JRE가 포함되어 있다.
    • 자바 컴파일러, Java 애플리케이션 런처, Appletviewer등이 포함
    • 컴파일러는 Java로 작성된 코드를 바이트 코드로 변환
    • Java 애플리케이션 런처는 JRE를 열고 필요한 클래스를 로드하고 기본 메소드를 실행한다.
  • JRE

    • JRE에는 클래스 라이브러리, JVM 및 기타 지원파일이 포함되어 있다. (디버거, 컴파일러 제외)
    • Math, swingetc, util, lang, awt 및 런타임 라이브러리같이 중요 패키지 클래스를 사용
    • Java Applet을 사용하는 경우 반드시 JRE가 설치 되어있어야 한다.

5-2. JDK, JRE(+JVM)의 차이점

위의 설명을 봤을때 JDK는 JRE를 모두 포함하고 있고 부가적으로 자바 컴파일러 Java 애플리케이션 런처, Appletviewer의 기능을 제공해준다.

반대로 JRE는 Java 프로그램을 작성하고 컴파일 하는 기능은 없으나, JVM이 실제로 구동되는 도구들의 모음이며 실행을 위한 도구들이 모여있다.

JDK는 JRE의 상위 집합이되고, JVM은 JRE의 하위 집합이다. (JDK > JRE > JVM)
이 3개 모두 플랫폼(OS)에 따라 다르지만 JVM은 특히 플랫폼에 크게 의존한다.

이들이 플랫폼에 종속적이게 되면서, 실제로 작성되는 Java 코드들은 플랫폼에 독립적일 수 있어진다. 아래 표는 실제 JDK, JRE, JVM의 차이를 보여준다.


6. JVM의 구성요소

1번에서 JVM이 무엇인지 살펴보았다 그렇다면 JVM은 어떤 요소로 구성되어있고 그들의 역할은 무엇인지 알아보자.

먼저 JVM은 Java Virtual Machine의 약자로 컴파일된 자바 바이트 코드를 실행할 수 있는 주체이며, 여기서 바이트 코드를 해당 운영체제가 이해할 수 있는 기계어로 변경을 한다(따라서 운영체제에 종속적)

JVM의 구성요소를 알기위해 제일 처음 사용했던 사진을 다시 가져왔다.

JVM은 Garbage Collector, Execution Engine, Class Loader, Runtime Data Area와 같이 크게 4개로 구성요소를 나눌 수 있다.

6-1. Class Loader

컴파일된 자바의 바이트 코드(.class)들을 엮어서 JVM이 운영체제로부터 할당받은 메모리영역인 Runtime Data Area로 적재하는 역할을 한다. (자바 애플리케이션이 실행중일때 작업 수행됨) 내부적으로 3개의 주요 기능을 수행한다.

  • Loading: 클래스의 유무 확인 (Class Not Found Exception 발생주체)
  • Linking: 해당 프로그램의 실행을 위한 Obj파일 및 라이브러리를 합쳐서 .exe파일을 만드는 과정
  • Initialization: 클래스의 모든 static members를 초기화 한다.

위 3가지 과정의 자세한 내용은 다음 링크에서 확인할 수 있다. -> 링크

6-2. Execution Engine

Class Loader에 의해 메모리에 적재된 바이트코드(클래스)들을 기계어로 번역해 명령어 단위로 실행하는 역할을 한다. 이 때 한 줄씩 읽는 Interpreter방식이 존재하고, JIT(Just-In-Time) 컴파일러 방식이 존재한다. JIT 컴파일러는 전체 바이트 코드를 네이티브 코드로 변경해 실행 엔진이 네이티브로 컴파일된 코드를 실행하는 것으로 성능을 높여준다. 다음절에 더 자세하게 알아보자

6-3. Gargage Collector

흔히 GC라고 많이 부르는 이것은 Heap 메모리 영역에 생성된 객체들 중에서 참조되지 않는 객체들을 탐색 후 제거하는 역할을 한다.
GC가 수행되는 동안 GC를 수행하는 쓰레드가 아닌 다른 쓰레드들은 모두 일시정지 상태가 된다.
따라서 만약 Full GC가 발생하면 전체 시스템이 일시정지되고 곧 장애로 이어질 수 있다.

GC의 동작은 아래 두 가지의 가정에 의해 등장했다.

  • 대부분의 객체는 금방 접근 불가능 상태가 된다.
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재

위 두가지 가정의 장점을 살리기 위해 HotSpot VM에서는 크게 2개로 물리적 공간을 나눈다.

  • Young Generation: 새롭게 생성한 객체의 대부분이 위치, 대부분이 금방 Unreachable상태가 되므로 생성되고 금방 사라짐 이를 Minor GC가 발생한다 말함
  • Old Generation: Young Generation에서 살아남은 객체가 여기로 복사됨, 대부분 Young Generation보다 크게 할당하며, 크기 덕분에 GC는 상대적으로 적게 발생함 여기서 객체가 사라지는 것을 Major GC(혹은 Full GC)라 말함

6-4. Runtime Data Area

JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.
다음과 같이 메모리의 영역이 나뉘게 된다.

  • Method Area
    • 필드의 정보, 메소드 정보, Type정보(Interface, class), Constnat Pool(상수 풀), static변수, final class 변수 등이 생성되는 영역
  • Heap Area
    • new 키워드로 생성된 객체와 배열이 생성되는 영역, 메소드 영역에 로드된 클래스만 생성이 가능하며 GC가 참조되지 않은 메모리를 확인하고 제거하는 영역이다.
  • Stack Area
    • 지역 변수, 파라미터, 리턴 값, 연산에 사용되는 임시값들이 생성되는 영역이다. 객체를 생성할때 객체의 이름이 저장되며 객체의 이름은 힙의 실제 new로 생성된 객체를 가리킨다(참조), 메소드를 호출할 때마다 개별적으로 스택이 생성된다.
  • PC Register
    • Thread가 생성될 때마다 생성되는 영역, Program Counter 즉 쓰레드가 실행되는 부분의 주소 및 명령을 저장하고 있는 영역 (CPU 레지스터와 다름)
  • Native Method Stack
    • 자바 외 언어로 작성된 네이티브 코드를 위한 메모리 영역 (C/C++등의 코드를 수행: JNI)

쓰레드가 생성됐을때 위 영역들의 공유 정보는

  • 공유: 메소드 영역, 힙 영역은 모든 쓰레드가 공유
  • 비공유: 스택영역, PC Register, Native Method Stack은 각각의 쓰레드마다 생성됨

7. JIT 컴파일러란

컴파일된 바이트코드(클래스)는 클래스 로더에 의해 Runtime Data Area로 보내지고, 이 영역의 저장된 바이트코드를 가지고 Execution Engine이 운영체제가 해석할 수 있는 언어인 기계어로 번역해 실행하게 된다. 이 때 인터프리터 방식과 JIT 컴파일러 방식이 존재한다.

7-1. Interpreter, JIT Compiler

  • 인터프리터 방식
    • 바이트 코드의 명령어를 하나씩 읽어서 해석하고 실행, 각 단위들의 개별적인 해석은 빠르나, 전체적인 인터프리팅 결과의 실행은 느리다(인터프리터 언어의 단점), 바이트코드라는 언어는 기본적으로 인터프리터 방식으로 동작한다.
  • JIT(Just-In-Time) 컴파일러
    • 인터프리터의 단점을 보완하기 위해 도입됨, 인터프리터 방식으로 실행하다 적절한 시점에 바이트코드 전체를 컴파일해 네이티브 코드로 변경하고 이후 더 이상 인터프리팅 하지 않고 네이티브 코드로 직접 실행하는 방식이다. 하나씩 인터프리팅 하는것보다 빠르며, 네이티브 코드는 캐시영역에 보관되므로 한 번 컴파일 된 코드는 재사용시에 훨씬 빠르게 수행된다.

다만 JIT 컴파일러가 컴파일하는 과정은 인터프리팅의 속도보다 훨씬 오래걸리므로, JIT을 사용하는 JVM은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하며 일정 정도를 넘으면 JIT 컴파일러가 컴파일을 수행한다.


8. 정리

어떻게 마무리 해야할까..?
백기선님의 라이브 스터디의 1 주차 스터디의 공부 및 정리를 마쳤다.
회고를 해보자면.. 아직 글이 전체적으로 다듬어지지 않고, 내 생각보단 참조를 더 많이 한 것 같아 아쉬움이 남는다. 아무래도 내 생각이란 것은 내가 정의한 개념, 철학이라 생각 된다. 이것을 하기 위해선 충분한 지식의 학습과 정확한 정보를 알고 있어야 좀 더 자유롭게 서술할 수 있을것 같은데.. 이 말은 더 열심히 공부해야겠는 말과 똑같네? 더 노력하자!
(어떤 글이던지 글을 쓰고나서 두고두고 차차 수정해 나가며 나만의 생각, 개념들을 보태봐야 할 것 같다.)

아직 1주차이지만 헷갈렸던 JVM, JDK, JRE의 개념을 제대로 알 수 있었고 더 나아가 내부적인 동작들도 전체적으로 공부할 수 있었다. 앞으로도 정진합시다!!


9. References

[블로그]

profile
꾸준함이 주는 변화를 믿습니다.

0개의 댓글