나는 C++을 사용할 때는 소멸자(Destructor)를 정의하거나 상속 받은 것을 재정의(override)해서 쓴 적이 있다. 그러나 Java로 개발하면서 생성자는 잘 써도 소멸자는 사용해 본 기억이 없었다.
처음 자바를 배웠을 때는 별 생각이 없었지만, 후에 알고 보니 자바는 가비지 컬렉션(Garbage Collectotion, GC)가 자동으로 쓰지 않는 객체를 회수하기 때문이었다.
문득, 한 가지 궁금한 점이 생겼다.
C++의 경우 객체가 delete되거나, 스코프를 벗어나면 소멸자가 호출되고, 그 다음에 객체가 소멸된다.
그러나 자바는 소멸자가 존재하지 않는다.
그렇다면 Garbage Collection은 객체의 사용 종료 시점을 어떻게 판별하는 걸까?
이를 알기 위해서는 Garbage Collection에 대해 조금 더 살펴보면 좋다.
자바의 Garbage Collection(GC)이란?
더 이상 사용되지 않는 객체를 자동으로 탐지하고 메모리에서 제거하는 시스템으로 new 로 생성된 객체는 힙(heap)에 저장되는데, 그 객체를 더 이상 참조하지 않으면 GC가 후에 메모리를 회수한다.
예를 들어, 다음 코드에서와 같이 기존에 만들어진 "철수"라는 이름을 가진 Person 객체는 더 이상 참조되지 않고 사용되지 않아 garbage가 된다.
Person person = new Person("철수");
// 가비지 발생
person = new Person("영희");
이 객체를 GC가 회수하는 것이다.
🗑️ Garbage Collector(GC)의 동작 방식
GC는 특정 객체가 가비지인지 아닌지 판단하기 위해서 Rechability(접근 가능)라는 개념을 사용한다.
📍 Step 1: Root부터 시작 (GC Root Set)
GC는 다음과 같은 'GC Root'라 불리는 특별한 참조 지점에서 시작해서 객체 그래프를 탐색한다.
- 스택에 있는 지역 변수
- 클래스의 static 필드
- JNI에 의해 참조된 객체 (native 코드)
📍 Step 2: 참조 그래프를 따라가며 '살아있는 객체' 찾기
GC는 Root에서 출발해서, 객체 A가 객체 B를 참조하고, B가 C를 참조하면
→ A, B, C는 전부 Reachable 객체니까 살려둔다
📍 Step 3: 못 도달하는 객체는? → 쓰레기!
이 참조 그래프에서 도달할 수 없는 객체들은
→ "더 이상 아무도 안 쓰는 객체구나!"
→ GC가 메모리 회수
이렇게 회수된 메모리는 자동으로 힙에 반환된다.
자바 GC의 세대 구분 (Generational GC)
대부분의 객체가 금방 생성되고 금방 사라진다는 사실에 기반하여 GC는 성능 향상을 위해 객체의 생애 주기(lifetime)에 따라 메모리를 세대로 나누어 관리한다. 이렇게 하면 수명이 짧은 객체는 빠르게 수거하고, 수명이 긴 객체는 자주 검사하지 않음으로써 효율을 높일 수 있다.
영역 | 설명 |
Young Generation | 새로 생성된 객체가 들어감. 대부분 여기서 죽음 (→ 빠르게 회수 가능) |
Old Generation | 오래 살아남은 객체. Young에서 몇 번 생존하면 이리로 이동 |
Permanent / Metaspace | 클래스 메타데이터 저장 (JDK 8부터는 Metaspace로 대체) |
GC는 언제 동작할까?
- 메모리가 부족해질 때
- JVM이 한가할 때
- 명시적으로 System.gc() 를 호출했을 때
위 경우에 GC가 실행된다. 그러나 System.gc() 를 호출하는 것은 시스템 상에 오류를 일으킬 수 있어 권장되지는 않는다. 또한, 호출한다고 해도 바로 GC가 동작하여 메모리를 회수하는 것도 아니다.
자바의 메모리 구조
여기서 말하는 메모리란, GC가 관리하는 heap 메모리를 말한다.
자바 프로그램을 실행하게 되면 JVM은 OS로부터 메모리를 할당받는다. 이 메모리는 크게 3가지로 이루어진다.
영역 | 설명 |
Heap | 객체가 저장됨. GC가 관리하는 메모리 공간 |
Stack | 지역 변수, 메서드 호출 정보 저장. 함수 끝나면 자동 소멸 |
Method Area (Metaspace) | 클래스 정보, static 변수 저장 |
GC는 이 중 Heap 메모리만 관리한다.
자동으로 메모리 회수를 해주는 GC는 자바의 강력한 장점 중 하나이다. 그러나 GC는 Heap 메모리 영역만 관리하기 때문에 static 필드, List에 쌓아두고 빼지 않는 객체 등은 회수하지 못하므로 메모리 누수가 생길 수 있다.
💥 메모리 누수
객체는 더 이상 필요하지 않지만 여전히 참조가 남아 있어서 GC가 회수 못하는 상황
💣 대표적인 누수 예시
1. static 필드에 객체를 계속 참조
public class Cache {
private static List<Object> cacheList = new ArrayList<>();
public static void add(Object obj) {
cacheList.add(obj); // 절대 제거 안 함 -> 계속 참조됨
}
}
- static은 프로그램 종료 시까지 살아있다.
- static이라는 건 클래스 로딩 시 생성되고, 프로그램 종료 시까지 메모리에 저장된다. 즉, 계속 참조 중이라 GC가 회수하지 못한다!
→ 메모리 점점 차오름 → 누수 발생
특히, static 캐시가 위험하다. 캐시 구조는 추가(add)와 제거(remove)가 반복되는데, 한 번 저장했던 객체를 캐시에서 지웠다 하더라도 메모리 상에서는 회수되지 않을 수 있기 때문이다.
public class Person {
String name;
public Person(String name) { this.name = name; }
}
public class Main {
static List<Person> peopleCache = new ArrayList<>();
public static void main(String[] args) {
Person p1 = new Person("Alice");
peopleCache.add(p1); // 캐시에 추가
peopleCache.remove(0); // 리스트에서 제거
// 여기서도 여전히 p1이 살아있음
System.out.println(p1.name); // → "Alice"
}
}
위의 코드를 보면 peopleCache.remove(0);를 통해 리스트에서 "Alice"라는 이름의 객체에 대한 참조는 사라졌다. 그러나 p1이라는 다른 참조 변수가 여전히 Person("Alice")를 가리키고 있다. GC는 이 객체가 아직 살아있다고 판별하여 회수하지 않는다.
정리하자면, 다음과 같다.
상태 | GC 입장에서 |
리스트에만 있는 객체 | 리스트에서 제거하면 참조 없음 → GC 가능 |
변수 등에서 여전히 참조 중 | 리스트에서 제거해도 회수 안 됨 |
캐시에서만 참조 중이고 제거됨 | 참조 없음 → GC 대상 |
위의 코드와 같은 상황에서 GC가 회수하게 만드려면 p1의 참조를 끊어줘야한다.
peopleCache.remove(0);
p1 = null; // 다른 참조도 끊기면, 이제야 GC 가능
이렇게 모든 참조가 끊어졌을 때만 진짜로 GC가 “이 객체는 쓸모 없다”고 보고 회수할 수 있다.
2. List, Map 등에 객체를 넣고 빼지 않음
Map<String, Object> sessionMap = new HashMap<>();
sessionMap.put("user1", new Object()); // session 끝났는데도 안 지움
- 여전히 Map이 참조하고 있어서 GC가 회수할 수 없다.
- 이런 건 직접 remove()로 정리해야한다.
3. 리스너, 콜백 등록 후 해제 안 함
button.addActionListener(new MyListener());
- 리스너 객체가 계속 참조됨 → 버튼과 함께 메모리에 계속 남아 있다.
4. ThreadLocal을 썼는데 remove() 안 함
ThreadLocal<MyData> local = new ThreadLocal<>();
local.set(new MyData()); // 끝나면 반드시 local.remove() 해야 함!
- 특히 서블릿이나 톰캣 환경에선 Thread가 재사용되기 때문에 심각한 누수로 이어질 수 있다.
✅ 메모리 누수 피하는 법
1. 사용이 끝난 참조는 null 처리 or remove() 하기
myList.clear(); // List 비우기
map.remove("user"); // Map에서 제거
2. 필요하면 약한 참조 사용하기 (WeakReference)
WeakReference<MyObject> weakRef = new WeakReference<>(new MyObject());
- WeakReference로 감싸면 약한 참조로 바뀌어 GC가 알아서 회수 가능하다.
- 단, 약한 참조는 갑자기 사라질 수 있기 때문에 캐시에 꼭 있어야 하는 객체에는 부적절하다.
- 참조는 하고 있지만 꼭 메모리에 남아있을 필요는 없는 객체에 사용하자.
(슈뢰딩거의 고양이 같은 느낌이다. 확인하기 전까지 메모리에 있는지 없는지 모른다.)
3. static 캐시에는 size 제한 or LRU 구조 사용
LinkedHashMap<K, V> lruCache = new LinkedHashMap<>(maxSize, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > maxSize;
}
};
- LRU(Least Recently Used) 알고리즘은 가장 오랫동안 참조되지 않은 객체를 제거한다.
- 오래된 항목은 자동으로 제거 → 참조 끊김 → GC가 회수 가능 → 누수 방지
4. 리스너/콜백 해제 꼭 하기
button.removeActionListener(myListener);
- Java GUI에서 많이 실수하는 부분이라고 한다.
- 등록만 하고 해제 안 하면 쌓임 → 메모리 잡아먹는다.
생각해보면 나도 과거에 체스 게임 만들 때 리스너 해제를 안 해줬던 것 같다. 반성하자.
5. 🧪 Heap Dump + 분석 도구 사용
-
- VisualVM
- Eclipse Memory Analyzer (MAT)
- JProfiler / YourKit
실전에서는 툴을 사용하는 것도 중요하다. Heap Dump 분석해서 어떤 객체가 오래 살아있는지, 누가 참조하고 있는지 볼 수 있다.
Heap Dump를 사용하여 메모리 누수를 찾고 해결하는 사례는 이분의 블로그를 참고하는 것도 좋을 것 같다.
Java Heap Dump 를 이용한 문제 해결
+ 문제 발생개인적으로 사용하고 있는 Spring 웹 서버가 하나 있다.언제부터인가 Out Of Memory Error 가 발생하며 웹 서버가 죽어 있는 것이였다. 갑자기 애플리케이션이 죽어 버리는 것은 아니고, 몇
lng1982.tistory.com
✨ 요약
위험 요소 | 해결법 |
static 필드 | 명시적으로 null 처리 or 캐시 제한 |
List/Map에 쌓아두기 | remove(), clear()로 제거 |
리스너/콜백 | 사용 끝나면 removeListener() 호출 |
ThreadLocal | 꼭 remove() 해주기 |
불필요한 참조 유지 | scope 벗어나게 or null 처리 |
'프로그래밍 언어 > 자바(Java)' 카테고리의 다른 글
Java 프로그래밍 06 : static (1) | 2025.04.22 |
---|---|
Java 프로그래밍 04 : 생성자와 소멸자 그리고 this 키워드 (0) | 2025.04.05 |
Java 프로그래밍 03 : 객체 지향 프로그래밍이란? (0) | 2025.04.04 |
Java 프로그래밍 02 : 프로그램의 실행 구조 (0) | 2025.04.03 |
Java 프로그래밍 01 : Java란? (1) | 2025.04.02 |