The Java Virtual Machine(JVM)
JRE는 자바 API와 JVM으로 구성되어 있다. JRE에서 가장 중요한 요소는 자바 바이트코드를 해석하고 실행하는 JVM(Java Virtual Machine)이다.
Virtual Machine(가상 머신)은 프로그램을 실행하기 위해 물리적 머신(즉, 컴퓨터)와 유사한 머신을 소프트웨어로 구현한 것을 말한다고 할 수 있다. 위에서 말했다시피 자바는 'Write Once Run Everywhere'라는 철학으로 시작된 프로그래밍 언어이다. 그래서 물리적인 머신과 별개의 JVM이라는 가상 머신을 기반으로 동작하도록 설계되었다. 그래서 자바 바이트코드를 실행하고자 하는 모든 하드웨어에 JVM을 동작시킴으로써 자바 실행 코드를 변경하지 않고도 모든 종류의 하드웨어에서 동작되게 한 것이다.
- JVM은 자바 세계의 OS라고 보면된다.
- 실제 OS와 메모리 구조도 비슷하다.
- OS - 프로세스(Thread-Unsafe): [Code, Data, Heap], 쓰레드(Thread-Safe): [Stack]
- JVM Runtime Data Area - Thread-Unsafe: [ByteCode, Data(static, 상수 등), Heap], Thread-Safe: [Stack]
JVM 동작 과정
JVM은 Class Loader System을 통해 Class 파일들을 JVM으로 로딩한다. 로딩된 Class 파일들을 Execution Engine을 통해 해석된다. 이렇게 해석된 프로그램은 Runtime Date Areas에 배치되어 실질적인 수행이 이루어지게 된다. 이러한 실행 과정 속에서 JVM은 필요에 따라 Thread 동기화와 Garbage Collection 같은 관리 작업을 수행한다.
Runtime Data Areas
Process로서 JVM이 프로그램을 수행하기 위해 OS로부터 할당받는 메모리 영역이다. 이 영역은 Java App, 특히 WAS를 사용할 때 가장 빈번하게 성능문제가 발생하는 영역이기도 하다. Memory Leak이나 Garbage Collection에서 발생하는 문제가 이에 해당한다.
Runtime Data Areas는 각각의 목적에 따라 5개의 영역으로 나뉜다. 그것은 각각 PC Registers, Java Virtual Machine Stacks, Native Method Stacks, Method Area, Heap이라는 명칭으로 되어있다.
- PC Register와 두 개의 Stack 영역은 각 Thread 별로 생성이 된다. (Thread-Safe)
- Method Area, Heap은 모든 Thread에게 공유된다. (Thread-Unsafe)
1. PC Registers
프로그램의 실행은 CPU에서 명령어, 즉 Instruction을 수행하는 과정으로 이루어진다. CPU는 이러한 Instruction을 수행하는 동안 필요한 정보를 레지스터(Register)라고 하는 CPU 내의 기억장치를 사용한다. Operand, Instruction에 대한 정보를 잠시 머무르게 하는 공간. 보통 하나의 CPU 내에는 여러 개의 레지스터(기억, 주소, 명령 등…)를 가지고 있다.
그러나 JVM의 Runtime Data Areas에 위치한 PC Registers는 Register-base로 구동되는 방식이 아니라 Stack-base로 작동한다. JVM은 CPU에 직접 Instruction을 수행하지 않고 Stack에서 Operand를 뽑아내어 이를 별도의 메모리 공간에 저장하는 방식을 취하고 있다. 이러한 메모리 공간을 PC Registers라고 한다.
- Java의 철학을 실현하기 위한 어쩔 수 없는 선택이라고 생각한다. 앞서 말한 레지스터는 CPU의 기억장치로 당연히 CPU에 종속될 수 밖에 없다.
- 반면 Java는 플랫폼에 있어서 독립적이다. 하지만 JVM도 OS나 CPU의 입장에서보면 머신에서 동작하는 하나의 프로세스에 지나지 않기 때문에 머신의 리소스를 사용해야 하는 것도 너무 당연하다. 그렇기 때문에 Java도 현재 작업하는 내용을 CPU에 Instruction으로 제공해야 한다. 이를 위한 버퍼공간으로서 PC Registers라는 메모리 영역을 생성한 것이다.
2.Java Virtual Machine Stacks
Thread의 수행 정보를 기록하는 Frame을 저장하는 메모리 영역이다.
- Thread 별로 하나씩 존재하며 Thread가 시작할 때 하나씩 생성된다.
- 여기에 있는 모든 데이터는 각 Thread가 소유하며, 다른 Thread는 접근이 불가능하다. 그렇기 때문에 동기화 이슈가 발생하지 않는다. (대표적인 예로, 로컬 변수가 있다)
해당 Stack은 Stack Frame들(Thread 수행 정보 기록)로 구성된다. JVM은 Stack Frame을 Java Virtual Machine Stacks에 단지 넣고 빼는 작업만 수행한다.
- WAS나 Java Application에서 어떤 문제가 있을 때 흔히 ‘kill -3 pid’로 Stack Trace 또는 Stack Dump를 얻어내어 분석을 하게 된다. 그때 나타나는 정보가 바로 이 Java Virtual Machine Stacks의 Stack Frame의 정보이다. Stack Trace는 Stack Frame을 한 라인으로 표현한 것이다
2-1. Stack Frame
Stack Frame은 Thread가 수행하고 있는 Application를 Method 단위로 기록하는 곳이다. Method의 상태 정보를 저장하는 Stack Frame은 Local Variable Section, Operand Stack, Frame data의 세 부분으로 구성되어 있다.
- Method를 실행하게 되면 Class의 메타 정보를 이용하여 적절한 크기로 생성된다.
- 그러나 이 Stack Frame의 크기는 가변이 아니며 Compile Time에 이미 결정된다. 그럴 수 있는 것이 Method 내에서 사용하는 변수나 연산에 관련된 내용, 그리고 반환 값의 Type등은 이미 Source Code 내에서 결정이 되기 때문이다.
- JVM은 생성된 Stack Frame을 Java Vitual Machine Stacks에 Push해 넣고 Method를 수행한다.
Stack Frame은 Thread가 수행하고 있는 Application를 Method 단위로 기록한다. 컴파일 시점에 크기가 정해진다. JVM은 이를 JVM Stacks에 push 해놓고 Method 수행한다.
2-1-1. Local Variable Section
Local Variable Section은 Method의 파라미터 변수와 로컬 변수들을 저장한다. 이 Local Variable Section은 0 부터 시작하는 인덱스를 가진 Array로 구성되어 있고, 이 Array의 인덱스를 통해 데이터에 접근하게 된다. Local Variable Section도 Stack Frame의 일부이므로 컴파일 시점에 크기 정해진다.
Stack Frame의 크기는 Compile Time에 정해진다고 앞서 언급하였다. 해당 Section도 일부이므로 마찬가지다. 로컬 변수나 파라미터 변수가 int 형과 같은 원시 타입(Primitive Type)인 경우는 고정된 크기로 할당된다. 그러나 만약 Object나 Array, String과 같은 객체는 그 크기가 정해져 있지 않은 가변크기이다. 이러한 값들은 어떻게 할당되는지 알아보자.
class JvmInternal{
public int method(int a, char b, long c, float d, Object e,
double f, String g, byte h, short i, boolean j){
return 0;
}
}
0 | reference | hidden this | |
1 | int | int a | |
2 | int | char b | |
3 | long | long c | |
5 | reference | Object e | |
6 | float | float d | |
7 | double | double f | |
9 | String | String g | |
10 | int | byte h | |
11 | int | short i | |
12 | int | boolean j |
참조형 변수 Object, String은 reference로 저장된다. 그렇기 때문에 가변 크기여도 상관없이 Local Variable Section은 고정된 크기를 갖는다. 자바에서 모든 객체가 저장되는 곳은 Heap 이라는 메모리 영역이다. 다시 말해 Object 정보는 Local Variable Section이나 Stack Frame이나 Stack에 직접 저장되는 것이 아니라 Heap의 위치를 말해주는 reference를 저장한다. 그리고 그 reference를 통해 실제 객체가 저장되어 있는 Heap을 찾아간다.
- Integer? int?
- int가 성능에 더 좋다. Integer는 reference형으로 저장되어 이를 사용하기 위해서 Stack에서 Heap으로 넘어가야 하기 때문이다.
- reference로 객체를 찾아다니는 작업은 cpu 연산을 필요로 한다. 그러므로 reference형은 cpu 사용률을 높인다.
char, byte, short, boolean도 int로 할당되어 자장된다.
- boolean 형을 제외하고 모든 원시타입은 JVM이 직접적으로 지원하는 형태이다. 그렇지만 byte, short, char는 Local Variable Section이나 Operand Stack에서는 int 형으로 저장되고 Heap 등 다른 곳에서는 원래의 형으로 원복하여 저장된다.
- boolean은 원복하지 않는다. JVM 내에서는 그저 숫자에 불과하다.
2-1-2. Operand Stack
이는 한 마디로 정의하면 JVM의 작업 공간이다. JVM이 프로그램을 수행하면서 연산을 위해 사용되는 데이터 및 그 결과를 Operand Stack에 집어넣고 처리하기 때문이다. 이도 역시 Array로 구성되어 있다. 그러나 인덱스를 사용하지는 않는다.
하나의 Instruction이 연산을 위해 Operand Stack 에 값을 밀어 넣으면 다음 Instruction에서는 이 값은 빼서 사용하게된다. 그리고 이 값들이 연산이 이루어진다면 그 결과가 다시 Operand Stack에 값을 밀어 넣으면 다음 Instruction에서는 이 값은 빼서 사용하게 된다.
class JvmInternal2{
public void operandStack(){
int a, b, c;
a = 5;
b = 6;
c = a + b;
}
}
해당 프로그램을 컴파일 한 후 'javap -c'를 사용하여 해당 메서드의 Bytecode를 추출하면 다음과 같다.
public void operandStack();
Code:
0: iconst_5
1: istore_1
2: bipush 6
4: istore_2
5: iload_1
6: iload_2
7: iadd
8: istore_3
9: return
이 Bytecode를 기준으로 Operand Stack과 Local Variable Stack 사이에서 데이터가 처리되는 과정은 다음과 같다.
2-1-3. Frame Data
Stack Frame을 구성하고 있는 또 하나의 영역은 바로 Frame Data이다. 이곳에는 Constant Pool Resolution 정보와 Method가 정상 종료 했을 때의 정보들, 그리고 비정상 종료 했을 시에 발생하는 Exception 관련 정보들을 저장하고 있다.
- Resolution: Class 파일에서 Java의 모든 참조 정보를 Symbolic Reference로 가지고 있다고 하였다. 이 Symbolic Reference는 JVM에서 실제로 접근할 수 있는 실질적인 Direct Reference로 변경되는데 이러한 것을 Resolution이라고 한다.
앞서 사용했던 예제에 try~catch문을 추가하여 바이트코드를 출력해보자.
class JvmInternal2_1{
public void operandStack(){
int a, b, c;
a = 5;
b = 6;
try {
c = a + b;
} catch (NullPointerException ex) {
c = 0;
}
}
}
다음과 같이 바이트코드 아래에 Exception Table이 추가된 것을 확인할 수 있다. Exception Table은 4가지 요소로 구성된다.
- from: try 블록이 시작되는 Bytecode의 엔트리 넘버
- to: try이 블록이 끝나는 엔트리 넘버
- target: try가 발생했을 때 점프해야 할 엔트리 넘버
- type: 정의한 Exception을 의미한다.
public void operandStack();
Code:
0: iconst_5
1: istore_1
2: bipush 6
4: istore_2
5: iload_1
6: iload_2
7: iadd
8: istore_3
9: goto 16
12: astore 4
14: iconst_0
15: istore_3
16: return
Exception table:
from to target type
5 9 12 Class java/lang/NullPointerException
3. Native Method Stacks
Java는 Java 외의 언어로 작성된 프로그램, API 툴킷 등과의 통합을 쉽게 하기 위하여 JNI라는 표준 규약을 제공하고 있다. 다시 말해 Native Code로 되어있는 Function의 호출을 Java 프로그램 내에서 직접 수행할 수 있고 그 결과 값을 받아 올 수도 있게 된 것이다.
- 해당 사이즈는 고정이 아니고 App에 따라 확장, 축소가 가능하다.
- 그러나 우리가 흔히 사용하고 있는 Hotspot JVM이나 IBM JVM은 두 Stack 영역의 구분을 두지 않고 있다.
해당 Stack은 작성한 언어에 맞게 생성된다. C로 작성하였다면 C Stack이 생성되어 수행되고 C++로 작성이 되었다면 C++ Stack이 생성된다.
4. Method Area
지금까지 설명했던 메모리 영역들이 각 Thread마다 할당되는 배타적인 공간인데 반해 Method Area는 모든 Thread들이 공유하는 메모리 영역이다. 이 영역은 적재된 Type(Class나 Interface)을 저장하는 논리적 메모리 공간으로 정의할 수 있다. 저장되는 정보는 Type(Class, Interface)의 Bytecode 뿐만 아니라, 모든 클래스(static) 변수, 상수(Constant pool에 저장), reference, data 등이 포함된다.
- Type Information: 가장 기본 정보 ( type 이름, type modifier(public, abstract, final 등), … )
- Constant Pool: JVM내에서 가장 중요한 곳. 말그대로 Constant 정보를 가지고 있는 부분.
- Literal 상수는 물론, Type Field(Member 변수, Class 변수), Method로의 모든 Symbolic Reference까지 지님.
- Field Information: Type에서 선언된 모든 Field 정보
- Method Information: Type에서 정의된 모든 Method 정보
- Class Variables: 말그대로 클래스 변수, static 변수
- Reference to class ClassLoader
- Reference to class Class
Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code.
method area는 논리적으로 힙의 일부이지만, 선택에 따라 GC나 압축을 해제할 수 있다. Specification에서는 메서드 영역의 위치나 컴파일된 코드를 관리하는 데 사용되는 정책을 요구하지 않는다.
- The Java® Virtual Machine Specification Java SE 18 Edition Java Virtual Machine (2.5.4 Method Area)
Method Area는 JVM이 기동할 때 생성이 되며 GC의 대상이 된다. 이 역시 벤더마다 구현이 다르다. 해당 영역은 Heap과 달리 GC의 필수 대상은 아니다.
- Hotspot JVM의 경우
Parmanent Area라는 명칭으로 특정 메모리 영역으로 구분되어 있다.- Removal of PermGen. Perm 영역은 Java 8부터 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다.
- IBM JVM의 경우 별도의 영역 구분 없이 Heap 내에 Class Object의 형태로 저장된다.
Hotspot JVM의 Method Area, Metaspace
아무래도 Hotspot JVM이 가장 많이 쓰이기 때문에 이 부분은 짚고 넘어가야 할 것 같다. Method Area가 Perm Gen라는 명칭으로 구분되었지만, Java 8부터는 Metaspace로 바뀌었다고 한다.
Java7 까지의 HotSpot JVM 구조를 보자.
<----- Java Heap -----> <--- Native Memory --->
+------+----+----+-----+-----------+--------+--------------+
| Eden | S0 | S1 | Old | Permanent | C Heap | Thread Stack |
+------+----+----+-----+-----------+--------+--------------+
<--------->
Permanent Heap
S0: Survivor 0
S1: Survivor 1
그리고 Java 8 HotSpot JVM 구조를 보자.
<----- Java Heap -----> <--------- Native Memory --------->
+------+----+----+-----+-----------+--------+--------------+
| Eden | S0 | S1 | Old | Metaspace | C Heap | Thread Stack |
+------+----+----+-----+-----------+--------+--------------+
Perm 영역은 보통 Class의 Meta 정보나 Method의 Meta 정보, Static 변수와 상수 정보들이 저장되는 공간으로 흔히 메타데이터 저장 영역이라고도 한다. 이 영역은 Java 8 부터는 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다. (다만, 기존 Perm 영역에 존재하던 Static Object는 Heap 영역으로 옮겨져서 GC의 대상이 최대한 될 수 있도록 하였다)
- 왜 Perm이 제거됐고 Metaspace 영역이 추가된 것인가? -> Metaspace 영역은 Heap이 아닌 Native 메모리 영역으로 취급하게 된다. 각종 메타 정보를 OS가 관리하는 영역으로 옮겨 Perm 영역의 사이즈 제한을 없앤 것이라 할 수 있다.
- 자주 Perm 영역에서 OutOfMemoryError가 발생했다고 한다.
Java 7 | Java 8 | |
Class 메타 데이터 | 저장 | 저장 |
Method 메타 데이터 | 저장 | 저장 |
Static Object 변수, 상수 | 저장 | Heap 영역으로 이동 |
메모리 튜닝 | Heap, Perm 영역 튜닝 | Heap 튜닝, Native 영역은 OS가 동적 조정 |
메모리 옵션 | -XX:PermSize -XX:MaxPermSize |
-XX:MetaspaceSize -XX:MaxMetaspaceSize |
JEP 122에서는 아래와 같이 기술하고 있다.
이 작업은 JRockit과 Hotspot를 통일시키려는 시도의 일환이다. JRockit 가상 머신에서는 permanent generation이 없으므로 JRockit 고객은 permanent generation을 설정하지도 않았고 할 필요도 없기 때문이다.
역주) JRockit은 Oracle에 의해 Hotspot과 통합되었다.
Hotspot의 perm gen 일부는 Java heap으로 옮기고 그 나머지는 native memory로 옮긴다.
... (생략)
제안된 Specification에 따르면 클래스 메타 데이터는 네이티브 메모리에 할당하고, interned String와 static 변수들은 Java heap으로 이동한다. Hotspot은 클래스 메타 데이터에 대한 네이티브 메모리를 명시적으로 할당하고 해제할 것이다. 새 클래스 메타 데이터의 할당은 네이티브 메모리의 사용 가능한 양에 의해 제한되며, 커맨드 라인을 통해 설정된 -XX:MaxPermSize 값으로 고정되지 않는다.
5. Heap
마지막으로 가장 친숙한 부분인 Heap이다. Java의 문제가 Memory 이슈에 집중이 되어 왔기 때문이라고 볼 수 있는데 이는 Java의 자동 Memory 해제, 즉 GC와도 연관이 깊다. 이러한 이유들로 인해 많은 사람들이 Java의 메모리 구조는 곧 Heap이라는 오해를 하고 있기도 하다. 그렇지만 Thread 고유의 정보는 Stack에 저장이 되고 Class나 Method 정보, Bytecode 등은 Method Area에 저장된다는 것을 이미 위에서 알아봤다.
Heap은 단지 Instance(또는 Object)와 Array 객체 두 가지 종류만 저장되는 공간일 뿐이다. 그리고 Heap은 모든 Thread에 공유된다. 그래서 이는 동기화 이슈가 발생할 수 있다. 원래 각 App은 철저히 분리되어서 서로 영향을 줄 수 없지만 동일한 Instance를 공유하거나 클래스(static) 변수를 사용하는 경우, 모든 쓰레드들이 접근할 수 있다.
- 동일한 인스턴스는 Heap
- 클래스 변수는 Method Area (Hotspot JVM에서는 위에서 말했다시피 Heap으로 이동)
Heap에는 메모리를 할당하는 Instruction(Bytecode로 new, newarray, anewarray, multinewarray)만 존재하고 메모리 해제를 위한 어떤 Java Code나 Bytecode도 존재하지 않는다. Java Heap의 메모리 해제는 오직 GC에 의해서만 수행된다.
Object Layout
Heap에 저장되는 Object와 Array는 모두 Header와 Data로 나뉘어져 있다. Header는 보통 고정 크기로 Object의 앞 부분에 위치하고 있고 그 뒤로 가변 크기의 Data가 들어간다.
- Hotspot JVM과 IBM JVM도 이러한 Object Layout을 따르고 있으나 약간의 차이가 있다.
Hotspot JVM의 Heap 구조
Young Generation과 Old Generation으로 나뉘어져 있는 Generational Heap 이다.
Generational Heap이란?
JVM GC 설계자들은 경험적으로 대부분의 객체가 생겨나자마자 쓰레기가 된다는 것을 알고 있었다.
- 이것을 '약한 세대 가설(weak generational hypothesis)'이라 부른다.
따라서 매번 전체를 검사하지 않고 일부만 검사할 수 있도록 generational한 구조를 고안해 내었다.
Young Generation
Young Generation은 Eden 영역과 Survivor 영역으로 구성되어 있다.
Eden 영역은 Object가 Heap에 최초로 할당되는 장소이다. Eden 영역이 꽉 차게 되면 Object의 참조 여부를 따진다. 만약 참조가 되어 있는 Live Object이면 Survivor 영역으로 넘기고 참조가 끊어진 Garbage Object이면 그냥 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 청소(Scavenge)한다.
Survivor 영역은 말 그대로 Eden 영역에서 살아남은 Object들이 잠시 기거하는 곳이다. 이 Survivor 영역은 두 개로 구성되어 있는데 Live Object를 대피시킬 때는 하나의 Survivor 영역만 사용하게 된다. 다른 한 공간은 가비지 객체를 수집하고 GC가 수행되면 해당 영역은 비게된다. 다음 GC에서는 두 Survivor의 역할이 변경된다. 이 전반의 과정을 Minor GC라고 한다.
Old Generation
Young Generation에서 Live Object로 오래 살아남아 성숙된 Object는 Old Generation으로 이동한다. 이러한 과정을 aging이라고 한다. Old Generation은 새로 Heap 에 할당되는 Object가 들어오는 것이 아니라, 비교적 오랜 시간 동안 참조가 되어 이용되어 앞으로도 계속 이용될 확률이 높은 Object 들을 저장하는 공간인 것이다. 이곳이 가득 차면 Major gc가 발생한다. Major gc는 비교적 더 많은 객체들을 처리하기 때문에 Minor gc보다 더 오래 걸린다.
정리
young generation
- 객체 대부분이 생성될 때 이곳으로 들어가며 대부분 여기서 죽는다.
- 객체는 처음에 대부분 eden에 할당된다.
- 살아남은 객체는 survivor 하나의 영역에 이동되고 다른 suvivor는 비어있다. 이곳이 가득차면 minor gc가 발생한다.
- minor gc가 발생하면 살아있는 객체들만 체크하고 나머지는 다 없애버린다.
- 살아남은 객체들 중 더 오래 쓸 것 같은 것들은 old generation으로 옮긴다.
old generation
- 이곳이 가득 차면 major gc가 발생한다.
- 비교적 오랜 시간 동안 참조가 되어 이용되어 생존 확률이 높은 Object 들을 저장하는 공간이다.
- 따라서 major gc는 더 많은 객체를 다루기 때문에 minor gc보다 더 오래 걸린다.
참고
HotSpot Virtual Machine Garbage Collection Tuning Guide
The Java® Virtual Machine Specification Java SE 18 Edition
Java Perfomance Fundamental - 김한도 저
https://www.oracle.com/java/technologies/javase/8-whats-new.html
https://johngrib.github.io/wiki/java8-why-permgen-removed/#fn:compare
'Dot Programming > Java' 카테고리의 다른 글
JVM 4 - Garbage Collection 개념 및 대상 (0) | 2022.06.04 |
---|---|
JVM 3 - Runtime Data Area에서의 객체 생성, 소멸 및 참조 과정 알아보기 (0) | 2022.06.01 |
JVM 1 - Java Architecture, JVM Specification (0) | 2022.05.31 |
[Play with Java] Java 8로 꼬리재귀 함수 만들기 (Tail Recursion) (3) | 2022.04.10 |
[Play with Java] 자바에서 오버플로우 없이 2개의 INT 평균 구하기 (0) | 2022.02.13 |