Java/Java 를 파헤쳐보자

[Java 파헤쳐보기] Java의 가비지 컬렉터(Garbage Collector)

동구름이 2023. 12. 16. 01:38

https://dcloud.tistory.com/98

 

[Java 파헤쳐보기] JVM을 파헤쳐보자

JVM과 자바의 메모리 영역에 대한 학습을 정리했다.  우선 전반적인 실행 과정을 간단히 살펴보고, 하나씩 깊게 살펴보자!  1. Java 프로그램의 전반적인 실행 과정 가장 먼저, 자바 컴파일러가

dcloud.tistory.com

지난 포스팅에서 JVM에 대해 살펴보았다. JVM의 메모리 영역에는 메서드, 스택, 힙 영역 등이 있었다. 그리고 가비지 컬렉터가 그 중 힙 영역을 청소하고, Heap 영역에 있는 객체를 가리키는 레퍼런스 변수는 Stack 영역에 저장된다고도 언급했다. 

 

 

이게 정확히 무슨 말일까? 이번 포스팅에서 JVM의 가비지 컬렉터(Garbage Collector)을 살펴보며 자세히 알아볼 것이다

 

 가비지 컬렉터(Garbage Collector)를 직역하자면, 쓰레기를 모으는 도구이다. 가비지 콜렉터는 프로그램이 동적으로 할당한 메모리 영역 중 사용하지 않는 영역을 탐지해 해제하는 기능을 수행한다.

 

 여기서 동적으로 할당되는 메모리 영역은 Heap영역을 말한다. 그리고 필요 없게 된 영역은 어떤 변수도 가리키지 않게 된 영역을 말한다. 

 

 

 이 부분들에 대해 이번 포스팅에서 자세히 알아보자!

 

 

1. Stack 과 Heap

 우선 스택과 힙에 어떤 데이터가 저장되는지를 알아야한다.

 

 Stack 은 정적으로 할당한 메모리 영역이고, 원시 타입(int, boolean, long.. )의 데이터가 값과 함께 할당되는 부분이다. Heap 영역의 객체 타입의 데이터 참조 값이 Stack에 할당된다. 

 

 반면에 Heap 영역은 동적으로 할당한 메모리 영역으로, 모든 객체 타입의 데이터가 할당된다. Heap 영역의 객체를 가리키는 참조 변수는 Stack에 할당된다.

 보다 쉬운 이해를 위해 코드와 코드에 쓰이는 변수가 어떤 영역에 저장되는지를 그림을 통해 알아보면 위와 같다.

Heap 영역의 데이터를 스택에서 가리키는 레퍼런스 변수 name이 스택에 존재하는 것을 알 수 있다.

 

Unreachable Object

 만약 위 그림처럼 어떤 변수도 가리키지 않는 Heap의 영역을 Unreachable Object라고 하고 이것이 가비지 콜렉터가 해제할 대상이 된다.

 

 

 

+ 스택과 힙에 저장되는 데이터 조금 더 쉽게 이해해보기

사실 스택에는 윈시 타입, 정적으로 할당한 값이 들어가고... 힙에는 객체가 들어가고... 이렇게 외우면 머리 속에서 받아들이기가 어렵다. 

 

이걸 조금 더 쉽게 이해하기 위해서는 스택과 힙의 특성을 간단하게 생각해볼 수 있다.

 

스택은 FILO 구조라는 것을 많이 들어보았을 것이다. 스택은 가장 먼저 들어간 값이 가장 나중에 나오는 자료 구조이다. 그런데 여기서 간과하기 쉬운 중요한 특징은, 스택은 최상단에만 데이터의 접근이 가능한 구조라는 것이다.

 

 즉 가장 top의 데이터에만 접근할 수 있고, 이것은 탐색의 시간이 O(1)이 되기 때문에 데이터의 접근이 빠를 수 밖에 없다. 하지만 힙은 그렇지 않다. 메모리 주소를 통해 접근해야하고, 그렇기에 탐색 시간이 필요하다.

 

 그럼 코드가 실행될 때, 예를 들어 매서드의 변수들은 당연히 바로 바로 접근이 가능해야하지 않을까? 그렇게 바로 바로 접근을 해야하는 값들은 스택에 들어간다.

 

 반대로 객체와 같은 부분은 다른 어느 부분에서 참조가 되어야하는 부분이다. 이런 데이터가 스택에 들어가면 다른 데이터의 흐름을 방해하게 되지 않을까? 그래서 말 그대로 참조할 수 있는 특성을 지닌 힙에 저장된다고 생각하면 이해가 쉬울 것이라고 생각한다.

 

 

 

 

 

2. GC 알고리즘

다시 돌아와, 어떻게 가비지 콜렉터가 해제할 동적 메모리를 판단하는지를 알아야 한다.

 

이때 대표적으로 두 가지 알고리즘이 있다.

 

(1) Reference Counting

출처 : https://rebelsky.cs.grinnell.edu/Courses/CS302/99S/Presentations/GC/

 

 그림에서 Root Space는 스택 변수, 전역 변수 등 Heap 영역 참조를 담은 변수이다.

 

 Reference Counting 알고리즘은 Heap 영역의 객체들이 Reference Count라는 변수를 가진 것으로 생각하는 것이다. Reference Count는 해당 객체에 접근할 수 있는 방법의 가짓 수이다. 만약 객체에 접근할 수 있는 방법이 하나도 없다면(Reference Count가 0) 가비지 컬렉션의 대상이 된다.

 

순환 참조 문제

 하지만 이 방법엔 한계점이 있는데, 바로 순환 참조 문제를 해결할 수 없다는 것이다.

 

 만약 위 그림처럼, Root Space에서의 접근을 끊는다면, 동그라미 안의 객체들은 서로가 서로를 참조 하고 있기 때문에 레퍼런스 카운트가 1로 유지가 된다. 결국 사용하지 않는 메모리 영역이 해제 되지 못하고 메모리 누수가 발생하게 된다.

 

 

 

(2) Mark and Sweep

Mark and Sweep 알고리즘은 이런 순환 참조 문제를 해결할 수 있다.

출처 : https://rebelsky.cs.grinnell.edu/Courses/CS302/99S/Presentations/GC/

 

 Mark and Sweep은 루트 스페이스로 부터 해당 객체에 접근 가능한지를 살펴보는 방법이다.

 

 루트 스페이스부터 그래프 순회를 통해 연결된 객체를 찾고, 연결이 끊어진 객체를 지운다. 연결된 객체를 파악하는 것을 Mark, 연결이 없는 객체를 지우는 것을 Sweep이라고 한다. 그리고 여기서 말하는 연결이 끊어진 객체가 위에서 잠깐 살펴보았던 Unreachable Object이다.

 

 이 방식을 사용하면 순환 참조되는 객체들도 모두 지울 수 있다. 자바와 자바 스크립트는 Mark and Sweep 알고리즘을 통해 메모리 관리를 한다. 

 

 

하지만 Mark and Sweep 알고리즘도 단점이 있다. Reference Counting은 레퍼런스 카운트가 0이 되면 알아서 객체를 지워버렸지만, Mark and Sweep은 의도적으로 가비지 컬렉션 동작을 실행해야한다. 

 

 그래서 어플리케이션 실행 도중 가비지 컬렉터에게 컴퓨터 자원을 양보해야하는 순간이 오게 된다. 이것을 Stop-the-world 라고 하는데 이런 이유로 어플리케이션 실행과 GC 실행을 병행하기 위해 어려운 최적화 작업이 필요하게 된다. 이것에 대한 내용은 포스팅 마지막에서 다뤄볼 것이다. 

 

 

 

 

3. JVM의 GC 메모리 영역

(1) Root Space

 그럼 가비지 컬렉터가 어떻게 동작하는지는 알겠는데, 위에서 말한 Root Space가 도대체 뭘까? 어디서부터 메모리의 탐색이 시작되는 걸까?

 

 지난 포스팅에서 JVM의 메모리에 대한 내용을 다루면서 JVM의 Runtime data area에 대해 살펴본 것을 기억할 것이다.

출처 : (https://hongsii.github.io/2018/12/20/jvm-memory-structure/)

 여기서 Stack의 로컬 변수와 Method Area의 Static 변수, Native Method Stack의 JNI가 바로 Root Space이다.

 

 

 

 

(2) 매번 모든 힙 영역을 탐색해야할까?

이제 실제로 가비지 컬렉터가 어떻게 동작하는지를 살펴보자. 그전에 한번 이런 의문을 던져볼 수 있다.

 

힙의 영역은 생각보다 크다. 그럼 가비지 컬렉터가 실행 될 때마다 반드시 모든 힙 영역을 전부 확인해야할까? 

만약 매번 힙의 모든 영역을 탐색하게 되면, 너무 큰 범위이고 시간이 오래 걸리지 않을까?

그렇다면 아까 말했던 stop-the-world 시간이 길어지게 되는데, 좀 더 효율적으로 접근해볼 수는 없을까?

 

 

그래서 개발자들이 가비지 컬렉터를 만들 때, 어떻게 하면 효율적으로 탐색할까를 고민하다 아래와 같은 통계를 보게 되었다.

 

 위 부분은 할당된 객체가 수명을 얼마나 가지는지를 나타낸 것이다. 할당된 대부분의 객체가 수명이 짧다는 것을 알 수 있다.

만약 이렇게 수명이 얼마 안된 객체들을 한 곳에 모아두고, 집중적으로 관리를 하면 어떨까? 매우 효율적일 것이다. (단순히 for 문을 생각해봐도 0*0000000부터 0*FFFFFFFF까지 탐색하는 것보다, 0*0000000부터 0*3245F4EE 정도를 탐색하는 것이 효율적인 것은 당연하다.)

 

 비유하자면 운동장에 뛰어다니는 수많은 아이들을 찾아다니며 타겟인지 일일이 확인하는 것보다, 그럴 가능성이 높은 아이들을 한 곳에 세워두고 관리하는 것이다.

 

 

 

(3) Young generation과 Old generation의 도입

출처 : https://www.linkedin.com/pulse/heap-memory-java-radhakrishna-prasad

 그래서 GC 시에 메모리의 특정 부분만을 탐색하며 해제하는 것이 효율적이기 때문에 영역을 구분하게 되었다. 대부분의 객체가 수명이 짧으니, Young generation안에서 탐색하여 효율성을 높인 것이다.

 

 다시 돌아와 정리하자면, JVM의 Heap 영역은 크게 Young generation과 Old generation으로 나뉜다. 그리고 Young generation은 다시 Eden과 Survival 0, 1로 나뉜다.

 

Eden은 새로 생성되는 객체들이 할당되는 영역이고, Survival 영역은 Minor GC로부터 살아남은 객체들이 할당되는 영역이다.

 

 

(4) 가비지 컬렉터 동작 방식

이제 JVM의 힙 영역에서 가비지 컬렉터가 어떻게 동작하는지 다시 알아보자.

 

 Survival 영역은 Survival 0과 1 중 하나는 비어있어야 하는 규칙이 있다.  그림을 통해 내용을 더 자세히 살펴보자.

그림의 회색 네모는 새로운 객체이다. 그리고 안의 숫자는 age-bit이다.

 

새로운 객체가 생성되다가 보면 Eden 영역이 꽉차게 되는데, 이때 Minor GC가 일어나 Mark and Sweep 이 일어나게 된다. 

 루트로부터 Reachable Object로 판단된 객체들은 Survival 0 영역으로 넘어가고 age-bit가 1씩 증가한다. Minor GC에서 살아남을 때마다 age bit는 1씩 증가한다. 

 

 

다시 Eden 영역이 꽉차고 GC가 발생하면 살아남은 객체가 Suvival 1 영역으로 이동한다.

 

 

 

이런 과정을 거치다가 age bit가 일정 크기 이상이 되면, 객체가 Old generation 영역으로 넘어간다.

 이것을 Promotion이라고 한다. 이 객체는 오래될 사용될 객체라고 판단한다.

 

 만약 Old Generation 영역도 가득 차면, 그때는 Major GC가 일어나 Mark and Sweep으로 필요없는 메모리를 비우게 된다.

 

 

 

 

4. JVM의 GC 방식 

마지막으로 가비지 컬렉터의 종류를 살펴보자. 위에서 Mark and Sweep 은 어플리케이션 실행과 GC 실행이 병행된다는 것을 언급했다. 그리고 병행되기 때문에 Stop The World가 일어난다는 것을 언급했다.

 

 Stop The World는 GC를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것을 말한다. 효율적으로 어플리케이션 실행과 GC 실행을 병행하기 위해 Stop The World 시간을 최소화해야한다.

 

그래서 Stop The World 최소화를 위한 여러 GC 방식들이 있는데, 간략히 살펴보자.

 

(1) Serial GC

 하나의 스레드로 GC를 실행하는 방법을 말한다. 하나의 스레드로 실행시키다보니 Stop The World 시간이 매우 길다. Single 스레드나 Heap이 매우 작을 때 사용한다.

 

(2) Parallel GC

Parallel GC는 여러 개의 스레드로 GC를 실행하는 방식이다. Java 8에서 기본으로 쓰이는 GC 방식이다.

 

(3) CMS GC

 CMS는 Concurrent-Mark-Sweep의 줄임말이다. 대부분의 GC 작업을 어플리케이션과 동시에 수행해 Stop the world 시간을 최소화하는 방법이다.

 하지만, 메모리를 많이 사용하고 Mark and Sweep 이후 메모리 파편화를 해결하는 Compaction이 기본적으로 제공되지 않는 단점이 있다. G1 GC 등장에 대체되었다.

 

 

(4) G1 GC

G1은 Garbage First의 줄임말이다. G1 GC는 힙 영역을 조금 다르게 사용하는데, 힙을 일정 크기의 Region으로 나누어, Young generation과 Old generation 영역으로 활용한다. 런타임에 필요에 따라 영역별 Region 개수를 튜닝해 Stop the world 시간을 최소화했다.

 

 Java 9 부터 기본 방식으로 사용하고 있다.

 

 

 

참고자료

[10분 테코톡] 🤔 조엘의 GC
https://www.youtube.com/watch?v=FMUpVA0Vvjw

[10분 테코톡] 👌던의 JVM의 Garbage Collector
https://www.youtube.com/watch?v=vZRmCbl871I

https://rebelsky.cs.grinnell.edu/Courses/CS302/99S/Presentations/GC/

https://www.linkedin.com/pulse/heap-memory-java-radhakrishna-prasad