Post

면접준비 - Java GC(Garbage Collection) 상세설명

오타, 지적 환영입니다.

배경

C언어는 메모리를 직접 해제할 수 있다. free()를 통해서 직접 메모리를 해제하고 malloc()등을 통해 메모리를 할당할 수 있다. 또한 포인터를 통해 주소값에 직접 접근이 가능하다.

하지만, Java에서는 JVM가 메모리를 관리한다. 직접 주소에 접근할 수도 없다.

앞의 포스팅에서 JVM의 메모리구조에 대해서 말했다.

  • Stack
  • PC Register
  • Native Method Stack
  • Method Area
  • Heap

으로 이루어져있는데, 보통 런타임시에는 빈번하게 접근이 일어나는게 StackHeap이다.

메소드와 지역변수들은 실행순서대로 스택에 쌓인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class main {

    public static void main(String[] args) {
        call();
    }

    public static void call(){
        Spot home = new Spot(3,4);
    }
}
class Spot{

    public int x;
    public int y;
    Spot(int x,int y){
        this.x = x;
        this.y = y;
    }

}

이러한 코드가 있다고 치면 스택과 힙에는 이렇게 쌓일것이다.

1
2
3
4
5
| home   |     |     |
| call() |     |Spot1| home -> Spot1
| main() |     |     |
+++++++++      +++++++
Stack           Heap

call()에서는 home 살아있다. 만약 call()호출이 끝나면 home인스턴스는 어떻게 되는것인가? 사라질 것이다. 그러면 가리키고 있던 Spot1은?

1
2
3
4
5
|        |     |     |
|        |     |Spot1| ? -> Spot1
| main() |     |     |
+++++++++      +++++++
Stack           Heap

이 정리를 GC가 담당한다.

그러면 GC는 언제 이 메모리들을 정리한다는 것일까?


Garbage Collection Algorithm

단순한건 메소드 호출이 끝날때마다 해주는 것이다. 하지만 메모리를 계속 모니터링해야하고, GC자체도 프로그램 성능에 영향을 주기에 문제가 있다. 그렇기에 GC알고리즘을 개선한다는 것은 GC시점을 언제 정하여 성능에 최대한 덜 지장을 주는가에 있다.

Reference Counting

GC를 수행할때 오브젝트에 몇 개의 레퍼런스가 연결되어 있는지 체크하는 방법이다.

위의 경우

1
2
3
4
5
| home   |     |     |
| call() |     |Spot1| home -> Spot1
| main() |     |     |
+++++++++      +++++++
Stack           Heap

에서 call()의 지역변수 homeSpot1이라는 오브젝트를 가리키므로 Spot1의 카운트는 1이다. 이처럼 객체를 참조하는 변수의 수가 늘어나면 1을 올리고 사라지면 1을 내려 카운트가 0이되면 GC를 수행할 때 제거하는 방법이다.
직관적이지만 문제가 있다. 서로가 서로를 가리킬 때이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Test {

    public static void main(String[] args) {
        call();
    }

    public static void call(){
        Home home = new Home(1,2);
        School school = new School(3,4);
        
        home.mySchool(school);
        school.myHome(home);
    }
}
class Spot{

    public int x;
    public int y;
    Spot(int x,int y){
        this.x = x;
        this.y = y;
    }
}

class Home extends Spot{
    private School school;
    Home(int x, int y) {
        super(x, y);
    }

    public void mySchool(School school){
        this.school = school;
    }
}

class School extends Spot{
    private Home home;
    School(int x, int y) {
        super(x, y);
    }

    public void myHome(Home home){
        this.home = home;
    }
}

이렇게 짜는 경우는 없겠지만.. 내 머릿속에서 생각한 예제의 한계이다.

아무튼 이 경우

1
2
3
4
5
6
7
8
| myHome()   | 
| mySchool() |
| school     |
| home       |     |school1[2] |  home -> Home1
| call()     |     |home1[2]   |  school -> School1
| main()     |     |           |  Home1 -> school -> School1 
+++++++++          ++++++++++++   School1 -> home -> Home1
Stack               Heap

이렇게 서로가 서로를 참조할 경우에는 call()이 끝나도 힙에 남아있는 Home1이 결국 School1을 가리킬것이기에 사라지지 않을 것이다.

Tracing

오브젝트의 레퍼런스를 추적한다.

처음에는 스택의 지역변수에서 시작해 해당 변수가 가리키는 오브젝트를 찾는다.

오브젝트가 연결되어 있다면 해당 오브젝트의 marked값을 true로 바꾼다.

1
2
3
4
5
6
7
8
| myHome()   | 
| mySchool() |
| school     |
| home       |     |school1[true] |  home -> Home1
| call()     |     |home1[true]   |  school -> School1
| main()     |     |           |  Home1 -> school -> School1 
+++++++++          ++++++++++++   School1 -> home -> Home1
Stack               Heap

지역변수로부터 레퍼런스가 끊기면 marked의 값을 false로 바꿔 GC수행시에 false인 오브젝트들을 메모리에서 해제시킨다.

1
2
3
4
5
6
|            |     |school1[false] |  ? -> Home1
|            |     |home1[false]   |  ? -> School1
| main()     |     |               |  Home1 -> school(School1)
++++++++++++++     +++++++++++++++++   School1 -> home(Home1)
Stack               Heap

하지만 이럴경우 메모리에 빈 공간이 생기게 되는데, 빈 공간이 늘어날때마다 이를 피해서 비집고 들어가는 상황이 발생하기에 메모리 공간은 충분하지만 할당되지 못하는 문제가 생긴다.
효율적이지 않다.

이미 할당된 메모리 공간을 다른 한 쪽으로 모아주는 compacting작업을 해줘야한다. 이것도 GC에서 수행되고, Tracing기법은 mark-and-sweep으로도 불린다.

mark-and-sweep -> Compacting 작업이 수행되기에 MSC(Mark,Sweep,Compacting)으로도 불린다.


Generational GC

JVM은 GC를 더 효율적으로 수행하기 위해 힙 메모리 구조를 더 세밀하게 분류한다. GC는 오버헤드가 큰 작업이다. 왜냐하면 GC가 시작될 때마다 JVM이 stop-the-world라는 프로그램의 스레드를 모두 멈추고 MSC를 수행한다. 따라서 GC주기가 잦으면 규모가 클수록 오버헤드가 커진다.

보통 오브젝트가 생성된지 얼마 안되어서 레퍼런스가 사라진다. 이러한 점을 착안해서 만든게 이 알고리즘이고, heap영역을 세대별로 쪼개 관리한다.

5가지 영역으로 Eden, S0, S1, Tenured, Permanent로 나누어진다. 이는 Java8 버전 이전이다. Java8버전 이후에는 Permanent가 사라지고 Metaspace가 생겼다.

Java8버전 이전이 설명하기 편하므로 이를 기준으로 정리해보겠다.

1
2
3
4
5
                 |--------- Old ---------|
+------+----+----+-----------+-----------+
| Eden | S0 | S1 |  Tenured  | Permanent |
+------+----+----+-----------+-----------+
|----- Young ----|

Eden, S0, S1은 Young영역이라고 생성된지 얼마 안 된 오브젝트들이 쌓이는 공간이다.

  • Eden : 오브젝트가 처음 생성되었을 때 Eden에 들어간다.
  • Survivor 0, Suvivor1 : 생성된 이후 시간이 흐르면 garbage되지 않은 오브젝트들이 eden에서 여기로 옮겨진다. 즉, 에덴에서 살아남은 오브젝트들이 들어가서 suvivor space라고도 불러진다. 두 개로 분리된것은 보통 하나의 Suvivor 공간이 꽉차게되면 다른 Suvivor공간으로 옮겨진다. 이 메커니즘 때문에 Suvivor들 중 하나의 공간은 비워지게 된다. 옮겨진 오브젝트들은 age count가 1 증가한 상태로 넘어간다.

이 과정에서 1차 GC라고 하는 Minor GC가 발생하게 된다. 이 GC를 통해 Eden 또는 Suvivor영역들에 사용되지 않은 객체들을 메모리 해제하게 된다.

이 과정을 반복해도 살아남은 객체들의 age counter가 일정 이상으로 넘어가버리면 Old영역인 tenured로 보내 Suvivor영역이 꽉 차지 않게 한다.

이렇게 오브젝트가 살아남아 다음 세대로 넘어가는 것을 promotion이라고 한다.

Old 영역은 GC가 잘 발생하지 않으므로 메모리가 크게 할당된다.

Permanent영역은 Method Area라고도 하는데, 객체나 문자열정보를 저장하는 곳이며, GC가 절대 발생하는 곳은 아니고 발생한다. 이는 Major GC에 포함된다.

Eden영역에서 바로 Old영역으로 넘어가는 객체가 있는데, 이는 객체의 크기가 아주 큰 경우를 말한다. Survivor영역의 크기가 20MB인데, 객체의 크기가 24MB인 그런 경우에 해당한다.


Java 8버전 이후 GC

not found

Permanent영역이 Metaspace로 변경되었다. 기존 Permanent 영역에는 다음과 같은 정보가 저정되어 있었다.

  • Class Meta정보
  • Method Meta정보
  • Static Object
  • 상수화된 String Object
  • Class 관련 배열 객체 및 객체 Meta 정보
  • JVM 내부적인 객체들과 최적화컴파일러(JIT)의 최적화 정보

이는 Metaspace가 생기면서 다음과 같이 바뀌었다.

  • Class Meta정보 -> Metaspace로 이동
  • Method Meta정보 -> Metaspace로 이동
  • Static Object -> Heap영역으로 이동
  • 상수화된 String Object -> Heap영역으로 이동
  • Class 관련 배열 객체 및 객체 Meta 정보 -> Metaspace로 이동
  • JVM 내부적인 객체들과 최적화컴파일러(JIT)의 최적화 정보 -> Metaspace로 이동

왜 이렇게 바뀌었냐면 Permanet에 있던 Static Object들이 문제를 유발했다고 한다. 이를 Heap영역으로 옮겨서 최대한 GC대상이 되도록 변경했다고 한다.

다음과 같은 오류를 발생했다고 한다.

1
OutOfMemoryError : PermGen Space error

이는 메모리 누수가 발생했을때 나타나는 예외이고 로딩된 클래스와 클래스 로더가 종료될때 이것들이 GC되지 않았을때 발생한다고 한다.

수정될 필요가 없는 정보만 Metaspace에 저장하고 JVM의 필요에 따라 리사이징할 수 있게 바뀌었다고한다.


GC 방식

  • Java7 : Parallel GC
  • Java8 : Parallel GC
  • Java9 : G1(proPosed)
  • Java10 : G1
  • Java15 : ZGC(proPosed)

1. Serial GC

JDK 5,6 에서 쓰이던 방식으로 Major GC, Minor GC 모두 싱글스레드로 실행되어 Stop-The-World가 다른 GC에 비하여 오래걸린다.
절대 사용하면 안되는 GC로 설명되어 있으며 MSC 알고리즘을 사용한다.

2. Parallel GC(Throughput Collector)

Serial GC와 동일하나 Young Gen을 멀티스레드를 통해 병렬 처리한다.

compaction 단계 이전에 summary라는 단계를 가지며, 해당 작업에서는 이전 GC 이후의 메모리를 인덱싱하는 작업을 수행하고 compact 작업을 수행한다.
공간에 대한 인덱싱 작업때문에 약간의 메모리를 더 소모할 수 있다.

업그레이드 방식으로 Parallel old GC가 있다. 이는 Old영역에서 발생하는 FullGC도 병렬로 처리한다.

3. CMS(Concurrent Mark & Sweep) GC

Full GC의 수행시간을 최소한으로 줄이는데 초점을 맞춘 GC이다. 즉 Stop-The-World의 시간을 최소화한다. 그렇기에 GC대상을 정밀하게 파악한다.

기존 Parallel GC 방식은 Full GC를 수행할때마다 Compaction 작업을 하여 시간을 많이 소요하였지만, 수행된 이후에는 메모리를 연속적으로 저장할 수 있어 메모리를 더 빠르게 할당할 수 있다.

CMS는 Compaction작업을 수행하지 않기 때문에 속도가 빠르지만 메모리 단편화로 인해 Concurrent mode failure가 발생할 수 있고, 이 경우 Compaction 작업을 수행한다.
다만, Compaction 과정이 Parallel GC보다 오래걸릴 수 있다. 그리고 CPU리소스가 부족해지거나 메모리 단편화로 인해 메모리 공간이 부족해지면 Serial GC방식의 Full GC가 발생 한다.

그러면 CMS는 어떤식으로 GC대상을 파악할까?

  • Initial Mark : 현재 살아남은 객체를 탐색하는데, GC ROOT에서 참조하는 객체들만 우선 탐색하기 때문에 STW발생 시간이 적다.
  • Concurrent Mark : 위 단계에서 탐색한 객체들이 참조하고 있는 객체를 찾아가며, GC대상인지 확인한다.(STW 발생 x)
  • ReMark : Concurrent Mark 과정 중 새로 생성된 객체나, 참조가 끊기는 등 변경된 객체가 있는지 다시 한번 점검한다.(STW 발생 - 멀티스레드로 인해 시간이 짧다.)
  • Concurrent Sweep : Remark 단계까지 검증이 완료된 GC대상 객체를 해제한다.(STW x)

4. G1 GC(Garbage First GC)

G1 GC는 기존 메모리 구조에서 좋은 성능을 내기 힘들어서 개선하고자 등장했다. 힙 메모리 영역 전체를 Region이라는 논리적인 단위로 나눠서 관리한다. Region은 특정 역할(Eden, Suvivor, Old 등)을 동적으로 부여한다.

CMS와 달리 Compaction 단계를 진행하고 메모리 단편화 문제를 없앴다. STW 시간을 예측할 수 있다는 것이 G1 GC가 가진 큰 장점 중 하나다.

error

이렇게 Region 단위로 나뉘는데, 2048개의 Region으로 Heap을 나눌 수 있다.

각 Region은 1MB ~ 32MB의 크기를 지정할 수 있다.

여기서 Humonogous, Avliable, Unused영역은 처음보는데 이를 정리해야 한다.

  • Humonogous : Region 크기의 50%를 초과하는 객체가 저장되는 공간이다. 이 공간에서는 GC가 효율적으로 일어나지 않는다.
  • Avaliable/Unused : 아직 사용되지 않은 비어있는 공간의 Region이다.

G1 GC에서는 Young GC를 수행할 때는 STW가 발생하고, STW시간을 줄이기 위해 멀티스레드로 GC를 진행한다.

Young GC는 Region 중 GC대상 객체가 가장 많은 Region(Eden or Survivor역할)에서 수행 되며, 이 Region에서 살아남은 객체를 다른 Region(Suvivor 역할)으로 옮긴 후, 비워진 Region을 사용 가능한 Region으로 돌리는 형태로 작동한다.

Old GC에서는 CMS처럼 백그라운드 쓰레드로 이 영역들을 정리한다.
Heap 메모리에 객체가 살아 있는지 동시적이고 전역적인 마킹을 수행하고, 어떤 영역의 메모리가 가장 많이 비어있는지 확인하여 메모리를 회수 한다. 그렇기 때문에 Garbage First라는 이름을 갖고 있는 것이다. G1에 의해서 교체를 해야하는 공간은 배출(evacuation)에 의해 GC를 시작한다.

배출은 Region 내의 unreachable object만 삭제하는 것이 아니고, Reachable object는 다른 Old영역Region으로 이동시키고 해당 Region 전체를 클리어한다. 다른 Region으로 옮기는 과정에서 Compacting을 하여 메모리 단편화가 생기지 않는다.

5. ZGC

jdk 15버전이상에서 사용되는 GC종류로, 조금 더 큰 메모리(8MB ~ 16TB)에서 효율적으로 GC하기 위한 알고리즘이다. 주된 목적은 STW시간을 줄이기 위해 Marking 시간에서만 STW를 진행하도록 하고 있다.

error

G1GC와 유사한 구조를 가지고 있는데, ZGC는 Colored pointersLoad barriers라는 2가지 주요 알고리즘을 가지고 있다.

  • Colored Pointers error

객체를 가리키는 변수의 포인터에서 64bit를 활용해서 Marking을 한다.

Finalizable : finalizer을 통해서만 참조되는 Object의 Garbage Remapped: 재배치 여부를 판단하는 Mark Marked 1/0 : Live Object

그렇기에 ZGC는 64비트 운영체제에서만 사용이 가능하다.

  • Load barriers
    위의 비트를 이용해서 G1GC와 다르게 메모리 재배치 과정에서 STW를 발생시키지 않는다. 그리고 비트를 이용해 참조 값과 mark 상태를 업데이트 한다.

마킹 과정은 내가 이해를 못해서.. 정리하기 어려우므로 다른 글을 읽는것을 추천한다.

  1. https://kchanguk.tistory.com/174
  2. https://huisam.tistory.com/entry/jvmgc

Reference

This post is licensed under CC BY 4.0 by the author.