티스토리 뷰

다른 시리즈 보기

 

 

JVM의 클래스로더에 의해 메모리 영역에 Byte Code(.class) 파일이 올라가면 사용할 준비가 끝난 것이다. 이제, 메모리 영역의 어디에 올라간다는 것인지 구조를 살펴보고 의미를 파악할 것이다. 이와 더불어 Method Area 내부에 Runtime Constant Pool이라는 단어가 보이는데, Constant Pool과 Runtime Constant Pool이 무슨 차이인지, Heap 영역의 String Pool( = String Constant Pool)은 무엇인지, Java 7까지의 Perm 영역과 Java 8 이후부터의 Metaspace 영역 등 다루는 이야기가 많아서, 이번 편은 조금 길다. 이 부분 유의하자.

 

Runtime Data Areas

그림1. JVM Runtime Data Areas

 

Runtime Data Areas는 JVM이 Host Operating Systems 위에서 실행되면서 OS로부터 할당받는 메모리 영역이다. 그림을 보면 Thread로 묶이는 부분과 묶이지 않는 부분을 볼 수 있는데, 이는 공유하는 것과 아닌 것의 차이이다. PC Register, Stack Area, Native Method Stack은 스레드가 생성될 때마다 생성되는 영역이고 Method Area, Heap Area는 JVM이 시작될 때 단 1개씩만 생성된다. Method Area와 Heap Area는 모든 스레드가 공유한다는 의미이다.

 

 

Method Area

Method Area는 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성된다. Metaspace라고 불리는 영역 내부에 속하는데, Method Area에는 JVM이 읽어들인 각각의 클래스와 인터페이스에 대한 Runtime Constant Pool, 필드와 메서드 정보, 메서드의 바이트코드 등이 보관된다. 특이하게도 static object와 String에 관련된 정보는 Method Area가 아닌 Heap Area에서 보관하는데 이에 대한 내용은 아래에서 소개한다. 우선 Runtime Constant Pool에 관한 이야기를 해보겠다.

 

[Java의 실행원리 1편] Compile-time 환경에서 Java Compiler(javac) 내부의 Lexcial Analyzer → Syntax Analyzer → Semantic Analyzer 거치면서 만들어져 Symbol Table 이용하여 클래스나 인터페이스 Constant Pool(상수 ) 만든다고 했었다. Symbol table은 컴파일러가 변수의 의미론적인 내용을 추적하기 위해 유지되는 데이터 구조인데, 변수의 스코프, 바인딩 정보, 변수의 인스턴스 등 다양한 엔티티의 정보를 저장하는 것이다.

 

이 내용을 기반으로 만들어진 Constant Pool(상수 풀)은 Byte Code(.class) 파일에서 필요한 Literal Constant, Type Field(Local Variable, Class Variable), Class 및 Method로의 모든 Symbolic Reference 등을 모아놓은 데이터 구조이다. 이 상수 풀은 Byte Code(.class) 파일 내에 있으며, 클래스 또는 인터페이스가 JVM 의해 로드될 때마다 해당 클래스 또는 인터페이스의 상수 풀이 내부적으로 만들어지는데, 내부적으로 만들어져서 런타임 데이터 구조를 가지는 상수 풀이 바로 Runtime Constant Pool(런타임 상수 풀)이다. 즉, 상수 풀과 런타임 상수 풀은 클래스, 인터페이스별로 각각 따로 존재한다는 의미이다. 

 

그림2. oracle docs에 나와있는 .class 파일 구조

 

자바 class 파일은 oracle docs 4.7에서와 같은 구조를 가지는데, 이 구조의 contant_pool_count, constant_pool[]이 흔히 말하는 Constant Pool(상수 풀)을 의미한다. 이때, 특정 상수에 대한 모든 인덱스 또는 참조를 16비트(type u2) 번호로 제공되며, 여기서 인덱스  1 표의  번째 상수를 나타낸다. 그러면, 클래스 파일을 역어셈블러(javap)를 이용하여 살펴보겠다.

 

// 역어셈블러(javap)를 사용하는 명령어
javap -v Main.class

그림3. 역어셈블러를 활용하여 Main.class 내용 보기

 

Constant Pool은 리터럴 상수 값을 저장하는 곳이라고 했다. 그림에서 보는 것처럼 모든 종류의 숫자, String, 문자열, 식별자 이름, Class Method 대한 symbolic refernce가 포함된다. 상수와 리터럴을 헷갈릴 수 있는데, 상수는 final 키워드를 이용하여 초기화 이후 값이 변하지 않는 수를 의미하고 리터럴도 상수의 일종인데, 선언없이 바로 사용할 수 있는 문자 그대로의(=리터럴한) 상수를 의미한다. 다시 말하면, Constant Pool은 클래스 파일 내부에 있고, 클래스 파일을 동적 로딩하여 JVM에 의해 메모리에 올라가면서 메서드 영역에 존재하는 상수 풀의 런타임 표현이 Runtime Constant Pool이라는 의미이다.

 

JVM의 특징에 관해서 설명할 때, 기본 자료형(primitive data type)을 제외한 모든 타입(클래스와 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조한다고 했었는데, Runtime Constant Pool에 있는 심볼릭 레퍼런스를 통해서 참조하는 원리이다. 

 

JVM은 이 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 참조한다. [Java의 실행원리 2편] Runtime 환경 - JVM Class Loader에서 설명한 것처럼 JVM의 클래스로더가 클래스 파일을 Load하고 Link의 Resolving 단계를 진행하면서 상수 풀 내 모든 symbolic references를 실제 메모리 주소로 변환하는 과정을 통해 실제 메모리 상 주소를 참조한다. 런타임 상수 풀에 있는 심볼릭 참조를 통해 현재 클래스로더가 로드한 실제 클래스를 확인하고 클래스 인스턴스에 대한 참조를 반환하는 형태이다. 즉, Runtime Constant Pool에 실제 메모리 주소 정보가 있다는 의미는 아니다. 심볼릭 레퍼런스는 java/lang/Object와 같이 class, field, method의 이름을 기반으로 참조하는 것이다.

 

Constant pool은 각 클래스와 인터페이스의 상수 뿐만 아니라 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이라서 JVM 명세에서도 중요하게 기술한다.

 

정리하자면, Metaspace 영역에 속하는 Method Area에는 클래스 자체의 메타데이터, Runtime Constant Pool과 같이 어떤 메서드나 필드를 참조하기 위한 심볼릭 레퍼런스와 모든 클래스 수준에 대한 정보가 저장된다.

 

 

Heap Area

Heap Area는 Method Area와 마찬가지로 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성된다. Method Area에 클래스 수준의 데이터를 저장했다면, Heap Area는 Runtime에 생성된 인스턴스, 객체(object)를 저장하는 메모리 영역이다. 힙 영역은 Garbage Collection의 대상이라서 Garbage Collector가 사용하지 않는 객체의 참조를 알아서 해제해준다. 그러나, Memory Leak 문제가 발생할 수 있어서 아무리 자동으로 GC된다지만 개발자도 신경써야한다.

 

메모리 누수 (Memory Leak)

메모리 누수란, 프로그램에서 필요하지 않은 메모리들이 계속 점유되고 있는 현상을 의미한다. 더이상 사용하지 않는 객체가 GC에 의해서 회수되지 않고 계속 누적되는 현상다. 메모리 누수가 계속 중첩되다보면 heap 영역의 공간이 부족해져서 OutOfMemeoryError가 발생할 수 있다.

 

 

아래 그림에서 보이는 Eden, Survivor 0, Survivor 1, Old 영역에 대한 자세한 설명과 GC 관련 내용은 [Java의 실행원리 4편] Runtime 환경 - JVM Execute Engine에서 다루도록 한다. 여기에서는 힙 영역은 인스턴스와 객체를 저장하는 메모리 영역이고 GC의 대상이 되는구나 정도만 이해하고 넘어가자.

 

그림4. Heap Area 자세히 보기

 

시리즈 개요에서 설명했듯이, Hotspot JVM을 기준으로 설명한 Heap Area 구조는 그림과 같다. Java 7까지라고 적혀있는데, 이는 Java 7에 존재하는 Permanent 영역이 Java 8부터는 Metaspace 영역으로 변하면서 바뀌었기 때문이다. 먼저, Java 7까지의 Hotspot JVM 구조는 아래와 같다.

 

// Java 7까지의 Hotspot JVM 구조

<----- Java Heap ----->             <--- Native Memory --->
+------+----+----+-----+-----------+--------+--------------+
| Eden | S0 | S1 | Old | Permanent | C Heap | Thread Stack |
+------+----+----+-----+-----------+--------+--------------+
                        <--------->
                       Permanent Heap
S0: Survivor 0
S1: Survivor 1

 

그림에서 Application에서 사용이라고 적혀있는 것처럼 Eden - Survivor 0 - Survivor 1 - Old는 Java Application이 사용하는 JVM의 메인 메모리인 힙 영역이고 Permanent 영역이라는 곳이 따로 있는데, 이는 앞에서 말한 JVM의 메인 메모리 Heap과는 분리되는 특수한 힙 영역이다. 원래 Permenant 영역 내부에 Method Area가 존재했었어서 클래스 메타 데이터, Static Object, Runtime Constant Pool 등이 Permanent 영역에 저장되었었다.

 

Java Heap Permanent Generation
  • Permanent 영역에 있는
    클래스의 인스턴스 저장
  • -Xms (min), -Xmx (max)로 사이즈 조절
  • 클래스, 메서드 등 메타 데이터 저장
  • Constant Pool 정보
  • JVM, JIT 관련 데이터
  • -XX:PermSize (min)
    -XX:MaxPermSize(max)

 

그러나, Permanent 영역은 JVM 메모리에 의해 관리되며 항상 고정된 최대 크기를 가진다는 문제가 있었다. 왜냐하면, OS에 의해 관리받는 Native Memory 보다 크기가 훨씬 작을 것이기 때문이다. 그래서, 예를 들어 Collection 객체를 static하게 구현하여 값을 계속해서 추가하다보면 perm 영역이 가득차서 java.lang.OutOfMemoryError: PermGen space 같은 오류가 났었다. 이런 이유로 OpenJDK JEP 122에서는 Perm 영역을 제거하면서 이를 Metaspace 영역으로 대체한다고 하였다.

 

// Java 8부터 Hotspot JVM 구조

<----- Java Heap -----> <--------- Native Memory --------->
+------+----+----+-----+-----------+--------+--------------+
| Eden | S0 | S1 | Old | Metaspace | C Heap | Thread Stack |
+------+----+----+-----+-----------+--------+--------------+

 

Java 8부터는 JVM 메모리 구조적인 개선 사항으로 Perm 영역이 Metaspace로 대체되면서 JVM에 의해 관리되던 Heap이 아닌 OS에 의해 관리되는 Native Memory 영역으로 취급하게 되었다. 즉, 메모리 영역 크기에 대한 고민을 개발자가 할 필요가 없어진 것이다. 기계인간님이 확인해본 바로는 Perm 영역의 MaxPermSize는 약 82MB이지만 Metaspace 영역의 MaxMetaspaceSize는 17,592,186,044,415 MB 정도로 64비트 프로세서가 취급할 수 있는 메모리 상한에 가까울 정도로 크기가 컸다.

 

그림5. openJDK docs JEP122에 명시된 내용

다만, Perm 영역을 Metaspace로 대체하는 과정에서 기존 Perm 영역에 존재하던 클래스,인터페이스,메서드의 메타 정보나 상수 정보들은 OS에 의해 관리받는 Native Memory에 속하는 Metaspace 영역으로 보내면서 Static Object, Intern String은 Heap 영역으로 이관하여 GC의 대상이 최대한 될 수 있도록 하였다. 

 

그림6. stackoverflow에 나오는 String pool 그림

 

아마, String pool에 관련된 그림으로 위와 같은 그림을 본 적 있을 것이다. Intern String이 String Pool( = String Constant Pool)이라고 이해하면 된다. Perm 영역이 Metaspace 영역으로 대체되면서 Static Object, Intern String을 Heap 영역으로 이관했다는 것이 위 그림과 연관된 내용이다. Java의 String은 "불변 객체"라는 특징이 있다. 그래서 클래스 파일 Constant Pool에 리터럴 String이 있다면, 문자열의 복사본을 String Pool에 하나만 저장하여 할당된 메모리 양을 최적화 하는 것이고 이 과정을 intern이라고 한다. String Pool에 문자열 리터럴을 캐싱하고 이후에 재사용하게 되면 힙 메모리 영역을 효율적으로 사용할 수 있다는 의미이다.

 

String pool은 String 객체만 관리하는 특수한 메모리 영역으로 문자열 리터럴을 선언하면 JVM이 String pool에 객체를 생성하고 해당 참조를 스택에 저장하는 원리이다. 이때, String Pool은 Hashmap으로 구현된다. 즉, 클래스로더에 의해 클래스가 로드되면 모든 클래스의 문자열 리터럴이 String Pool로 이동한다. Constant Pool의 문자열 리터럴은 직렬화되어있어서 전송 가능한 형태일 뿐, Java 객체가 아니기 때문에 String Pool에서 객체화하여 사용하기 위함이다.

 

이러한 특성 때문에, String 리터럴을 쌩으로 더하는 연산의 성능은 좋지 않다. 더한 경우 결과가 Heap 영역에 새로운 객체로 저장되기 때문이다. 

String s1 = new String("Cat");
String s2 = s1 + " Dog";

이와 같은 코드가 있을 때 "Cat Dog"가 Heap에 새로 생성된다. 피연산자인 s1의 경우 문자열 처리 이후 쓸모없어져서 GC 대상이 되기에 일거리가 늘어나기 때문에 문자열 연산이 많은 경우에는 StringBuilder나 StringBuffer를 사용하는 것이 좋다. 

String을 단순 문자열로 활용하고 싶을 때는 불변객체가 적절하지 않을 수 있다는 말이다. 문자열 연산 +이 많은 경우 문자열 연산 등으로 기존 객체의 공간이 부족해지는 경우, 기존의 버퍼 크기를 늘려 유연하게 동작하는 가변객체를 이용할 수 있다.

StringBuffer는 각 메서드별로 Synchronized Keyword가 존재하여, 멀티스레드 환경에서도 동기화를 지원하지만 StringBuilder는 동기화를 보장하지 않는다.

String : 문자열 연산 자체가 적고 멀티스레드의 경우
StringBuffer : 문자열 연산이 많고 멀티스레드의 경우
StringBuilder : 문자열 연산이 많고 단일스레드고 동기화를 고려하지 않아도 되는 경우

보통 Java-Spring에서는 멀티스레드 환경을 지원하고 있기 때문에 보통은 String, StringBuffer를 사용하는 편이다.

 

 

최종적으로 정리하면,

 

Constant Pool (상수 풀)

  • Byte Code(.class) 파일 내부에 존재하는 constant_pool[] 테이블이다.
  • Literal Constant, Type Field(Local Variable, Class Variable), Class Method로의 모든 Symbolic Reference 등을 모아놓은 데이터 구조이다.

 

Runtime Constant Pool (런타임 상수 풀)

  • 클래스로더에 의해 클래스 파일이 동적 로딩되면서 메서드 영역에 생성되는 상수 풀의 런타임 데이터구조이다.
  • Java 7의 Perm 영역이 없어지고 Java 8부터는 Metaspace 영역이 생기면서 Metaspace의 메서드 영역에 저장된다.
  • String Pool, Static Object는 런타임 상수 풀이 아닌 Heap 영역에 저장되어 최대한 GC의 대상이 되게 한다. 

 

String Pool ( = String Constant Pool, 문자열 풀 )

  • Heap 영역에 있는 String 객체를 위한 특수한 메모리 영역이다.
  • Heap에 있어서 문자열 객체가 더이상 참조되지 않으면 GC의 대상이다. 

 

Heap에 있는 String Pool의 Intern String 객체와 Static 객체는 GC 대상이다.

  • Heap 영역에 있기도 하고 메모리 누수를 방지하기 위해 GC 대상이 된다.
  • String이든 Static이든 더이상 객체가 참조되지 않으면 GC 된다.
  • 하지만, 일반적으로 참조를 해제할 일이 없어서 GC되지 않고 프로그램 수명동안 메모리에 있다고 생각하면 된다.

 

Intern String 객체든 Static 객체든 메서드 영역에 있나 힙 영역에 있나는 그렇게 중요한 사실이 아니다. 어디에 있든 스레드가 공유하는 자원이고 여러 개 생성되지 않고 하나만 생성되고 여러 스레드가 이를 참조한다는 사실이 중요하다.

 

 

PC Register

PC(Program Counter) Register는 각 스레드마다 하나씩 존재하며, 스레드가 시작될 때 생성된다. 특정 스레드의 위치를 표시하므로 각각의 스레드별로 생성되는 것이다. PC 레지스터는 현재 수행 중인 JVM의 명령의 주소를 가지고 스레드가 어떤 명령을 실행하게 될 지에 대하여 기록하는 곳이다.

 

 

Stack Area

Stack Area는 각 스레드마다 하나씩 존재하며, 스레드가 시작될 때 생성된다. 스택은 흔히 아는 것처럼 LIFO(Last In First Out) 자료구조이고 이 스택 영역을 JVM 스택이라고도 한다. Stack Frame(스택 프레임)이라는 구조체를 저장하는 스택으로 JVM은 오직 스택에 스택 프레임을 추가하고(push) 제거하는(pop) 동작만 수행한다. 이 영역에 너무 많은 데이터가 저장되는 경우 발생하는 문제가 stack overflow error이다.

 

그림7. 네이버 d2 -&nbsp;https://d2.naver.com/helloworld/1230에 나와있는 그림

 

Stack Frame (스택 프레임) : JVM 내에서 메서드가 수행될 때마다 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택 프레임이 제거된다. 스택 프레임은 지역 변수 배열(Local Variable Array), 피연산자 스택(Operand Stack), 현재 실행 중인 메서드가 속한 클래스의 런타임 상수 풀(Runtime Constant Pool)에 대한 레퍼런스 갖는다. 지역 변수 배열, 피연산자 스택의 크기는 컴파일 시에 결정되기 때문에 스택 프레임의 크기도 메서드에 따라 크기가 고정된다.

 

정리 하자면, Frame 데이터와 부분적인 결과를 저장하고, 동적인 linking 수행하고, 메서드를 위해 값을 리턴하고, 예외를 dispatch 하기 위해 사용된다프레임은 메서드를 호출하는 스레드의 JVM Stack으로 부터 할당되어지고 메서드가 호출되어질 생성되고 메서드가 완료되어질 소멸한다.

 

 

Local Variable Array (지역 변수 배열) : 0부터 시작하는 인덱스를 가진 배열이다. 0은 메서드가 속한 클래스 인스턴스의 this 레퍼런스이고, 1부터는 메서드에 전달된 파라미터들이 저장되며, 메서드 파라미터 이후에는 메서드의 지역 변수들이 저장된다.

 

Operand Stack (피연산자 스택) : 메서드의 실제 작업 공간이다. 프로그램 연산을 수행하면서 필요한 데이터 및 결과를 저장하는 곳이다.각 메서드는 피연산자 스택과 지역 변수 배열 사이에서 데이터를 교환하고, 다른 메서드 호출 결과를 추가하거나(push) 꺼낸다(pop). 피연산자 스택 공간이 얼마나 필요한지는 컴파일할 때 결정할 수 있으므로, 피연산자 스택의 크기도 컴파일 시에 결정된다.

 

Reference to Constant Pool (상수 풀 참조) : 각 클래스의 런타임 상수 풀에 대한 레퍼런스를 가진다. 

 

그림8. 역어셈블러(javap)를 이용하여 본 클래스 파일의 바이트 코드

 

[Java의 실행원리 1편] Compile-time 환경에서 설명한 JVM의 특징 중에 스택기반 가상머신이라는 의미가 이것 때문이다. 피연산자 스택과 x86 아키텍처의 어셈블리에 나오는 OpCode(피연산자) 형식이 비슷해보이지만, Byte Code(.class) 파일에는 메모리 주소나 오프셋을 쓰지 않는다는 것을 확인할 수 있다.

 

JVM은 레지스터를 사용하지 않고 스택을 사용하며, 자체적으로 메모리를 관리하기 때문에 실제 메모리 주소 대신 #1와 같이 인덱스 번호를 사용하는 것이다. 상수 풀의 인덱스인데, JVM은 각 클래스마다 상수 풀을 생성하여 실제 대상의 레퍼런스를 보관하고 있다는 의미이다. 정리하자면 실제 동작에서 각각의 클래스 인스턴스들, intern String, Static Object는 힙에 할당되고, 클래스 메타데이터는 Metaspace의 메서드 영역에 저장될 것이다.

 

 

Native Method Stack

Native Method Stack은 자바가 이외의 언어인 네이티브 코드로 작성된 메서드를 위한 스택이다. Native Method Stack을 호출할 시 JVM 스택은 작동하지 않고 Frame을 push, pop한다. JNI(Java Native Interface)를 통해 호출하는 C/C++ 코드를 수행하기 위한 스택이며 언어에 맞게 C 스택이나 C++ 스택이 생성된다. 역시 이부분도 스레드별로 생성된다.

 

JNI(Java Native Interface)는 JVM에서 실행 중인 Java 코드에서 다른 언어(C/C++)로 작성된 코드를 호출할 수 있도록 도와주는 프레임워크이다. 이를 통해 JVM은 C/C++ 라이브러리를 호출하고 하드웨어에 고유한 C/C++ 라이브러리에서 호출할 수 있게 된다.

Native Method Libraries는 Execute Engine에 필요한 Native Libraries(C/C++)의 모음이다.

 

 

이렇게 Runtime Data Areas을 세부적으로 알아봤다. 이제 마지막 남은 시리즈에서는 어떻게 실행되는지 원리를 보며 JVM의 꽃이기도 한 Garbage Collection의 여러 알고리즘을 살펴보겠다.

 

 

 

참고

 

 

※ 본 게시글은 공부하면서 작성했기에 틀린 부분이 있을 수 있습니다. 질문 사항이나 댓글로 알려주시면 감사하겠습니다.

댓글