2023. 5. 14. 20:03ㆍSpring/Java
본 포스팅은 JDK, JRE, JVM에 대한 큰 그림을 그리고 각 구성요소가 어떤 기능을 하는지 깊이 있게 공부합니다.
바야흐로 금요일 저녁 javap 관련 글을 보다가 ...
아니 그래서 ClassFile은 뭘 확인할 수 있는데, 아니 그래서 JDK 9에서 뭐가 바꼈는데, 아니 그래서 JVM Architecture이 어떻게 구성되었는데 ...
이렇게 "아니 그래서 ..." 삼진 아웃 맞고 주말 내내 이어진 디깅이 포스팅이 되었습니다 🤦🏻♀️
사실 한 번쯤 파보고 싶던 내용이라 확실히 재밌게 공부했습니다. 본 글이 많은 분들에게 도움이 되길 바랍니다.
해당 포스팅의 이미지의 저작권은 본 블로그 관리자에게 있습니다.
2차 사용 시 출처 표시 부탁드립니다.
무단 배포는 삼가해 주세요.
본 포스팅은 JVM Class Loader, JVM Memory, JVM Execution Engine 로 나뉘어져 조금 더 상세한 내용을 담을 예정입니다.
개발자가 작성한 Java 코드가 실행될 때 까지 어떤 과정을 거치게 될까요?
JDK으로 개발하고 난 후, JVM의 메모리에 컴파일된 바이트 코드를 올리면, JRE를 통해서 해당 로직을 실행하게 됩니다.
본 포스팅에서는 해당 내용을 하나씩 파헤쳐보려 합니다.
JDK · JVM · JRE의 개념을 먼저 살펴보고, 이 중 JVM에 대해 깊이 있게 알아봅니다.
이를 통해 Java 파일을 컴퓨터가 실행할 수 있는 코드로 변환되고 실행되는 과정을 살펴보도록 합시다.
Java Architecture
JDK
Java Development Kit 는 Java application과 Applet을 개발하기 위해 사용되는 소프트웨어 개발 환경입니다.
JDK는 다음을 포함합니다.
- JRE (Java Runtime Environment)
- Interpreter / loader (java)
- Compiler (javac)
- Archiver (jar)
- Documentation generator (Javadoc)
- Etc...
JDK와 JRE를 이해하기 위해서는 JDK 8 → JDK 9 사이의 변화를 살펴볼 필요가 있는데요.
Java 9 Platform Module System (JPMS)으로 인해 JDK 내부 구조가 크게 변했기 때문입니다.
Java 공식 계정에서 Alex Buckley가 발표한 Modules in JDK 9 영상에서 잘 설명하고 있습니다.
해당 글에서는 핵심적인 내용만 볼 예정이기 때문에 자세한 내용은 따로 한 번쯤 찾아보시길 권장합니다.
(곧 포스팅할 예정이긴 합니다 ㅎㅎ)
📌 JDK 8 → JDK 9
Java SE 8에 이르기까지 JDK에는 다음과 문제점들이 존재했습니다.
다양한 자바 기본 패키지를 제공하는 거대한 코드 베이스를 관리하기가 굉장히 어려웠고,
공식 패키지를 공유하는 방법은 오직 Public 접근자로 열어두는 것이기 때문에,
한 패키지의 모든 코드가 모든 곳에 열릴 수 밖에 없었습니다.
패키지는 클래스를 구성하고 조직해두기 좋지만, 더 제한적인 관리가 필요했습니다.
결론적으로, Jdk 9 부터 모듈 시스템을 도입하면서 아래의 문제점들을 해결했습니다.
✔️ 무거운 JDK
Jdk 8:
JDK 자체가 너무 커서 성능을 높이기 어렵고, 낮은 성능의 기기에서 실행하기 어렵습니다.
JDK와 JRE는 monolithic으로 JDK 내에 JRE를 포함하고 있었습니다.
내장된 JAR 파일이 점점 많아지면서 해당 파일 관리가 어려워지고 크기가 커졌습니다.
JRE 사이즈가 점점 커졌는데, 가령 rt.jar
만해도 60Mb를 차지했습니다.
rt.jar
는 runtime의 약자로 ClassLoader 섹션에서 언급할 예정입니다.
이처럼 JDK가 점점 무거워지면서 Application의 성능 또한 개선하기 어려웠습니다.
Jdk 9:
필요한 모듈만 사용할 수 있도록 Java API를 73개의 모듈로 나누었습니다.
정확히는, 파일 Layout을 변경하여 rt.jar
와 같은 몇 Jar를 제거하고, .jmod
파일 속에 rt.jar
와 tools.jar
코드를 포함시켰습니다.
모두 읽어보면 좋겠지만, 특히 주목할만한 내용인 우측 jdk-9 하위의 jmods를 확인해보면,
Raw platform modules as .jmod files; thier code was formerly found in rt.jar, tools.jar, and others. Not present in JREs.
라는 내용을 확인할 수 있습니다.
이렇게 모듈로 분리함으로써, 개발자는 Jlink 도구를 생성하여
애플리케이션에 정말 필요한 모듈만 포함하는 사용자 정의 JRE를 생성할 수 있게 되었습니다.
실제 Jdk bin 디렉토리를 확인해보면 아래와 같은 도구들을 확인할 수 있습니다.
✔️ public
접근 제어자, 내부 접근 가능
Jdk 8:
사용자가 내부 API에도 액세스할 수 있기 때문에 보안에 큰 문제였습니다.
Java 시스템에 강력한 캡슐화가 없어 누구나 접근할 수 있습니다.
Jdk 9:
Jdk 9 부터 지원하는 모듈은 재사용을 위해 설계된 패키지 모음입니다.
모듈은 특정 패키지에서 원하는 부분은 공개하고 특정 부분은 재사용할 수 없게 만듭니다.
가령, java.base 패키지를 확인해보면 쉽게 이해할 수 있습니다.
java.base를 개발한 후 해당 패키지를 사용하는 모든 개발자가 java.base 내부의 모든 코드에 접근할 수 있었습니다.
때문에 java.base 내부의 sun.nio.ch
나 sun.security.provider
와 같은비핵심 API의 코드 내부까지 액세스할 수 있었습니다.
java.base 패키지에는 java.lang, java.io, java.net, java.util을 배포하면 Jdk 8까지는 모든 내부 패키지들이 공개되었습니다.
Jdk 9 부터는 module-info.java에 오직 외부에 공개하려는 패키지만을 exports로 정의함으로써 노출시킵니다.
즉, 내부 api인 sun.reflect.annotation, sun.security.provider는 정의되어 있지만 보호될 수 있는 것입니다.
JRE
Java Runtime Environment는 Java 응용 프로그램을 실행하기 위한 최소 요구 사항을 제공합니다.
자바로 만들어진 프로그램을 실행시키는데 필요한 라이브러리들과 각종 API, 그리고 자바 가상 머신 (JVM)이 포함되어 있습니다.
JRE은 다음을 포함합니다.
- Java Virtual Machine(JVM)
- Java core packages
- classes
- supporting files
JVM
❙ The abstract specification, a concrete implementation, or a runtime instance.
Java Virtual Machine은 Java 바이트 코드를 실행할 수 있는 런타임 환경을 제공하는 규격입니다.
JVM이 Java 바이트코드(.class file
)를 실행하고 컴퓨터 하드웨어가 이해할 수 있는 다른 언어(Native Machine Language)로 변환할 수 있는 플랫폼을 만듭니다.
JVM은 설치하는 것이 아니라, JRE가 설치되면 코드가 배포되면서 특정 플랫폼에 대한 JVM을 생성합니다.
이것이 바로 JVM이 다양한 하드웨어 및 소프트웨어 플랫폼에서 사용할 수 있는 이유입니다.
자, 이제 본론으로 들어가겠습니다.
Internal Structure of JVM
JVM을 크게 세 가지로 구분하여 매커니즘, 즉 작동되는 방식을 확인할 수 있습니다.
JDK에서 Java 코드를 개발하고 나면 Java Compiler에 의해 Class 파일로 변환시킵니다.
먼저, Class File을 먼저 살펴보도록 하겠습니다.
Chapter 1. Class File
▪️ Compiling: javac
▪️ Decompiling: javap
▪️ Class File Structure
Class File
Class 파일은JVM에서 실행할 수 있는 Java byte code를 포함한 .class 확장자 파일입니다.
Java 클래스 파일은 컴파일이 성공한 결과로 .java 파일에서 Java Compiler에 의해 생성됩니다.
javac 명령어로 java 파일을 class 파일로 컴파일할 수 있습니다.
가령, 아래와 같은 코드가 있다면,
void spin() {
int i;
for (i = 0; i < 100; i++) {
// Loop body is empty
}
}
컴파일러는 아래와 같이 컴파일할 것 입니다.
0 iconst_0 // Push int constant 0
1 istore_1 // Store into local variable 1 (i=0)
2 goto 8 // First time through don't increment
5 iinc 1 1 // Increment local variable 1 by 1 (i++)
8 iload_1 // Push local variable 1 (i)
9 bipush 100 // Push int constant 100
11 if_icmplt 5 // Compare and loop if less than (i < 100)
14 return // Return void when done
📌 Compiling: javac
Javac 명령어로 컴파일을 진행하여 모든 Java 소스 파일을 Class 파일로 변환합니다.
정확히는 프로그래머가 작성한 Java 코드를 Runtime 시 Interpreter 혹은 JIT Compiler가 읽을 수 있는 형태인 Java Virtual Machine Code로 변환하여 .class 확장자를 가진 파일로 저장합니다.
Class 파일은 java 파일 내 모든 클래스의 정의 별로 각각 생성됩니다.
가령, java 파일 내에 A, B 클래스가 정의 되어 있다면, A.class, B.class 파일이 생성됩니다.
또, 정의가 되어 있지 않은 java 파일은 Class파일은 생성되지 않습니다.
참고로, Nested Class는 OuterClass$InnerClass.class
와 같이 생성됩니다.
먼저 Hello.java
파일을 다음과 같이 컴파일하면 Class 파일이 생성됩니다.
$ cat Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("HELLO");
}
}
$ javac Hello.java
$ ls
Hello.java Hello.class
📌 Decompiling: javap
javap는 프로그래머가 컴파일한 코드를 확인하고 JVM의 실행을 예상할 수 있는 파일을 생성합니다.
Javac를 통해 Java Virtual Machine code를 생성할 수 있다고 했는데요.
그런데, 종종 이 코드가 어떻게 생성되는지 분석하고 싶거나, 궁금할 때가 생깁니다.
javap는 Oracle JDK가 제공하는 Java Virtual Machine code를 "virtual machine assembly language"로 불리는 형식으로 변환합니다. virtual machine assembly language는 Oracle javap 유틸리티에서 비공식적으로 불리는 이름이며, Java Virtual Machine code에 주석이 달린 일련의 코드 목록으로 구성됩니다.
Virtual machine assembly language 는 아래와 같은 형식을 가집니다.
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]
<index>: 지시어 opcode 인덱스
Java Virtual Machine code의 바이트 배열의 인덱스를 나타냅니다.
다른 관점으로 보면, 프로그램의 메소드 시작 오프셋으로 생각할 수 있습니다.
<opcode>: 명령어의 opcode에 대한 mnemonic
<operandN>: 명령어의 피연산자
<comment>: 선택 사항, 주석 구문
실제로 실행해보면 아래와 같은 코드를 확인할 수 있습니다.
$ javap -c SimplePrint.class
Compiled from "SimplePrint.java"
public class SimplePrint {
public SimplePrint();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]) throws java.lang.Exception;
Code:
0: invokestatic #7 // Method printHelloWorld:()V
3: return
public static void printHelloWorld();
Code:
0: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #18 // String Hello
5: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
각 명령어는 opcode의 작업을 추적하여 실행 과정이나 메모리 할당을 예상해볼 수 있습니다.
📌 Class File Structure
JDK 17 기준으로 Class File의 구조를 나타내보면 아래와 같습니다.
ClassFile {
magic;
minor_version;
major_version;
constant_pool_count;
constant_pool[constant_pool_count-1];
access_flags;
this_class;
super_class;
interfaces_count;
interfaces[interfaces_count];
fields_count;
fields[fields_count];
methods_count;
methods[methods_count];
attributes_count;
attributes[attributes_count];
}
더 자세한 사항은 oracle java spec을 참고하시길 바랍니다.
javap -v
option을 통해 Class File의 정보를 확인할 수도 있습니다.
이렇게 만들어진 Class 파일이 실행되려면 어떻게 해야 할까요?
JDK으로 개발하고 난 후, JVM의 메모리에 컴파일된 바이트 코드를 올리면, JRE를 통해서 해당 로직을 실행하게 됩니다.
개발을 하고 Class 파일로 생성한 것까지 확인했으니,
이 번엔 컴파일된 바이트 코드를 JVM 메모리에 올리는 과정을 알아보도록 하겠습니다.
Chapter 2. Class Loader System
Types of ClassLoader
≤ JDK 8
▪️ Application Class Loader
▪️ Extension Class Loader
▪️ Bootstrap Class Loader
≥ JDK 9
▪️ Application Class Loader
▪️ Platform Class Loader
▪️ Bootstrap Class Loader
How Do Class Loaders Work
1/ Loading
2/ Linking
▪️ Verify
▪️ Prepare
▪️ Resolve
3/ Initialization
Class Loader System
컴파일 이후 여러개의 Class 파일이 생성되는데, 이 파일들은 서로 다른 디렉터리에 위치되어 있습니다.
Class Loader System 단계에서는 ClassLoader를 통해 서로 다른 디렉터리에 분산된 파일들을 메모리에 올리는 역할을 합니다.
또, 이미지와 같은 연결된 리소스를 연결하는 역할을 하기도 합니다.
ClassLoader
ClassLoader는 말그대로 Class의 Loading을 담당하는 객체입니다.
특정 Class의 Binary name이 주어지면, 해당 클래스가 정의된 데이터를 찾거나 생성하려는 시도를 합니다.
일반적으로, Class 이름을 File 명으로 변환한 다음, 파일 시스템에서 해당 이름의 클래스 파일을 읽습니다.
Types of ClassLoader
ClassLoader는 java.base 패키지에서 확인할 수 있는 하나의 Abstract Class입니다.
해당 클래스는 Jdk 9 부터 모듈 도입으로 구조가 바뀌면서 같이 변경되었습니다.
대부분 검색을 해보면 ClassLoader 설명에서는 Java 8까지의 ClassLoader를 설명을 하는데,
그래서 실제 코드를 확인해보면 Jdk 9부터 ClassLoader의 사용이 설명과는 다른 것을 확인할 수 있었습니다.
요약하자면, Jdk 8 이전의 무거운 java API를 개별의 모듈로 분리하면서
Jdk 9 부터 JRE의 런타임에 불러오는 방식으로 변경되었고, 이하부터 해당 내용을 설명합니다.
≤ JDK 8
ClassLoader는 각 클래스 파일들의 성격에 따라 세 가지 유형으로 나눠 로딩합니다.
✔️ Application/System ClassLoader: classpath, -cp, Manifest에서 클래스 로드
✔️ Extension ClassLoader: JRE/lib/ext
에서 클래스 로드
✔️ Bootstrap ClassLoader: JRE/lib/rt.jar
에서 클래스 로드
자세한 내용은 이하 '1/ Loading' 단계의 Parent Delegation Model으로 확인할 예정이며,
본 내용은 어떤 ClassLoader가 있는지, 각 ClassLoader가 클래스를 로딩하는 위치가 다르구나를 확인하시면 됩니다.
≥ JDK 9
기본적으로, JDK 9는 기존에 존재했던 클래스 로더의 계층 구조를 유지하며, 다음과 같이 변경되었습니다.
변화된 내용을 살펴보도록 하겠습니다.
해당 내용은 Oracle JDK 9E Official Docs 을 참고하여 정리한 내용입니다.
📌 JDK 9: rt.jar 제거
Java 8까지 JDK와 JRE는 monolithic이었으며, Bootstrap ClassLoader가 클래스를 찾는 rt.jar만해도 60Mb를 차지했습니다. 이를 해결하기 위해 Java 9부터 모듈 시스템을 도입했습니다.
그 결과, JDK 9부터는 rt.jar가 제거되고 73개의 모듈로 나뉘었습니다. 자세히 말하자면, Java 9부터 파일 Layout을 변경하여 module로 불리는 .jmod 파일 속에 rt.jar와 tools.jar 코드를 포함합니다. 이렇게 모듈로 분리함으로써 개발자는 Jlink 도구를 생성하여 애플리케이션에 정말 필요한 모듈만 포함하는 사용자 정의 JRE를 생성할 수 있게 되었습니다.
✔️ Application Class Loader
: Java SE 또는 JDK 모듈을 제외한 모듈 내의 클래스에 대한 기본 로더입니다.
더 이상 URLClassLoader의 인스턴스가 아닌 internal 클래스의 인스턴스입니다.
즉, 아래 코드를 Jdk 8 이전 환경에서 실행시키면 if 절은 true가 되고, Jdk 9 이후 환경에서 실행시키면 false가 될 것입니다.
if (classLoader instanceof URLClassLoader) {
URLClassLoader urlClassLoader = (URLClassLoader) classLoader;
URL[] urls = urlClassLoader.getURLs();
// Do something with the URLs
} else {
System.out.println("Application class loader is not an instance of URLClassLoader");
}
✔️ Platform Class Loader
: Extension Class Loader → Platform Class Loader 이름 변경
: 더 이상 URLClassLoader의 인스턴스가 아닌 internal 클래스의 인스턴스
Java SE Platform의 모든 클래스는 Platform Class Loader를 통해 참조됩니다. 또한 Java SE Platform의 일부는 아니지만 Java Community Process에 따라 표준화된 모듈의 클래스도 Platform Class Loader를 통해 참조됩니다.
단순히 Platform Class Loader를 통해 클래스를 볼 수 있다고 해서 해당 클래스가 실제로 Platform Class Loader에 의해 정의되는 것은 아닙니다. Java SE 플랫폼의 일부 클래스는 Platform Class Loader에 의해 정의되는 반면, 다른 클래스는 Bootstrap Class Loader에 의해 정의됩니다. 중요한 점은 어플리케이션이 Class Loader가 어떤 플랫폼 클래스를 정의하는지에 따라 달라지지 않아야 한다는 것입니다.
JDK 9의 변경 사항으로, Bootstrap Class Loader를 부모로 사용하려면 바로 위에서 살펴본 Application LoaderClass의 코드와 같이 코드를 변경해야 할 가능성이 있습니다.
(참고: ClassLoader.getPlatformClassLoader)
✔️ Bootstrap Class Loader
: 여전히 Java Virtual Machine에 기본 내장되어 있으며, ClassLoader API에서 null로 표시
java.base와 같은 소수의 중요 모듈에서 클래스를 정의합니다.
따라서 JDK 8보다 훨씬 적은 클래스를 정의하며, 아래와 같은 상황에서 코드를 변경해야 할 수 있습니다.
-Xbootclasspath/a
로 배포되는 애플리케이션- 부모로 null인 클래스 로더를 만드는 애플리케이션
ClassLoader의 타입을 알아보았으니, 이제는 ClassLoader가 하는 일에 대해 살펴보도록 하겠습니다.
📌 How Do ClassLoader Work
ClassLoader는 크게 Loading, Linking, 그리고 Initialization의 세 가지 역할을 합니다.
- Loading: 클래스 파일를 메모리에 적재. Type에 대한 binary data 찾고 가져오기
- Linking: 클래스 파일 기본 값으로 초기화 및 검증
2.1 Verification: 가져온 type의 정확성 확인
2.2 Preparation: 클래스 변수에 메모리 할당 후 메모리를 기본값으로 초기화
2.3 Resolution: (Optionally) Symbolic References를 type에서 Direct References로 변환 - Initialization: 클래스 변수를 시작 값으로 초기화한 Java 코드를 호출
하나씩 살펴보도록 하겠습니다.
1/ Loading
JVM이 파일 로딩 시, ClassLoader가 아래와 같은 데이터를 로드하고 읽는 단계입니다.
- FQCN, Fully qualified class name (패키지명을 모두 포함한 클래스 명)
- 변수 정보 (인스턴스 변수)
- 바로 위 Parent 정보
- Class / Interface / Enum 인지 확인
java 명령어에 -verbose:class 옵션을 추가하여 탑재 과정을 확인할 수 있습니다.
$ java -verbose:class Test.java
[0.103s][info][class,load] java.lang.Object source: shared objects file
[0.108s][info][class,load] java.io.Serializable source: shared objects file
[0.109s][info][class,load] java.lang.Comparable source: shared objects file
[0.109s][info][class,load] java.lang.CharSequence source: shared objects file
[0.109s][info][class,load] java.lang.constant.Constable source: shared objects file
[0.110s][info][class,load] java.lang.constant.ConstantDesc source: shared objects file
[0.110s][info][class,load] java.lang.String source: shared objects file
[0.111s][info][class,load] java.lang.reflect.AnnotatedElement source: shared objects file
[0.111s][info][class,load] java.lang.reflect.GenericDeclaration source: shared objects file
[0.111s][info][class,load] java.lang.reflect.Type source: shared objects file
...
특정 클래스가 JVM에 로드되면, 해당 클래스 유형의 객체가 생성되고, Heap Area에 배치됩니다.
해당 클래스 타입 객체는 클래스가 JVM에 처음 로드될 때에만 생성됩니다.
Parent Delegation Model
ClassLoader.loadClass() 메소드가 런타임 시 Fully Qualified Class Name 으로 클래스를 로딩하는 역할을 합니다.
실행 후 해당 클래스가 로드되지 않았다면, Parent class loader에게 해당 작업을 위임합니다.
이 작업 위임은 재귀적으로 발생합니다.
클래스를 찾는 기준은 '개발자가 정의한 클래스 파일'인지 '확장 패키지 하위의 클래스 파일인지', 혹은 'Java에서 제공받는 클래스 파일'인지의 기준에 의해 구분됩니다. 이 구분은 따로 갖고 있는 정보가 아니라, 시도하고 없으면 다음을 찾는 형식입니다.
즉, Application → Extension → Bootstrap 순서로 아래 과정을 재귀적으로 진행합니다.
Class 찾기 → Class 없음 → Parent Class로 위임
ClassLoader.loadClass()로 Class를 찾고 없으면 부모에게 위임하여 부모 클래스가 ClassLoader.loadClass()를 호출하는 형식입니다.
JDK 8 이하에서는, 만약 Parent Class Loader가 끝까지 클래스를 찾지 못하면, URLClassLoader가 파일 시스템 자체 내 클래스를 찾아보기 위해 URLClassLoader.findClass()를 호출합니다. 만약 이번에도 못찾으면 java.lang.NoClassDefFoundError 이나 java.lang.ClassNotFoundException 오류를 던집니다.
실제 오류를 살펴보면, 아래와 같은 코드를 확인해볼 수 있습니다.
① ClassLoader는 loadClass 호출을 시도 후 없으면 Parent 재귀적 위임을 시도
② URLClassLoader의 findClass 메소드를 호출
③ 결론적으로 찾지 못해 ClassNotFoundException 오류가 발생한 것을 확인할 수 있습니다.
JDK 9 이상에서는 아래와 같이 출력됩니다.
이번에는 ClassNotFoundException가 발생할 때까지의 오류를 다시 살펴보면 다음과 같습니다.
2/ Linking
클래스 파일의 데이터를 메모리 영역에 연결합니다.
Linking 단계에서는 Verify → Prepare → Resolve 단계를 거칩니다.
✔️ 2.1 Verify
클래스 파일과 컴파일러를 유효성 판단을 위해 아래 사항을 가장 먼저 검증합니다.
- Compiler가 유효한지
- Class 파일의 포맷 혹은 구조가 올바른지
가령, 보안 스레드를 고려할 때 멀웨어와 같은 일부 프로그램이 클래스 파일에 일부 콘텐츠를 변경하거나 추가할 수 있습니다.
이 경우 byte code verifier에 의해 식별되고, "verify exception" 예외를 던집니다.
✔️ 2.2 Prepare
Verification 완료 후 preparation 단계로, 모든 변수가 기본값으로 초기화됩니다.
int 변수에는 0, 모든 객체에는 null, 모든 부울 변수에는 false 등을 할당합니다.
예를 들어 아래와 같은 클래스 내에 아래와 같은 정의가 있었다면,
JVM은 Preparation 단계에서 enabled
를 위한 메모리를 할당하고 boolean의 default value인 false
를 설정합니다.
private static final boolean enabled = true;
현재 단계에서는 무조건 default 값을 할당하고, "3/ Initialization" 에서 해당 값을 초기화합니다.
✔️ 2.3 Resolve
Symbolic references를 Runtime Constant Pool에 있는 Direct references로 대체됩니다.
예를 들어, 아래와 같은 Salutation 클래스가 있다고 해봅시다.
Loading 단계에서 아래 사용되는 객체들을 모두 로드한 상태입니다.
주의할 점은 아직 작성된 Java 코드는 실행되지 않았고, 메모리 할당과 초기화만 시켜둔 상태라는 점입니다.
즉, 클래스 fields 값은 개발자가 지정한 시작 값이 아닌 default 값입니다.
<clinit>()
(main 메소드)를 실행하기 전, Resolution 단계가 선택적으로 실행됩니다.
만약 Symbolic Reference가 실제로 사용이 되어진다면 Resolution을 진행하고,
존재는 하지만 실제 사용되지 않는다면 해당 단계를 지나칩니다.
6 | CONSTANT_Class_info | 45 |
13 | CONSTANT_Methodref_info | 6, 20 |
14 | CONSTANT_Double_info | 2.99 |
20 | CONSTANT_NameAndType_info | 51, 21 |
21 | CONSTANT_Utf8_info | "()D" |
45 | CONSTANT_Utf8_info | "java/lang/Math" |
51 | CONSTANT_Utf8_info | "random" |
Class Salutation's constant pool
이 때, choice
는 Math.random()
를 할당하기 위해 java/lang/Math
라는 Symbolic Reference를 들고 있다가,
Resolution을 위해 다음을 진행합니다.
- Reference가 될 클래스를 찾거나 필요 시 로딩
- 기호 참조를 클래스, 필드 또는 메서드에 대한 포인터 또는 오프셋과 같은 직접 참조로 변경
더 자세하게는, Salutation 클래스 Constant pool 에서 choice를 가리키는 하위 엔트리의 Symbolic Reference을 실제 객체가 저장된 Direct Reference로 변경하는 것입니다.
이 세 단계가 끝나면 Java 파일이 메모리 영역에 로드됩니다.
Resolution 단계에 대한 자세한 자료가 거의 없고 찾기 어려웠는데,
Inside the Java Virtual Machine - Bill Venners 에 constant pool 구조까지 설명되어 있습니다.
다만 오래된 책이니 현재와 동일한 내용인지 다시 한 번 확인하시길 바랍니다.
3/ Initialization
이 단계에서는 모든 정적 및 인스턴스 변수에 실제 값들이 할당됩니다.
모든 static 변수와 인스턴스 변수에 실제 값이 할당됩니다.
Class loading의 마지막 단계입니다.
Initialization 단계에는 클래스 또는 인터페이스의 초기화 메소드(<clinit>) 실행이 포함됩니다.
해당 단계에서는 클래스의 생성자 호출, static block 실행, 모든 static variables에 값 할당을 포함합니다.
클래스를 실사용active use되기 전에는 반드시 초기화Initialization 되어야 합니다.
참고로, 다음과 같은 6가지 실사용 규칙이 있습니다.
- Use new keyword.
- Invoke static methods.
- Assign values for static fields.
- Initialize class.
- Use getInstance() in Reflection API.
- Instantiation of sub-class.
Linking의 preparation 단계에서 선언한 하기 코드를 예로 들자면, 위의 단계까지는 기본값인 false로 설정되었습니다.
private static final boolean enabled = true;
Initialization 단계에서 이 변수에는 실제 값 true가 할당됩니다.
⚠️ JVM은 멀티 스레드입니다.
따라서, 여러 스레드가 동시에 동일한 클래스를 초기화하려고 할 수 있습니다.
이로 인해 동시성 문제가 발생할 수 있기 때문에, 안전하게 스레드를 처리해야 합니다.
Chapter 3. Runtime Data Area
Method Area
Heap Area
Stack
PC Register
Native Method Stack
Runtime Data Area
Runtime Data Area은 따로 더 자세한 포스팅을 준비중이기 때문에 아주 자세한 내용은 생략했습니다.
Runtime Data Area는 프로그램이 동작하는 동안 데이터들이 저장되는 공간입니다.
아래의 5개의 요소로 구성되어 있습니다.
Method Area
Method Area는 JVM 당 오직 하나만을 가지며, 아래와 같은 Class 레벨의 데이터를 저장합니다.
✔️ ClassLoader reference
✔️ Type Information
✔️ Run time constant pool
✔️ Constructor data — Per Constructor : parameter types (in order)
✔️ Method data — Per method: name, return type, parameter types (in order), modifiers, attributes
✔️ Field data — Per field: name, type, modifiers, attributes
Heap Area
모든 객체들과 그에 해당하는 인스턴스 변수들이 Heap Area에 저장됩니다.
가령, 아래와 같은 "Employee"를 호출하는 코드를 포함한 프로그램을 작성했다고 가정해봅시다.
Employee employee = new Employee();
클래스가 로드될 때, 그의 인스턴스 employee 가 생성되고 Heap Area에 로드됩니다.
Stack
스레드마다 분리된 Stack 영역을 갖습니다. Stack은 호출되는 메소드 실행을 위해 해당 메소드를 담고 있는 역할을 합니다. 메소드가 호출되면 새로운 Frame이 Stack에 생성됩니다. 이 Frame은 LIFO 로 처리되며, 메소드가 실행이 완료되면 제거됩니다.
PC Register
PC Register는 현재 실행되고 있는 명령어instruction의 주소를 저장합니다. Multi-thread 프로그래밍 환경에서 한 thread가 작업하다가 다른 thread로 잠시 CPU 점유를 넘겨주고 다시 돌아왔을 때 이전에 어떤 명령을 수행하고 있었는지를 기억하고 있어야 이전 작업을 다시 이어서 수행할 수 있겠죠.
현재 PC Register가 가르키고 있는 명령어가 실행되면, PC Register은 다음 명령어를 가르키도록 업데이트됩니다. 만약, 현재 실행되는 메소드가 'native'라면, program counter register는 정의되지 않은 상태undefined가 됩니다.
Native Method Stack
Native Method는 다른 프로그래밍 언어로 구현된 자바 메소드입니다. 가령 C 나 C++ 등으로 작성될 수 있습니다.
해당 메모리 영역은 이러한 native method들의 정보를 담는 역할을 합니다.
Chapter 4. Execution Engine
Interpreter
JIT Compiler
▪️ Interpreter vs. JIT Compiler
Garbage Collector
Execution Engine
로딩과 저장하는 과정이 끝나고 나면, JVM은 마지막 단계로 Class File을 실행시킵니다.
Execution Engine은 다음과 같은 세 가지 요소로 구성됩니다.
Interpreter
프로그램 실행 시작 시 Interpreter는 Bytecode를 한 줄씩 읽어 기계가 이해할 수 있도록 변환을 시킵니다. 마치 사전을 보고 '해석Interpret'하는 역할로 볼 수 있죠.
Interpreter의 '로드 속도'와 '실행 속도'는 매우 빠르지만, Interpreter 자체의 속도는 비교적으로 느린 편입니다. 그 이유는 모든 코드를 한 줄 한 줄 읽고 처리해야 하고, 동일한 코드 반복을 줄일 수 없어 실행 시간을 최적화할 수 없습니다.
다시 말해, Method 재사용이나 Loop와 같은 동일한 코드 블록을 실행할 때, 이를 반복적으로 처리해야 한다는 것입니다.
JIT Compiler
Just In Time Compiler는 Interpreter의 주요 단점을 극복하기 위해 도입되었습니다.
JIT Compiler는 실행되는 반복되는 코드 블록을 기억합니다.
가령, "Employee"라는 클래스 내 getEmployeeID()
라는 메소드가 선언되었다고 해봅시다.
getEmployeeID()
메서드를 1000번 호출할 때, 인터프리터는 매번 해당 코드를 실행합니다.
하지만 JIT 컴파일러는 반복되는 코드 구간segment을 식별할 수 있으며, 캐시에 native code로 저장됩니다.
덕분에, 다시 해당 코드 구간을 캐시에 저장된 네이티브 코드를 사용합니다.
JIT Compiler는 다음과 같은 네 개의 컴포넌트를 갖습니다.
- Intermediate Code Generator - intermediate code 를 생성
- Code Optimizer - 성능을 위한 intermediate code 최적화
- Target Code Generator - intermediate code를 native machine code 로 변경
- Profiler - hotspots(반복적으로 실행되는 코드) 탐색
Interpreter vs. JIT Compiler
그렇다면 언제 Interpreter가 실행되고, 언제 JIT Compiler가 실행될까요?
바로 HotSpot Code의 여부에 따라 달라집니다.
애플리케이션의 성능은 전체 코드 중 일부만 자주 실행되는 일부가 얼마나 빠르게 실행되는가에 의해 좌우됩니다.
자주 실행되는 이 영역을 애플리케이션의 핫스팟이라고 합니다.
Interpreter와 JIT Compiler를 이해하기 위한 예시를 확인해보겠습니다.
int sum = 10;
for(int i = 0 ; i <= 10; i++) {
sum += i;
}
System.out.println(sum);
👉🏻 Interpreter
Loop의 매 반복마다 sum
값을 메모리에서 꺼내와서 i
값을 더해주고, 다시 메모리에 write합니다.
매번 메모리에 접근하니, 꽤나 큰 비용이 필요한 것을 알 수 있습니다.
👉🏻 JIT Compiler
반면 JIT Compiler는 해당 코드가 HotSpot을 가지고 있다고 간주하고, 해당 코드를 최적화시킵니다. 해당 스레드의 PC Register에 Local Copy하고, 루프가 다 돌 때까지 i
를 계속해서 더합니다. 해당 루프가 종료되면 다시 메모리에 write합니다.
JIT Compiler는 컴파일 시간이 필요하기 때문에 Interpreter 보다 더 많은 시간이 필요합니다. 때문에 만약 프로그램이 단 한 번만 실행되는 것이라면 Interpreter를 사용하는 것이 낫습니다.
Garbage Collector
자세한 내용은 이전에 작성한 "Garbage Collector, 제대로 이해하기" 참고하세요.
위에서 설명한대로 JVM의 실행이 시작되기 전, 모든 객체들은 Heap Area에 저장됩니다. 하지만, 이 영역은 제한되어 있기 때문에 해당 영역을 주기적으로 청소해서 효율적이게 사용해야 합니다. 여기서 청소는 더 이상 사용하지 않는 객체들을 지우는 것을 비유한 말입니다.
Heap 메모리 영역에서 필요없는 객체를 지우는 과정을 흔히 알고있는 메모리 관리 중 한 부분인 Garbage Collection이라고 합니다.
더 이상 사용하지 않는 객체를 찾는 방법은 아래 두 가지 방법이 있습니다.
1. null 참조
아래 employee는 Employee 객체를 참조하고 있다가 null 값을 할당받게 됩니다. 이 때, Employee 객체를 가리키는 참조는 없어지게 됩니다. 따라서 해당 객체는 더 이상 참조되지 않는unreachable object로 간주됩니다.
Employee employee = new Employee();
employee = null;
2. 다른 객체 참조
결론적으로, 아래 코드에서 employee1
는 employee2
를 가리키게 됩니다.
때문에 첫 째줄에 생성한 Object 1에 해당하는 Employee 객체는 더 이상 참조 되지 않습니다.
Employee employee1 = new Employee(); //object 1
Employee employee2 = new Employee(); //object 2
employee1 = employee2;
GC는 백그라운드에서 돌고 있는 Daemon Thread 이며, 아래 두 단계로 실행됩니다.
1. Mark: GC가 메모리 상에 사용하지 않는 객체를 식별 → 위 도식의 붉은 도형 Object 5, 6, 7, 8
2. Sweep: 첫 번째 과정 중 식별된 객체를 제거
Garbage Collection에 의해서 자동으로 수행되긴 하지만, 프로그래머가 System.gc()
메소드를 호출해서 GC를 발생시킬 수도 있습니다.
📌 System.gc()
System.gc() 또는 Runtime.getRuntime() API를 직접 호출하는 건 이미 잘 알려진 Bad Practice입니다.
System.gc()가 호출되면 Full GC 이벤트를 발생시킵니다. Full GC가 Stop-the-world 진행하는 동안 모든 고객 트랜잭션이 일시 중지는데, 일반적으로 이 Full GC는 완료하는 데 오래 걸립니다.
프로그래머가 GC를 직접 호출하지 않아도 JVM가 GC를 트리거할 시점에 대한 복잡한 알고리즘로 모든 계산을 백그라운드에서 항상 작동합니다. 또, JVM이 1 ms 전에 GC 이벤트를 트리거했는데, 애플리케이션에서 System.gc()를 다시 호출할 경우를 생각해보면 문제를 확인할 수 있겠죠. 애플리케이션은 GC가 언제 실행되었는지 알 수 없기 때문에 충분히 가능한 상황입니다.
Types of Garbage Collector
JVM에는 네 가지 유형의 Garbage Collector가 있습니다:
✔️ Serial GC - GC의 가장 단순한 구현 형태이며, 단일 스레드 환경에서 실행되는 소규모 응용 프로그램을 위해 설계되었습니다. 가비지 수집을 위해 단일 스레드를 사용합니다. 실행 시 전체 응용 프로그램이 일시 중지되는 "Stop the world" 이벤트가 발생합니다. Serial GC 사용을 위해서는 -XX:+UseSerialGC
JVM 인자를 입력하면 됩니다.
✔️ Parallel GC - Throughput Collector라고도 합니다. 가비지 수집에 여러 스레드를 사용하지만 실행 중 응용 프로그램을 일시 중지합니다. Parallel GC 사용을 위해서는 -XX:+UseParallelGC
JVM 인자를 입력하면 됩니다.
✔️ G1 GC - Garbage First GC는 사용 가능한 힙 크기가 큰(4GB 이상) Multi Thread Application을 위해 설계되었습니다. Heap을 동일한 크기의 영역 집합으로 분할하고 여러 스레드를 사용하여 검색합니다. G1GC는 가비지가 가장 많은 영역을 파악하여 해당 영역에서 가비지 수집을 먼저 수행합니다. G1 Garbage Collector 사용을 위해서는 -XX:+UseG1GC
JVM 인자를 입력하면 됩니다.
✔️ ZGC - Z Garbage Collector는 Concurrent 가비지 수집기로, 무거운 작업들을 처리할 때에도 스레드가 실행되는 동안 작업을 마칩니다. 즉, 스레드의 실행을 중지하지 않으면서 GC를 실행할 수 있습니다. 덕분에 기존에 GC가 애플리케이션 응답 시간에 미치던 영향이 크게 줄었습니다. ZGC의 확장 가능한 저지연 가비지 수집기로, 아래 목표를 기반으로 설계되었습니다.
- 최대 일시 중지 시간 millisecond 미만
- heap, live-set 또는 root-set 크기에 따라 일시 중지 시간이 증가하지 않음
- 8MB에서 16TB에 이르는 크기의 heap 처리
ZGC는 처음에 JDK 11에서 실험적 기능Experimental feature으로 도입되었으며 JDK 15에서 운영 준비Production Ready 상태로 선언되었습니다.
CMS(Concurrent Mark Sweep) GC라는 Garbage Collector도 있습니다. 하지만 Java 9 이후로 더 이상 사용되지 않으며 JDK14에서 G1GC를 위해 완전히 제거되었습니다.
Chapter 5.
Java Native Interface
Java는 Java Native Interface(JNI)를 통해서 native code 실행을 지원합니다.
경우에 따라 native code(C/C++, non-java)를 사용해야 할 때가 있습니다. 가령 하드웨어와 인터렉션한다거나, 메모리 관리나 성능 제약을 극복해야 하는 경우가 있습니다. JNI는 C, C++ 등의 다른 프로그래밍 언어에 대한 지원 패키지를 허용하는 일종의 Bridge 역할을 합니다.
특히 C로만 작성할 수 있는 몇몇 플랫폼 기능과 같이, Java에서 코드를 지원하지 않는 경우에 유용합니다. native 키워드를 사용하여 해당 메서드가 native 라이브러리로 구현되었다는 것을 명시할 수 있습니다. System.loadLibrary()
를 호출해서 공유 native 라이브러리를 메모리에 로드하고 Java에서 사용 가능한 function으로 만들어야 합니다.
Chapter 6.
Native Method Libraries
Native Method Libraries는 C, C++ 및 assembly와 같은 다른 프로그래밍 언어로 작성된 라이브러리입니다. 이러한 라이브러리는 일반적으로 .dll
또는 .so
파일 형식으로 제공됩니다. 이러한 기본 라이브러리는 JNI를 통해 로드할 수 있습니다.
Chapter 7.
Common JVM Errors
ClassNotFoundException - ClassLoader가 Class.forName()
, ClassLoader.loadClass()
또는 ClassLoader.findSystemClass()
를 사용하여 클래스를 로드하려고 하지만 지정한 이름의 클래스에 대한 정의를 찾을 수 없는 경우에 발생합니다.
NoClassDefFoundError - 컴파일러가 클래스를 성공적으로 컴파일했지만 클래스 로더가 런타임에서 클래스 파일을 찾을 수 없는 경우에 발생합니다.
OutOfMemoryError - 메모리가 부족하여 JVM이 개체를 할당할 수 없고 가비지 수집기가 더 이상 사용할 수 있는 메모리를 만들 수 없을 때 발생합니다.
StackOverflowError - 스레드를 처리하는 동안 새 Stack Frame을 생성하는 동안 JVM의 공간이 부족한 경우에 발생합니다.
| Reference |
"Inside the Java Virtual Machine" Book by Bill Venners
Youtube: Modules in JDK 9 by Alex Buckley
Youtube: Stack and Heap: Memory Management In Java
Artima.com: Inside Jvm - Linking model
Slideserve.com: java-virtual-machine-jvm
Freecodecamp.org: java-virtual-machine-architecture
'Spring > Java' 카테고리의 다른 글
JDK 17 ~ 21 Release, 제대로 이해하기 (4) | 2023.10.10 |
---|---|
JDK 11 ~ 17 Release, 제대로 이해하기 (8) | 2023.06.08 |
Garbage Collector, 제대로 이해하기 (5) | 2023.01.11 |
Reactor, 제대로 이해하기, Flux Create (0) | 2022.12.08 |
Java Time, 제대로 사용하기 (1) | 2022.09.14 |
Backend Software Engineer
𝐒𝐮𝐧 · 𝙂𝙮𝙚𝙤𝙣𝙜𝙨𝙪𝙣 𝙋𝙖𝙧𝙠