
1. 그걸 왜 알아야 하는데?
프로그램을 운영하다 보면, 메모리 오버플로와 누수 문제를 해결해야 하는 상황이나 더 높은 동시성 달성을 위해 GC가 방해되는 상황이 옵니다.
저는 아직 그런 경험을 직접 해본 적은 없지만, 네이버나 배민 등 많은 기술블로그에서 해당 이슈를 확인할 수 있었습니다.
https://d2.naver.com/helloworld/1326256
https://techblog.woowahan.com/2628/
도움이 될수도 있는 JVM memory leak 이야기 | 우아한형제들 기술블로그
도움이 될지도 모르는 JVM memory leak 얘기인데 제목을 뭐라고 하지? 안녕하세요. 배민 플랫폼실 주문중계 시스템팀 오민철입니다. 이 글은 몇 줄의 코드와 어디서 걸렸는지 모를 dependency library로
techblog.woowahan.com
특히, 자바 힙과 메서드 영역은 불확실한 것이 많습니다. 스택이나 코드 영역은 보통 컴파일 타임에 메모리 크기가 결정되지만, 힙과 메서드 영역은 오직 런타임에서만 메모리 요구량을 알 수 있습니다. 따라서 이 메모리 영역이 어떻게 관리되는지 아는 것은 정말 중요합니다.
그래서 더 단단한 서버 개발자가 되기 위해서는 GC 구조를 이해하고, 관련된 이슈에 대응할 수 있는 능력이 중요하다고 생각했습니다.
그래서 '책 JVM 밑바닥부터 파헤치기' + 제 생각을 더해 GC에 대해 깊게 들어가 보고자 합니다.
2. 죽은 객체 찾기
자바에서는 모든 객체 인스턴스가 힙에 저장됩니다. GC가 힙을 청소하려면 객체가 살아 있고, 죽었는지 판단해야 합니다. 여기서 '죽었다'는 의미는 프로그램에서 더이상 사용하지 않음을 의미합니다.
여기서 죽었는지를 판단하기 위해 사용하는 알고리즘은 대표적으로 2가지가 있습니다.
실제 면접에서도 두 알고리즘에 대해 비교해보라는 질문을 받은 적이 있습니다. 따라서 TradeOff를 명확히 이해하면 좋습니다.
2-1. 참조 카운팅 알고리즘
기본적인 구조는 아래와 같습니다.
- 객체를 가리키는 참조 카운터를 추가한다. 참조하는 곳이 늘어날수록 +1 한다.
- 참조하는 곳이 사라질때마다 -1 한다.
- 참조 카운터가 0인 객체는 더 이상 사용하지 않는다. == 해당 객체가 죽었다고 판단한다.
간단하게 생각해봤을 때 장점은 원리가 간단한다는 것이고, 단점은 카운팅을 위한 추가 메모리를 사용한다는 것입니다.
JVM의 GC는 해당 알고리즘을 사용하지 않습니다. 왜일까요?
가장 대표적인 예로는 순환참조 문제가 있습니다. 해당 문제에 대해 자세히 알아봅시다.
class Node {
int value;
Node next; // 다른 Node를 참조하는 변수
public Node(int value) {
this.value = value;
}
}
public class CircularReferenceExample {
public static void main(String[] args) {
Node node1 = new Node(1);
Node node2 = new Node(2);
// 순환 참조 생성
node1.next = node2;
node2.next = node1;
// 참조 해제
node1 = null;
node2 = null;
System.gc();
}
}
다음 코드에서 node1 과 node2는 순환참조 후에 모두 할당을 해제했습니다. 이 객체는 더 이상 사용하지 않은 객체이지만, 각각의 참조 카운터가 여전히 1이기 때문에 이 두 객체를 회수하지 못하는 문제가 발생합니다.
JVM에서는 해당 알고리즘을 사용하지 않는다고 했기 때문에 실제 해당 객체를 회수하는지 검증해보겠습니다.


할당을 해제한 경우와 해제하지 않은 경우의 힙 사용량이 달라진 것을 확인할 수 있습니다. 이를 통해 JVM은 참조 카운팅 알고리즘을 사용하지 않는 것을 간접적으로 확인할 수 있습니다.
그럼 JVM에서는 어떤 알고리즘을 사용할까요?
2-2. 도달 가능성 분석 알고리즘
이 알고리즘의 기본 아이디어는 GC루트라고 하는 루트 객체들을 시작 노드 집합으로 쓰는 것입니다. 시작 노드들로부터 출발하여 참조하는 다른 객체를 탐색합니다. 이 탐색 과정에서 만들어지는 경로를 참조체인이라고 합니다. 특정 객체가 참조 체인에 없다면 회수 대상이 됩니다.
여기서 reachability라는 개념이 나오는데 어떤 객체에 유효한 참조가 있으면 'reachable'로, 없으면 'unreachable'로 구별합니다.

그렇다면, GC 루트는 구체적으로 어떤 것일까요?

런타임 데이터 영역은 위와 같이 스레드가 차지하는 영역들과, 객체를 생성 및 보관하는 하나의 큰 힙, 클래스 정보가 차지하는 영역인 메서드 영역, 크게 세 부분으로 나눌 수 있습니다. 위 그림에서 객체에 대한 참조는 화살표로 표시되어 있습니다.
힙에 있는 객체들에 대한 참조는 다음 4가지 종류 중 하나입니다.
- 힙 내의 다른 객체에 의한 참조
- Java 스택, 즉 Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
- 네이티브 스택, 즉 JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
- 메서드 영역의 정적 변수에 의한 참조
이들 중 힙 내의 다른 객체에 의한 참조를 제외한 나머지 3개가 GC 루트로, reachability(도달 가능성)를 판가름하는 기준이 됩니다.
2-3. 다시 참조 이야기
객체의 생사 판단과 참조와는 떼어서 생각할 수 없습니다. 참조 카운팅 알고리즘이든, 도달가능성 분석 알고리즘이든 모두 참조를 통해 분석하기 때문입니다.
JVM이 발전하며 기존의 참조의 개념으로 표현할 수 없는 '버리기는 아까운' 객체를 표현하는 방식이 생겨납니다. "메모리가 여유롭다면 그냥 두고, GC 하고 나서도 메모리가 부족하면 그때 회수하자."라는 개념이 도입됩니다.
a. 강한 참조
Object obj = new Object();
전통적인 참조를 의미합니다. 관계가 남아 있는 객체는 GC가 절대로 회수하지 않습니다.
b. 부드러운 참조
import java.lang.ref.SoftReference;
public class SoftReferenceExample {
public static void main(String[] args) {
Object obj = new Object(); // 강한 참조
SoftReference<Object> softRef = new SoftReference<>(obj); // 부드러운 참조 생성
obj = null; // 강한 참조 해제
System.gc(); // GC 호출
if (softRef.get() != null) {
System.out.println("부드러운 참조 객체는 아직 남아 있음");
} else {
System.out.println("부드러운 참조 객체는 수거됨");
}
}
}
유용하지만 필수는 아닌 객체를 표현합니다. 부드러운 참조만 남은 객체라면 메모리 오버플로우가 나기 전에 두 번째 회수를 위한 회수 목록에 추가됩니다. SoftReference클래스 형태로 구현됩니다.
c. 약한 참조
import java.lang.ref.WeakReference;
public class WeakReferenceExample {
public static void main(String[] args) {
Object obj = new Object(); // 강한 참조
WeakReference<Object> weakRef = new WeakReference<>(obj); // 약한 참조 생성
obj = null; // 강한 참조 해제
System.gc(); // GC 호출
if (weakRef.get() != null) {
System.out.println("약한 참조 객체는 아직 남아 있음");
} else {
System.out.println("약한 참조 객체는 수거됨");
}
}
}
부드러운 참조와 비슷하지만, 연결 강도가 더 약합니다. 대상 객체를 참조하는 경우가 WeakReferences 객체만 존재하는 경우 GC의 대상이 됩니다. 다음 GC 실행 시 무조건 힙 메모리에서 제거됩니다.
d. 유령 참조
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class PhantomReferenceExample {
public static void main(String[] args) {
Object obj = new Object(); // 강한 참조
ReferenceQueue<Object> queue = new ReferenceQueue<>(); // 참조 큐 생성
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue); // 유령 참조 생성
obj = null; // 강한 참조 해제
System.gc(); // GC 호출
// 큐를 통해 수거된 유령 참조 감지
if (queue.poll() != null) {
System.out.println("유령 참조 객체가 수거됨");
} else {
System.out.println("유령 참조 객체는 아직 수거되지 않음");
}
}
}
참조 중에 가장 약합니다. 객체 자체에 접근할 수는 없고, 수거 후의 동작을 정의하는 데 사용됩니다. 객체가 수거된 후 참조 큐로 이벤트를 전달합니다.
2-4. 도달 불가능한 객체는 반드시 죽는 걸까?
도달 불가능하다고 해서 객체가 반드시 죽었다고 할 수 있을까요? 아닙니다. 아직 '유예'단계가 있습니다. 사망 선고를 내리려면 두 번의 표시(marking) 단계를 거쳐야 합니다.
도달 가능성 알고리즘으로 참조 체인을 찾지 못한 객체에는 첫 번째 표시가 이루어지며 이어서 필터링이 진행됩니다. 필터링 조건은 "종료자(finalizer) 메서드를 실행해야 하는 객체인가" 입니다.
finalize()를 실행해야 하는 객체로 판명되는 F-Q(F-Queue)라는 대기열에 추가됩니다. 그러면 가상 머신이 나중에 우선순위가 낮은 종료자 스레드를 생성해 F-Q에 들어있는 객체들의 finalize()를 실행합니다.
참고로 JVM은 스레드를 시작만 하고, 기다리지 않습니다. 즉 무한루프가 돌거나 너무 오래 걸리면 큐에 모든 객체는 대기해야 하고, 최악의 경우 GC시스템이 망가질 수 있습니다.
그래서 finalize()는 사용하지 않는 것을 권장하며, JDK 9부터는 Deprecated 가 되었습니다.
2-5. 메서드 영역 회수
힙 영역뿐만 아니라 메서드 영역도 GC의 영역입니다. 메서드 영역에서의 GC는 크게 2가지를 회수합니다. '상수' 및 '클래스'입니다.
상수
참조가 있는지 확인하고, 없으면 삭제합니다. (힙에서 객체 회수하는 방법과 유사)
클래스
클래스는 아래 3가지 조건을 부합하면 회수합니다.
- 이 클래스의 인스턴스가 모두 회수되었다. (힙에 해당 클래스와 하위 클래스의 인스턴스가 하나도 존재하지 않는다.)
- 클래스 로더가 회수되었다.
- 이 클래스에 해당하는 java.lang.Class 객체를 아무 곳에서 참조하지 않고, 리플랙션 기능으로 이 클래스의 메서드를 이용하는 곳도 없다.
이와 같이 회수 자체는 할 수 있지만, 힙에 비해 효율이 좋지 않습니다.
3. 다음으로
여태까지 저희는 어떤 객체의 생존 유무를 판단하는 원리를 알아봤습니다.
이 원리를 바탕으로 여러 GC들이 어떤 목표를 가지고 어떤 부분을 최적화하려고 했는지 알아보려고 합니다.
가장 초기에는 모든 프로그램을 멈추고 GC를 실행시켰지만, 비동기 동작이나 더 높은 처리량을 위해 점점 GC가 발전되는 것을 확인할 수 있을 것입니다.
다음에는 본격적으로 GC 알고리즘에 대해 알아보겠습니다.
출처 :
- 책 JVM 밑바닥부터 파헤치기
'BE > JVM' 카테고리의 다른 글
JVM에서 객체 들여다보기 (2) | 2024.12.17 |
---|
댓글