본문 바로가기

Java

어떻게 JVM은 메모리를 관리할까

1. GC란?

C 와 같은 언어로 프로그래밍을 한다면 메모리를 할당 후, 사용하지 않는 대상에 대해서는 메모리 해제를 해주어야만 메모리 누수현상을 방지할 수 있습니다.

하지만 Java를 포함한 많은 언어에서는 메모리를 해제하는 작업을 해주지 않는데도 메모리 부족현상 없이 잘 작동합니다.

그 이유는 GC(Garbage Collector) 덕분입니다.

GC는 메모리 관리기법중 하나로, 프로그램이 동적으로 할당했던 메모리 영역(Heap 영역)필요 없게된 영역을 알아서 해제 해주는 것입니다.

1 - 1 GC의 장단점

장점

  • GC가 메모리를 관리해주기 때문에 개발자가 메모리 걱정 STOP
  • 메모리 누수 STOP

단점

  • GC 작업은 순수 오버헤드 (STOP THE WORLD)
  • 개발자는 언제 GC가 작동하는지 모른다.

3. GC 알고리즘

그렇다면 어떻게 GC는 해제할 메모리 영역을 알아서 판단하게 될까요?

3 - 1 Reference Counting

image

Reference Counting알고리즘에서 GC는 Heap영역에 있는 각 객체에 대한 포인터의 개수를 카운트로 유지합니다. 이 카운트는 객체에 대한 참조가 생성되거나 소멸될 때 필요에 따라 증가하거나 감소합니다.

Reference Counting은 몇 가지 방법으로 해당 객체에 접근할 수 있는지를 뜻하는 것입니다. 해당 객체에 접근할 수 있는 방법이 하나도 없다면, 즉 Reference Counting이 0이 된다면 GC의 대상이 되는 것입니다.

하지만 Reference Counting는 순환 참조 를 해결할 수 없다는 단점을 가지고 있습니다.

image

만약 위 사진처럼 연결이 끊어질 경우, 세 객체들은 서로를 순환참조하고 다른 곳에서는 사용이 되지 않을 것입니다. 따라서 GC의 대상이 되어야 하지만 Reference Counting은 1로 유지되기 때문에 메모리 누수가 발생하게 됩니다.

3 - 2 Mark-Sweep

image
image

Mark-Sweep 알고리즘은 다음과 같이 동작합니다.

1단계: 루트 집합에서 시작하여 메모리 그래프를 추적합니다. 도달한 모든 개체를 표시합니다.

2단계: 메모리를 스윕하여 표시되지 않은 공간을 모두 회수합니다.

Mark-Sweep 알고리즘은 Reference Counting 방식에 비해 여러 장점이 있습니다.

  1. 성능 향상: Reference Counting 방식은 객체에 대한 참조가 생성되거나 제거될 때마다 카운트를 업데이트해야 합니다. 이는 각 메모리 할당 및 해제 작업에 오버헤드를 추가하며, 특히 참조가 빈번히 변경되는 시스템에서는 성능 저하의 원인이 될 수 있습니다. 반면, Mark-Sweep 알고리즘은 가비지 컬렉션 주기 동안에만 메모리를 검사하므로, 일반적인 메모리 작업에서는 추가적인 부담이 없습니다.
  2. 간단한 메모리 관리: Mark-Sweep 알고리즘은 메모리 할당과 해제가 자주 일어나는 시스템에서도 비교적 단순하게 작동합니다. 참조 카운팅 방식에서는 순환 참조 같은 복잡한 문제들을 처리해야 할 필요가 있지만, Mark-Sweep는 이러한 문제에 덜 민감합니다.
  3. 메모리 할당의 유연성: Reference Counting은 객체가 더 이상 필요하지 않을 때 즉시 메모리를 해제합니다. 이는 장점일 수도 있지만, 빈번한 메모리 할당과 해제로 인해 메모리 단편화가 발생할 수 있습니다. 반면에, Mark-Sweep 알고리즘은 가비지 컬렉션을 통해 메모리를 일괄적으로 정리하여 효율적인 메모리 재사용이 가능하게 합니다.

반면 다음과 같은 단점이 존재합니다.

  1. Stop-the-world: 가비지 컬렉션 과정에서 애플리케이션의 모든 일반 작업이 일시적으로 중단될 수 있습니다. 이는 GC가 메모리를 효과적으로 검사하고 정리할 수 있도록 하기 위함인데, 큰 힙 메모리를 가진 시스템에서는 이 시간이 길어질 수 있어 성능 저하를 일으킬 수 있습니다.
  2. 메모리 단편화: Mark-Sweep 알고리즘은 사용되지 않는 객체를 메모리에서 제거하지만, 이로 인해 메모리 내에 빈 공간이 불규칙하게 생길 수 있습니다. 이러한 메모리 단편화는 시간이 지남에 따라 메모리 사용 효율성을 떨어뜨리고, 대규모 객체에 대한 메모리 할당을 어렵게 만들 수 있습니다.
  3. 가비지 컬렉션 오버헤드: 가비지 컬렉터가 작동할 때, 시스템의 전반적인 처리 능력의 일부를 차지합니다. 이는 특히 메모리 사용이 많은 애플리케이션에서 눈에 띄는 성능 저하를 일으킬 수 있습니다.

4. JVM의 GC

이번엔 어떻게 JVM에서 GC가 작동하는지 알아보도록 하겠습니다.

image
JVM은 크게 세 부분으로 분리됩니다.

  1. 바이트코드를 읽고, 클래스 정보를 메모리의 Heap/Method Area에 저장하는 클래스 로더
  2. 실행중인 프로그램의 정보가 올라와 있는 메모리
  3. 바이트코드를 네이티브 코드로 변환시켜주고, GC를 실행시켜주는 실행엔진

JVM 실행 엔진이 어떻게 GC를 실행시키는지 알기 위해선 메모리 영역 을 자세히 들여다보아야 합니다.

JVM 메모리

JVM 메모리는 OS로부터 메모리를 할당받은 후, 용도에 따라 메모리를 다섯 가지의 영역으로 나누어서 관리합니다.

이 영역은 다시 크게 두 가지로 분류할 수 있습니다.
image

Method Area / Heap

모든 스레드가 공유하는 영역

  • Method 영역은 프로그램의 클래스 구조를 메타데이터처럼 가지고, 메서드의 코드를 저장합니다.
  • Heap 영역은 어플리케이션 실행중에 생성되는 객체 인스턴스들을 저장하는 영역으로 GC의 대상이 됩니다.

JVM Language Stack / PC Register / Native Method Stack

각 스레드마다 고유하게 생성되며, 스레드 종료시 소멸되는 영역

  • stack은 메서드 호출을 스택 프레임이라는 블록으로 쌓으며, 로컬 변수 중간 연산 결과들이 저장되는 영역입니다.
  • pc register는 스레드가 현재 실행할 스택 프레임의 주소를 저장합니다.
  • native method stack은 C/C++ 등의 low level 코드를 실행하는 영역입니다.

이렇게 JVM의 메모리 영역을 알아본 이유는 GC의 시작점인 Root Space가 어디인지 살펴보기 위함입니다.
image

JVM은 기본적으로 Mark-Sweep 으로 작동하는데, 이 알고리즘은 Root Space에서 해당 객체에 접근할 수 있는지 확인합니다.

5. JVM의 Heap 영역

image

Heap 영역은 Young Generation과 Old Generation으로 구분됩니다.

Young Generation에서는 Minor GC

Old Generation에서는 Major GC가 작동합니다.

5 - 1 객체 생성

새로운 객체가 생성될 경우 Young Generation의 Eden 영역에 생성됩니다.
image
위와 같이 객체가 생성된 후 GC가 작동하면서 Reachable 객체와 UnReachable 객체로 구분하게 됩니다.

5 - 2 Minor GC

객체가 생성된 후 Minor GC가 동작한다면 , 도달할 수 있는 객체와 도달할 수 없는 객체로 구분된 후 제거됩니다.
image

GC가 동작한 후 살아남은 객체는 숫자가 1 증가하게 됩니다.
image
다시 객체가 생성되고 Minor GC가 작동한다면 Eden 영역에서 Reachable 객체와, Survival 0 영역에서 Reachable 객체는 Survival 1 영역으로 이동하게 됩니다.

image

여러번의 Minor GC가 동작하면서 객체들의 age-bit 가 점차 올라갈 것입니다.

JVM GC에서는 객체의 age-bit가 일정 수준을 넘어간다면 오래 사용될 객체라고 판단하여 Old Generation으로 넘겨줍니다.

5 - 3 Major GC

image
Minor GC가 계속해서 동작하면 언젠가는 Old Generation 도 가득차게 될 것입니다.

이때 Major GC가 동작하게 됩니다.
image

Reachable 객체와 UnReachable 객체로 구분한 후 필요없는 메모리는 해제하게 됩니다.

6. GC 실행 방식

6 - 1 Serial GC

image

Young 영역에서의 GC는 앞 절에서 설명한 방식을 사용합니다.

Old 영역의 GC는 mark-sweep-compact이라는 알고리즘을 사용합니다.

이 알고리즘의 첫 단계는 Old 영역에 살아 있는 객체를 식별(Mark)하는 것입니다. 그 다음에는 힙(heap)의 앞 부분부터 확인하여 살아 있는 것만 남기게 됩니다(Sweep). 마지막 단계에서는 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서 객체가 존재하는 부분과 객체가 없는 부분으로 나눕니다.(Compaction).

Serial GC는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식입니다.

6 - 2 Parallel GC

image

Parallel GC는 Serial GC와 기본적인 알고리즘은 같습니다.

그러나 Serial GC는 GC를 처리하는 스레드가 하나인 것에 비해, Parallel GC는 GC를 처리하는 쓰레드가 여러 개입니다.

그렇기 때문에 Serial GC보다 빠르게 객체를 처리할 수 있습니다.

Parallel GC는 메모리가 충분하고 코어의 개수가 많을 때 유리합니다.

6 - 3 CMS GC

image
Initial Mark

초기 Initial Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체만 찾는 것으로 끝내기 때문에 멈추는 시간이 매우 짧습니다

그리고 Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인합니다.

이 단계의 특징은 다른 스레드가 실행 중인 상태에서 동시에 진행된다는 것입니다.

Remark

그 다음 Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인합니다.

Concurrent Sweep

마지막으로 Concurrent Sweep 단계에서는 쓰레기를 정리하는 작업을 실행합니다. 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행합니다.

이러한 단계로 진행되는 GC 방식이기 때문에 stop-the-world 시간이 매우 짧습니다. 모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용합니다.

그런데 CMS GC는 stop-the-world 시간이 짧다는 장점에 반해 다음과 같은 단점이 존재합니다..

  • 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다.
  • Compaction 단계가 기본적으로 제공되지 않는다.

6 - 4 G1 GC

image

마지막으로 G1 GC 입니다.

G1 GC는 지금까지의 방법과는 다르게 동작합니다.

Heap 영역을 일정 크기의 Region으로 잘게 나누어서 어떤 영역은 Young Generation, 어떤 영역은 Old Generation으로 사용합니다.

런타임시 G1 GC가 영역별 Region 개수를 튜닝한다고 합니다. 그에 따라 Stop The World를 최소화할 수 있습니다.

참고
https://rebelsky.cs.grinnell.edu/Courses/CS302/99S/Presentations/GC/
https://ko.wikipedia.org/wiki/자바_가상_머신#
https://youtu.be/FMUpVA0Vvjw?si=eysVYnfdUFt3v0TI
https://sihyung92.oopy.io/java/garbage-collect/1
https://d2.naver.com/helloworld/1329?source=post_page-----2d046f73da4f--------------------------------

'Java' 카테고리의 다른 글

디미터의 법칙  (0) 2023.11.14
Spring Test - H2 적용하기  (0) 2023.10.31
[JUnit5] @ParameterizedTest  (0) 2023.10.30
Java Record  (0) 2023.10.23
== & Equals  (0) 2023.10.15