DEV-STUDY/Java

[Java] Garbage Collection

HwangJerry 2023. 11. 24. 21:26

What is Garbage Collection ?

자바를 공부하다보면 자주 마주하게 되는 "가비지 컬렉션"이라는 말. 이번 기회에 한번 제대로 알아보자.

 

"가비지 컬렉션"이란, 말 그대로 쓰레기를 모아서 버려주는 것을 의미한다.

 

 

마치내가 방 안에서 살다 보면 처음엔 잘 쓰던 물건이더라도, 어느샌가 손도 대지 않는 물건이 존재하듯이, 프로그램도 계속 작업을 수행하다보면 끊임없이 필요 없는 데이터들이 메모리 위에 쌓여나가게 되는 것이 일반적이다.

 

 

고전적인 프로그래밍 랭귀지인 C나 C++의 경우에는 이렇게 메모리를 차지하는 데이터에 대하여 수동으로 free를 해줘야 한다. 이를 처리하지 않으면, 필요하지도 않은 데이터가 메모리에 지속적으로 공간을 차지하여 이를 활용하지 못한다 해서 "메모리 누수(memory leak)"가 발생하였다고 말한다.

 

 

C, C++ 이후에 출시된 javascript, python, go 와 같은 프로그래밍 언어들과 같이 자바도 이러한 작업을 매번 개발자가 처리해야 하는 번거로움, 그리고 휴먼 에러로 인해 발생하는 메모리 누수를 방지하고자 자동으로 이러한 작업을 수행하는 "가비지 컬렉션"이라는 개념을 구현하였다. 이는 low level에서 처리해야 하는 일들을 일일히 개발자가 신경쓰지 않는 대가로 low level의 제어권을 포기한다는 사상을 갖는 자바의 특징 중 하나라고도 해석할 수 있다.

 

 

다시 말해서 "가비지 컬렉션"이란, 필요가 없어진 객체 등 동적으로 메모리에 할당된 데이터 공간이 개발자가 아닌 '가비지 컬렉터'에 의해 해제되는 현상을 의미한다.

 

 

Why do we use Garbage Collection ?

사람이 해야 했던 일을 자동으로 해준다는 거 같은데, 그러면 좋기만 한 것인가?

 

메모리는 하드디스크마냥 넓지도, 저렴하지도 않다. 메모리는 소위 우리의 통장에 있는 돈처럼 작고 소중하다. 즉, 아껴 써야 한다는 말이다.

 

 

만약 C++ 언어를 활용하여 개발을 하였는데, .free()를 놓친 프로그램이 있다고 해보자. 만약 잠깐 켜고 끄는 프로그램이라면 메모리 누수가 크게 문제가 되지 않을 수 있다. 하지만 오랫동안 실행되어야 하거나, 작업량이 많은 프로그램이라면 지속적으로 메모리 누수가 일어나게 되면서 나중에는 동적으로 메모리 공간을 할당하는 힙 공간이 부족한 시점에 이르게 되고, 결국 더 이상 메모리를 할당할 수 없는 컴퓨터는 그 프로그램으로 인해 다운될 것이다.

 

 

이처럼 한정된 메모리 자원을 관리하는 것은 우리의 생각보다 더 중요하며, 그만큼 부담스러우며 번거로운 작업이다. 더군다나 대부분의 객체는 할당된 직후 쓸모를 잃어버린다고 한다. (아래 그래프 참조)

 

출처 : https://j-k4keye.tistory.com/61

 

따라서 이를 자바 등의 언어에서는 '가비지 컬렉터'를 활용하여 메모리 관리를 수행해주게 되고, 덕분에 개발자는 코드를 구성할 때 비즈니스 로직에 더욱 집중할 수 있게 되었다.

 

 

그렇다면, 가비지 컬렉터는 장점만 있을까? 아쉽지만 세상은 그리 호락호락하지 않다.

 

 

가비지 컬렉터는 그 적용 알고리즘에 따라 메모리 관리를 수행하는 시기가 정해지는데, 개발자가 이렇게 메모리가 해제되는 시점을 정확하게 예측하기 어렵고, 당연히 이를 제어하기도 힘들다. 따라서 항공우주로켓 등 밀리세컨드 단위로도 그 결과가 달라지는 프로그램을 설계할 때에는 명확하게 개발자가 메모리를 관리하고 있다.

 

 

또한 프로그램 내에서 메모리 관리를 위해 가비지 컬렉션이 발생할 때에는 그 동작 원리상 다른 작업 쓰레드들을 모두 멈추어야 한다. 이를 stop the world라고 표현하며, 이는 매우 치명적인 문제일 수 있는 부분이라 가비지 컬렉터를 이용하는 프로그래밍 언어를 활용한 프로그램을 제작할 때에는 stop the world가 발생하는 시간을 최소화하는 GC 최적화 작업이 동반되어야 한다.

 

 

How Garbage Collection works (in Java) ?

가비지 컬렉션은 내부적으로 어떻게 필요 없어진 객체들을 알아보고, 이를 제거해주는 걸까?

 

자바는 Garbage Collection을 구현하기 위해 Reachability Analysis 알고리즘을 사용하고 있다. 이는 GC Roots로부터 참조 체이닝을 통해 '도달 가능한 객체'를 사용하고 있는 객체라고 인지하는 방식으로 이해하면 된다. 즉, 참조 체이닝을 통해 도달하지 못한 객체들은 GC의 대상이 된다. 아래 설명에서는, 사용하고 있는 객체를 Reachable Object, 사용하지 않는 객체를 Unreachable Object라고 표현한다.

Reachability Analysis 방식 외에도 "참조 계수"를 활용하여 사용중인 객체를 판단할 수도 있다.
(하지만 이는 순환 참조 문제를 효과적으로 처리할 수 없으므로 그다지 사용되지 않는다.)

이는 어떤 객체가 참조될 때마다 참조 계수를 카운트하여, 그 계수를 통해 객체가 사용되고 있는지 확인하는 방식이다. 만약 특정 object의 참조 계수가 0에 도달하면 이는 사용하지 않는 객체로 판단되어 gc의 대상이 되는 원리이다.
MyClass obj1 = new MyClass();  // 참조 계수: 1
MyClass obj2 = new MyClass();  // 참조 계수: 1
obj1.setReference(obj2);       // obj1이 obj2를 참조하므로 obj2의 참조 계수: 2
obj1 = null;  // obj1이 더 이상 해당 객체를 참조하지 않음. obj2의 참조 계수: 1

 

 

 

method area는 정적 변수나 소스 코드가 저장되는, 불변값들이 저장된 공간이라고 이해하면 되고, stack은 멤버 변수나 지역 변수 등 런타임 환경에서 갖게 되는 데이터들이라고 이해하면 된다.

 

 

그리고 heap에는 자바에서 Object를 상속한 모든 객체가 저장된다고 이해하면 된다. 이는 Collection에 따라 array list나 hash map에 담겨서 존재하기도 한다. 어찌되었건 객체들은 모두 heap에 그 데이터가 저장되어 있으며, 그 heap 안의 '데이터가 저장된 주소값'을 참조하기 위한 참조 변수를 stack 등에 저장해 두어 데이터를 조회하게 된다.

 

 

위 사진과 함께 아래 코드를 통해 unreachable 객체를 이해해보자.

public class GarbageCollectionExample {

    public static void main(String[] args) {
        List<MyObj> myList = new ArrayList<>();

        // 객체 생성 후 리스트에 추가
        myList.add(new MyObj());
        myList.add(new MyObj());

        // 리스트의 객체 참조 변수를 null로 설정
        myList = null;

        // Garbage Collection 트리거를 수동으로 발동
        System.gc();
    }
}

 

위 코드에서 myList는 GC Root이며, JVM 데이터 영역 중 stack에 저장된다. GC Root인 myList로부터 연결된 객체들(리스트에 담겨있는 객체들)이 도달 가능한 객체로 취급되고, 그 외의 객체들은 도달 불가능하다고 판단된다. 위 코드에서는 myList에 null이 할당되는 순간, myList가 참조하던 ArrayList에 있던 objects들은 GC Root로부터 도달 가능하지 않은 객체로 판단되어 Garbage Collection의 대상이 된다.

 

*** 이렇게 reachable / unreachable object를 구별하는 과정을 Mark 라고 칭한다.

 

 

이제 Garbage Collection의 대상이 되는 객체를 언제 지우는지 이해해보자.

 

힙은 new generation과 old generation으로 나뉘며, 말 그대로 new generation에는 새로 생성된 객체가 할당되는 공간, old generation은 오래된 객체가 저장되는 공간으로 이해할 수 있다. (만약 객체의 크가가 큰 경우에는 바로 old generation에 할당되기도 한다고 한다.)

 

이렇게 generation으로 메모리 영역을 구분하여 사용하는 것은 새로운 객체와 오래된 객체 간의 생명 주기를 고려하여 Garbage Collection을 효과적으로 수행하기 위함이라고 한다.

 

 

eden 영역은 새로 생성된 객체가 저장되는 공간이다. 여기에 객체를 차곡차곡 저장하다가, 가득 차게 되면 new generation 공간을 대상으로 gc가 수행되며, 이를 minor gc라고 한다. survivor0과 survivor1은 survivor area라고 부르며, eden이 가득 차서 minor gc가 수행된 이후에 살아남은 객체들(reachable objects)이 저장되는 공간이다. (s0, s1는 s1, s2 또는 to space, from space 라고 부르기도 한다.)

 

 

minor garbage collection은 다음과 같은 과정으로 이루어진다.

1. eden 공간이 가득 차면 minor gc가 수행된다.

2. eden공간의 unreachable object들은 메모리 할당이 해제되고, eden공간의 reachable object는 s0로 이동되어 차곡차곡 메모리 할당을 받는다.

---

1. eden 공간이 가득 차면 minor gc가 수행된다.

2. s0공간에서 살아남은 reachable object는 s1로 이동되어 차곡차곡 메모리 할당을 받는다.

3. eden공간의 unreachable object들은 메모리 할당이 해제되고, eden공간의 reachable object는 s1로 이동되어 차곡차곡 메모리 할당을 받는다.

 

*** 이렇게 unreachable object에 할당된 메모리를 해제하는 것을 sweap이라고 칭한다.

즉, survivor area는 s0 또는 s1 둘 중 하나는 반드시 비어있는 채로 운영된다. 여기서, survivor area가 두 개로 나뉘어 있는 이유는, 메모리 공간 재배치 과정(compaction)을 통해 메모리 공간 파편화 방지를 효율적으로 구현하여 연속된 메모리 공간을 확보하기 위해서이다.

 

 

object들은 survivor area에 들어간 순간부터 각각 age 계수를 할당받는다. 그리고 minor gc가 발동될 때마다,  survivor 영역을 번갈아가면서 저장되는 과정에서 계속 age++ 과정을 거치게 된다.

 

 

위 과정이 반복되다가 age threshold를 오버하는 object가 발생하게 된다. 이 객체들은 이제 old generation에 메모리를 할당받아 이동되며, 이를 promotion이라고 부른다.

 

 

이 과정의 반복을 거듭하다가 old generation도 가득 차게 되면 major gc라고 부르는 가비지 컬렉션이 수행된다. 이는 old generation을 대상으로 수행되는 가비지 컬렉션이다. 여기서 주의할 점은, 앞서 서술한 대로 gc를 수행할 때 stop the world 현상이 발생하게 되는데, minor gc의 경우 new generation이 담당하는 메모리 크기가 작기 때문에 0.5초 ~ 1초 정도 선에서 모두 마무리되지만, old generation은 new generation에 비해 더 큰 공간을 가지므로, 이를 mark & sweap하는 major gc의 경우 minor gc의 10배 정도까지 걸릴 수도 있기 때문에 서비스에 치명적인 문제가 될 수 있다. 따라서 이를 최적화하는 gc 튜닝이 필요하다.

 

 

How to optimize Garbage Collection ?

stop the world 시간을 줄이는 것이 gc 최적화이자 gc 튜닝이다. 이를 구현하기 위해 사용되는 gc 알고리즘은 serial gc부터 paralell gc, cms gc, g1 gc, shenandoah gc, zgc 까지 개발되어 왔다. 아래는 이 알고리즘을 간단히 설명한 것이다.

 

1. Serial GC:

목적: 단일 스레드로 GC 작업을 수행하는 가장 간단한 알고리즘으로, 주로 작은 규모의 애플리케이션 또는 단일 프로세서 시스템에서 사용
특징: Stop-the-World 형태의 GC로, GC 작업을 수행하는 동안 애플리케이션의 모든 스레드가 일시 정지됨


2. Parallel GC:

목적: 다중 스레드를 사용하여 효율적인 GC 작업을 수행하는 알고리즘으로, 멀티코어 시스템에서의 GC 최적화
특징: Stop-the-World 형태의 GC이지만, 여러 스레드가 병렬로 작업을 수행하므로 GC 작업을 더 빠르게 완료할 수 있음


3. CMS (Concurrent Mark-Sweep) GC:

목적: 응답 시간을 최적화하고 Stop-the-World 시간을 최소화하는 것
특징: Mark-and-Sweep 알고리즘을 사용하며, GC 작업 중에도 애플리케이션의 일부가 계속 실행됨. 하지만 Old Generation을 정리하기 위해 Stop-the-World 단계가 필요


4. G1 (Garbage-First) GC:

목적: 전반적인 성능과 예측 가능한 GC 시간을 향상시키기 위해 개발된 알고리즘
특징: G1은 Young, Old, Perm 영역을 관리하며, GC 작업을 세분화하여 실행. Stop-the-World 단계도 존재하지만, 예측 가능하게 조절 가능


5. Shenandoah GC:

목적: 낮은 일시 정지 시간을 유지하면서 대용량 힙에 대한 GC를 수행
특징: 애플리케이션의 작동 중에 객체를 병렬로 처리하여 일시 정지 시간을 최소화. Young 및 Old Generation을 함께 처리하며, 대용량 힙을 효과적으로 다룸.


6. ZGC (Garbage Collector for Z):

목적: 매우 낮은 일시 정지 시간. 애플리케이션의 처리량 향상에 중점을 둠.
특징: java 15에 출시되었으며, 힙 크기에 관계없이 매우 짧은 일시 정지 시간을 가지므로 대용량 메모리를 다루는데 적합.

 

 

출처:

망나니개발자

박사 학위 논문인 가비지 컬렉션

인파 블로그