JVM
Java는 모든 플랫폼에서 동일한 동작하기 위해 JVM이라는 가상머신을 기반으로 동작하도록 설계되어 있습니다. JVM은 Class Loader, Execution Engine, 런타임 데이터 영역로 구성되어 있습니다. Java Complier를 통해 Java소스파일을 바이트코드(.class file)로 컴파일하고 Class Loader가 바이트코드를 런타임 데이터 영역에 로드 시킵니다. 그리고 로드된 바이트코드를 Execution Engine을 통해 읽고 실행하며 Garbage Collector는 실행중에 Heap영역에 생성된 객체를 Mark&Sweep하여 Unreachable 객체들을 메모리에서 제거하는 역할을 합니다.
- 기본자료형의 크기가 모든 플랫폼에서 동일합니다.
- 바이트코드(.class)는 플랫폼의 독립성을 유지하기 위해 고정된 바이트 오더인 네트워크 바이트 오더를 사용하며, 이는 빅엔디안 형식입니다.
바이트코드(.class)
WORA(Write Once Run Anywhere)를 구현하기 위해 JVM은 언어와 기계어 사이의 중간 언어인 자바 바이트 코드를 사용합니다. 바이트코드의 한 메서드의 크기가 65,535Byte를 넘을 수 없다는 JVM 명세 자체의 제한이 있습니다.
바이트코드를 javap -c 옵션으로 역어셈블할 수 있습니다.
클래스로더(Class Loader)
동적으로 바이트코드를 검증하고 런타임 데이터 영역(MetaSpace)에 바이트코드를 저장합니다.
이를 위해 Loading - Linking - initialization 과정을 수행합니다.
-
Loading :
Java 8이하
- Bootstrap Class Loader: JVM을 기동할 때 생성되고, 기본 JAVA API를 로드합니다. 일반적으로 $JAVA_HOME/jre/lib 경로의 jar파일을 로드합니다.
- Extension Class Loader: 기본 JAVA API를 제외한 확장 클래스들을 로드합니다. 일반적으로 $JAVA_HOME/jre/lib/ext 경로의 jar 파일을 로드합니다.
- System Class Loader: 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드 합니다.
Java 9+
-
Bootstrap Class Loader: 핵심 모듈만 로드합니다.
-
Platform Class Loader: Java SE 플랫폼 API 구현 클래스 및 JDK 특정 런타임 클래스가 포함됩니다.
${JAVA_HOME}/jre/lib/ext 폴더나 java.ext.dirs 환경 변수를 지원하지 않습니다.
-
System Class Loader: 일반적으로 응용 프로그램 클래스 경로, 모듈 경로 및 JDK 관련 도구에서 클래스를 정의하는 데 사용됩니다.
- Linking
-
Verify: 생성된 바이트코드가 자바 언어 명세 및 JVM 명세에 명시된 대로 잘 구현되었는지 검증합니다.
-
Prepare: 클래스가 필요로 하는 메모리를 할당하고 클래스에서 정의된 필드, 메서드 등의 데이터 구조를 준비합니다.
-
Resolve: 클래스의 상수 풀 내에 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경합니다.
TestMan testman = new TestMan(); 에서 new TestMan()는 실제 레퍼런스를 가르키진 않고, Prepare 단계에서 클래스를 Heap에 할당한 실제주소로 변경 해준다.
-
- initialization: Static 변수의 값을 할당, SuperClass 초기화 등 초기화 작업을 수행합니다.
실행엔진(Excution Engine)
Excution Engine은 Interpreter와 JIT(Just-In-time) 컴파일러를 이용하여 런타임 데이터 영역에 배치된 바이트 코드를 native코드로 변환하고 실행합니다. Interpreter는 Row 단위로 바이트코드를 해석하여 실행 가능한 native코드로 만들고, JIT(Just-In-time) 컴파일러는 인터프리터가 바이트 코드를 읽는 도중에 어떤 영역을 hotspot영역이라고 판단하면 JIT 컴파일러에게 컴파일을 요청 합니다. 그럼 JIT 컴파일러는 native 코드로 컴파일을 하고 캐싱합니다. 이후 같은 hotspot영역을 읽으려고 할 때 Interpreter를 사용하지 않고 캐시된 native 코드를 수행할 수 있도록 합니다.
(특정 영역의 코드가 자주 읽히게 되면 그 영역을 hot spot영역이라고 부릅니다.)
런타임 데이터 영역
프로그램이 실행되기 위해서는 먼저 프로그램이 메모리에 로드되어 있어야하고, 이를 위해 운영체제는 코드영역, 데이터 영역, 스택 영역, 힙 영역을 제공합니다. JVM은 운영체제 처럼 Runtime Data Area라는 메모리 영역을 제공합니다. Runtime Data Area 영역은 PC 레지스터, 메서드 영역, Heap, JVM 스택, 네이티브 메서드 스택 으로 나뉩니다.
-
PC 레지스터(PC Register): 각 스레드가 시작될 때 생성되고 현재 스레드가 수행중인 JVM 명령의 주소를 저장하고 있습니다.
-
JVM 스택(Stack): 각 스레드가 시작될 때 생성되고, 스레드 내에서 메서드가 호출되면 스택프레임을 JVM스택에 push하고 메서드가 종료되면 스택프레임을 pop하여 제거합니다. 만약 저장공간이 부족할 때 새로운 객체를 저장하려고 하면 StackOverFlowError가 발생합니다
(스택프레임: 함수 반환 주소값, 지역 변수, 매개변수 등의 함수의 호출 정보입니다. )
-
네이티브 메서드 스택: Java Native Interface(JNI)를 통해 호출되는 C나 C++언어로 작성된 API를 위한 영역입니다.
-
메서드 영역: 모든 스레드가 공유하는 영역으로 클래스, 인터페이스, 런타임 상수 풀 등이 저장됩니다.
(런타임 상수풀: 각 클래스와 인터페이스의 상수뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스까지 담고 있으며 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 실제 메모리상 주소를 찾아서 참조합니다.)
-
Heap 영역: 모든 스레드가 공유하는 영역으로 자바 new연산을 통해 생성된 클래스의 인스턴스나 배열 등의 런타임 데이터를 저장하는 영역입니다. 초기 힙사이즈(InitialHeapSize)는 메모리 크기의 1/64이고 최대 힙 사이즈(MaxHeapSize)는 1/4 크기로 설정됩니다. 예를들어 16GB램이 설치되어있다면 초기 힙사이즈는 256MB, 최대 힙사이즈는 4GB로 설정됩니다. 만약 저장 공간이 부족할때 객체를 생성하려고 하면 OutOfMemoryError가 발생합니다.
Garbage Collection
Heap영역에 생성된 객체를 Mark&Sweep하여 Unreachable 객체들을 메모리에서 제거하는 역할을 합니다. 효율적인 GC를 위해 메모리 영역이 Young Generation 과 Old Generation로 구분되어 있고, 이렇게 구분되는 이유는 약한 세대 가설(weak generational hypothesis)
을 바탕으로 GC를 설계 했기 때문입니다.
-
대부분의 객체는 금방 접근 불가능 상태(Unreachable)가 되고
-
오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다라는 것입니다.
두 영역에서 공통적으로 수행되는 Mark and Sweep과 Stop the World 과정이 있습니다.
- Mark and Sweep: GC를 실행할 때 Reachable 객체에 Marking하는 작업과 Unreachable 객체들을 메모리에서 해제하는 Sweep하는 과정을 말합니다.
- Stop the World: JVM이 GC를 실행하는 스레드를 제외한 모든 스레드들의 작업을 중단시키고 GC가 완료되면 작업이 재시작하게 되는데 이 과정을 Stop the World라고 말합니다.
GC가 Reachable과 Unreachable을 구분하는 방법은 힙에 있는 객체들에 대한 참조를 검사하는 방법이 있습니다
- 힙 내의 다른 객체에 의한 참조
- JVM Stack 내의 지역변수, 매개변수에 의한 참조
- Method Area의 정적 변수에 의한 참조
- JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
이 중 첫번째를 제외한 3개가 root set으로 Reachable을 판가름 하는 기준이 됩니다.
java.lang.ref 패키지의 Reference 클래스들(SoftReference, WeakReference, PhantomReference)을 이용하면 객체들에 대한 GC의 동작을 다르게 지정하여 효율적인 메모리 관리를 할 수도 있습니다. 이는 GC 대상 여부를 판별하는 부분에 사용자 코드가 개입할 수 있음을 의미합니다.
- Young Generation은 Eden과 두개의 서바이벌 영역(S0, S1)으로 나뉘며 새로운 객체를 할당하기 위해 힙에 확보된 공간입니다. 이 영역에서 garbage를 수집하는 과정을 Minor GC라고 하고 오래동안 살아남은 객체들을 old generation으로 이동시킵니다.
- Eden : new연산으로 객체들이 할당되는 공간이며, 이 영역이 가득차면 Mark&Sweep 과정에서 Reachable 객체들을 두개의 Survivor영역 중 객체가 쌓이고 있는 Survivor영역으로 이동시킵니다.(Minor GC)
- Survivor: Eden에서 살아 남은 객체들의 공간이며, 이 공간이 가득차면 Mark&Sweep한 다음 Reachable 객체들을 다른 Survivor 공간으로 이동 시킵니다. 이때 GC에서 살아 남은 회수를 의미하는 age값을 Object Header에 기록하며 age값을 보고 Old 영역으로 이동시킨다. 이러한 과정을 Promotion이라고 하고, Promotion되기 전까지는 Survivor 두 개 공간을 스위칭하면서 age값을 증가 시킵니다. 이러한 매커니즘 때문에 Survivor 영역은 두 개의 공간 중 한개는 반드시 빈 상태가 되어야 합니다.
- Old Generation: Survivor 영역에서 오랫동안 살아 남은 객체들이 저장되는 공간이며, 이 공간이 가득차면 Mark&Sweep을 진행하고 이 과정을 Major GC라고 합니다. 보통 Young Generation보다 Old Generation의 크기가 크기 때문에 Minor GC보다 Major GC가 오래 걸리고 성능상 이슈가 발생할 수 도 있습니다.
-
Permanent Generation(PermGen): Class나 Method의 MetaData, Static변수와 상수 정보들이 저장되는 공간입니다. java7이하에서만 사용되고 java8부터는 PermGen영역은 제거되고 Native memory에 Metaspace 영역이 추가되었습니다. 이는 각종 메타 정보를 OS가 관리하는 영역으로 옮겨 Perm 영역의 사이즈 제한을 없앤 것이라 할 수 있습니다.
-
HotSpot VM에서 보다 빠른 메모리 할당을 위해 두가지 기술을 사용합니다.
-
bump-the-pointer: Eden 영역에 마지막으로 할당된 객체를 마킹하고, 그 객체 다음에 객체를 할당하는 기술을 말합니다. 마지막 객체의 위치와 크기만 알면 새로운 객체를 할당할 수 있는지 알 수 있기 때문에 속도가 빠릅니다.
-
TLABs(Thread-Local Allocation Buffers): bump-the-pointer는 Thread-Safe하기 위해서 만약 여러 스레드에서 사용하는 객체를 Eden 영역에 저장하려면 락(lock)이 발생할 수 밖에 없고, lock-contention 때문에 성능은 매우 떨어지게 될 것입니다. 그래서 TLABs는 멀티 스레드 환경에서 스레드 별로 작은 메모리를 하나를 갖게 되고, 각 스레드에는 자기가 갖고 있는 TLAB에만 접근할 수 있기 때문에, bump-the-pointer라는 기술을 사용하더라도 아무런 락이 없이 메모리 할당이 가능하게 됩니다.
(많은 수의 스레드가 영역을 할당받은채 사용되지 않거나 영역 내의 자투리 공간이 메모리 단편화의 원인이 될수 도 있기 때문에 GC 스레드 개별 사이즈를 감소시키거나 Old Generation 전체 사이즈를 늘리는 방법으로 문제를 회피할 수도 있습니다.)
Garbage Collection 종류
-
Serial GC
CPU 코어가 1개일 때 사용하기 위해 개발된 알고리즘이며, 기존에 Mark&Sweep과정 후 분산된 메모리 파편들을 Compact 알고리즘을 수행하여 객체들을 앞에서부터 채워서 메모리 단편화를 방지하도록 합니다. 가장오래된 GC이며 Stop the World 시간이 너무 길기 때문에 요즘에는 사용되지 않습니다.
java -XX:+UseSerialGC -jar Application.java
-
Parallel GC
Java8까지의 기본 GC로 사용되었으며, Serial GC의 기본처리 과정에다 멀티 스레드를 통해 병렬 GC를 수행하므로서 GC의 오버헤드를 줄여줍니다. 옵션을 통해 애플리케이션의 최대 지연시간 또는 GC를 수행할 스레드의 갯수 등을 설정해줄 수 있습니다.
java -XX:+UseParallelGC -jar Application.java // 사용할 스레드의 갯수 -XX:ParallelGCThreads=<N> // 최대 지연 시간 -XX:MaxGCPauseMillis=<N>
-
CMS(Concurrent Mark Sweep) GC
여러개의 쓰래드를 이용하면서 기존GC 수행방식과 다르게 Mark&Sweep과정을 동시에(Concurrent) 수행합니다. 이러한 CMS GC는 Application의 지연시간을 최소화 하기 위해 고안되었지만 다른 GC방식보다 과정이 복잡하여 메모리와 CPU를 더 많이 필요로 하며 Compact 단계를 수행하지 않기 때문에 메모리 단편화가 발생하여 Stop The World 시간이 길어지는 문제가 발생할 수 있습니다.
initial Mark: GC 대상인지 판단
Concurrent Mark: initial Mark에서 찾은 대상이 참조하는 객체들을 대상으로 GC 대상인지 확인
Remark: stop the world를 유발하며 멀티 스레드로 Concurrent Mak 과정을 검증.
Concurrent Sweep: Stop the world 없이 remark 단계에서 검증 완료된 GC 대상 객체를 메모리에서 제거.
java -XX:+UseConcMarkSweepGC -jar Application.java
-
G1(Garbage First) GC
CMS GC를 개선하고자 나온 방식입니다. java7부터 지원되었고 Java9부터 기본 가비지 컬렉터(Default Garbage Collector)로 사용되고 있습니다. G1 GC는 Eden 영역에 할당하고 Survivor로 카피하는 등 과정을 사용하지만 물리적으로 메모리 공간을 나누지 않습니다. 대신 Heap을 동일한 크기의 Region으로 나누고 Garbage가 많은 Region(지역)에 대해 우선적으로 GC를 수행하는 방식입니다.
각 지역은 Eden, Suvivor, Old 이외에 Humongous와 Available/Unused 라는 2가지 역할이 추가 되었습니다. Humongous는 Region 크기의 50%를 초과하는 객체를 저장하는 지역이고, Available/Unused는 사용되지 않는 지역을 의미합니다. 보통 Region은 옵션을 통해 개당 1MB~32MB로 2048개로 나눌수 있습니다.
XX:G1HeapRegionSize
Young GC와 Full GC로 나누어 수행되는데 Young GC는 가비지 가장 많은 (Garbage First) Region을 찾아서 Mark&Sweep를 수행합니다. 살아남은 객체는 다른 Region으로 이동시킵니다. Full GC는 Garbage가 많은 Region을 조합하여 해당지역에 대해서만 GC를 수행하며 Concurrent하게 수행되기 때문에 애플리케이션의 지연도 최소화 할 수 있습니다.
Initial Mark -> Root Region Scan -> Concurrent Mark -> Remark -> Cleanup -> Copy 과정을 거침.
- Initial Mark : Old Region 에 존재하는 객체들이 참조하는 Survivor Region 을 찾는다. 이 과정에서는 STW(Stop The World) 현상이 발생합니다.
- Root Region Scan: Initial Mark 에서 찾은 Survivor Region에 대한 GC 대상 객체 스캔 작업을 진행합니다.
- Concurrent Mark: 전체 힙의 Region에 대해 스캔 작업을 진행하며, GC 대상 객체가 발견되지 않은 Region 은 이후 단계를 처리하는데 제외되도록 합니다.
- Remark: 애플리케이션을 멈추고(STW) 최종적으로 GC 대상에서 제외될 객체(살아남을 객체)를 식별합니다.
- Cleanup : 애플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 Region 에 대한 미사용 객체 제거 수행한다. 이후 STW를 끝내고, 앞선 GC 과정에서 완전히 비워진 Region 을 Freelist에 추가하여 재사용될 수 있게 합니다.
- Copy : GC 대상 Region이었지만 Cleanup 과정에서 완전히 비워지지 않은 Region의 살아남은 객체들을 새로운(Available/Unused) Region 에 복사하여 Compaction 작업을 수행합니다.
-
-
Z GC
java 11부터 추가된 GC이며 확장가능하고 낮은 지연시간을 가진 GC입니다.
Stop the World 처리시간이 최대 10ms를 초과하지 않음
힙 크기가 증가해도 Stop the World 처리시간이 증가하지 않음
8MB~16TB에 이르는 스펙트럼의 힙 처리가 가능
G1 GC 보다 애플리케이션 처리량이 15% 이상 떨어지지 않을 것
Z GC는 ZPages라는 개념을 사용하고 G1 GC의 Region과 비슷한 영역의 개념이지만 Region은 고정된 크기인 것에 반해 ZPages는 크기가 2MB의 배수로 동적으로 생성 및 삭제될 수 있습니다.
Z GC는 Colored pointers와 Load barriers라는 2가지 알고리즘을 사용합니다.
-
Colored pointers : 객체를 가리키는 변수의 포인터에서 64bit 메모리를 활용해 Mark를 진행하고 객체 상태 값을 저장해 사용하는 방식이기 때문에 64bit 운영체제에서만 작동하며 JDK11, 12까지는 4TB의 메모리만 지원하였고 JDK13에서 16TB까지 메모리 확대가 가능합니다. 42bit는 객체를 가리키는 주소 값으로 사용하고, 나머지 22bit 중 4bit를 각각 Finalizable, Remapped, Marked 1, Marked 0로 나눠서 사용.
-
Finalizable : finalizer을 통해서만 참조되는 Garbage 객체.
-
Remapped : 재배치 여부를 판단하는 Mark.
-
Marked 1, 0 : 사용되는 객체를 판단하는 Mark.
-
-
Load barriers: 스레드에서 참조 객체를 Load 할 때 실행되는 코드를 말하며 Z GC는 재배치에 대해서 Stop the World 없이 동시적으로 재배치를 실행하기 때문에 참조를 수정해야 하는 일이 일어나게 되는데, 이때 Load barriers가 아래의 순서대로 Remap Mark bit와 Relocation Set을 확인하며 참조와 Mark를 업데이트하고 올바른 참조값으로 수정해 줍니다.
-
*기타
오라클은 TCK(Teahnology Compatibility Kit)라는 테스트 툴을 제공합니다. 다양한 방법으로 잘못 작성된 수많은 클래스 파일을 비롯하여 수만 개의 테스트를 수행하여 JVM 명세를 검증하는 TCK를 통과해야만 JVM이라고 이름을 붙일 수 있습니다. 이는 JVM 명세 뿐만 아니라 새로운 자바 기술 명세를 제안하고 제공하는 JCP(Java Community Process)에서도 마찬가지로 제안하는 JSR(Java Specification Reqeust)에 대한 명세 문서, 참조구현, TCK가 작성 완료되어야 JSR이 마무리 됩니다. JSR로 제안된 새로운 자바 기술을 이용하려는 사용자는 RI 제공자에게 구현체를 라이선스 받거나 직접 구현하여 TCK를 통과한 후 해당 기술을 사용할 수 있습니다.
*참고
https://beststar-1.tistory.com/16
https://d2.naver.com/helloworld/329631
https://d2.naver.com/helloworld/1329
https://steady-snail.tistory.com/67
https://mangkyu.tistory.com/119
댓글남기기