본문 바로가기

Dot Programming/Java

JVM 3 - Runtime Data Area에서의 객체 생성, 소멸 및 참조 과정 알아보기

    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 Areas

     

    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에 변수 할당을 된 것을 보면 다음과 같다.

     

    Runtime Data Area에서의 변수 할당

    클래스 static 변수

    먼저 static 변수 ci, csMethod 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, lsJava 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에 적재

    1. 우선 ClassLoader에 의해 JVM에 적재되는 과정을 거쳐야 한다.
    2. CLass가 로딩되면 우선 Method Aread에 Class 정보가 올라간다.
    3. 2번 작업이 완료되면 Heap에는 JvmInternal의 인스턴스가 하나 생성된다. 
    4. 또한 Stack에는 이를 수행하는 Stack Frame이 생성된다.

     

    Class JvmInternal3가 JVM에 적재된다.

     

    여기까지 수행된 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_0putstatic은 Class Variable인 cv에 값 0을 집어 넣었음을 의미한다. 그러나 final static으로 선언한 fcv는 보이지 않는다. 이는 상수로 취급했기 때문이다. 그래서 Constant Pool에 들어가 있다. 

     

    2. main() Method를 수행

    이제 main() Method를 수행할 차례이다.

    1. main() Method를 수행하게 되면 이 Method에 해당하는 Java Stack에 새로운 Stack Frame이 하나 생성되어 Push된다. 
    2. 그리고 이 Method를 호출하면서 args[]의 Reference 데이터를 그대로 넘겨준다.
    3. Java Stack에는 Frame Data 라는 영역을 가지고 있다. 이 Frame Data는 Constant Pool Resolution과 Method가 종료 시 사용할 정보가 저장된다. 
      1. Constant Pool Resolution에는 이 Method에서 사용되는 java.Iang.Integer 객체나 java.lang.String 객체의 Symbolic Reference에 대한 실제 Pointer가 저장되어 있다.
      2. main() Method를 호출할 hidden this()의 Reference도 마찬가지로 가지고 있다. 이는 main() Method가 정상 종료 후 복귀하기 위한 용도로 사용된다.

     

    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이 수행된다.

    1. 우선 args[0]의 값을 알기 위해 Local 변수에 저장된 args[]의 Reference를 Operand Stack에 Push한다.
    2. 그 후 값을 가져올 args[]의 인덱스를 지정한다.
    3. Heap에 저장된 args[]의 Array Data에서 0번지의 값을 Pop하여 그 Reference로 "10"을 찾아 Operand Stack에 집어넣는다.

     

    args[0] 값 획득

     

    parseInt의 파라미터 변수를 얻었으니 이제 Integer 객체를 찾아 parseInt() Method를 수행한다. "10"이 10으로 변경되고, Local Variable Section의 1번 인덱스에 저장된다.

     

    a = Integer.parseInt(args[0]) 수행

     

    2-2. main() Method 내부 실행  b = Integer.parseInt(args[1]); 

    b = Integer.parseInt(args[1]); 도 이와 같은 과정을 거쳐 Local Variable Section의 2번 인덱스에 저장된다. 

     

    b = Integer.parseInt(args[1]) 수행

     

    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이기 때문에 사라지게 된다

     

    addTwoArgs(a, b) 호출

     

    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는 다음 그림 화살표 순대로 진행된다.

    1.  Frame Data에 있는 ref를 통해 fcv의 위치를 찾아낸다. fcv는 final static으로 선언되었기 때문에 Constant Pool에 저장되어 있다.
    2. 그렇게 찾은 fcv의 값을 Operand Stack에 가져온다.
    3. 그리고 이 값을 Pop하여 cv로 접근하게 된다. cv는 static 변수로 선언된 class 변수이기 때문에 JvmInternal3의 Instance에 저장되어 있는 것이 아니라 Method Area의 Class Variable 영역에 저장되어 있다.
    4. 그렇게 Thread는 Class Variable의 cv로 접근하여 100이라는 값을 얻어 이것으로 변경한다.

     

    cv = fcv 수행

     

    addTwoArgs()  값 Return

    cv를 변경하였으니 인수로 넘어온 값을 더해서 반환하는 작업을 수행할 차례이다.

    1. 이 작업도 이전에 해왔던 것처럼 Local Variable에서 인덱스를 통해 값을 Operand Stack으로 Push하는 것이 먼저 수행된다.
    2. 이후 이 두 값을 Pop하여 iadd를 통해 값을 연산한 후 결과값을 Operand Stack에 다시 Push한다.
    3. 이후 ireturn으로 addTwoArgs() Method의 Stack Frame에서의 작업이 완료되어 Java Stack의 반환 값은 main의 Operand Stack으로 Push된다.
    4. Thread는 addTwoArgs() Method의 Frame Data에 저장되어 있는 main() Method의 Stack Frame으로 이동한다. 

     

    addTwoArgs의 Return

     

    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을 삭제하고 프로그램을 종료한다.

     

    addTwoArgs 종료 후 작업

     

     

    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이 빈번하게 발생하게 된다.

     

     


    참고

    The Java® Virtual Machine Specification Java SE 18 Edition

    Java Perfomance Fundamental - 김한도 저