-
[Java] JVM이란? JVM(Java Virtual Machine)의 모든것을 알아보자Java/자바를 더 깊게 2022. 2. 17. 09:06728x90
JVM이란?
Java Virtual Machine의 약자로 자바 가상 머신이라고 불립니다.
자바와 운영체제 사이에서 중계자 역할을 합니다.
따라서 자바가 운영체제 종류에 영향받지 않고 돌아갈 수 있도록 합니다. = (Write Once Run AnyWhere)
C언어나 C++언어 같은 경우에는 16비트, 32비트, 64비트 플랫폼에 따라 int 형의 크기가 변하지만
JVM은 기본 자료형인 int는 4바이트(2^32)로 고정시켜 타입의 안정성을 꾀하고 플랫폼 독립성을 보장합니다.
어떤것들은 JVM이라고 부를 수 있을까? (JVM Specification)
Specification은 '규격' 또는 '사양'이라고 번역됩니다.
특정 기술분야에 대해, 이 기술은 이런 규격을 가졌으니 이 기술을 사용하거나 구현할 사람은 이용하라는 뜻입니다.
길이를 어떻게 측정하더라도 1M라는 규격에 맞춰 측정하면 이는 1M입니다.
어떤 프로그램이라도 jpeg 포맷에 맞춰서 사진 파일을 생성하면 이 파일은 jpeg입니다.
따라서 어떤것이라도 JVM의 Specification을 지킨다면 이는 JVM입니다.
JVM의 Specification
JVM을 어느정도 공부해보셨다면 중요하게 여기는 GC(Garbage Collection)와 JIT 컴파일러에 구현 세부 정보가 사실 JVM의 Specification에는 존재하지 않습니다.
JVM 공식문서의 Chaper.2 첫 번째 단락을 해석해보면 다음과 같습니다.
JVM 규격에 명시되어 있는 않은 구현과 관련된 상세 사항들은 구현자의 창의성을 저해하는 불필요한 제약이 될 수 있다.
데이터 영역의 메모리 레이아웃이나 GC에 사용되는 알고리즘, JVM 명령어 실행과 관련된 내부의 최적화(기계어로 번역하는 과정 등)에 관한 것들은 구현자의 재량으로 남겨둔다.즉, JVM Specification만 지켜서 class 파일을 읽어서 실행할 수 있는 추상적(가상의) 기계를 모두 JVM이라고 할 수 있습니다.
따라서 소프트웨어도 될 수 있고 하드웨어도 될 수 있습니다.
실제로 바이트코드를 실행할 수 있는 마이크로프로세서가 개발된 적도 있습니다.
이러한 Specification을 지켜서 배포하는 사람들을 vendor라고 부릅니다.
어떤 vendor라도 JVM 명세만 따른다면 만들 수 있으며 구현된 형태에 대해서는 제약이 없습니다.
가장 유명한건 Oracle의 Hotspot이며 IBM에서 만든 JVM도 유명합니다.
왜 JVM을 알아야 할까?
메모리 효율성을 위해 메모리 구조를 알아야 하기 때문입니다.
동일한 기능의 프로그램이더라도 메모리 관리에 따라 성능이 좌우됩니다.
우리는 한정된 메모리를 효율적으로 사용하여 최고의 성능을 내야 합니다.
가장 일반적인 JVM과의 상호 작용은 GC의 알고리즘을 변경하거나 JVM의 메모리 설정을 조정하는 것입니다.
소규모 프로젝트나 간단한 프로그램을 만드는 개발자들은 JVM메모리에 대해서 이해해야 할 필요성을 느끼지 않을 수 있습니다.
하지만 대규모의 프로젝트를 개발할 때는 JVM 메모리 구조에 대한 이해는 필수적입니다.
위의 사진을 보면 정보시스템의 성능 저하 요인 중 JVM 밑 메모리 문제가 10.9%로 보입니다.
JVM에 대해 본격적으로 알아보기에 앞서 전체적인 자바의 실행 과정을 알아보겠습니다.
보통 우리가 작성하고 실행하는 파일은 바로 .java 파일입니다.
앞서 JVM에 대하여 살펴보았을 때 JVM은 .class 파일을 운영체제에 맞는 시스템 명령어로 번역하여 실행합니다.
따라서 javac라는 컴파일러(프로그램)가 인간이 이해할 수 있는 언어를 JVM이 이해할 수 있는 언어로 번역해줍니다.
이는 기존의 어셈블리어와 유사한 모습입니다.
일반적으로 소스파일을 컴파일한 파일을 목적 파일이라 하는데 C, C++ 같은 언어는 목적 언어로 바이너리 형태의 코드를 가지지만 자바에서는 바이트 코드를 가진 파일로 변환됩니다.
바이너리 코드는 CPU가 이해할 수 있는 언어이며, 바이트 코드는 JVM이 이해할 수 있는 언어입니다.
그러면 javac 컴파일러와 JIT compiler의 차이점은 무엇일까요?
javac 컴파일러는 자바 소스코드를 바이트 코드로 변환합니다.
JIT 컴파일러는 변환된 바이트코드를 해석하고 실행합니다.
JIT 컴파일러에 대해서는 아래에서 더 자세하게 살펴보겠습니다.
JVM의 3가지 주요 구성요소
Class Loader
클래스 로더는 JVM 아키텍처의 첫 번째 주요 구성요소로써 Loading -> Linking -> Initialization 단계를 거칩니다.
클래스 로더는 .java에서 컴파일된 .class 파일을 메모리의 Method Area에 로드하는 역할을 수행합니다.
1. Loading
Bootstrap, Extension, Application - 세 가지 컴포넌트들에 의해 클래스들이 로드됩니다
이 세가지 클래스 클래스 로더들은 모두 상속관계로 정의되어 있으며 delegate(위임) 방식으로 작업을 진행합니다.
위임 방식은 조금 아래에서 자세하게 설명하겠습니다.
Bootstrap ClassLoader
가장 최상위 클래스 로더로써 가장 최우선으로 로드됩니다.
jre의 lib폴더에 있는 rt.jar 파일을 찾아 기본 자바 API 라이브러리를 메모리에 로드합니다.
rt.jar 파일에는 우리가 기본적으로 사용했던 Java.lang.String 또는 Java.lang.Object과 같은 런타임 시 Java에서 제공되는 모든 라이브러리가 포함되어 있습니다. (rt = RunTime의 약자입니다.)
예를 들어, 우리가 System.out.println과 같은 System 클래스를 사용할 수 있는 것도 이 때문입니다.
Extension ClassLoader/Platfrom ClassLoader
Bootstrap ClassLoader의 child입니다.
Java 8 에서는 Extension ClassLoader 라고 불리며 Java 9 에서는 Platform ClassLoader라고 불립니다.
$JAVA_HOME/jre/lib/ext 경로에 위치해 있는 자바의 확장 클래스들을 메모리에 로딩하는 역할을 합니다.
JDBC와 같은 외부 라이브러리를 들을 로딩합니다.
Application ClassLoader/System ClassLoader
Extension ClassLoder의 child입니다.
Java 8 에서는 애플리케이션 클래스 로더라고 불리며 Java 9 에서는 시스템 클래스 로더 또는 불립니다.
애플리케이션을 실행할 때 주는 -classpath 옵션 또는 $CLASSPATH에 설정된 경로를 탐색하여 그곳에 있는 클래스들을 메모리에 로딩하는 역할을 합니다.
사양자가 직접 정의한 만든 .class 확장자 파일이 여기서 로딩됩니다.
위임 방식이란?
만약 Application Class loader가 라는 Internal이라는 클래스를 로딩할 때 그 로딩 요청은 부모 클래스 로더들로 거슬러 올라가서 부트 스트랩 로더에 다다른 후 그 밑으로 로딩 요청을 수행합니다.
2. Linking
- 검증(Verify)
가장 오래 걸리는 과정으로, 메모리로 로드된 바이트 코드가 JVM 스펙, 자바 언어 스펙을 따르는지 검증합니다.
- 준비(Prepare)
static int count = 0; 과 같은 정적 변수에 대한 메모리에 기본값이 할당됩니다.
- 해석(Reslove)
심볼릭 메모리 레퍼런스를 메서드 영역에 있는 실제 레퍼런스(실제 메모리 주소)로 교환합니다.
Test test = new Test();
어떤 객체가 다른 객체를 레퍼런스 하면(Test.java 클래스 생성 후 App.java 에서 new 연산자를 통하여 Test 클래스에 대한 정보를 참조하는 상황) Test에 대한 레퍼런스는 링크하는 과정에서 심볼릭 메모리 레퍼런스입니다. 이것을 실제 힙에 들어있는 Test 객체를 가리키도록 하는 과정이 Resolve때 벌어지기도 하고 아닐 수도 있습니다.(Optional 이기 때문입니다)
3. Initialization
클래스 로딩의 마지막 단계로써 준비 단계에서 확보한 메모리 영역의 모든 정적 변수에 자바 코드에 명시된 값이 할당됩니다.
Runtime Data Area
런타임 데이터 영역은 JVM 아키텍처의 두 번째 주요 구성 요소입니다.
자동차가 달리기 위해서는 도로가 필요하고, 기차가 달리기 위해서는 철도가 필요한 것처럼 JVM에 .class 파일을 저장하고 실행하기 위해 메모리 영역이 필요합니다.
.class 파일은 클래스 로더에 의해서 JVM내로 로드 되고, 실행엔진에 의해 기계어로 해석되어 메모리 상(Runtime Data Area)에 배치되게 됩니다.
JVM은 OS에서 할당받은 메모리 영역(Runtime data aeras)을 세 영역으로 구분합니다.
세가지 영역 - 메서드 영역, 힙 영역, 스레드 영역
PC 레지스터, 스택, 네이티브 메서드 스택은 스레드마다 하나씩 생성됩니다.
힙, 메서드 영역을 모든 스레드가 공유해서 사용합니다.
메서드 영역(Method Area)
모든 스레드가 공유하며 JVM을 시작할 때 생성됩니다.
클래스 로더가 클래스 파일을 읽어와 클래스 정보를 파싱 하여 메서드 영역에 저장합니다.
클래스 멤버 변수의 이름, 데이터 타입, 접근 제어자 정보같은 필드 정보와 메서드의 이름, 리턴 타입, 파라 미터 등이 메서드 영역에 저장됩니다.
힙(HEAP)
모든 객체 밑 인스턴스 변수는 이 메모리에 저장됩니다.
객체/배열의 저장 공간으로 JVM 내의 모든 Thread가 공유합니다.
Memory 해제는 Garbage Collection을 통해서만 가능하며 사용되지 않는 개체는 GC가 자동으로 제거합니다.
힙의 영역은 다음과 같이 구성되어있는데 이는 GC에 대하여 설명하면서 자세하게 알아보겠습니다.
스택(Stack)
스레드 별로 스택이 생성됩니다.
멀티스레드를 사용한다면 멀티스레드의 개수만큼의 스택이 생성됩니다.
메서드를 호출할때마다 Frame을 스택에 추가(push)되고 메서드가 종료되면 Frame을 제거(pop)합니다.
지역 변수, 연산자 스택, 프레임 데이터가 저장됩니다.
PC 레지스터( Register)
각 스레드는 메서드를 항상 실행하고 있습니다.
pc 레지스터는 실행중인 메서드 안에서 몇 번째 줄을 실행해야 하는지 나타내는 역할을 합니다.
CPU의 Register와는 다르게 CPU에 직접 연산을 수행하지 않고 , JVM은 연산을 위해 필요한 피연산자를 Stack에서 뽑아 별도의 메모리 공간에 임시로 저장하기 위한 용도로 사용합니다.
네이티브 메서드 스택(Native Method Stack)
자바 바이트코드가 아닌 다른 언어로 작성된 메서드를 의미합니다.
그러면 main 메서드를 가진 SomeClass.class 를 실행하면 Runtime Data Area는 어떻게 동작할까요?
위의 그림은 화질이 많이 깨지는데 아래의 글을 이해하는데의 전체적인 흐름 정도로만 봐주시면 좋을 것 같습니다!
- 클래스로더가 SomeClass파일을 읽어와 메서드 영역에 저장합니다.
- main 스레드가 생성되고 main 메서드를 호출합니다. JVM스택에 main Frame이 추가(push)됩니다.
- main 메서드는 String[] args 매개변수를 가지고 있습니다. String[]은 객체/배열로 힙 영역에 생성되고 메모리 주소를 할당받습니다. (이 주소를 100번지라고 가정하겠습니다.) main 스레드의 JVM스택의 main Frame에는 args라는 지역변수가 생성이 되며 이 값을 100번지의 주소 값을 가지고 있습니다.
- main 메서드가 int sum = 0; 과 같은 변수를 가지고 있다면 main 스레드의 JVM스택의 main Frame에는 sum이라는 이름의 지역변수가 생성되며 0이라는 값이 들어가게 됩니다.
- main 메서드가 add(int a, int b) 라는 메서드를 호출하여 return값을 sum에 저장한다면 main 스레드의 JVM 스택의 add Frame이 추가(push) 됩니다. add Frame은 지역변수 a, b의 값을 가지며 이 값은 add가 호출될 때 인자로 들어온 값이 할당됩니다.
- add 메서드가 끝나게 되면 add Frame을 제거(pop)합니다. 또한 add 메서드가 리턴한 값이 main Frame의 sum에 할당됩니다.
- if문이 끝나게되면 if문 안에 선언되었던 v2, v3 지역변수가 main Frame에서 제거됩니다.
- 마지막으로 sum의 값을 출력하고 main 메서드가 종료되게 되면 지역변수인 sum과, 매개변수인 args도 제거됩니다.
- main 메서드가 종료되었으므로 main Frame이 제거(pop)됩니다.
- 이후에 JVM이 종료되면서 런타임 데이터 영역이 사라지게 됩니다.
Execution Engine
실행 엔진은 JVM 아키텍처의 세 번째 주요 구성 요소입니다.
바이트 코드를 기계어로 변환하고 명령어를 실행하는 실제 엔진입니다.
- 인터프리터
인터프리터는 컴파일러처럼 사람이 이해할 수 있는 언어를 기계어로 해석해주는 번역 프로그램입니다.
컴파일러는 전체 소스코드를 보고 명령어를 수집하고 재구성하는 반면 인터프리터는 소스코드의 각 행을 연속적으로 분석하여 실행합니다.
따라서 일반적으로 각 행마다 실행하는 인터프리터보다는 컴파일러가 더 빠릅니다.
인터프리터의 느린 수행속도를 개선하기 위해 도입된 것이 바로 JIT 컴파일러입니다.
- JIT 컴파일러
Just-in Time의 약자로 JIT 컴파일러는 바이트 코드 전체를 읽어 한꺼번에 변환합니다.
하지만 이렇게 되면 수행 초기에 속도가 너무 느리게 됩니다.
따라서 초기에 인터프리터 방식으로 바이트 코드로 변환되며 그 코드를 캐싱하여, 같은 함수가 여러 번 불릴 때 캐싱된 값을 사용합니다.
하지만 한 번만 실행되는 코드는 인터프리터 방식이 훨씬 유리합니다.
따라서 JIT 컴파일러를 사용하는 JVM은 내부적으로 해당 메서드가 자주 수행되는지를 체크하여 일정 정도가 넘을 때만 JIT 컴파일을 수행합니다.
또한 JIT 컴파일러는 컴파일 과정에서 최적화를 수행합니다.
b = obj1.equals(obj2);
equals() 메서드의 경우 모든 자바 객체에서 사용할 수 있는 메서드이며 흔히 오버라이드 됩니다.
인터프리터는 위의 문장을 보고 실행시킬 equals() 메서드가 어떤 것인지 알기 위해 obj1의 타입을 찾기 위해 동적 look up을 해야 합니다. (이는 시간이 많이 소요됩니다.)
인터프리터 방식으로 바이트 코드로 변환되며 JVM이 obj1의 타입이 String이라는 사실을 알게 된다면 JVM은 obj1.equals()를 Stirng.equals()로 최적화한 코드를 만들 수 있습니다.
오라클의 JIT 컴파일러
오라클의 Hot Spot VM은 핫스폿 컴파일러라고 부르는 JIT 컴파일러를 사용합니다.
핫스폿이라고 부르는 이유는 내부적으로 가장 컴파일이 필요한 부분(Hot Spot)을 찾아 이 부분을 네이티브 코드(기계어)로 번역합니다.
한번 JIT 컴파일러를 통해 컴파일된 바이트코드라도 더 이상 자주 사용되지 않는다면 캐시에서 네이티브 코드를 제거하고 다시 인터프리터 방식으로 동작합니다.
JIT 컴파일러의 종류
JIT 컴파일러는 보통 두 가지 형태로 나뉩니다.
JVM 개발자들은 흔히 c1 컴파일러 ( = 클라이언트 컴파일러)와 c2 컴파일러 ( = 서버 컴파일러)라고 부릅니다.
c1 컴파일러와 c2 컴파일러의 주요한 차이점은 무엇일까요?
클라이언트 컴파일러(c1 컴파일러)는 서버 컴파일러(c2 컴파일러)보다 먼저 컴파일 하기 시작합니다.
이는 서버 컴파일러보다 상대적으로 더 많은 코드를 컴파일한다는 의미이며 클라이언트 컴파일러가 더 빠르게 동작합니다.
서버 컴파일러는 클라이언트 컴파일러보다 더 많은 정보를 바탕으로 컴파일하고 더 나은 최적화를 제공합니다.
애플리케이션을 장기적으로 작동한다면 서버 컴파일러가 클라이언트 컴파일러보다 더 빠를 것입니다.
이 두 가지 방법을 혼합한 방법이 바로 티어드 컴파일(tiered complication)이라고 합니다.
티어드 컴파일을 이용하면 코드는 먼저 클라이언트 컴파일러로 컴파일되고 많이 쓰이게 되면 서버 컴파일러로 재컴파일됩니다.
티어드 컴파일은 자바 7부터 릴리즈 되었으며 자바 8에서는 기본으로 사용할 수 있습니다.
컴파일러를 위한 튜닝
대게 컴파일러 튜닝이란 대상 머신에 설치하기에 알맞은 JVM과 컴파일러 모드(-client, -server - XX:+TieredCompliation)를 선택하는 일입니다.
애플리케이션의 성격에 따라 컴파일러 모드를 다르게 선택할 수 있습니다.
코드 캐시 튜닝
JVM이 코드를 컴파일할 때 코드 캐시 내에는 어셈블리 언어 명령 세트가 들어있습니다.
이 코드 캐시는 고정 크기이며 캐시가 가득 차게 되면 JVM은 더 이상 코드를 추가적으로 컴파일할 수 없습니다.
즉, 많은 코드가 인터프리터로 실행될 수 있습니다.
머신에서 사용 가능한 메모리의 크기만 충분하다면 코드 캐시의 최대 크기를 마음껏 늘려도 됩니다.
컴파일 임계치
코드 컴파일을 유발하는 요소는 얼마나 자주 코드를 실행했는가입니다.
코드 실행이 정해진 임계치에 도달하게 되면 컴파일러는 코드를 컴파일 하기에 충분한 정보가 쌓였다고 생각합니다.
따라서 컴파일 임계치 값을 더 낮게 변경한다면 더 빨리 컴파일될 수 있습니다.
- Garbage Colloctor
GC는 프로그램이 동적으로 할당했던 메모리 영역(Heap) 중 더 이상 사용되지 않는 객체를 삭제합니다.
C / C++는 사용자가 직접 메모리를 할당하고 해제합니다.
하지만 Java는 Garbage Collector가 이 작업을 수행합니다.
그러면 우리가 사용하는 close()는 무엇일까요?
Object 사용중지의 의사표현일 뿐 Object를 메모리에서 삭제하겠다는 의미는 아닙니다.
System.gc()나 Runtime.getRuntime().gc()를 통해 명시적으로 호출해서 사용할 수 있지 않나요?
공식문서를 찾아보면 두 메서드는 동일한 작업이며 system.gc()를 통해 runtime.getRuntime().gc() 메서드를 편리하게 호출할 수 있습니다.
Full GC를 수행시키는 메서드이며 Stop the world 시간이 길고 무거운 작업이며 즉시 수행한다는 보장이 없는 메서드 이기 때문에 사용을 지양해야 합니다.
GC의 장점
개발자의 실수로 인한 메모리 누수를 막을 수 있습니다.
해제된 메모리에 접근을 하지 않을 수 있습니다.
해제한 메모리를 또 해체하는 것을 막을 수 있습니다.
GC의 단점
GC가 어떠한 메모리 영역이 해제의 대상이 되는지 검사하고 해제하는 일은 우리의 프로그램이 해야 하는 일을 못하도록 하는 오버헤드입니다.
따라서 실시간성이 중요한 프로그램의 경우는 GC에게 메모리 관리를 맡기는 것이 알맞지 않을 수 있습니다.
개발자는 언제 GC가 메모리를 해제되는지 모릅니다.
GC의 알고리즘 - Reference Counting
Root Space는 스택의 지역 변수, 전역 변수 등 heap 영역 참조 주소를 담은 변수입니다.
Reference Counting 알고리즘은 Heap 영역에 선언된 객체들이 각각 Reference Count라는 별도의 숫자를 가지고 있습니다. ( 그림의 점선 부분이 Reference Count입니다.)
Reference Count란 몇 가지 방법으로 해당 객체에 접근할 수 있는지를 의미합니다.
즉, 해당 객체에 접근할 수 있는 방법이 없다면 (= 0이라면) GC의 대상이 됩니다.
하지만 Reference Counting 알고리즘에는 문제점이 있습니다.
만약 Root Space에서 Heap Space의 접근을 모두 끊으면 어떻게 될까요?
해당 부분에서 서로서로 순환 참조하기 때문에 이들은 GC의 대상이 되지 않습니다.
하지만 실제로는 Root Space로 접근할 수 있는 방법은 없습니다. ( Memory Leak 발생)
GC 알고리즘 - Mark And Sweep
Reference Counting의 순환 참조 문제를 해결할 수 있습니다.
Mark And Sweep은 루트에서부터 해당 객체에 도달할 수 있는지를 해제의 기준으로 삼습니다.
루트부터 그래프 순회를 통해 참조할 수 있는 객체를 찾아냅니다. (Mark) - Reachable
연결이 끊어져 있는 객체들은 지워냅니다. (Sweep) - Unreachable
여기서 흩어져 있는 객체들을 모아주어 메모리 파편화를 막는 작업인 Compaction이 존재하는데 이는 필수과정은 아닙니다.
실제로 JVM에서는 어떻게 GC를 진행할까요?
JVM은 일반적으로 Mark And Sweep 방법으로 GC를 진행합니다.
여기서 Root Space는 런타임 데이터 영역의 stack, native method stack, method area입니다.
GC는 의도적으로 실행시켜줘야 하는데 언제 실행될까요?
GC를 언제 실행하는지 알아보기 전에 먼저 Heap 영역에 대해 먼저 알아야 합니다.
Heap영역은 위에서 설명드렸듯이 런타임 데이터 영역에 속하며 객체 변수들이 여기서 저장됩니다.
위의 그림은 자바의 힙 영역입니다.
Heap 영역은 Young generation과 Old generation으로 나뉩니다.
Young generation에서 발생하는 GC는 minor GC라고 합니다.
Old generation에서 발생하는 GC는 majog GC입니다.
Young generation은 세 가지 영역인 Eden / Survival 0 / Survival 1으로 나뉩니다.
Eden에는 새롭게 생성된 객체들이 할당됩니다.
Survival 0 / Survival 1 영역은 minor GC에서 살아남은 객체들이 존재하는 영역입니다.
Survival 영역에는 특별한 규칙이 하나가 존재합니다.
바로 Survival 0 / Survival 1 둘 중 하나는 무조건 비어있어야 합니다.
Eden 영역의 크기가 가득 차게 되면 minor GC가 발생합니다.
Make And Sweep이 발생하여 Reachable 객체들은 Survival 0 / Survival 1중에 비어있는 영역으로 이동합니다.
그리고 여기서 살아남은 객체들은 숫자들이 0에서 1로 변하는데 이는 age bit를 뜻합니다.
GC는 age bit가 일정 값을 넘어가게 된다면 이 객체는 오래도록 참조될 객체라고 판단하고 해당 객체를 Old generation으로 넘겨주는데 이 과정을 Promotion이라 합니다.
또다시 Eden 영역이 꽉 차게되면 minor GC가 발생합니다.
이번엔 Reachable이라 판단된 객체들은 Survival 1으로 이동합니다.
여기서 만약 또 다시 Eden이 가득 차게 되면 minor GC가 발생합니다.
Reachable 객체들은 Survival 0으로 이동하며 age bit는 1이 증가합니다.
여기서는 age bit의 값이 3이면 JVM이 이 객체는 오랫동안 참조될 객체라고 판단하고 이를 Old generation으로 이동시킵니다. (Promotion 발생)
Java 8에서는 Parallel GC 방식 사용 기준 age bit가 15가 되면 promotion이 진행됩니다.
시간이 많이 지나면 언젠가는 Old generation도 가득 차는 날이 올 겁니다.
이때는 Major GC가 발생하면서 Mark And Sweep 방식을 통해 필요 없는 메모리를 비워주는데 Minor GC보다 시간이 더 오래 걸립니다.
왜 굳이 Heap 영역을 Young generation과 Old generation으로 나누었을까요?
GC 설계자들이 애플리케이션을 분석해보니 대부분의 객체가 수명이 짧다는 것을 알아냈습니다.
Minor GC가 발생하면 객체의 수가 엄청나게 줄어듭니다. (시간 적 게걸림)
하지만 Major GC가 발생한다면 객체의 수는 변화가 적습니다. (시간 오래 걸림)
따라서 GC는 두 가지 가설 하에 만들어졌습니다.
1. 대부분의 객체는 금방 접근 불가능 상태가 된다.
2. 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.
이 가설의 장점을 최대한 살리기 위해 영역을 둘로 나누어 관리합니다.
애플리케이션 실행과 GC의 실행 방식
GC를 실행하기 위해서는 JVM이 어플리케이션 실행을 멈추어야 합니다.
이를 Stop The Wolrd라고 합니다.
따라서 Stop The World를 최소화하는 것이 중요합니다.
JVM에서는 애플리케이션과 GC를 병행하여 실행할 수 있는 여러 옵션들을 제공합니다.
Serial GC (-XX:+UseSerialGC)
Serial GC는 하나의 스레드로 GC를 실행시키는 방식입니다.
하나의 쓰레드로 GC를 실행시키다 보니 Stop The World 시간이 깁니다.
싱글 쓰레드 환경 및 Heap 영역이 매우 작을 때 사용하기 위해 나온 방식입니다.
실제 운영 서버에서는 절대 사용하면 안 되는 방식입니다!
Parallel GC (-XX:+UseParallelGC)
Serial GC와 기본적으로 알고리즘은 같지만 Serial GC는 단일 스레드이고 Parallel GC는 멀티스레드입니다.
따라서 SerialGC보다 더 빠르게 처리할 수 있습니다.
CMS GC(-XX+UseConcMarkSweepGC)
다음 그림은 Serial GC와 CMS GC의 절차를 비교한 그림입니다.
CMS는 Concurrent-Mark-Sweep의 줄임말로 CMS GC가 더 복잡해 보입니다.
Initail Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체만 찾는 것으로 끝납니다.
따라서 멈추는 시간이 매우 짧습니다.
Concurrent Mark 단계에서는 다른 스레드가 애플리케이션 실행 중인 상태에서 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인합니다.
Remak 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인합니다.
Concurrent Sweep 단계에서도 다른 스레드가 애플리케이션을 실행 중인 상황에서 Sweep ( 쓰레기를 정리)하는 작업을 실행합니다.
Concurrent 하게 진행되기 때문에 Stop The World 시간이 매우 짧으며, 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용합니다.
CMS GC에도 단점은 존재합니다.
대신에 다른 GC방식보다 메모리와 CPU를 더 많이 사용합니다.
Compaction 단계가 기본적으로 제공되지 않습니다.
따라서 조각난 메모리가 많아 Compaction 작업을 실행하면 다른 GC방식보다 오래 걸릴 수 있으므로 Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 합니다.
G1 GC
Java9 이상부터는 GC의 기본 실행방식이 G1 GC입니다.
G1은 Garbege First의 줄임말로써 G1 GC는 지금까지의 Young 영역과 Old 영역을 사용하지 않습니다.
이름이 G1인 이유?
G1은 Garbage First의 약어로 Garbage만 있는 Region을 먼저 회수하여 빈공간을 더 빨리 확보하고자 합니다.
다음 그림처럼 G1 GC는 바둑판의 각 영역에 객체를 할당하고 GC를 실행합니다.
그러다가 해당 영역이 가득 차게 되면 다른 영역에 객체를 할당하고 GC를 실행합니다.
Heap을 일정 크기의 Region으로 잘게 나누어 어떤 영역을 Young Gen, 어떤 영역은 Old Gen으로 사용합니다.
런타임 시에 G1 GC가 필요에 따라 영역별 Region 개수를 튜닝하며 Stop the world를 최소화할 수 있습니다.
또한 메모리 Compaction 과정까지 지원하고 있어 Java 9부터 기본 GC 방식으로 채택되었습니다.
GC에 관여하기 - java.lang.ref 클래스
최초의 Java에서는 GC작업에 애플리케이션의 사용자 코드가 관여하지 않도록 구현되어 있습니다.
그러나 GC작업에서 좀 더 다양한 방법으로 객체를 처리하려는 요구가 있었습니다.
이에따라 JDK 1.2부터는 java.lang.ref 패키지를 추가하여 제한적이지만 사용자 코드와 GC가 상호작용 할 수 있게 하고 있습니다.
java.lang.ref 패키지는 전형적인 객체 참조인 strong reference 외에도 soft, weak, phantom 3가지의 새로운 참조 방식을 각각 Reference 클래스로 제공합니다.
이 3가지 클래스를 통하여 GC에 일정 부분 관여할 수 있고, LRU(Least Recently Used)캐시 같이 특별한 작업을 하는 애플리케이션을 더 쉽게 작성할 수 있습니다.
LRU란?
OS에서 사용되는 알고리즘으로 가장 오랫동안 참조되지 않은 페이지를 교체하는 기법
위에서 설명했듯이 Java GC는 객체가 가비지인지 판별하기 위해서 reachability라는 개념을 사용합니다.
이 그림에서 참조는 모두 java.lang.ref 패키지를 사용하지 않은 일반적인 참조이며, 이를 흔히 strong reference라고 합니다.
Soft, Weak, Phantom Reference
java.lang.ref는 soft reference와 weak reference, phantom reference를 클래스 형태로 제공합니다.
이렇게 생성된 WeakReference 객체는 다른 객체와 달리 java GC가 특별하게 취급합니다.
다음은 WeakReference 클래스가 객체를 생성하는 예입니다.
WeakReference<Sample> wr = new WeakReference<Sample>( new Sample()); Sample ex = wr.get(); ... ex = null;
이렇게 생성된 WeakReference 클래스의 객체는 new() 메서드로 생성된 Sample 객체를 캡슐화한 객체입니다.
참조된 Sample 객체는 두 번째 줄에서 get() 메서드를 통해 다른 참조에 대입됩니다.
이 시점에서 WeakReference 객체 내의 참조와 ex 참조, 두 개의 참조가 처음 생성한 Sample 객체를 가리킵니다.
위 코드의 마지막 줄에서 ex 참조에 null을 대입하면 처음 생성한 Sample 객체는 오직 WeakReference 내부에서만 참조됩니다.
이 상태의 객체를 weakly rechable 객체라고 합니다.
이렇게 SoftReference, WeakReference, PhantomReference 3가지 클래스에 의해 생성된 객체를 reference object라고 부릅니다.
반면에 new Sample() 생성자로 생성된 객체는 referent입니다.
이를 알면 Java 스펙 문서를 참조할 때 좀 더 이해하기 쉽습니다.
WeakReference와 GC의 연관관계
원래 GC대상의 여부는 reachable 또는 unreachable로 구분하였고 이를 사용자 코드에서는 관여할 수 없었습니다.
하지만 WeakReference를 이용하면 GC 대상 여부를 판별하는 부분에 사용자 코드가 개입할 수 있습니다.
녹색으로 표시한 중간의 두 객체는 WeakReference로만 참조된 weakly reachable 객체입니다.
파란색으로 표시한 객체는 reachable 객체입니다.
GC가 동작할 때, unreachable 객체뿐만 아니라 weakly reachable 객체도 GC의 대상으로 간주되어 메모리에서 회수됩니다.
root set으로부터 시작된 참조 사슬에 포함되어 있음에도 불구하고 GC가 동작할 때 회수되므로, 참조는 가능하지만 항상 유효할 필요는 없는 LRU 캐시와 같은 임시 객체들을 저장하는 구조를 쉽게 만들 수 있습니다.
SoftReference와 PhantomReference는 자주 쓰이지 않기 때문에 나중에 GC에 관여할 일이 생긴다면 더 알아보겠습니다.
이 부분에 대하여 더 자세하게 알고 싶으신 분들은 아래의 링크를 참조하세요!
https://d2.naver.com/helloworld/329631
GC 튜닝
GC튜닝이란?
Old 영역으로 넘어가는 객체의 수를 최소화하며, Full GC(Major GC)의 실행시간을 줄이기 위해 여러 가지 옵션을 주며 테스트하는 것을 의미합니다.
우선 GC튜닝은 꼭 해야 할까요?
다음과 같은 Java 기반의 서비스에서는 GC 튜닝을 진행할 필요는 없습니다.
- Xms 옵션과 -Xmx 옵션으로 메모리 크기를 지정했다.
- server 옵션이 포함되어 있다.
- 시스템에 Timeout 로그와 같은 로그가 남지 않는다.
다시 말해, 메모리 크기도 지정하지 않고 Timout 로그가 수도 없이 출력된다면 GC 튜닝이 필요합니다.
하지만 GC 튜닝은 가장 마지막에 해야 하는 작업입니다.
GC 튜닝을 하는 이유는 객체를 GC가 지우는 시간이 오래걸리는것을 최적화 하는것입니다.
생성된 객체가 많을수록 GC가 처리해야 하는 대상이 많아지기 때문에 우선 객체 생성을 줄이는 작업을 먼저 해야 합니다.
Old 영역으로 넘어가는 객체의 수 최소화하기
G1 GC 방식을 제외한 Oracle JVM에서 제공하는 모든 GC는 young, old 영역을 사용합니다.
따라서 Old 영역의 GC로 이동하는 객체의 수를 줄이면 Full GC가 발생하는 빈도를 많이 줄일 수 있습니다.
Full GC 시간 줄이기
Full GC의 실행 시간은 상대적으로 Minor GC에 비해서 깁니다.
Full GC 실행이 1초이상 소요된다면 연계된 여러 부분에서 타임아웃이 발행할 수 있습니다. ( DB Connection Error 등)
Full GC 실행시간을 줄이기 위해 Old 영역의 크기를 줄이면 자칫 OutofMemoryError가 발생하거나 Full GC 횟수가 증가합니다.
반대로 Old 영역의 크기를 늘리면 Full GC 횟수는 줄어들지만 실행 시간이 늘어납니다.
따라서 Old 영역의 크기를 적절하게 잘 설정해야 합니다.
GC의 성능을 결정하는 옵션
어떤 곳에서 GC 옵션을 이렇게 썻을 때 성능이 잘 나왔다고 해서 똑같이 적용하려고 생각하면 안됩니다.
서비스마다 생성되는 객체의 크기도 다르고 살아있는 기간이 다르기 때문에 자신의 서비스마다 맞는 옵션이 존재합니다.
따라서 GC 튜닝을 진행하기로 결정하였다면 GC방식을 선정하고 메모리의 크기를 지정합니다.
이후에 모니터링을 통해 GC방식 / 메모리 크기를 변경해가며 최적의 옵션을 찾아 나가야 합니다.
다음 표는 성능에 영향을 주는 GC 옵션 중 메모리 크기와 관련된 옵션입니다.
구분 옵션 설명 힙(heap) 영역 크기 -Xms JVM 시작 시 힙 영역 크기 -Xmx 최대 힙 영역 크기 New 영역의 크기 -XX:NewRatio New영역과 Old 영역의 비율 -XX:NewSize New영역의 크기 -XX:SurvivorRatio Eden 영역과 Survivor 영역의 비율 다음 표는 성능에 많은 영향을 주는 GC 방식에 대한 옵션입니다.
구분 옵션 비고 Serial GC -XX:+UseSerialGC Parallel GC -XX:+UseParallelGC
-XX:ParallelGCThreads=valueParallel Compacting GC -XX:+UseParallelOldGC CMS GC -XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+CMSParallelRemarkEnabled
-XX:CMSInitiatingOccupancyFraction=value
-XX:+UseCMSInitiatingOccupancyOnlyG1 -XX:+UnlockExperimentalVMOptions
-XX:+UseG1GCJDK 6에서는 두 옵션을 반드시 같이 사용해야 함 이상 JVM에 대하여 알아보았습니다.
잘못된 부분은 수정하고, 더 디테일한 부분들은 추가해 보겠습니다. 감사합니다.
출처
https://docs.oracle.com/javase/specs/jvms/se7/html/(JVM 공식문서)
https://kingofbackend.tistory.com/123(자바의 동작과정 Java Compiler와 JVM)
https://www.youtube.com/watch?v=vCKJacN_C3c(자바 메모리 사용 영역 : 20분)
https://www.youtube.com/watch?v=6reapO0gLPs (JVM Specification : 8분)
https://www.youtube.com/watch?v=g5yFxKeeW7M (JVM 강좌 : 20분 )
https://www.youtube.com/watch?v=vXLChm1OM8c ( (JVM + GC) (Hotspot과 IBM) 강좌 : 60분)
https://www.youtube.com/watch?v=UzaGOXKVhwU (JVM의 Stack과 Heap : 16분)
https://www.youtube.com/watch?v=QHIWkwxs0AI (JVM Architecture 강좌 : 8분)
https://www.youtube.com/watch?v=FMUpVA0Vvjw (GC - 조엘 : 14분)
https://joel-dev.site/94 (JVM의 GC)
https://asfirstalways.tistory.com/159 (가비지 컬렉션)
https://d2.naver.com/helloworld/1329(d2 GC)
https://junghyungil.tistory.com/133(자바 메모리 누수 방지)
https://d2.naver.com/helloworld/37111 (d2 GC 튜닝)
https://d2.naver.com/helloworld/6043(d2 GC 모니터링)
https://velog.io/@pond1029/JVM (자바의 실행 과정)
https://93jpark.tistory.com/54(자바의 컴파일 과정)
https://sas-study.tistory.com/262(JVM 메모리 구조 및 아키텍쳐(클래스 로더, 실행엔진 등)
https://engkimbs.tistory.com/606(Java 클래스 로딩 과정)
https://homoefficio.github.io/2018/10/13/Java-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A1%9C%EB%8D%94-%ED%9B%91%EC%96%B4%EB%B3%B4%EA%B8%B0/(Java 클래스로더 훑어보기)
https://show400035.tistory.com/167(Java 클래스 로더)
https://velog.io/@youngerjesus/%EC%9E%90%EB%B0%94-JIT-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC( JIT 컴파일러 튜닝법)
https://aboullaite.me/understanding-jit-compiler-just-in-time-compiler/(JIT 컴파일러 이해)
https://madplay.github.io/post/java-garbage-collection-and-java-reference
https://d2.naver.com/helloworld/329631 (D2 Java Reference와 GC)
'Java > 자바를 더 깊게' 카테고리의 다른 글
자바 Inner static class 로딩 시점 (0) 2022.04.10 StringBuilder의 초기화 방법 (0) 2022.03.12 [Java] Java Multi-Thread Programming의 모든것을 알아보자 (0) 2022.03.02 [Java] Java Collection Framework의 모든것을 알아보자 (0) 2022.02.22 [Java] String이 불변 객체인 이유는? (0) 2022.02.15