[JAVA] 가비지 컬렉션(Garbage Collection, GC)에 대한 이해

kindof

·

2021. 6. 20. 17:36

0. 들어가기 전에

자바 가비지 컬렉터(GC)에 대해 설명하기 전에 스택과 힙 영역에 대해 잠깐 짚고 넘어가려고 하는데요. 이 부분을 모른 채로 GC를 이해하는 것이 좀 어려울 것 같다고 생각합니다.

 

1) 스택(Stack) 영역

이전에 JVM에 대한 글을 썼을 때, JVM은 스택 기반 가상 머신이며 효율적인 연산과 메모리 사용이 가능하다는 이야기를 했었습니다. 그러면 스택에는 무언가 연산을 할 대상이 담겨있어야 하지 않을까요?

 

 

[JAVA] JVM과 자바 코드의 동작

1. JVM이란 무엇인가? JVM(JAVA Virtual Machine)은 자바 애플리케이션을 클래스 로더를 통해 읽어 들여 자바 API와 함께 실행해주도록 하는 가상 머신입니다. JVM은 JAVA와 OS 사이에서 중개자 역할을 하는

studyandwrite.tistory.com

스택 영역은 1) 정적 메모리의 할당과 2) 쓰레드의 실행을 위한 값들을 저장하기 위해 필요한 공간입니다.

 

이 영역에는 원시(Primitive) 타입(int, double, boolean 등)의 데이터가 값과 함께 할당되고, 힙 영역에 저장된 객체에 대한 참조값이 저장되죠.

 

예를 들어 메서드 A가 호출되었다고 해볼까요? 그러면 스택 영역에는 메서드가 가지고 있는 변수들과 메서드 내 다른 객체에 대한 참조값 등을 가지고 있을겁니다. 그리고 이 값들을 바탕으로 메서드에서 구현한 코드를 실행해가죠.

 

한편, 지역 변수들은 변수 스코프에 따른 Visibility를 가집니다. 임의의 지역 변수가 foo()라는 함수 내에서 스택에 할당된 경우, 해당 지역 변수는 다른 함수에서 접근할 수 없다는 것이죠. 당연한 얘기일지도 모릅니다.

 

마지막으로 각 쓰레드는 자신만의 스택 영역을 가집니다. 쓰레드가 생성될 때마다 고유한 스택 영역도 생긴다고 보면 됩니다. 그리고 이 각각의 스택은 Main thread와 heap을 참조하죠.

 

 

2) 힙(Heap) 영역

Heap 영역에는 주로 긴 생명주기를 가지는 객체 데이터가 저장되는데요.

 

객체(Object)는 크기가 크고(클 수도 있고), 서로 다른 코드 블록에서 공유되는 경우가 많기 때문에 긴 생명주기를 가졌다고 보는 겁니다.

 

힙 영역에 존재하는 객체들의 주소값은 스택에 저장되고 그 스택에 저장된 포인터값을 찾아보면 힙 영역으로 향하게 됩니다.

 

한편, 몇 개의 쓰레드가 존재하든 힙 영역은 단 하나만 존재합니다. 쓰레드마다 가지고 있는 스택 영역에서 힙 영역을 참조할 뿐입니다.


아래 예시를 통해 위의 스택과 힙 영역에 대한 이해를 해보면 좋을 것 같습니다.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> listArgument = new ArrayList<>();
        listArgument.add("yaboong");
        listArgument.add("github");

        print(listArgument);
    }

    private static void print(List<String> listParam) {
        String value = listParam.get(0);
        listParam.add("io");
        System.out.println(value);
    }
}

Stack, Heap 메모리 구조

 

메인 함수에서 listArgument라는 List 객체에 String 문자열을 추가하면 스택과 힙 영역은 위와 같은 모습을 하게 됩니다. 스택은 listArgument라는 객체의 참조값을 가지고 있고, 실제 객체의 데이터들은 힙 영역에 들어가있는 것을 볼 수 있죠.

 

다음으로 print(listArgument); 구문에 의해 함수 호출이 일어납니다. 이 때 listArgument라는 참조 변수를 print()라는 메서드에 넘겨주게 되죠. 

 

print(List<> listParam){} 메서드에서는 listParam이라는 참조변수를 인자로 받게 되어 있습니다. 따라서 print()함수호출에 따른 메모리 변화는 아래와 같습니다.

listParam이라는 참조변수가 새롭게 stack에 할당되어 기존 List를 참조하게 되는데, 기존 인자인 listArgument가 가지고 있던 값(List에 대한 reference)을 그대로 listParam이 가지게 됩니다.

 

그리고 print()함수 내부에서 listArgument는 scope밖에 있게 되므로 접근할 수 없는 영역이 되죠.

 

print() 함수가 실행되고 함수가 종료되기 직전의 스택과 힙 영역은 아래와 같아집니다.

Stack, Heap 메모리 구조

이제 함수가 닫는 중괄호 } 에 도달하여 종료되면 print() 함수의 지역변수는 모두 stack 에서 pop() 되어 사라집니다.

 

이때, List는 Object 타입이므로 지역변수가 모두 stack 에서 pop 되더라도 heap 영역에 그대로 존재합니다.

 

즉, 함수호출시 레퍼런스 값을 복사하여 가지고 있던 listParam과 함수내부의 지역변수인 value만 스택에서 사라지고 나머지는 모두 그대로인 상태로 함수호출이 종료되는 것이죠.

 

최종적으로 함수 호출이 종료된 시점에서 스택과 힙 영역은 아래와 같습니다.

최종 Stack, Heap 메모리 구조

 


스택과 힙 영역에 대한 이해가 어느정도 되셨다면 이제 JVM의 GC에 대해 알아보겠습니다.

 

 

1. Java Virtual Machine(JVM)

C 나 C++ 에서는 OS 레벨의 메모리에 직접 접근하기 때문에 free() 메서드를 호출하여 할당받았던 메모리를 명시적으로 해제해주어야 합니다. 그렇지 않으면 메모리 누수(Memory leak)가 발생하게 되고, 다른 프로그램에도 영향을 끼칠 수 있습니다.

 

반면, 자바는 OS 의 메모리 영역에 직접적으로 접근하지 않고 JVM 이라는 가상머신을 이용해서 간접적으로 접근합니다. 쉽게 말해 JVM은 객체가 필요해지지 않는 시점에서 알아서 free()를 수행하여 메모리를 확보하는 것이죠. 

 

그리고 JVM이 메모리 누수 현상을 방지하는 방법을 Garbage Collection이라고 볼 수 있습니다.

 

 

2. 자바 Garbage Collection란?

가비지 컬렉션(Garbage Collection, GC), 앞으로 GC라고 명명하겠습니다.

 

GC는 더 이상 사용되지 않는 객체들을 자동으로 메모리에서 제거하는 역할을 하여 사용자로 하여금 힙 영역을 사용할 수 있는만큼 자유롭게 사용할 수 있게 합니다.

 

이 때 "더 이상 사용되지 않는 객체"란, 힙 영역의 객체들 중에서 스택에서 도달 불가능한(Unreachable)한 객체"들을 말하는데요.

 

위에서 살펴본 스택과 힙 영역의 구조를 떠올려보면 스택은 힙 영역에 저장된 객체의 참조값을 저장하고, 이 참조값을 통해 실제 객체에 접근한다고 했습니다.

 

따라서, 스택에서 도달 불가능한 객체는 곧 무의미한 객체가 되는 것이죠.

 

 

이게 무슨 말인지 아래 간단한 예제를 통해 이해해보겠습니다.

public class Main {
    public static void main(String[] args) {
        String url = "https://";
        url += "yaboong.github.io";
        System.out.println(url);
    }
}

위 코드에서 String url = "https://"; url += "yaboong.github.io"; 구문이 수행된 이후 스택과 힙 영역의 상태는 아래와 같습니다.

 

문자열 더하기 연산이 수행되는 과정에서, String은 불변객체이므로 기존에 있던 "https://" 스트링에 "yaboong.github.io"를 덧붙이는 것이 아니라, 문자열에 대한 더하기 연산이 수행된 결과가 새롭게 heap 영역에 할당되죠.

따라서 기존의 "https://"라는 문자열을 레퍼런스하고 있는 변수는 아무것도 없으므로 String https://는 스택에서 도달 불가능한 객체가 되는 겁니다.


3. Garbage Collection 동작 원리

= Mark and Sweep

Mark는 JVM의 Garbage Collector가 스택의 모든 변수를 스캔하면서 각각 어떤 객체를 레퍼런스하고 있는지 찾는 과정입니다.

 

이 때, 도달 가능한 객체가 레퍼런스하고 있는 객체 또한 마킹합니다.

 

그리고 나서 마킹되어있지 않은 모든 오브젝트들을 Heap에서 제거하는 과정이 Sweep입니다.

 

따라서 바로 위에서 살펴본 예제에서 Garbage collection이 일어난 후 메모리 상태는 아래와 같게 됩니다.


4. Garbage Collection Process

GC의 역할을 관찰하기 위해 VisualVM의 VisaulGC탭을 보면 위처럼 Heap 영역이 몇 개의 영역으로 나누어져 있는 것을 볼 수 있습니다.

 

Garbage Collector는 위와 같이 나누어진 영역을 기준으로 프로세스를 진행하게 되는데, 구체적인 과정은 아래와 같습니다.


1. 새롭게 생성된 객체는 최초에 Eden 영역에 할당됩니다. 두 개의 Survivor Space는 비워진 상태로 시작합니다.

 

2. Eden 영역이 가득차면, MinorGC가 발생합니다.

 

3. MinorGC가 발생하면, Reachable 객체들은 S0으로 옮겨집니다. Unreachable 객체들은 Eden영역이 클리어 될때 함께 메모리에서 사라집니다.

 

4. 다음 MinorGC가 발생할때, Eden 영역에는 3번과 같은 과정이 발생합니다. Unreachable 객체들은 지워지고, Reachable 객체들은 Survivor Space로 이동합니다. 기존에 S0에 있었던 Reachable 객체들은 S1으로 옮겨지는데, 이때, age값이 증가되어 옮겨집니다. 살아남은 모든 객체들이 S1으로 모두 옮겨지면, S0와 Eden은 클리어됩니다. Survival Space에서 Survival Space로의 이동은 이동할 때마다 age 값이 증가합니다.

 

5. 다음 MinorGC가 발생하면, 4번 과정이 반복되는데, S1이 가득 차 있었으므로 S1에서 살아남은 객체들은 S0로 옮겨지면서 Eden과 S1은 클리어됩니다. 이 때에도, age 값이 증가되어 옮겨지죠. Survivor Space에서 Survivor Space 로의 이동은 이동할때마다 age값이 증가합니다.

 

6. Young Generation 에서 계속해서 살아남으며 age 값이 증가하는 객체들은 age 값이 특정값 이상이 되면 Old Generation 으로 옮겨지는데 이 단계를 Promotion이라고 합니다.

 

7. MinorGC가 계속해서 반복되면, Promotion 작업도 꾸준히 발생하게 되겠죠?

 

8. Promotion 작업이 계속해서 반복되면서 Old Generation 이 가득차게 되면 MajorGC가 발생하게 됩니다.


뭔가 굉장히 복잡한 것 같습니다. 그런데 천천히 그림과 설명을 비교해서 읽어보면 시간이 지나고 객체가 쌓이면 쌓일수록 도달 불가능한 객체는 날라가고, 살아남은 객체도 오른쪽으로 나이를 먹으며 밀려난다는 겁니다.

 

그리고 나이를 많이 먹은 객체는 결국 Promotion으로 Old Generation에 위치하게 되고 major GC가 일어나면 오래된 객체들이 날라간다는 것이죠.

 

외우지는 마시고 대략적인 흐름을 이해하시면 될 것 같습니다.


 

5. 나가면서

이번 포스팅에서는 스택과 힙 영역에 대해 다시 정리해보고, JVM의 중요한 역할 중 하나인 가비지 컬렉션(Garbage Collection)에 대해 알아보았습니다.

 

GC는 워낙 어렵고 방대한 개념인 것 같습니다. 지금 정리한 내용이라도 우선 잘 이해해보고 다음에 더 많은 내용을 담아보도록 하겠습니다.