02.Runtime Data Areas
22 Jul 2024Runtime Data Areas
- PC Registers
- JVM Stacks
- Native Method Stacks
- Method Area
- Heap
Method Area와 Heap는 모든 스레드가 공유하며 PC Registers, JVM Stacks, Native Method Stacks는 각 스레드별로 생성하며 공유되지 않는다.
PC Registers
Runtime Data Areas의 PC Registers는 CPU의 레지스터와 달리 Register-Base가 아니라 Stack-Base로 동작한다.
CPU Register는 CPU에 종속적이기 때문에, Java는 플랫폼 독립적으로 구현하기 위해 JVM은 CPU에 직접 Instruction을 수행하지 않고, Stack에서 Operand를 뽑아내 이를 별도의 메모리 공간에 저장하는 방식을 취한다.
하지만 자바도 시스템(OS & CPU) 입장에선 하나의 프로세스이기 때문에 CPU에 Instruction을 제공해야 하는데, 이를 위한 버퍼 공간이 PC Registers이다.
PC Register는 각 스레드가 시작할 때 생성된다. Java Method를 수행하고 있다면 현재 수행중인 JVM Instruction의 주소를 갖고 있고, Native Method를 수행하고 있다면 플랫폼에 종속적이기 때문에 JVM을 거치지 않기 때문에 undefined 상태로 있게 된다.
JVM Stacks
JVM은 메서드를 실행하기 전에 Stack Frame을 만들어 Push한 후 메서드를 실행하며 실행이 끝나면 Pop되어 사라진다.
Stack Frame는 Method의 Class 정보를 가지고 있으며 스택 가장 위의 Frame을 Current Frame, Class 정보를 Current Class라고 지칭한다.
Stack Frame
- Local Variable Section
- Operand Stack
- Frame Data
세 부분으로 구성된다. Method 내의 변수나 연산, 반환 값 등의 Type은 모두 Source Code 내에서 결정되기 때문에 Stack Frame은 Class의 메타 정보를 통해 컴파일 타임에 적절한 크기로 결정되어 생성된다.
Local Variable Section
Local Variable, Parameter Variable들을 저장한다. Local Variable Section은 0부터 시작하는 인덱스를 가진 Array로 되어 있다. 선언된 순서대로 인덱스가 할당되며 로컬 변수는 컴파일러가 알아서 인덱스를 할당한다. 선언하고 사용하지 않는 변수가 있다면 인덱스를 할당하지 않을 수도 있다.
public class JumInternal {
public int testMethod (int a, char b, long c, float d, Object e, double f, String g, byte h, short i boolean j) {
return 0;
}
}
만약 위와 같은 메서드가 존재한다면 아래와 같이 Local Variable Section에 할당된다. Reference 타입의 객체 같은 경우엔 Heap 메모리에 생성되어 주소값만 저장되기 때문에 Local Variable Section도 컴파일 타임에 사이즈가 정해질 수 있다.
만약 Reference 객체를 참조하게 된다면 stack 메모리에서 heap을 찾아가야 하기 때문에 성능적인 측면에선 원시타입이 유리하다.
Reference 객체를 찾아다니는 작업은 CPU 연산을 추가시키며 Heap에 변수가 존재한다는건 Method Area에 Reference 타입의 Class 정보를 읽어 Instance를 생성한다는 것을 의미한다.
Operand Stack
JVM이 프로그램을 수행하면서 연산을 위해 사용되는 데이터 및 결과를 Operand Stack에 집어넣고 처리하기 때문에 JVM의 작업 공간이라고 할 수 있다.
public class JvnInterna12 {
public void operandStack() {
int a, b, c;
a = 5;
b = 6;
c = a + b;
}
}
위의 코드는 int 변수 3개를 선언해서 5와 6을 더하는 연산을 수행하고 종료하게 된다. 해당 소스코드를 컴파일한 후 ‘javap -c’를 사용하여 Method의 바이트코드를 추출하면 아래와 같다.
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
iconst_5
: 상수 5를 스택에 pushistore_1
: 스택의 값을 로컬 변수 1번 인덱스에 저장bipush 6
: 상수 6을 스택에 pushistore_2
: 스택의 값을 로컬 변수 2번 인덱스에 저장iload_1
: 로컬 변수 1번 인덱스의 값을 스택에 로드iload_2
: 로컬 변수 2번 인덱스의 값을 스택에 로드iadd
: 스택의 두 값을 더해 결과를 스택에 pushistore_3
: 스택의 값을 로컬 변수 3번 인덱스에 저장
여기서 언급되는 스택은 모두 Operand Stack이다.
Frame Data
Constant Pool Resolution 정보와 Method가 정상 종료했을 때의 정보들, 비정상 종료 시 발생하는 Exception 관련 정보들을 저장하고 있다.
Constant Pool Resolution의 의미
Java Class File은 모든 참조 정보를 Symbolic Reference로 가지고 있다. Symbolic Reference는 Direct Reference로 변경되는데, 이 과정을 Resolution이라고 한다. Symbolic Reference는 Method Area의 Constant Pool에 저장되어 있기 때문에 이를 Constant Pool Resolution이라 부른다.
Frame Data에 저장된 Constant Pool Resolution 정보는 관련 Constant Pool의 Pointer 정보이다. JVM은 해당 Pointer를 통해 Method Area의 Constant Pool을 찾아간다. 자바의 모든 Reference는 Symbolic Reference이기 때문에 Class나 Method, 변수나 상수에 접근할 때도 Resolution이 수행된다. 또한, 특정 Object가 특정 Class나 Interface에 의존관계가 존재하는지 확인하기 위해서도 Constant Pool의 Entry를 참조한다.
Method 수행 완료 시
Method가 수행 완료되면 JVM Stack에서는 Stack Frame이 Pop되어 사라진다. 이때 돌아가야 할 이전 Stack Frame의 정보를 가지고 있는 것이 Frame Data이다. Frame Data에는 자신을 호출한 Stack Frame의 Instruction Pointer가 들어가 있기 때문에 Method가 종료되면 JVM은 이 정보를 PC Register에 설정하고, Stack Frame을 빠져나간다. 만약 Method 반환 값이 있다면, 이 반환 값을 다음 번 Current Frame인 자신을 호출한 이전 Stack Frame의 Operand Stack에 Push하는 작업도 병행한다.
Method가 Exception을 발생시키며 강제 종료될 때
Method가 Exception을 발생시키며 강제 종료되었을 경우, Exception 정보도 Frame Data에 저장된다. 이 경우 Exception Table의 Reference를 통해 catch 절에 해당하는 Bytecode로 점프하게 된다.
import java.lang.NullPointerException;
public class JvmInternal2 {
public void operandStack() {
int a, b, c;
a = 5;
b = 6;
try {
c = a + b;
} catch (NullPointerException e) {
c = 0;
}
}
}
// Decompiled Bytecode
public class JvmInternal2 {
public JvmInternal2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
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
}
Exception table의 각 데이터 의미
- from: try 블록이 시작되는 엔트리 번호
- to: try 블록이 끝나는 엔트리 번호
- target: Exception이 발생했을 때 점프해야 할 엔트리 번호
- type: 정의한 Exception
try 블록 내에서 Exception이 발생하거나 Throw되면, Exception 타입이 type의 Class 정보와 비교하여 일치하면 target으로 점프하여 수행하고, 일치하지 않으면 JVM은 Current Frame을 종료하고 자신을 호출한 다음 번 Current Frame에 해당 Exception을 다시 던져 처리를 반복한다.
Native Method Stacks
Java는 JNI(Java Native Interface)를 통해 Native Code로 되어 있는 함수를 호출한다.
JVM은 Native Method를 위해 Native Method Stacks 메모리 공간을 마련했다. 애플리케이션에서 Native Method를 호출하면 Method의 Stack Frame이 있는 JVM Stacks을 남겨두고 Native Method Stacks에 새로운 Stack Frame을 생성하여 Push하여 Native Function을 계속 수행할 수 있게 한다.
Native Method Stacks는 작성한 언어에 맞게 생성되며 Native Code가 C라면 C Stack이 생성돼 수행되고, C++이라면 C++ Stack이 생성된다.
또한 Native Method도 Stack Operation을 수행하게 되며 이 때 JVM Stack과 다르게 Native Method Stacks의 사이즈는 고정이 아니라 애플리케이션에 따라 확대&축소가 가능하다.
Native Method의 수행이 끝나면 다시 Native Method Stacks에서 나와 해당 Thread의 JVM Stacks로 돌아오게 된다. 이 때 Native Method를 호출한 Stack Frame으로 돌아가는게 아니라 새로운 Stack Frame을 생성하여 다시 작업을 수행한다.
실제 JVM 구현?
두 JVM은 2개의 Stack 영역을 통합하여 구현했다. 그 이유는 JVM이 사용하는 Thread가 모두 Native Thread라는 것과 관계가 깊다. 자바 초기에 Native Thread가 아닌 Green Thread만을 사용했다.
Hotspot JVM에서 Stack Size를 조정하는 옵션은 2가지가 존재한다.
-Xss
를 통해 Thread의 Native Stack Size를 조정하는 옵션-Xoss
를 통해 Java Stack Size를 조정하는 옵션
Native Memory는 가용한 Process Memory 공간에서 Java Heap, Method Area 등 Java Runtime Memory를 뺀 만큼 사용할 수 있다. Native Memory가 부족할 때 OOM이 발생하게 되는데, 이 경우 Java Runtime Memory를 줄이는 것도 방법이 되지만 Native Memory Leak을 의심해볼 필요가 있다.
Method Area
Load된 Type(Class or Interface)을 저장하는 논리적 메모리 공간으로 정의할 수 있다.
Method Area는 JVM이 기동할 때 생성되며 GC의 대상이 된다. 이 영역은 벤더마다 다르게 구현되며 Hotspot JVM은 Permanent Area라는 명칭으로 특정 메모리 영역으로 구분되어 있다.
Method Area내 Type 정보
- Type Information
- Constant Pool
- Field Information
- Method Information
- Class Variables
- Reference to Class (ClassLoader)
- Reference to Class (Class)
Type Information
가장 기본이 되는 Type 전반 내용이 포함된다.
- Package.class의 형태를 지니는 Type의 전체 이름(Full Qualified Name)
- Type의 직계 super class의 전체 이름, Type이 Interface이거나, java.lang.Object class이거나, super class가 없는 경우는 제외된다.
- Type이 Class인지 Interface 여부
- public, abstract, final 등 Type의 Modifier
- Interface의 경우 직접 Link되고 있는 객체의 리스트로 객체는 전체 이름(package.class)으로 표현된다.
Constant Pool
Symbolic Reference가 이곳에 저장되며 JVM은 참조하는 객체에 접근할 필요가 있다면 Constant Pool의 Symbolic Reference를 통해 해당 객체가 위치한 메모리 주소를 찾아 동적으로 연결하게 된다. (:DynamicLinking)
여기서 Constant는 상수의 의미를 가진 Literal Constant, Type, Field(Member or Class Variable), Method를 포함하여 Symbolic Reference까지 확장한 개념으로 받아들여야 한다.
Field Information
Type에서 선언된 모든 Field의 정보이다. 이곳에는 Field의 정보가 선언된 순서대로 기록되며 다음과 같이 구성된다.
- Field 이름
- Field Data Type, 선언 순서
- public, private, protected, static, final, volatile, transient 같은 Field Modifier
Method Information
Type에서 선언된 모든 Method 정보를 의미한다. 기본적으로 아래와 같은 정보로 구성된다.
- Method 이름
- Method 반환 값의 Data Type 또는 void
- Method Parameter의 수와 Data Type, 선언 순서
- public, private, protected, static, final, synchronized, native, abstract와 같은 Method Modifier
만약 Method가 native나 abstract가 아니라면 다음 정보가 추가된다.
- Method의 Bytecode
- Method Stack Frame의 Operand Stack 및 Local Variable Section의 크기
- Exception Table
Class Variable
Class에서 static으로 선언된 변수이며 Class에서 하나의 값으로 유지된다. Class를 사용하기 전부터 Method Area에 미리 메모리를 할당 받는다.
Class Variable을 final로 선언할 경우에는 변수로 취급하지 않고 상수로 취급하며 Constant Pool에 Literal Constant로 저장된다.
Member Variable(Instance Variable)은 Heap에 생성된 Instance에 값이 저장되고, Class Variable은 Class 정보가 있는 Method Area에 저장된다.
Reference to Class ClassLoader
Type이 JVM에 Load될 때 항상 이 Type은 어떤 ClassLoader를 경유하여 Loading되었는지 추적하게 된다. 한 Type이 다른 Type을 참조할 대 같은 ClassLoader를 사용하도록 되어 있기 때문이다.
이 때 ClassLoader는 크게 2가지로 나뉜다.
- User-Defined ClassLoader
- Bootstrap ClassLoader
User-Defined ClassLoader를 통해 Loading된 경우 ClassLoader Reference를 Type의 정보 중 하나로 저장하며, Bootstrap ClassLoader를 사용한 경우 Reference는 null로 저장된다.
이 정보는 Dynamic Linking을 할 때 해당 Type과 동일한 ClassLoader를 통해 참조하는 Type을 Loading하기 위해 사용된다.
Reference to Class class
Type이 JVM에 Load되면 항상 java.lang.class
class의 Instance가 하나 생성된다.
그래서 Method Area에는 Type 정보의 일부로 이 Instance의 Reference를 저장하고 있다.
Method Table
Java는 Reference를 통해 객체를 찾아 다니는 일은 매우 빈번하게 발생하며 Method Area에서 원하는 정보를 찾는 속도는 성능의 중요한 이슈가 될 수 있다.
이를 위해 Direct Reference를 갖는 자료구조인 Method Table을 사용한다.
Method Table은 Interface나 Abstract Class가 아닌 실체를 가진 Class 정보의 일부로 Method Area에 저장되며 해당 Class의 Method 뿐 아니라 Super Class에서 상속된 Method의 Reference까지 포함한다.
Method Table이 Class의 하위 자료이기 때문에 Class가 Loading될 때 함께 생성된다.
class A {
void a1() {}
int a2() {}
}
class B extends A {
void a1() {}
char b1() {}
}
Class B가 Class A를 상속받은 상태에서 Class A의 Method a2()를 참조하게 되는 경우를 가정해보자.
Class B의 Instance에서 Method Area 주소를 얻어 점프하게 된다. Method Area의 정보를 통해 해당 Method가 Class A를 상속받았다는 것을 알게 되고, 다시 이를 Class A의 Method Area로 가서 적당한 Instance를 찾게 될 것이다.
만약 이 때 Method Table이 있다면 Class B는 상속받은 Method에 대한 Heap의 Instance 정보를 가지고 있기 때문에 이를 통해 Class A의 Instance를 바로 찾아갈 수 있게 된다.
Java Heap
Object Layout
Heap에 저장되는 Object와 Array는 모두 Header와 Data로 나뉜다. Header는 고정 크기이고, 가변 크기의 Data 들어간다. (주로 쓰는건 Hotspot이기 때문에 Hotspot만 정리함)
하나의 Header는 1 Word이다. First header는 Mark Word라고 불리며 GC와 동기화(Synchronization) 작업에 사용된다.
여기서 Mark Word는 아래와 같은 레이아웃을 가지고 있다.
또한 Biased bit와 Tag는 이렇게 정의된다.
Biased bit | Tag | 상태 |
---|---|---|
0 | 01 | Unlocked |
0 | 0 | Light-weight locked |
0 | 10 | Heavy-weight locked |
0 | 11 | Marked for GC |
1 | 01 | Biased / Biasable |
첫번째 Header는
- Biased Bit가 1이면 Biased Lock을 사용한다는 것을 의미하며 동기화 작업을 수행할 때 Lock을 획득하기 위한 연산을 수행하지 않고 가볍게 Lock을 획득함을 의미한다.
- Hash Code 또는 Thread ID는 23 Bits를 할당받으며 Biased Bit와 Tag에 따라 값이 결정된다.
- Biased Bit가 1이면 Biased Lock을 획득한 Thread ID가 기록된다.
- Biased Bit가 0이고,이전 동기화 작업 때 산출된 Hash Code가 있다면 Tag에 따라 각각 다른 값이 저장된다.
- Light-Weight Locked 상태에서는 Lock Record Address, Heavy-Weight Locked 상태에서는 Monitor Address 등의 Lock과 관련한 Pointer 정보, 그리고 Marked for GC에서는 Forwarding Address 정보를 Hash Code로 갖고 있게 된다.
- Age 정보는 6 Bits로 Young Generation의 Object가 Eden과 Survivor를 넘나든 횟수를 기록한다.
두번째 Header는 Method Area의 Class 정보를 가리키는 Reference 정보가 저장된다.
이 2개의 Header는 Object&Array 모두 동일하다. 그러나 Array는 Array Size를 위한 Header가 하나 더 추가되며 Object Data는 Object에 따라 적절한 구조와 크기를 가지게 된다.
Heap 구조
Hotspot JVM은 익히 알려지듯 Young Generation과 Old Generation으로 나누어진 Generational Heap이다.
Eden 영역은 Object가 최초 Heap에 할당되는 장소이며 Eden Area가 꽉 차게 되면 Live Object이면 Survivor 영역으로 넘기고, 참조가 끊어진 Garbage Object이면 그냥 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 모두 청소한다.
Survivor 영역은 말 그대로 Eden 영역에서 살아남은 Object들이 잠시 기거하는 곳이다. Survivor 영역은 2개로 구성되어 있는데 Live Object를 대피시킬 때는 하나의 Survivor 영역만 사용하게 된다.
이 과정을 Minor GC라고 한다.
Young Generation에서 오래 살아남아 성숙된 Object는 Old generation으로 이동하게 되는데 이것을 Promotion이라고 한다. Old Generation은 새로 Heap에 할당되는 Object가 들어오는 것이 아니라 비교적 오래 참조되어 앞으로도 이용될 확률이 높은 Object들을 저장하는 공간이다.
Runtime Data Areas Simulation
Java Variable Arrangement
Java Code에서 Runtime Data Areas의 각 Module에 생성하는 것 중 다수를 차지하는 것은 변수(Variables)이다.
Java의 변수가 항상 Runtime DAta Areas에 동일한 영역, 방식으로 생성되는 것은 아니다.
Java의 4가지 유형의 변수가 존재한다.
- Class Variables
- Member Variables
- Parameter Variables
- Local Variables
Class Variable은 흔히 Static 변수라는 명칭으로도 사용된다. 이 Class Variable은 Class에 속해 있는 변수이다. Method Area에 Class Variable 영역에 할당받게 된다. Method Area는 모든 Thread에 공유되기 때문에 모든 Thread에 공유된다.
Member Variable은 Instance에 속하기 때문에 Instance 변수라는 별명을 가지고 있다. Instance는 Method Area의 정보를 바탕으로 Heap에 생성되는 Method Area의 구현체로 정의할 수 있다.
Class와 Instance는 붕어빵 틀과 붕어빵으로 비유하곤 한다. 여기서 Class는 Method ARea의 Class Meta 정보를 의미하고, Instance는 Class Meta 정보를 바탕으로 Heap에 생성되는 Class 구현체이다. Member Variable은 Heap에 생성되는 Instance에 할당되고 Method Area의 Field Information에 변수 정보가 들어있다.
Parameter Variable은 Method 인수를 의미한다. 해당 변수 정보는 Method Area의 Method Information에 포함되어 있다. 이 Parameter Variable은 JVM Stacks에 할당받게 된다.
Local Variable은 Parameter Variable과 선언되는 곳 차이만 나고 동일한 부류다. Method Information에 변수 정보가 위치하고 JVM Stacks에 할당받게 된다.
public class VariableArrange {
static int ci = 3; // Class Variable
static String cs = "Static"; // Class Variable
int mi = 4; // Member Variable
String ms = "Member"; // Member Variable
void method(int pi, String ps) { // Parameter Variable
int li = 5; // Local Variable
String ls = "Local"; // Local Variable
}
}
Primitive 변수들인 ci
, mi
는 값을 직접 가지고 있지만, Reference 변수인 cs
, ms
는 Reference 주소를 가지고 있다. 이는 변수의 선언도 성능에 영향을 줄 수 있음을 시사한다.
그림에서는 Member Variable에 mi
, ms
와 같은 변수명이 표현되어 있지만, 실제 Instance에서는 변수명을 알 수 없다.
4
와 ref2
가 Instance 내에 있음은 확실하지만, 어떤 변수의 정보인지는 실제로 Field Information에 저장되어 있다. Member Variable에 접근할 때는 Method Area의 Field Information을 통해 경유해야 한다.
그리고 Method의 Parameter Variable, Local Variable은 Thread 내의 JVM 스택에 저장되는 값들이기 때문에 Thread Safe하다. 반면, Class Variable과 Heap의 데이터들은 Thread들끼리 공유가 가능하여 Thread Safe하지 않다.