Runtime Data Area 구조
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)
Runtime Data Area에서 변수 적재 위치
App이 수행하는 과정에서 Rinmtime Data Area에서 객체의 생성, 소멸, 참조가 어떻게 이루어지는지 살펴보자. 우선 Java의 변수는 총 4가지이다.
- 클래스 변수 (Class Variables): static 변수
- 멤버 변수 (Member Variables): instance 변수
- 로컬 변수 (Local Variables): method내에 있는 변수
- 파라미터 변수 (Parameter Variables): method 호출 생성, method 블록 마지막에 소멸
JVM specification 기준으로 각 변수들이 저장되는 위치는 다음과 같다.
- 클래스(Static) 변수: 클래스 메타 데이터를 담는 method area에 저장된다. (Java8 이후 Hopspot JVM은 method area를 native area metaspace로 구현했다. 그리고 OOM 이슈로 인해 static 변수와 상수는 heap에 저장된다. 자세한 건 JVM 2 - Runtime Data Area 구조 참고)
- 인스턴스 변수: Method Area의 정보를 바탕으로 Heap에 생성되는 구현체로 볼 수 있다.
- 파라미터 변수, 로컬 변수: 이들은 각각의 스레드가 소유한 Java Stack에 저장된다. 이는 메서드 실행 과정에서 일어나는 스택 프레임 단위의 연산에 해당하는 정보를 포함한다. 여기에는 지역 변수와 매개변수, 그리고 메서드 실행에 따른 중간 연산 값 등이 포함된다. 이런 값들은 메서드가 호출될 때 스택 프레임에 push되고, 메서드가 종료되면 pop되어 제거된다.
변수 | 적재 위치 |
Hotspot JVM 구현체 (Java8 이후) |
thread 안전한가? |
클래스(static) 변수 | method area | heap | unsafe |
인스턴스 변수 | heap | heap | unsafe |
파라미터 변수 | stack | stack | safe |
로컬 변수 | stack | stack | safe |
Runtime Data Area에 적재하기
흔히 사용하는 명으로 각 static, instance, local, parameter 변수로 칭하고 각 변수가 어떻게 선언되고 어디에 할당되는지 예제를 통해 알아보자. 해당 과정은 Specification 기준으로 이루어진다.
- Hotspot JVM 기준으로 보면 Class 메타 데이터는 Native 메모리에 적재되고 Constant Pool, Class(static) 변수들은 Heap에 저장된다고 보면된다.
다음 코드에는 각 변수당 두 개의 Primitive 타입과 Reference 타입을 선언하였다.
public class VariableArrange {
static int ci = 3; // static 변수
static String cs = "Static"; // static 변수
int mi = 4; // instance 변수
String ms = "Member"; // instance 변수
void method(int pi, String ps){ // parameter 변수
int li = 5; // local 변수
String ls = "Local"; // local 변수
}
}
Runtime Data Area에 변수 할당을 된 것을 보면 다음과 같다.
클래스 static 변수
먼저 static 변수 ci, cs는 Method Area의 Class Variable에 할당 받는다. 그런데 Primitive Type인 int 형으로 선언한 ci의 경우는 값이 그대로 들아간 반면, String 객체로 선언한 cs는 "ref1"이라는 Reference를 가지고 있다. 해당 Reference는 Heap에 생성된 String 클래스의 인스턴스를 향하고 있으며 cs 값인 "Static"도 Instance에 저장되어 있다.
- 만약 int 대신 Integer를 사용했다면 String과 똑같이 Reference로 대체되었을 것이다. 즉, 변수의 선언도 성능에 영향을 줄 수 있다.
instance 변수
instance 변수 mi, ms는 Heap의 인스턴스에 생성된다. 해당 String 변수 또한 "ref2"라는 Reference를 가지고 있다. instance 변수 정보는 원래 Method Area의 Field Information에 저장된다. Instance 내에서 각 변수 값을 가지고 있지만 변수명은 알 수 없다. 그런데 그냥 편의상 한 번에 다 표현하였다. (실제로 instance 변수에 접근하려면 해당 Field Information을 통과해야 한다.)
parameter, local 변수
parameter 변수 pi, ps와 local 변수 li, ls는 Java Virtual Machine Stack의 Local Variables에 할당된다. 이 또한 Primitive 타입인 경우 그대로 저장되지만 객체로 선언한 경우 Reference 정보만을 가지고 있게 된다.
Thread-Safe? Unsafe?
Stack 영역에 적재되는 parameter, local 이 두 종류의 변수들은 Thread-Safe하다. Stack 영역은 Thread 마다 하나씩 할당되기 때문에 다른 쓰레드 간에 의해 접근이 불가능하다. 이는 OS 쓰레드와 같다.
클래스 변수인 static 변수는 공유 변수라는 별명이 있을 정도로 Thread 간의 공유가 자유롭다.instance 변수도 마찬가지이다. Method Area와 Heap 모두 Thread끼리 공유가 가능하기 때문에 Thread-Unsafe하다. 즉, static 변수와 instance 변수는 멀티 쓰레드 환경에서 유의해야 하
Runtime Data Area에서 객체 생성, 소멸 및 참조 과정
모든 종류의 변수들을 적재하였으니 이제 Java 코드를 수행하는 과정에서 Data Area의 작업이 어떻게 이루어지고 있는지 살펴보자. 물론 실제로는 더 복잡하겠지만 최대한 단순화하여 표현하였다.
0. 자바 프로그램 생성
- static 변수로 cv와 fcv를 생성하고 fcv는 final로 선언하였다.
- main()에는 변수 세 개를 선언하여 a, b는 인수로 받은 값을 int 형으로 저장하고 c는 addTwoArgs()라는 Method의 결과값을 저장하고 종료하게 된다.
// Runtime Data Areas Simulation
public class JvmInternal3 {
static int cv = 0;
final static int fcv = 100;
public static void main(String[] args) {
int a, b, c;
a = Integer.parseInt(args[0]);
b = Integer.parseInt(args[0]);
c = addTwoArgs(a, b);
}
static int addTwoArgs(int x, int y) {
cv = fcv;
return x + y;
}
}
해당 자바 프로그램을 class 파일로 컴파일 한 후 'javap -c' 명령어로 Bytecode를 추출하면 다음과 같다.
>> javap -c JvmInternal3
Compiled from "JvmInternal3.java"
public class jvm.internal3.JvmInternal3 {
static int cv;
static final int fcv;
public jvm.internal3.JvmInternal3();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: aload_0
1: iconst_0
2: aaload
3: invokestatic #2 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
6: istore_1
7: aload_0
8: iconst_0
9: aaload
10: invokestatic #2 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
13: istore_2
14: iload_1
15: iload_2
16: invokestatic #3 // Method addTwoArgs:(II)I
19: istore_3
20: return
static int addTwoArgs(int, int);
Code:
0: bipush 100
2: putstatic #5 // Field cv:I
5: iload_0
6: iload_1
7: iadd
8: ireturn
static {};
Code:
0: iconst_0
1: putstatic #5 // Field cv:I
4: return
}
1. Class JvmInternal3가 JVM에 적재
- 우선 ClassLoader에 의해 JVM에 적재되는 과정을 거쳐야 한다.
- CLass가 로딩되면 우선 Method Aread에 Class 정보가 올라간다.
- 2번 작업이 완료되면 Heap에는 JvmInternal의 인스턴스가 하나 생성된다.
- 또한 Stack에는 이를 수행하는 Stack Frame이 생성된다.
여기까지 수행된 Bytecode는 다음과 같다.
public jvm.internal3.JvmInternal3(); // Hidden this Method
Code:
0: aload_0 // local variable 영역의 0번 인덱스에서 args의 reference를 로딩
1: invokespecial #1 // Method java/lang/Object."<init>":()V
// invokespecial : instance의 초기화 Method 를 호출
// Heap에 JVMInternal instance를 생성
4: return
static {};
Code:
0: iconst_0 // 0이라는 값을 push
1: putstatic #5 // Field cv:I
// static field cv에 값을 집어넣음
4: return
}
invokespecial : Instance의 초기화 Method를 호출하여 Heap에 Instance를 하나 생성했음을 의미한다.
aload_0 : Array로 구성되어 있는 Local Variable Section의 0번 인덱스에서 Reference를 로딩하여 Operand Stack으로 Push하라는 의미다. 여기서 0번 인덱스의 값은 프로그램 실행 시 인수로 넘겨줄 args[]의 Reference이다. 그런데 이것은 String Array이기 때문에 Method Area에 하나의 Array 객체로 생성된다. 여기서 프로그램 실행 시 인수 값이 10, 20을 사용했다고 가정한다.
그리고 static {}에서 iconst_0 과 putstatic은 Class Variable인 cv에 값 0을 집어 넣었음을 의미한다. 그러나 final static으로 선언한 fcv는 보이지 않는다. 이는 상수로 취급했기 때문이다. 그래서 Constant Pool에 들어가 있다.
2. main() Method를 수행
이제 main() Method를 수행할 차례이다.
- main() Method를 수행하게 되면 이 Method에 해당하는 Java Stack에 새로운 Stack Frame이 하나 생성되어 Push된다.
- 그리고 이 Method를 호출하면서 args[]의 Reference 데이터를 그대로 넘겨준다.
- Java Stack에는 Frame Data 라는 영역을 가지고 있다. 이 Frame Data는 Constant Pool Resolution과 Method가 종료 시 사용할 정보가 저장된다.
- Constant Pool Resolution에는 이 Method에서 사용되는 java.Iang.Integer 객체나 java.lang.String 객체의 Symbolic Reference에 대한 실제 Pointer가 저장되어 있다.
- main() Method를 호출할 hidden this()의 Reference도 마찬가지로 가지고 있다. 이는 main() Method가 정상 종료 후 복귀하기 위한 용도로 사용된다.
2-1. main() Method 내부 실행 a = Integer.parseInt(args[0]);
이제 main() Method 내부에 들어가보자. 소스 코드에서 가장 먼저 수행되는 부분은 Local Variable인 a, b, c를 선언하고 Method Parameter로 제공된 args[0]의 값을 String에서 int로 변형하여 a에 저장하는 것이다.
- Local 변수 선언은 Bytecode에서 나타나지 않고 Method의 Attribute 정보로 들어갈 뿐이다. 만약 변수를 선언하면서 값을 넣었으면 그에 대한 연산이 이루어졌을 것이다. 그러나 여기서는 변수가 선언된 후 값을 집어넣기 때문에 Bytecode에 명확하게 나타난다.
이미 선언된 a 라는 변수에 값을 집어 넣는 과정은 다음과 같다.
// 소스 코드 -> a = Integer.parseInt(args[0]);
public static void main(java.lang.String[]);
Code:
0: aload_0 // Local 변수 영역의 0번 인덱스를 load
// 0번 인덱스의 값은 args임
1: iconst_0 // Reference를 통해 가져온 args의 인덱스 값으로 0이라는 상수를 취함
// Array args 에서 어떤 값을 가져올지에 대해 array index를 지정
2: aaload // args[0]의 값을 heap에서 reference를 통해 가져옴
3: invokestatic #2 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
// parseInt 라는 Method를 호출하여 실행
6: istore_1 // Local 변수의 1번 인덱스에 위의 결과를 저장
a = Integer.parseInt(args[0]); 라는 연산을 수행하기 위해 5개의 Instruction이 수행된다.
- 우선 args[0]의 값을 알기 위해 Local 변수에 저장된 args[]의 Reference를 Operand Stack에 Push한다.
- 그 후 값을 가져올 args[]의 인덱스를 지정한다.
- Heap에 저장된 args[]의 Array Data에서 0번지의 값을 Pop하여 그 Reference로 "10"을 찾아 Operand Stack에 집어넣는다.
parseInt의 파라미터 변수를 얻었으니 이제 Integer 객체를 찾아 parseInt() Method를 수행한다. "10"이 10으로 변경되고, Local Variable Section의 1번 인덱스에 저장된다.
2-2. main() Method 내부 실행 b = Integer.parseInt(args[1]);
b = Integer.parseInt(args[1]); 도 이와 같은 과정을 거쳐 Local Variable Section의 2번 인덱스에 저장된다.
3. addTwoArgs() Method를 수행
이 부분은 a, b와는 다른 형태로 전개된다. 이미 Local Variable Section에 들어있는 변수 a, b의 값을 인수로 하여 내부의 다른 Method의 결과값을 받는 형태이다.
// 소스 코드 -> c = addTwoArgs(a, b);
public static void main(java.lang.String[]);
Code:
14: iload_1 // Local Variable 1번 인덱스 값을 load 함 (a=10)
15: iload_2 // Local Variable 2번 인덱스 값을 load 함 (b=20)
16: invokestatic #3 // Method addTwoArgs:(II)I
// Static Method addTwoArgs를 호출
이 작업을 위해 우선 Local Variable에 저장되어 있는 값을 Thread의 작업 공간인 Operand Stack으로 가져온다. 그리고 나서 이 값을 인수로 하여 addTwoArgs() 라는 Method를 호출한다. 그렇게 되면 Java Stack에 addTwoArgs() Method의 Stack Frame이 Push된다.
- main()의 Operand Stack은 값을 전달한 후 Pop이기 때문에 사라지게 된다
3-1. addTwoArgs() Method 내부 실행
이후에는 addTwoArgs() Method에서의 작업이 진행된다.
// 소스 코드 -> static int addTwoArgs(int x, int y) {
// 소스 코드 -> cv = fcv;
// 소스 코드 -> return ( x + y );
// 소스 코드 -> }
static int addTwoArgs(int, int);
Code:
0: bipush 100 // stack에 byte 값 100을 집어 넣음
// 값이 byte의 범위에 있으므로 bipush임
// 128이 넘으면 short로 인식하여 sipush로 처리함
2: putstatic #5 // Field cv:I
// static 변수 cv에 100을 집어 넣음
5: iload_0 // Local Variable 0번 인덱스에서 값을 load함
6: iload_1 // Local Variable 1번 인덱스에서 값을 load함
7: iadd // 두 int를 더함
8: ireturn // Method에서 int값을 반환
cv = fcv
먼저 addTwoArgs()의 Frame Data에는 Method addTwoArgs()를 호출한 main() Method의 Stack Frame에 대한 Reference 정보(ref)가 들어간다.
cv = fcv는 다음 그림 화살표 순대로 진행된다.
- Frame Data에 있는 ref를 통해 fcv의 위치를 찾아낸다. fcv는 final static으로 선언되었기 때문에 Constant Pool에 저장되어 있다.
- 그렇게 찾은 fcv의 값을 Operand Stack에 가져온다.
- 그리고 이 값을 Pop하여 cv로 접근하게 된다. cv는 static 변수로 선언된 class 변수이기 때문에 JvmInternal3의 Instance에 저장되어 있는 것이 아니라 Method Area의 Class Variable 영역에 저장되어 있다.
- 그렇게 Thread는 Class Variable의 cv로 접근하여 100이라는 값을 얻어 이것으로 변경한다.
addTwoArgs() 값 Return
cv를 변경하였으니 인수로 넘어온 값을 더해서 반환하는 작업을 수행할 차례이다.
- 이 작업도 이전에 해왔던 것처럼 Local Variable에서 인덱스를 통해 값을 Operand Stack으로 Push하는 것이 먼저 수행된다.
- 이후 이 두 값을 Pop하여 iadd를 통해 값을 연산한 후 결과값을 Operand Stack에 다시 Push한다.
- 이후 ireturn으로 addTwoArgs() Method의 Stack Frame에서의 작업이 완료되어 Java Stack의 반환 값은 main의 Operand Stack으로 Push된다.
- Thread는 addTwoArgs() Method의 Frame Data에 저장되어 있는 main() Method의 Stack Frame으로 이동한다.
4. main() Method의 나머지를 수행
이제 addTwoArgs()의 반환 값을 변수 c에 저장할 차례이다.
// 소스 코드 -> c = addTwoArgs(a,b);
// 소스 코드 -> }
19: istore_3 // Local Variable의 3번 인덱스에 결과를 저장
20: return // void 형 Method에서 return
Operand Stack에 있는 30이라는 값을 pop하여 Local Variable의 3번 인덱스로 Push한다. 그리고 나서 차례로 Stack Frame을 삭제하고 프로그램을 종료한다.
END
지금까지 JVM의 Runtime Data Area의 각 부분에 대해 알아봤다. 물론 JVM 동작이 이와 완벽하게 일치하지 않지만 대략적으로 맥락은 비슷하다.
Reference 사용 → CPU 사용률 증가 → 성능 저하
간단하게는 Java Stack, Method Area, Heap의 전 부분을 계속해서 Jump하는 작업을 이루는 과정에서 Reference 사용이 왜 성능에 그렇게 좋지 않은지에 대해 알아볼 수 있었다.
Memory의 특정 부분으로 Jump하는 과정은 상당히 빨리 이루어지지만 이것은 CPU의 자원을 사용하는 것이다. 그렇기 때문에 불필요하게 Reference를 남발하게 되면 CPU의 사용률이 높아진다는 것을 유추해볼 수 있다. 다음 int와 Integer를 사용하는 코드로 직접 비교해보자.
public class Test {
public static void main(String[] args) {
primitiveTypeFunc();
referenceTypeFunc();
}
static void primitiveTypeFunc() {
long start = System.currentTimeMillis();
long result = 0;
for(int i=0; i<1e9; i++){
result += i;
}
System.out.println(result);
System.out.println("primitive time: " + (System.currentTimeMillis()-start));
}
static void referenceTypeFunc() {
long start = System.currentTimeMillis();
long result = 0;
for(Integer i=0; i<1e9; i++){
result += i;
}
System.out.println(result);
System.out.println("reference time: " + (System.currentTimeMillis()-start));
}
}
간단하게 int와 Integer의 성능을 비교해보면 약 2배 넘게 나는 것을 볼 수 있다.
- int로 하는 계산은 Stack에서만 push, pop 연산으로 이루어진다.
- Integer로 하는 계산은 ref로 해당 값을 불러올 때 Stack에서 Heap으로 Memory Jump를 하기 때문에 시간이 더 오래 걸린다.
무한 재귀 함수 → Stack Frame 개수 증가 → StackOverFlowError 발생
불필요하게 Method의 Depth가 깊어지도록 참조에 참조를 거듭하는 것은 Java Stack에서 그만큼 Stack Frame을 많이 생성하고 push, pop이 빈번하게 발생하게 된다.
참고
'Dot Programming > Java' 카테고리의 다른 글
JVM 5 - Garabage Colletion에 사용하는 알고리즘 (0) | 2022.06.20 |
---|---|
JVM 4 - Garbage Collection 개념 및 대상 (0) | 2022.06.04 |
JVM 2 - Runtime Data Area 구조 (0) | 2022.05.31 |
JVM 1 - Java Architecture, JVM Specification (0) | 2022.05.31 |
[Play with Java] Java 8로 꼬리재귀 함수 만들기 (Tail Recursion) (3) | 2022.04.10 |