안녕하세요, 이번에 Kernel 360 과정에 참여하게 되면서 기술 세미나를 맡아 준비하게 되었는데요.

거의 대부분의 팀들이 JavaSpring Framework 환경으로 프로젝트를 진행하고 있었습니다 (역시,, 자바민국)

그로 인해, 자바라는 언어를 처음 접하는 크루들도 굉장히 많았는데요.

그래서 이번 기술 세미나를 통해 자바의 장점을 설명하고 싶었어서 자바의 가비지 콜렉션(GC) 에 대해 발표를 하기로 하였습니다.

첫 블로그 글이라 기대가 되는데요, 가비지 콜렉션에 대해 함께 알아보도록 하겠습니다!

1. 가비지 콜렉션이란?

#include <stdio.h>
#include <stdlib.h>

void main()
{
	int* pPoint;
	pPoint = (int*)malloc(sizeof(int)*5);

	pPoint[0] = 25;
	pPoint[1] = 45;

	pPoint[2] = 50;
	pPoint[3] = 70;
	pPoint[4] = 99;

	int i = 0;
  for ( i = 0; i < 5; i++ )
		printf("pPoint[%d] : %d\n", i, pPoint[i]);
	
	free(pPoint);
}

이 C언어 코드는 malloc 함수를 이용해 Int 형으로 5개의 포인터를 할당하는 코드입니다.

여기서 마지막 free() 함수는 무슨 역할을 할까요?

바로 메모리에 할당 했던 변수들을 할당 해제 시켜주는 것인데요.

이처럼 C언어 같은 언어들은 메모리에 할당된 데이터를 할당 해제시키는 코드를 작성해야하는 번거로움이 있었습니다.

자바에서는 이러한 문제를 어떻게 해결할까요?

2. JVM의 메모리 관리

프로그램을 개발하다 보면 유효하지 않은 메모리인 가비지가 발생합니다.

Java 언어를 사용해서 개발할 때도 마찬가지이죠.

Member member = new Member();
member.setName("Park");
member = null;

member = new Member();
member.setName("Park SeokHee");

이 코드를 보시면 member 라는 객체가 생성되고 JVMHeap Memory 내에 어느 한 주소에 할당됩니다.

이후 그 해당 주소에 값은 Null이 되고, 또 다른 메모리 주소에 새로운 member 가 생성되어 할당됩니다.

그렇다면 첫번째로 선언한 member 객체는 앞으로 사용불가능한 상태이고 메모리 안에서 마치 쓰레기(가비지) 처럼 존재하게 됩니다.

이를 해결하기 위해 C나 C++ 같은 언어에선 개발자가 직접 메모리에 할당 해제 합니다.

이는 효율적으로 메모리를 관리하도록 프로그램을 개발하게 도와주지만 반대로 이것이 잘되지 않았을 경우 프로그램 크래시를 일으키기도 합니다. Untitled

자바에서는 JVM의 가비지 콜렉터가 이 역할을 자동으로 수행합니다.

그래서 개발자는 객체의 할당을 신경쓰지 않고 비즈니스 로직 그 자체에 집중해서 개발을 할 수 있도록 도와줍니다.

Untitled

JVM은 객체나 동적 데이터를 JVM 메모리 내의 힙 영역에 저장합니다.

JVM 메모리 영역 중 가장 큰 블록이고 가비지 콜렉션이 이루어지는 곳 입니다. 힙에 대해서는 아래에 더 자세히 다루겠습니다.

스택 영역은 스택 메모리의 영역이며, 프로세스의 스레드 당 하나의 스택 메모리가 쌓이게 됩니다. (스택) 여기에는 로컬 변수나 중간 연산 결과등이 저장되는 영역입니다. 그 외에도 쓰레드가 실행되는 주소를 저장하고 C/C++ 로 작성된 코드가 실행되는 영역(JNI)이기도 합니다.

메서드에리어는 프로그램의 클래스 구조와 메서드들의 코드를 메타데이터 같은 방식으로 저장하는 영역입니다.

3. 가비지 콜렉터가 객체와 가비지를 구분하는 기준

가비지 콜렉터는 객체와 가비지를 구분해야 하는 역할이 있습니다.

이를 위해 Mark and Sweep 이라는 알고리즘을 따라 동작하는 것입니다.

Mark 는 살아있는 객체를 찾는 과정입니다.

Untitled

파란색으로 표시된 객체는 닿을 수 있는 객체(reachable), 붉은색으로 표시된 객체는 닿을 수 없는 객체(unreachable) 입니다. 한때는 참조되었지만 지금은 범위를 벗어난 객체죠.

GC root 는 Stack 영역의 로컬 변수 / 메서드 영역의 static 변수 / JNI 로부터 시작됩니다.

마크 단계에서는 GC Root 단계에서 시작해서 접근할 수 있는 모든 객체에 접근하며 접근 가능한 객체에 마킹합니다.

그리고 이후 마크되지 않는 메모리들을 힙 영역에서 삭제합니다. 이 단계를 쓸어낸다고 하여 스윕 이라고 합니다.

그래서 GC의 동작 방식을 Mark And Sweep 이라고 합니다.

Untitled

JVM 의 Heap 영역은 2가지 전제를 두고 설계 되었습니다.

  • 대부분의 객체는 금방 접근 불가능한 상태가 된다.
  • 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.

즉, 객체는 대부분 일회성이고 메모리에 오래 남아있는 경우는 드물다는 것입니다.

이러한 전제에 따라 힙 메모리 안에서 객체들을 더 효율적으로 관리하기 위해서 Young generation 영역과 Old generation 영역의 두 영역으로 나누어 관리하게 됩니다.

이렇게 메모리를 나눔으로써 모든 객체를 하나하나 추적하는 일을 방지할 수 있고, 효율적으로 가비지 콜렉션 처리를 할 수 있습니다.

결과적으로 GC 비용을 줄일 수 있게된 거지요.

Young generation 영역에서 일어나는 가비지 콜렉션을 Minor GC , Old generation 영역에서 일어나는 가비지 콜렉션을 Major GC 라고 부르게 됩니다.

4. Minor GC / Major GC

Young generation 영역은 Eden 영역과 Survivor 영역으로 나누어지는데, 에덴 영역은 새로 생성된 객체가 저장되는 영역이고 서바이버 영역은 에덴 영역에서 살아남은 객체가 저장되는 곳입니다.

Untitled

우선 객체가 새로 생성될 때마다 에덴 영역에 계속해서 생성되고, 어느 순간 에덴 영역이 꽉 차게 되면 그 때 Minor GC가 발생하게 됩니다.

Untitled

이때 마크 앤 스윕이 일어나고 살아남은 객체는 Survivor 영역으로 옮겨집니다.

그리고 Survivor 에 할당된 객체에는 age가 1 증가하게 됩니다.

Untitled

다시한번 같은 과정을 통해 에덴이 꽉 차게되고 마크앤 스윕이 일어나면 서바이버영역에 있던 객체와 gc로부터 살아남은 객체가 또 다른 서바이버 영역으로 이동합니다.

Untitled

이는 heap fragment(힙 메모리 단편화) 문제를 막기 위함인데요.

Sweep된 객체들의 메모리 주소가 연속되지 않기 때문에, 충분한 자리가 있음에도 객체가 할당되지 못하는 현상을 말합니다. 이를 방지하기 위해, 모든 살아남은 객체를 한 쪽으로 모아서 빈 공간을 모으는 동작을 하게 됩니다.

이를 Mark, Sweep and Compact 라고 하기도 합니다.

Untitled

이후 age 값이 임계값에 달하면 (default: 31) old generation 영역으로 이동합니다. 이후 이 영역도 꽉 차면 Old generation을 대상으로 발생하는 Major GC가 발생합니다.

5. 다양한 자바의 가비지 컬렉션 알고리즘

- Serial GC

java -XX:+UseSerialGC -jar Application.jar

Serial GC 는 마크-스윕-컴팩트 알고리즘을 사용합니다.

보통 소규머 데이터 세트와 단일 프로세스 환경에서만 사용합니다. 하지만 단일 프로세스를 사용하므로

멀티 프로세스 환경에서는 적합하지 않으며 Garbage Collection 이 일어나는 동안 애플리케이션이 작동을 멈추는 현상을 Stop the world 라고 하는데요, 이 시간이 굉장히 길어지기 때문에 적합하지 않습니다.

- Parallel GC

java -XX:+UseParallelGC -jar Application.jar

// 사용할 쓰레드의 갯수
-XX:ParallelGCThreads=<N>

// 최대 지연 시간
-XX:MaxGCPauseMillis=<N>

Parallel GC 는 마크 스윕 컴팩트 알고리즘을 사용합니다.

Serial GC 와 다른점은 여러 스레드를 사용하여 동시에 수행되기 때문에 처리량과 속도가 향상됩니다.

따라서 멀티 프로세서 혹은 멀티 코어 시스템에 효과적입니다.

다만 Serial GC 에 비해 더 많은 메모리를 사용하게 되는 단점 또한 존재합니다.

대규모 서버 환경 같은 경우에 효과적으로 사용되지만, 응답 시간이 중요한 어플리케이션의 경우 다른 컬렉션 알고리즘을 재고하는 것이 좋습니다.

- CMS(Concurrent Mark Sweep) GC

java -XX:+UseConcMarkSweepGC -jar Application.jar

CMS GC 는 응답시간을 줄이는데 초점을 둔 알고리즘입니다.

마크-스윕 알고리즘을 사용하며, 마크 단계에서는 애플리케이션을 멈추지 않고 함께 동작합니다.

CMS GC는 낮은 지연시간이 필요한 상황에 선호되는 알고리즘으로 응답 시간이 중요한 애플리케이션 웹서버, GUI 환경에서 유용하게 사용됩니다.

단점으로는 다른 GC 알고리즘보다 효율적으로 컬렉션 작업이 수행되지 않으며, 메모리를 가장 많이 사용하므로 오버헤드가 발생할 수 있습니다.

- G1(Garbage First) GC

java -XX:+UseG1GC -jar Application.jar

대규모 힙 메모리를 사용하는 애플리케이션에 용이하며, 낮은 지연시간과 높은 처리량 두마리 토끼를 모두 잡을 수 있습니다.

G1 GC 는 위에서 살펴본 알고리즘입니다. (지금의 default)

백그라운드 스레드에서 실행되며 마크 스윕 컴팩트 모든 과정을 내포하고 있습니다.

GC 동작 중 중단 시간 최소화를 위한 지연 시간 목표를 설정할 수 있습니다.

다만 단점으로는 그만큼 복잡하기 때문에 설정과 튜닝이 복잡합니다.

올바른 성능을 얻기 위해서는 여러가지 설정 조정이 필요합니다.

6. JVM 튜닝

마지막으로, Spring Boot 환경에서 JVM 튜닝을 통해 성능을 개선하는 방법을 알아보겠습니다.

JVM 튜닝을 하기 전에 알아두어야 할 것은 JVM 튜닝은 배포 전 마지막에 수행하는 튜닝 방식입니다. 코드를 개선하거나 아키텍처 구조를 개선하는 것이 더 높은 효율을 가지고 있다는 것이죠.

JVM 튜닝은 정답이 없으므로 각 프로젝트의 환경 특성 목적에 따라 최적화 된 값이 천차만별로 달라집니다.

따라서 지속적인 개선을 통해 최선의 옵션을 찾아야합니다.

bootRun {
	jvmArgs = [
		'-Xmx1024m',
		'-Xms512m',
		'-XX:+UseG1GC',
		'-XX:NewRatio=3',
		'-XX:MaxGCPauseMillis=100',
		'-Xlog:gc*:file=logs/gc.log:time,tags:filecount=10,filesize=10M'
	]
}

프로젝트의 build.gradle 에 이런식으로 작성하면, 해당 옵션에 맞춰 JVM 설정이 변경됩니다.

-Xmx1024m 옵션은 JVM이 사용할 수 있는 최대 힙의 크기를 1024MB로 설정한다는 뜻입니다.

-Xms512m 옵션은 위와 반대로 최대 힙의 크기를 512MB로 설정한다는 뜻이죠.

이 두 옵션을 통해서 JVM의 힙 메모리에 할당되는 메모리 용량의 크기를 조절할 수 있습니다.

-XX:+UseG1GC 옵션은 위에서 알아본 자바의 GC 알고리즘 중 G1을 사용한다는 뜻 입니다.

마찬가지로 해당 옵션에 SerialGc, ParallelGC, ConcMarkSweepGC 등의 옵션을 넣을 수 있습니다.

-XX:NewRatio=3 옵션은 Young generation과 Old generation의 목표 비율을 설정하는 옵션입니다.

해당 목표 비율에 따라 맞춰지도록 young generation에서 old generation으로 넘어가는 Promotion age 를 조절하게 됩니다.

-XX:MaxGCPauseMillis=100 옵션은 StopTheWorld 현상에 의해 멈춰지거나 지연되는 시간을 100ms 이하로 설정한다는 뜻입니다. 해당 설정에 따라 GC의 소요시간과 빈도등이 달라질 수 있습니다.

마지막으로, -Xlog:gc*:file=logs/gc.log:time, tags:filecount=10, filesize=10M 옵션은 GC의 로그 파일을 남기는 옵션입니다. 해당 옵션의 값에 따라 로그 파일에 나타나는 값, 최대 파일 크기, 최대 파일 개수 등이 결정됩니다. 해당 로그를 먼저 확인 후 최적의 값을 찾기 위해 값을 조절하면 되겠습니다.

7. 발표를 마치며

우선 첫 발표라 굉장히 떨렸는데 많은 크루분들께서 발표에 집중해주시고 퀴즈도 잘 풀어주셔서 감사하다는 말을 남기고 싶습니다.

자바는 잘 관리된 언어 Well Managed Language 라고 합니다. 그만큼 GC는 사용자 편의와 생산성을 증가시켜주는 자바의 장점입니다.

자바의 장점을 잘 알고 잘 사용하는 Kernel360 크루 모두가 되길 바라는 마음에 발표를 준비하였답니다. 귀중한 경험 감사드리고 다음번에는 더 잘 준비해서 발표할 수 있도록 노력하겠습니다 👊👊👊

감사합니다.

8. 참고 문헌

https://deepu.tech/memory-management-in-programming/

https://www.udemy.com/course/java-in-depth-become-a-complete-java-engineer/

https://stackoverflow.com/questions/3798424/what-is-the-garbage-collector-in-java