프로그래밍에 맨 처음 입문할 때 자바의 장점에 대해 들은적이 있다.
객체지향언어이다, 운영체제에 독립적이다, 멀티쓰레드를 지원한다, Garbage Collector가 메모리를 관리해준다 등등..
처음 배울 당시엔 전혀 이해가 가지 않았던 장점들이다ㅎㅎ
이번 포스팅은 위에 나열한 장점중 Garbage Collector에 대해 적어보려고 한다! (하지만 자바에만 있는 것은 아닌...)
(Garbage Collection을 이해하기 위해서는 JVM의 메모리 구조를 이해하고 있으면 좋다.)
목차
1-1. Garbage Collector란?
프로그램을 개발하다보면 유효하지 않은 메모리인 Garbage가 발생하게된다.
C / C++ 같은 경우 free()라는 함수를 이용해서 개발자가 직접 메모리를 해줘야한다.
그러나 자바는 메모리를 직접 해제할 일이 없다.
왜냐하면 JVM의 Garbage Collector가 불필요한 메모리를 알아서 정리해주기 때문이다.
Garbagr Collection 이란 사용되지 않는 메모리 객체를 모아 주기적으로 제거해주는 프로세스를 말한다.
그렇다면 불필요한 메모리는 언제 생기고, Garbage Collector (이하 GC)는 언제 작동하는걸까?
예를 들어, 아래와 같은 코드를 작성했다고 해보자.
for (int i = 0; i < 10000; i++) {
Calculator cal = new Calculator();
cal.add();
}
해당 코드를 돌리면 Stack영역에는 1개의 참조주소가 쌓일 것이고(push), for문이 끝나는 순간 Stack영역에서 사라지게 될 것이다(pop).
그러나 Heap영역은 다르다. for문이 돌면서 Stack의 메모리에 참조 되었다가 끊어지고, 참조 되었다가 끊어지고를 10000번 반복하며 Heap영역에는 10000개의 cal인스턴스가 쌓이게 된다.
이 말은 Heap영역에는 더 이상 참조되고 있는 곳이 없는 쓰레기 인스턴스가 10000개나 있다는 말이 된다.
만일 이런 객체들이 메모리를 계속 점유하고 있다면, 다른 코드를 실행하기 위한 메모리는 지속적으로 줄어들 것이다.
그럼 당연히 애플리케이션의 실행에 영향을 미칠것이고, 이런 쓰레기 객체들을 없애주기 위해 GC가 일을 한다.
그러나 GC가 자동으로 메모리 관리를 해준다고해도, 단점은 존재한다.
일단 메모리가 언제 해제되는지 정확하게 알 수 없으며, GC가 동작하는 동안에는 다른 쓰레드들이 모두 멈추기 때문에 오버헤드가 발생되는 문제가 있다.
1-2. GC의 대상
위에서 코드로 잠깐 설명했는데, GC의 대상이 되는 것은 더 이상 참조되지 않는 쓰레기 객체들이다.
이것은 접근성, 도달능력 등으로 불리는데, 영어로는 Reachable, Unreachable 이라고 한다.
- Reachable : 객체가 참조되고 있는 상태 / 접근 가능한 상태
- Unreachable: 객체가 참조되고 있지 않은 상태 / 접근 불가능한 상태 → GC의 대상
위 그림과 같이 참조변수가 Stack에서 사라지고 Heap에 남은 객체를 Unreachable 이라고한다.
바로 이런 객체들을 GC가 주기적으로 제거해주는 것이다.
2-1. GC의 청소 방식
1) Stop The World
위에서 언급했듯이, GC가 동작하면 다른 쓰레드들이 모두 멈추게 된다.
이를 전문용어로 Stop The World(STW)라고 한다.
모든 쓰레드들의 작업이 중단되면 애플리케이션이 멈추기 때문에, 서비스 이용에 차질이 생길 수 있다.
그렇기에 'GC의 성능개선, 튜닝' 이라함은 보통 이 STW의 시간을 줄이는 작업을 말하며, 이 시간을 최소화 시키는 것이 쟁점이라고 할 수 있다.
2) Mark and Sweep
- Mark: 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업
- Sweep: Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업
Stop The World를 통해 모든 작업을 중단시키면, GC는 스택의 모든 변수 또는 Reachable객체를 스캔하면서 각각이 어떤 객체를 참조하고 있는지를 탐색하게 된다.
그리고 사용되고 있는 메모리를 식별하는데, 이러한 과정을 Mark라고 한다.
이후에 Mark가 되지 않은 객체들을 메모리에서 제거하는데, 이러한 과정을 Sweep라고 한다.
3) Heap영역의 구조
Heap영역은 크게 두 공간으로 나뉘어진다.
그 이유는 Heap이 처음 설계 될 때의 전제 때문인데, 내용은 아래와 같다.
- 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
- 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
즉, 객체는 대부분 일회성되며, 메모리에 오랫동안 남아있는 경우는 드물다는 것이다. (이 전제를 'weak generational hypothesis'라 한다.)
이 전제의 장점을 최대한 살리기 위해서 Heap영역을 크게 2개의 물리적 공간으로 나누었다.
둘로 나눈 공간이 Young 영역과 Old 영역이다.
- Young 영역(Yong Generation 영역): 새롭게 생성되는 객체의 대부분이 여기에 위치한다.
대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에 생성되었다가 사라진다.
이 영역에서 객체가 사라질때 Minor GC가 발생한다고 말한다. - Old 영역(Old Generation 영역): 접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 여기로 복사된다.
대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역보다 GC는 적게 발생한다.
이 영역에서 객체가 사라질 때 Major GC(혹은 Full GC)가 발생한다고 말한다.
이렇게 공간을 둘로 나눴기 때문에 힙 전체에서 GC를 진행하지 않고 Young영역에서 먼저 GC를 수행(Minor GC)하게된다.
그런데 그림을 보면 Young영역보다 Old영역이 훨씬 큰 걸 알 수 있다. 결론부터 말하면 그 이유는 아래와 같다.
크기가 작은 영역에서 GC가 먼저 일어나기 때문에 소요되는 시간도 훨씬 적고, 여기서 대부분의 garbage가 수거되기 때문에 메모리 낭비를 막을 수 있다.
2-2. GC의 동작 방식
1) Young 영역
Young 영역은 다시 3개의 영역으로 나뉜다.
- Eden 영역
- Survivor 영역(2개)
Survivor 영역이 2개이기 때문에 총 3개의 영역으로 나뉘는 것이다. 각 영역의 처리 절차는 다음과 같다.
- 새로 생성된 객체는 Eden 영역에 위치한다.
- Eden 영역이 가득차면 GC가 발생하고(Mark and Sweep), reachable해서 살아남은 객체는 Survivor 영역 중 하나로 이동된다.
- 살아남아서 Survivor영역으로 이동한 객체는 age(객체가 살아남은 횟수)가 1증가한다.
- GC가 반복적으로 실행되며 살아남은 객체들은 Survive영역을 왔다갔다하며 age가 점점 증가하게 된다.
- 이 과정을 반복하다가 계속해서 살아남아 있는 객체는 age가 일정 임계점을 돌파할 때, Old 영역으로 이동(Promotion)하게 된다.
여기서 특이한 점은, Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 한다는 것이다.
만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 시스템이 정상적인 상황이 아니라는 반증이 된다.
2) Old 영역
Young영역에서 특정 age가 넘은 참조 메모리들이 이동하는 공간이다. (age의 임계점은 GC알고리즘마다 다르다.)
객체들이 계속 Promotion되어 이 공간이 가득차면, major GC(mark and sweep)가 일어난다.
Major GC는 Old 영역은 데이터가 가득 차면 GC를 실행하는 단순한 방식이다.
즉, Old 영역에 할당된 메모리가 허용치를 넘게 되면, Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC가 실행되게 된다.
하지만 Old영역은 Young영역에 비해 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸리게 된다.
예를들어 Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다.
그렇기 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다.
하지만 Old 영역의 Major GC는 일반적으로 Minor GC보다 시간이 오래걸리며, 10배 이상의 시간을 사용한다.
바로 여기서 초반에 소개했던 Stop The World문제가 발생하게 된다.
Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해야 해서 CPU에 부하를 주기 때문에 멈추거나 버벅이는 현상이 일어나기 때문이다.
위 문제의 해결방법은 개발자가 본인의 애플리케이션에 맞게 어떤 GC알고리즘을 선택하느냐에 따라 달려있다.
이번 포스팅은 GC의 동작과정에 대해 작성한 글이기 때문에, GC알고리즘에 관한 글은 다음에 써보도록 하겠다.
여기까지 설명한 Minor GC와 Major GC의 차이점을 표로 간단히 정리하면 아래와 같다.
GC 종류 | Minor GC | Major GC |
대상 | Young Generation | Old Generation |
실행 시점 | Young영역이 꽉 찼을 때 | Old영역이 꽉 찼을 때 |
실행 속도 | 빠르다 | 느리다 |
3. Memory Leak(메모리 누수)
예전에 GC와 관련된 영상을 보다가, '그렇다면 Java는 GC가 메모리를 자동으로 관리해주기 때문에, Memory Leak과 같은 문제는 고려하지 않아도 되는건가요?' 라는 질문을 본 적이 있다. 그 땐 '어..그러게..?'하고 그냥 넘어갔던 기억이...난다...
(**Memory Leak : 프로그램이 필요하지 않는 메모리를 해제하지 않고 계속 점유하고 있는 현상)
결론부터 말하면, GC가 메모리를 알아서 관리해준다고 해도 Memory Leak은 나타날 수 있다!
Memory Leak이 발생하면 Old영역에서 Major GC가 빈번하게 작동하게 되면서 프로그램 응답속도가 늦어지고, 결국 성능 저하를 불러와 OutOfMemoryError로 프로그램이 종료되게 된다.
그렇다면 왜 GC는 필요하지 않은 객체들을 회수해가지 않아 Memory Leak이 발생하게 되는걸까?
일단, '사용되지 않는 객체'에서 '사용되지 않는다'의 의미를 어디까지 볼 것인가에 대한 문제도 있다.
알아본 결과, GC는 현재 사용하고 있지 않는 Object로 Garbage를 판단한다.
그리고 현재 사용 여부는 바로 Root Set과의 관계로 판단한다.
다시 말해 Root Set에서 어떤 식으로든 Reference 관계가 있다면 Reachable Object라고 한다.
이것을 현재 사용하고 있는 Object로 간주하게 된다. Root set이 참조하는 정보는 다음과 같다.
- Stack의 참조(Ref) 정보
- Constant Pool에 있는 참조(Ref) 정보
- Native Method로 넘겨진 객체 참조(Ref) 정보
이 세 가지 Ref 정보에 의해 직, 간접적으로 참조되고 있다면 모두 Reachable 객체이고 아니면 Garbage 취급을 한다.
참고로, 오른쪽 아래 객체처럼 reachable 객체를 참조하더라도, 다른 reachable 객체가 이 객체를 참조하지 않는다면 이 객체는 unreachable 객체이다.
그리고 Reachable 객체에서도 Live 여부에 따라 둘로 나뉘어진다. Live는 실제로 해당 객체를 사용하는 지에 대한 여부이다.
Reachable객체 중 Live하지 않는 객체 (Reachable but not Live)는 메모리릭을 야기한다고 한다!
아래 예시코드를 보자.
class Node {
Node next;
public Node() {
System.out.println("Node 생성");
}
}
public class MemoryLeakExample {
public static void main(String[] args) {
Node nodeA = new Node();
Node nodeB = new Node();
nodeA.next = nodeB;
nodeB.next = nodeA;
// nodeA와 nodeB는 서로를 참조하고 있음
// 이후에 더 이상 사용하지 않는데도 불구하고,
// 두 노드가 서로를 참조하고 있기 때문에 가비지 컬렉터는 이들을 회수하지 못함
// 메모리 누수 발생
}
}
이 코드에서 Node 클래스는 다음 노드를 가리키는 next라는 참조 변수를 가지고 있다.
main 메서드에서는 nodeA와 nodeB를 생성하고 서로를 참조하도록 했다.
이후에는 이 두 노드가 더 이상 사용되지 않는데도 불구하고, 서로를 참조하고 있기 때문에 가비지 컬렉터는 이들을 회수하지 못한다.
이것이 Reachable but not Live에 의한 메모리 누수의 예시이다.
이런 메모리 누수를 피하기 위해선 개발자가 코드도 잘 짜야하지만 역시 GC의 알고리즘을 잘 선택하는 방법도 있다.
또한 강한참조가 아닌 약한참조(java.lang.ref.WeakReference 클래스)로 메모리릭을 피하는 방법도 있다.
해당 내용 역시 내용이 길어질 것이기 때문에 추후 GC알고리즘 포스팅에서 다루거나 따로 내용을 정리해봐야겠다.
4. 마무리
여기까지 GC에 대한 동작과정과 그동안 궁금했던 Memory Leak에 대해 정리해보았다.
이번 Garbage Collection은 알면 알수록 더 깊어지는 주제인 것 같다.
가령, 이 글에선 언급하지 못한 GC알고리즘이나... Young영역, Old영역에서도 더 자세한 내용들도 많이 있더라..하하!
이런 부분들도 더 공부해서 정리해야겠지...!
사실 메모리릭도 다루지 않으려고 했는데, 궁금해서 이왕 알아본거 정리도 하자!! 하는 마음에 같이 작성하게 되었다ㅎㅎ
이번에도 머릿속에서만 돌아다니던 지식들을 한데 모아 정리하니 정말 다시한 번 이해도 할 수 있었고, 몰랐던 사실들도 알게된 뿌듯한 정리였다 ㅎㅎㅎㅎㅎ
더 깊은 내용들도 이해하고 정리하는 그날까지~! 힘내보자고!
'Java' 카테고리의 다른 글
[Java] Java 메모리 구조 (JVM, Stack, Heap, Static) (1) | 2024.01.30 |
---|
댓글