Java
JVM 내부 구조 (2) - 런타임 데이터 영역
Just a devlog
2023. 11. 29. 03:12
JVM 메모리 영역 - Runtime Data Area
- 자바 프로그램이 실행되면 JVM은 OS로부터 메모리를 할당받고, 그 메모리를 여러 영역으로 나누어 관리함
- JVM 메모리 영역 "Runtime Data Area"는 크게 세 가지 영역으로 구분할 수 있음 - 메소드 영역, 힙 영역, 스택 영역
- 메소드 영역과 힙 영역은 모든 스레드가 공유하는 영역
- 스택 영역과 기타 영역들은 스레드별로 생성되는 영역
메소드 영역 (static 영역)
- 클래스 로더에 의해 클래스가 로딩될 때 생성
- 클래스 로더가 읽어 들인 클래스에 대한 런타임 상수 풀, 필드 데이터, 메소드 데이터 등을 저장
- 모든 스레드가 공유하는 영역이기 때문에 공유 데이터의 경우 thread safe 해야 함
클래스 파일 구조
컴파일된 클래스 파일은 아래와 같이 구성된다.
public class MyProgram {
private static final int INFINITY = Integer.MAX_VALUE;
private static final double PI = Math.PI;
private static final String author = "JO";
public static void main(String[] args) {
int sum = sum(5, 10);
System.out.println(sum);
}
public static int sum(int a, int b) {
int sum = a + b;
return sum;
}
}
// javap -v -p -s -sysinfo -constants MyProgram
Classfile ....../src/main/java/MyProgram.class
Last modified 2023. 11. 28.; size 654 bytes
SHA-256 checksum e5e846fdfbb9ceb1f31e39aa7d92ff601ae85d9709a3c58ab8f5c6a3100726ec
Compiled from "MyProgram.java"
public class MyProgram
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #12 // MyProgram
super_class: #6 // java/lang/Object
interfaces: 0, fields: 3, methods: 3, attributes: 1
Constant pool:
#1 = Class #2 // java/lang/Integer
#2 = Utf8 java/lang/Integer
#3 = Class #4 // java/lang/Math
#4 = Utf8 java/lang/Math
#5 = Methodref #6.#7 // java/lang/Object."<init>":()V
#6 = Class #8 // java/lang/Object
#7 = NameAndType #9:#10 // "<init>":()V
#8 = Utf8 java/lang/Object
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Methodref #12.#13 // MyProgram.sum:(II)I
#12 = Class #14 // MyProgram
#13 = NameAndType #15:#16 // sum:(II)I
#14 = Utf8 MyProgram
#15 = Utf8 sum
#16 = Utf8 (II)I
#17 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#18 = Class #20 // java/lang/System
#19 = NameAndType #21:#22 // out:Ljava/io/PrintStream;
#20 = Utf8 java/lang/System
#21 = Utf8 out
#22 = Utf8 Ljava/io/PrintStream;
#23 = Methodref #24.#25 // java/io/PrintStream.println:(I)V
#24 = Class #26 // java/io/PrintStream
#25 = NameAndType #27:#28 // println:(I)V
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (I)V
#29 = Utf8 INFINITY
#30 = Utf8 I
#31 = Utf8 ConstantValue
#32 = Integer 2147483647
#33 = Utf8 PI
#34 = Utf8 D
#35 = Double 3.141592653589793d
#37 = Utf8 author
#38 = Utf8 Ljava/lang/String;
#39 = String #40 // JO
#40 = Utf8 JO
#41 = Utf8 Code
#42 = Utf8 LineNumberTable
#43 = Utf8 main
#44 = Utf8 ([Ljava/lang/String;)V
#45 = Utf8 SourceFile
#46 = Utf8 MyProgram.java
{
private static final int INFINITY = 2147483647;
descriptor: I
flags: (0x001a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL
ConstantValue: int 2147483647
private static final double PI = 3.141592653589793d;
descriptor: D
flags: (0x001a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL
ConstantValue: double 3.141592653589793d
private static final java.lang.String author = "JO";
descriptor: Ljava/lang/String;
flags: (0x001a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL
ConstantValue: String JO
public MyProgram();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #5 // 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=2, args_size=1
0: iconst_5
1: bipush 10
3: invokestatic #11 // Method sum:(II)I
6: istore_1
7: getstatic #17 // Field java/lang/System.out:Ljava/io/PrintStream;
10: iload_1
11: invokevirtual #23 // Method java/io/PrintStream.println:(I)V
14: return
LineNumberTable:
line 8: 0
line 10: 7
line 11: 14
public static int sum(int, int);
descriptor: (II)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn
LineNumberTable:
line 14: 0
line 16: 4
}
SourceFile: "MyProgram.java"
JVM 내부에서 사용되는 타입 표기자는 아래와 같다.
B byte 부호가 있는 8비트 정수
C char UTF-16 인코딩된 유니코드 문자
D double 64비트 부동 소수점
F float 32비트 부동 소수점
I int 32비트 정수
J long 64비트 정수
S short 부호가 있는 16비트 정수
V void 반환 타입 없음
Z boolean 논리값
[ reference 1차원 배열 참조
LClassName; reference 'ClassName' 클래스의 인스턴스 참조
예를 들어, ([Ljava/lang/String;)V 의 경우 리턴 타입이 void이고, 매개변수로 Sring[] 타입을 받음을 의미한다.
즉, public static void main(String[] args) 메소드의 시그니처라고 볼 수 있다.
메소드 영역에 저장되는 데이터
- 클래스 정보
- 런타임 상수 풀
- 필드 데이터 - 필드명, 타입, 접근 제어자 등
- 메소드 데이터 - 메소드 명, 리턴 타입, 파라미터 타입, 접근 제어자 등
- 메소드 코드 - 바이트 코드, Operand stack size, Local variable size, Local variable table 등
런타임 상수 풀 (Runtime Constant Pool)
Java 바이트 코드가 실행될 때 데이터가 필요하며, 이 데이터를 바이트 코드에 직접 저장하기에는 너무 크다. 따라서 데이터는 상수 풀에 저장되고, 바이트 코드는 상수 풀을 참조한다. 또한, 런타임 상수 풀은 동적 링킹에 사용된다.
동적 링킹
C/C++에서는 일반적으로 실행에 필요한 여러 파일을 Linking 과정에서 함께 컴파일되어 dll과 같은 아티팩트를 생성하는 반면, Java에서는 이러한 과정을 런타임에 동적으로 수행된다. (JVM 구현에 따라 참조 시기가 달라질 수 있음)
Java 코드가 컴파일될 때 바이트 코드가 참조하는 변수나 메소드는 상수 풀에 있는 심볼릭 레퍼런스, 즉 가상의 논리적인 위치이다. 클래스 로더에 의해 로드된 후 Verifying 단계 또는 바이트 코드가 실행되어 심볼릭 레퍼런스가 처음 사용될 때 실제 해당 데이터가 위치를 참조하게 된다.
런타임 상수 풀에는 아래 유형의 데이터를 포함한다. (위 클래스 파일의 일부 코드를 예시로 참조함)
Integer | 4 바이트 정수 리터럴 | |
Long | 8 바이트 정수 리터럴 | |
Float | 4 바이트 소수 리터럴 | |
Double | 8 바이트 소수 리터럴 | |
Utf8 | Utf8 인코딩된 문자열 리터럴 | #40 = Utf8 JO |
String | 상수 풀에 정의된 다른 Utf8 문자열을 가르키는 진입점 | #39 = String #40 |
Class | 상수 풀에 정의된 Utf8 클래스명 가르키는 진입점 | #12 = Class #14 .. #14 = Utf8 MyProgram |
NameAndType | 콜론으로 구분된 두 값을 가지며, 두 값은 각각의 진입점을 가르킴 콜론 앞의 값은 메소드명 또는 필드명 나타내는 Utf8 콜론 뒤의 값은 매개변수와 리턴 타입 또는 클래스명을 나타내는 Utf8 |
#13 = NameAndType #15:#16 .. #15 = Utf8 sum #16 = Utf8 (II)I |
Fieldref Methodref InterfaceMethodref |
(.) 으로 구분된 두 값을 가지며, 두 값은 각각의 진입점을 가르킴 콜론 앞의 값은 Class 를 가르킴 콜론 뒤의 값은 NameAndType 를 가르킴 |
#11 = Methodref #12.#13 #12 = Class #14 #13 = NameAndType #15:#16 #14 = Utf8 MyProgram #15 = Utf8 sum #16 = Utf8 (II)I |
힙 영역
- 런타임에 클래스 인스턴스와 배열을 할당하는 데 사용
- 스택 프레임은 생성된 후 크기가 변하지 않기 때문에 동적으로 크기가 변경될 수 있는 배열과 객체는 스택에 저장될 수 없음
- 스택 프레임은 힙에 저장된 인스턴스나 배열을 가리키는 참조만 저장
- 스택 프레임에 저장되는 데이터와 달리 인스턴스는 항상 힙에 저장되어 메소드가 끝나더라도 제거되지 않기 때문에 GC에 의해 제거
- 모든 스레드가 공유하는 영역이기 때문에 공유 데이터의 경우 thread safe 해야 함
힙 메모리 관리
인스턴스와 배열은 명시적으로 메모리 상에서 제거되지 않는 대신 Garbage Collector가 자동으로 메모리를 회수한다.
Java 버전 별로 힙 영역을 구분하는 기준과 GC 기법이 상이하기 때문에 추후 작성 예정
스택 영역
- 각 스레드에는 해당 스레드에서 실행되는 메소드에 대한 정보를 담고 있고, 이를 '프레임' 이라고 함
- 스택은 LIFO 데이터 구조이므로 현재 실행 중인 메소드가 스택의 최상단 프레임에 위치
- 메소드를 호출할 때마다 새 프레임이 생성되어 스택에 추가
- 메소드가 종료되거나 예외 발생 시 프레임이 제거(Pop)됨
- 일반적으로 스택 영역의 사이즈는 고정되어 있기 때문에 스레드에 할당된 스택보다 더 큰 스택이 필요한 경우 StackOverflowError 발생
- 새로운 스레드에 프레임을 할당할 메모리가 충분하지 않다면 OutOfMemory 발생
- 프레임은 네 가지 요소로 구성됨 - Local Variable Array, Operand Stack, Return Value, Runtime Contants Pool Reference
지역 변수 배열 (Local Variable Array)
- this에 대한 참조, 메소드 매개변수, 지역 변수를 포함한 메소드 실행 중에 사용되는 모든 변수를 포함
- static 메소드의 경우 배열의 0번째 슬롯부터 저장되고, 인스턴스 메소드의 경우 0번째 슬롯은 this로 지정
- 지역 변수로 등록되는 타입은 boolean, byte, char, short, int, long, float, double, reference, returnAddress 로 구성
- long과 double 타입의 데이터는 두개의 연속된 슬롯(32bit * 2)에 저장되고, 나머지 타입은 하나의 슬롯을 차지
피연산자 스택 (Operand Stack)
- 바이트 코드 명령어를 실행하는 동안 사용
- 바이트 코드 명령어는 push, pop, duplicating, swapping, execution 작업을 통해 피연산자 스택을 사용
- 따라서 지역 변수 배열과 피연산자 스택 간에 값을 이동하는 명령어가 매우 자주 발생
지역 변수 배열과 피연산자 스택 예시
public class MyProgram {
public static void main(String[] args) {
int sum = sum(5, 10);
System.out.println(sum);
}
public static int sum(int a, int b) {
int sum = a + b;
return sum;
}
}
아래 코드는 위 자바 코드를 컴파일한 바이트 코드와 지역변수 배열을 의미한다.
public static void main(java.lang.String[]);
Code:
stack=2, locals=2, args_size=1
0: iconst_5
1: bipush 10
3: invokestatic #7 // Method sum:(II)I
6: istore_1
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: return
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 args [Ljava/lang/String;
7 8 1 sum I
public static int sum(int, int);
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 a I
0 6 1 b I
4 2 2 sum I
스택 프레임 | 바이트 코드 | Operand Stack | 비고 | |
[main] | iconst_5 | 정수 리터럴 5 push | [5] | main 스택 프레임 생성 |
[main] | bipush 10 | 정수 리터럴 10 push | [5, 10] | |
[main] | invokestatic #7 | #7 sum:(II)I 메소드 호출 | [] | 두개의 매개변수를 가지므로 pop 두번 후, 매개변수로 입력 |
[main, sum] | iload_0 | Local Variable Array 슬롯 0의 값 push | [5] | sum 스택 프레임 생성 |
[main, sum] | iload_1 | Local Variable Array 슬롯 1의 값 push | [5, 10] | |
[main, sum] | iadd | 스택의 두 값을 pop 더한 값을 다시 push |
[15] | |
[main, sum] | istore_2 | 스택에서 pop Local Variable Array 슬롯 2에 저장 |
[] | |
[main, sum] | iload_2 | Local Variable Array 슬롯 2의 값 push | [15] | |
[main, sum] | ireturn | 스택에서 pop한 값 return sum 메소드 종료 |
[] | sum 스택 프레임 제거 |
[main] | invokestatic #7 | sum에서 return 받은 값을 push | [15] | invokestatic은 return 값을 push하는 것까지임 |
[main] | istore_1 | 스택에서 pop Local Variable Array 슬롯 1에 저장 |
[] | |
[main] | getstatic #13 | #13(java/lang/System.out)의 정적 필드(PrintStream) push | [System.out.PrintStream] | |
[main] | iload_1 | Local Variable Array 슬롯 1의 값 push | [System.out.PrintStream, 1] | |
[main] | invokevirtual #19 | #19 println:(I)V 메소드 호출 |
[] | 한개의 매개변수를 가지므로 pop 한번 후, 매개변수로 입력 메소드를 호출하기 위해 System.out.PrintStream을 pop 리턴 타입이 V(void)기 때문에 스택 변화 없음 |
[main] | return | main 메소드 종료 | [] | |
[] | [] | main 스택 프레임 제거 |
바이트 코드의 실행 흐름은 위와 같다.
메소드를 호출할 때마다 해당 메소드에 대한 스택 프레임이 추가되고, 해당 메소드의 바이트 코드가 실행된다. 위 예시에선 볼드 처리된 스택 프레임이 현재 실행 중인 메소드이다.
iconst_<N>과 bipush N
두 명령어 모두 정수 리터럴을 스택에 push 연산을 처리하는데 왜 다른 명령어로 처리되는지 궁금해서 찾아본 결과,
iconst_<N>은 -1 ~ 5까지의 정수 리터럴만 처리 가능하며, bipush N 은 -128 ~ 127까지의 정수 리터럴을 처리할 수 있다.
iconst_<N>은 특정한 인자가 있는 것이 아니라 iconst_m1, iconst_0, .. , iconst_5와 같이 JVM 내 이미 정의된 명령어를 통해 수행된다.
반면, bipush N은 입력할 정수를 인자(N)에 입력함으로써 처리되고 있다. 결국 N을 저장할 메모리가 추가로 필요하다는 점에서 -1 ~ 5 범위의 있는 정수를 입력할 땐 iconst_N가 사용되는 것이라고 생각된다.
추가적으로 iconst와 bipush에서 i는 integer를 의미하며, 다른 타입에 대한 명령어도 지원하고 있다. 참조