본문 바로가기

개발/Java

Java GC(Garbage Collector)

배경


오늘은 java의 GC에 대해 알아볼고 합니다. 메모리 관리를 위해 java에서 사용한다고 알고만 막연하게 알고 있는데, 같이 알아보도록 하죠!

 

내용


< GC가 필요한 이유 >

왜 GC가 필요할까요? 우리는 프로그램 중에 많은 변수, 메서드 등을 사용하고 있습니다. 이 변수와 메서드, 다른 객체들이 사용되지 않는데 메모리에 계속 남아 있게 된다면 메모리가 사용할 수 있는 공간이 줄어들어 점점 성능이 떨어지게 될 겁니다. 이를 방지해 주는게 GC죠. GC는 프로그램이 동적으로 할당했던 메모리 영역 중 필요없게 된 영역을 알아서 해제해주는 역할을 합니다.

 

여기서 동적으로 할당한 메모리 영역은 Heap 영역을 뜻하고, 필요없게 된 영역은 어떤 변수도 가리키지 않게 된 영역을 말합니다. C나 C++에서는 이를 직접 관리를 해서 귀찮은 부분이 있지만, java에서는 GC가 이를 자동으로 해주고 있습니다. 즉 메모리 누수를 멈춰주는 이점을 가질 수 있는거죠. 하지만 GC가 어떤 메모리 영역이 해제의 대상이 될지 검사하고 해제 될지 일을 하고 있기 때문에 순수 오버헤드가 발생할 수 있죠. 이러한 일은 개발자가 직접 통제할 수 없는 부분이 되기 때문에 실시간 특성이 강조되는 프로그램의 경우 GC에게 메모리 관리를 맡기는 것이 좋지 않을 수도 있습니다.

 

< GC 알고리즘>

몇가지 알고리즘들 중 2가지 정도를 소개해 드리려고 합니다. 첫번째는  Reference Counting입니다.

 

[ Reference Counting ]

위 그림에서 root space는 스택 변수, 전역 변수 등 heap 영역 참조를 담은 공간입니다. 오른쪽에 heap space에 있는 변수들이 각각 reference count라는 별도의 숫자를 가지고 있는데, 이 reference count라는 것은 해당 객체에 접근할 수 있는 방법입니다. 이 객체가 접근할 수 있는 방법이 0이 된다면 GC가 동작을 하는거죠. 하지만 reference count는 문제가 있습니다. 그게 뭘까요? 바로 순환 참조입니다. 

순환 참조의 경우 각각의 reference count가 1로 유지되고 있어서 GC가 발동되지 못하고 메모리 누수가 발생하게 됩니다.

 

[ Mark And Sweep ]

Mark And Sweep 알고리즘은 reference count의 순환참조 문제를 해결할 수 있습니다. Mark And Sweep은 루트에서부터 해당 객체에 접근 가능한지 여부를 해제의 기준으로 삼습니다.

그림의 왼쪽이 mark, 오른쪽이 sweep을 나타냅내다. mark를 통해 어떤 객체에 접근 가능한지 보고, sweep을 통해 필요없는 객체를 지우고 깔끔하게 정리를 하는거죠. java와 java script는 Mark And Sweep 방식의 알고리즘을 사용하고 있습니다. 하지만 Mark And Sweep방식도 단점이 존재합니다. reference count 방식과 달리 의도적으로 발생시켜야 한다는거죠. 즉, 실행중인 app이 있다면 app의 리소스를 GC에게 의도적으로 할당을 해주어야 합니다.

 

< JVM의 GC >

java 8기준 JVM의 GC에 대해 알아봅시다.

먼저 JVM의 구조에 대해 알아보도록 하죠 JVM은 크게 3가지 구조로 나뉘게 됩니다. 먼저 제일 위의 Class  Loader는 바이트 정보를 읽고 메모리의 heap/method 영역에 저장하는 역할을 합니다. JVM memory는 실행중인 프로그램들의 정보가 들어있습니다. 마지막으로 Execution Engine은 바이트 코드를 native코드로 변환시켜주고 GC를 실행하는 역할을 합니다.

 

JVM Memory는 OS로부터 메모리를 할당받아 용도에 따라 크게 2가지 영역으로 나누고 있습니다. 각 영역에 대해서도 간단히 말씀드리면 Method와 Heap영역은 모든 쓰레드가 공유하는 영역이고, 나머지 3가지는 각 쓰레드마다 고유하게 생성하여 사용하는 영역이 있습니다. 그림으로 보시면 이해가 좀 더 쉽게 가실거에요.

method area는 프로그램의 클래스 코드들을 저장해두고 heap은 app중 실행되는 객체 인스턴스들을 저장해둡니다. 이 heap영역이 바로 GC에 의해 관리가 되어지는 부분이 됩니다. stack은 메서드 호출을 스택 변수로 쌓아 관리하며 로컬변수나 중간 연산 결과들이 저장되는 공간입니다. pc register는 스레드가 현재 실행할 스택 프레임의 주소를 저장하고 있습니다. 마지막 native method stack은 C/C++등의 low lovel 코드를 실행하는 stack입니다.

 

이렇게까지 알아보는 이유는 위에서 보았떤 root space가 어디인지를 보기 위해서입니다. root space는 여기서 stack의 로컬 변수, method area의 static 변수, native method stack의 JNI 참조가 있습니다.

 

이제 root space가 어디인지는 알게 되었고, 그럼 JVM의 GC는 언제 실행해야 할까요? 방금 의도적으로 실행해야 한다고 했는데 이를 보기 위해선 heap 영역에 대해 좀 더 알아볼 필요가 있습니다.

 

[ Heap ]

JVM의 heap 영역에는 크게 두가지 영역이 위 그림처럼 존재하고 있습니다. young generation에서 발생하는 GC는 minor GC, Old generation에서 발생하는 GC는 major GC라고 합니다. young generation는 또다시 위 그림처럼 3가지 영역으로 나뉘게 되죠. 각 부분에 대해 좀더 설명 드리면 eden은 새롭게 생성된 객체들이 할당되는 영역, survival는 minor GC로부터 살아남은 객체들이 존재하는 영역입니다. 특징으로는 survival0, 1 중에 하나는 꼭 비어있어야 한다는 것입니다. 왜 그렇게 되어야 할까요? 

 

새로운 객체가 계속 생겨나다보면 eden영역이 꽉차는 순간이 발생하게 됩니다. 이떄 minor GC(mark and sweep)가 진행됩니다. 루트로부터 reachable(접근 가능)이라 판단된 객체는 survival0으로 옮겨지게 되는데 이때 age-bit라는 것을 사용하여 살아남은 숫자를 기록합니다. 다음번 eden영역에 꽉차게 되어 minor GC가 발생하게 되면 survival0에 있던 객체들과 살아남은 객체들을 survival1로 옮기며 age-bit를 또 증가시키게 됩니다. 이렇게 왔다갔다 하면서 age-bit가 증가하는데 일정 수준을 넘게 되면 이 객체를 old generation으로 넘겨주게 됩니다.  java8기준 15가 되면 옮겨지게 되는데 이를 promotion이라고 합니다.

 

시간이 지나게 되면 old generation도 다 채워지는 일이 발생하게 되는데 이때 major GC가 발생하게 되면서 mark and sweep방식을 통해 필요없는 메모리들을 다 지워줍니다. 그런데 왜 이렇게 나누어서 설계가 되어진 것일까요?

그림을 보시게 되면 대부분의 객체가 수명이 짧다는 것이 보입니다. GC도 결국 리소스가 소모되기 때문에 특정 메모리 부분만 탐색하며 GC를 실행시키면 훨씬 효율적이라고 판단되었기 때문입니다.

 

< GC와 어플리케이션 >

GC와 어플리케이션은 병행되어야 한다고 했습니다. JVM이 GC를 수행하기 위해서는 어플리케이션 실행을 멈춰야 하는데 이  Stop the World를 최소화 해야 어플리케이션이 정상적으로 돌아가는데 문제가 없어지겠죠?

 

동작 방식에는 크게 serial GC와 parallel GC, CMS GC, G1 GC가 존재합니다. serial은 하나의 스레드로 동작되기 때문에 당연히 Stop the World시간이 길겠죠? parallel GC의 경우 여러개의 스레드로 GC를 실행하기 때문에 Stop the World시간이 훨씬 짧아지게 됩니다.CMS GC는 Stop the World시간을 최소화 하기 위해 고안된 방법으로 GC 작업을 멀티스레드로 app과 동시에 진행시킵니다. 하지만 mark and sweep과정이후 발생하는 메모리 파편화를 해결하는 compaction이 제공되지 않아 G1 GC 등장 이후 deprecated 되었습니다.

 

마지막 java 9 이후부터 default GC 동작방식으로 채용된 G1 GC입니다. G1 GC는 heap이 이전 방식과 조금 다르게 사용합니다. heap을 작은 영역으로 잘게 나누고 이 영역들에 대해 young generation, old generation으로 나누고 있죠.

런타임 도중 G1 GC가 필요해짐에 따라 영역별 개수를 튜닝하는 기능을 가지고 있습니다.

 

< GC 튜닝 >

GC 튜닝은 사실상 성능 개선의 최종단계입니다. 이전에 코드에서 객체 생성을 줄이는 등의 작업이 먼저 진행되어야 하죠. 이 부분에 대해서는 너무 깊게 들어가는 부분이라 다음번에 기회가 되면 다뤄보도록 하겠습니다.

 

오늘 이렇게 GC에 대해 알아보았는데 정말 좋은 내용인 것 같습니다. 막연하게만 알고 있던 GC의 동작방식을 이해하고 나니 java를 좀더 알게된 기분이랄까요 ㅎㅎ..

 

긴글 읽어주셔서 감사합니다!

 

 

 

참고

https://www.youtube.com/watch?v=FMUpVA0Vvjw

 

'개발 > Java' 카테고리의 다른 글

TaskScheduler를 이용한 주문 취소 구현해보기  (0) 2024.11.27
디버깅  (0) 2024.11.25
Fetch Join  (0) 2024.11.24
Block vs Non-Block & Sync vs Async  (0) 2024.11.23
Thread Pool  (0) 2024.11.22