본문 바로가기
개발/WHITESHIP 온라인 자바 스터디

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

by 손너잘 2020. 12. 27.

github.com/whiteship/live-study/issues/1

 

1주차 과제: JVM은 무엇이며 자바 코드는 어떻게 실행하는 것인가. · Issue #1 · whiteship/live-study

목표 자바 소스 파일(.java)을 JVM으로 실행하는 과정 이해하기. 학습할 것 JVM이란 무엇인가 컴파일 하는 방법 실행하는 방법 바이트코드란 무엇인가 JIT 컴파일러란 무엇이며 어떻게 동작하는지 JV

github.com

[목표 ]

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

 

[JVM이란 무엇인가]

JVM(Java virtual machine) 은 "자바를 실행하기 위한 가상 컴퓨터" 이다.

그림1. Operation System과 JVM, Class file의 상관관계

JVM은 바이트코드를 실행시킬 수 있으며 플랫폼에 종속적이다. 하지만 바이트코드는 JVM 위에서 동작하기 때문에 플랫폼 호환성이 있다고 볼 수 있다. 이러한 특성을 통해 WORA(Write once, run anywhere)를 가능하게 한다.

 

[컴파일 하는 방법]

기본적으로 우리는 eclipse나 intellij와 같은 ide를 사용한다. 이때 이러한 ide를 사용하는것 그 차제 만으로도 우리는 컴파일을 한다고 볼 수 있다.

그림2. 컴파일 에러를 잡아주는 IDE

그림2. 와 같이 ide는 따로 컴파일을 진행하지 않더라도 컴파일 에러를 잡아내는것을 볼 수 있다. 심지어는 Devide by Zero와 같은 런타임 에러도 warning을 띄워주기도 한다.

 

하지만 ide를 사용한다고 하더라도 우리는 전통적인 컴파일 방법을 이해해야 할 것이다.

[javac]

javac는 JDK에 포함된 자바 컴파일러이며 바이트코드를 생성한다. (ref. ko.wikipedia.org/wiki/Javac)

백기선님이 관련 Option 들을 정리해보라고 하였는데, 이게 버전마다 지원하는것, 그리고 아직 공부하지 않은 내용(module) 이 섞여있어 따로 정리하기가 쉽지 않은 것 같다.

따라서 이 부분에서는 리뷰방송에서 언급하셨던 부분만 언급하고 넘어간다.

 

일단 javac를 이용한 컴파일은 아래와 같은 명령어로 간단하게 가능하다

 

javac [FILE_NAME]

 

public class Main {
        public static void main(String[] args) {
                System.out.println("Hello Java");
        }
}

위 소스를 컴파일 하는 방법은 아래와 같다.

sms2831@ubuntu:~/Desktop/java_example$ javac Main.java 
sms2831@ubuntu:~/Desktop/java_example$ ls
Main.class  Main.java

간단하게 javac를 이용하여 class파일을 생성하였다.

 

우리는 여기에서 한가지 생각해 봐야 할 부분이 있다. 만일 상위버전의 컴파일러로 생성한 바이트코드를 하위버전의 자바기계로 실행한다면 어떻게 될까?? 또는 그 반대는 어떻게 될까?

 

실험은 jdk15와 jdk1.8을 이용하여 진행하였다.

먼저 jdk15로 생성한 바이트코드를 jdk1.8로 실행시켜 봤다.

Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: Main has been compiled by a more recent version of the Java Runtime (class file version 59.0), this version of the Java Runtime only recognizes class file versions up to 52.0
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
	at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
	at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
	at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
	at java.security.AccessController.doPrivileged(Native Method)
	at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
	at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:601)

UnsupportedClassVersionError가 뜬다. 에러의 내용을 살펴보면 클래스 파일 버전은 59.0인데 자바 런타임은 52.0까지만 지원한다고 나와있다. 즉, 상위버전에서 생성한 바이트코드는 하위의 자바기계에서 사용할 수 없다는 것 이다.

각 자바 버전과 매핑되는 class file version은 아래 테이블과 같다.

(ref. docs.oracle.com/javase/specs/jvms/se15/html/jvms-4.html)

Java SE Released Major Supported majors
1.0.2 May 1996 45 45
1.1 February 1997 45 45
1.2 December 1998 46 45 .. 46
1.3 May 2000 47 45 .. 47
1.4 February 2002 48 45 .. 48
5.0 September 2004 49 45 .. 49
6 December 2006 50 45 .. 50
7 July 2011 51 45 .. 51
8 March 2014 52 45 .. 52
9 September 2017 53 45 .. 53
10 March 2018 54 45 .. 54
11 September 2018 55 45 .. 55
12 March 2019 56 45 .. 56
13 September 2019 57 45 .. 57
14 March 2020 58 45 .. 58
15 September 2020 59 45 .. 59

백기선님의 말에 따르면 실제로 UnsupportedClassVersionError 에러를 볼일이 왕왕 있다고 한다. 프로젝트 자체는 1.8까지 지원하도록 하였는데 어떤 maven dependency는 jdk 9로 컴파일 해버린다던지... 이런 경우에 위와 같은 에러를 확인하면 빠르게 대처할 수 있을것이다.

 

문득 공부를 하다가 상위 버전이 생성한 바이트코드와 하위 버전이 생성한 바이트코드의 비교를 하고싶어졌다. 그 결과는 아래와 같다.

그림3. jdk15로 생성한 바이트코드(왼쪽)과 jdk1.8로 생성한 바이트코드(오른쪽)

당연히, 컴파일러 버전이 다름에 따라 최적화 방법도 달라졌을꺼고, 따라서 바이트코드를 다르게 생성하는건 당연한것이다. 이걸 통해 볼 수 있는 재미있는 사실은, java 개발자들의 재치랄까..? 파일 시그네쳐가 cafe babe로 시작하는 점이다. 역시 커피 맛집 자바인가..?

또 다른 점으로는 8바이트부분을 보면 왼쪽은 3b, 오른쪽은 34로 되어있다. 10진수로 바꾸면 각각 59, 52가 된다. 즉, 클래스파일의 버전을 표현한것이다.

우리는 위에서 단순한 sysout 구문임에도 불구하고 jdk15로 컴파일한 바이트코드가 jdk1.8에서 안돌아가는걸 확인했다. 근데 솔직히 단순히 생각하기에는 단순 print구문인데 안돌아간다는게 이해가 가지 않았다. 그래서 한가지 실험을 해보기로 했다. jdk15로 컴파일한 바이트코드의 버전정보를 8로 바꾸면 어떻게 될까? 결과는 아래와 같다.

그림4. jdk15로 컴파일 한 바이트코드의 클래스

ghex를 이용해서 버전부분을 3b -> 34로 바꾸고 jdk 1.8로 실행시켜보았다.

sms2831@ubuntu:~/Desktop/java_example$ /usr/local/bin/jdk1.8.0_271/bin/java Main
Hello Java

 

제대로 실행이 된다. 이를 통해 알 수 있는것은, jvm이 class 파일의 버전확인을 단순히 위의 저 필드를 확인하는것으로 끝 마친다는 것 이다. 

 

그렇다면 다른 궁금증도 생겼다. 만약에 하위에서 존재하지 않는 문법을 컴파일 하고 버전만 바꾼다면 어떻게 될까?

public class Main {
        public static void main(String[] args) {
                var test = String.valueOf(1);
                System.out.println(test);
        }
}

위와 같은 자바 소스를 jdk15로 컴파일하고 위와같은 과정을 통해 실행해 보겠다.

sms2831@ubuntu:~/Desktop/java_example$ /usr/local/bin/jdk1.8.0_271/bin/java Main
1

엥? 돌아가네..? 뭐, 이런것 뿐만 아니라 다른 많은 부분에서 호환이 되지 않으니 자바 개발자분들이 막았을것이라 생각한다. 이건 단순 호기심으로 생각한 뻘짓이니 그냥 넘어가자..

 

[바이트코드란 무엇인가]

바이트코드란 JVM이 인식할 수 있는 기계어이다. 일반적으로 우리가 이야기하는 기계어보다는 조금 더 추상적인 개념이라고 이해하면 좋다.

바이트코드라고 부르는 이유는 각 명령어가 1바이트로 이루어져 있기 때문이다.

1바이트는 8비트, 즉 2^8의 명령어를 가질 수 있으며 현재 약 200개 정도의 명령어가 정의되어 있다고 한다.

 

javap -c [CLASS_FILE] 을 통해서 바이트코드를 확인할 수 있다.

public class Main {
	public static void main(String[] args) {
		int number = 1;
		Integer numberObj = 1;	
		System.out.println(number);
		System.out.println(numberObj);
	}
}

위 코드를 컴파일 한 뒤 class파일의 바이트코드를 보면 아래와 같다.

sms2831@ubuntu:~/Desktop/java_example$ javap -c Main.class 
Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_1
       3: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       6: astore_2
       7: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
      10: iload_1
      11: invokevirtual #19                 // Method java/io/PrintStream.println:(I)V
      14: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
      17: aload_2
      18: invokevirtual #25                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
      21: return
}

어셈블리어처럼 생겼다. 각 라인에 있는 명령어(iconst, istore...)가 현재는 사람의 눈으로 볼 수 있도록 위처럼 보이지만 실제로는 1바이트 명령어이다.

 

[JIT 컴파일러란 무엇이며 어떻게 동작하는가?]

JIT(Just-in-time)은 바이트코드를 기계어로 번역하는 컴파일러이다.

C언어와 같은 컴파일러와 다른점이 있다면 실행시간에 컴파일을 진행한다는 점이다.

그렇다면 인터프리터와 다른점은 또 무엇인가에 대하여 궁금증이 생길 수 있다.

인터프리터와 다른점이라면 JIT 자주 사용되는 바이트코드를 캐시해놓고 가져다 씀으로써 성능을 끌어올린 점에있다.

따라서 반복되는 부분을 매번 컴파일 하지 않고 즉각적으로 실행시킬 수 있다.

이러한점으로 요즘에는 컴파일언어와 실행성능에서 큰 차이점을 보이지 않는다.

 

[JVM 구성 요소]

그림5. JVM의 전체 구조

JVM은 크게 Class Loader, Execution Engine, Runtime Data Area로 나눌 수 있다.

[Class Loader]

Runtime 시점에 클래스를 로드할 수 있도록 해주며 클래스의 인스턴스를 메모리에 적제해주는 역할을 한다.

[Runtime Data Areas]

Method: 모든 쓰레드가 공유하는 공유 메모리 영역, 클래스, 인터페이스, 메소드, 필드, static 변수등의 바이트 코드를 보관한다.

Haep: 동적으로 할당된 객체들이 존재하는 영역, new Object()와 같이 새로운 인스턴스를 생성하면 Heap에 할당된다.

JVM Stack: 생성되는 각 Thread 에 할당되는 메모리 영역. JAVA는 메소드를 호출할 때 마다 Frame을 생성하며 이 프래임을 JVM Stack에 저장한다. 이 영역에는 호출한 메소드의 지역변수, 매개변수, 임시변수 등 life cycle이 해당 메소드까지인 변수들이 저장된다.

PC register: PC Register는 각 스레드별로 하나씩 존재한다. Program counter의 원래 기능과 같이 수행하는 instruction의 주소를 가리킨다. 만일 Narive Method를 수행한다면 undefined상태가 된다. PC Register가리키는 주소는 Navtive Pointer일 수도 있고, Method Bytecode일 수도 있다.

Native Method Stack: Java 외의 언어로 작성된 네이티브 코드를 위한 Stak이다. JNI(Java Narive Interface)를 통해 호출되는 C/C++등의 코드를 수행하기 위함에 존재한다. JVM Stack을 사용하지 않고 Native Method Stack이 따로 존재하는 이유는 JNI를 이용하여 JVM내부를 조작하는걸 방지하기 위함이다.

[Execution Engine]

바이트코드를 실행하는 역할을 한다. 

Class Loader가 클래스를 Runtime Data Area에 배치하면 해당 바이트코드를 Execution Engine이 읽고 실행한다.

Execution Engine에는 Garbage colletor 또한 포함된다.

Garbage collector: 자바의 메모리 관리 도구이다. 기존의 C, C++과 같은 언어는 동적 메모리 할당을 진행하였으면 메모리의 후처리(반환 등..)을 모두사용자에게 맡겼다. 하지만 이러한 방식은 메모리 leak등 여러 문제점을 내포하고 있으면 프로그래밍의 어려움을 급증시킨다. 이를 해결하기 위해 자바는 GC를 자체적으로 운용함으로써 사용하지 않는 메모리를 특정 시키마다 자동으로 반환시킨다. 이때문에 GC로 인한 성능 하락등의 이슈가 존재하였지만 이 또한 점점 좋아지고 있어 현재는 크게 문제되지 않는다.

[JDK와 JRE의 차이]

그림6. JDK의 계층구조

JRE는 JDK의 일종의 subset이라고 볼 수 있다.

JRE(Java Runtime Environment)는 자바의 실행에만 필요한 라이브러리 및 기타 유틸을 가지고 있으며 

JDK(Java Development Kit) 은 자바 개발에 필요한 (JRE를 포함한)모든 데이터를 가지고 있다.

그림7. JDK의 전체 구조 (ref. https://dzone.com/articles/jvm-architecture-explained)

 

댓글