Reference Types — WeakReference, SoftReference, PhantomReference
new Object()로 만든 객체는 변수가 참조하는 한 절대 GC되지 않습니다. 그런데 "참조는 하고 싶지만, 메모리가 부족하면 GC가 가져가도 괜찮아"라는 상황이 있다면 어떻게 해야 할까요? 자바는 이런 요구를 위해 Strong 외에 세 가지 참조 타입을 제공합니다.
참조 타입의 계층 — Strong부터 Phantom까지
자바의 참조는 GC와의 관계에 따라 네 단계로 나뉩니다.
| 참조 타입 | 클래스 | GC 수거 시점 | 주 용도 |
|---|---|---|---|
| Strong | 일반 변수 | 참조가 살아있는 한 수거 안 함 | 기본 |
| Soft | SoftReference | 메모리 부족 시 | 캐시 |
| Weak | WeakReference | 다음 GC 사이클 | 메타데이터 매핑 |
| Phantom | PhantomReference | finalize 이후 | 리소스 정리 |
강도 순서를 한 줄로 정리하면 이렇습니다.
Strong > Soft > Weak > Phantom
Strong 참조가 하나라도 남아있으면 나머지 참조 타입은 의미가 없습니다. GC는 Strong 참조가 모두 끊어진 객체에 대해서만 Soft → Weak → Phantom 순서로 처리를 진행합니다.
Strong Reference — 우리가 매일 쓰는 그것
// 가장 기본적인 참조 — Strong Reference
Object obj = new Object();
변수 obj가 가리키는 한 이 객체는 절대 GC되지 않습니다. 너무 당연해서 별도 클래스가 없고, 우리가 평소에 쓰는 모든 참조가 Strong입니다.
문제는 ** 의도치 않게 Strong 참조를 유지하는 경우 **입니다. 이걸 보통 메모리 누수(memory leak)라고 부릅니다.
public class Cache {
// 키를 넣으면 영원히 GC되지 않는 구조
private static final Map<String, byte[]> cache = new HashMap<>();
public static void put(String key, byte[] data) {
cache.put(key, data);
// 제거 로직이 없으면 데이터가 계속 쌓임
}
}
이런 패턴이 실무에서 OOM을 일으키는 대표적인 원인입니다. Soft/Weak 참조는 이 문제를 완화하는 데 쓸 수 있습니다.
SoftReference — 메모리가 부족할 때만 수거
import java.lang.ref.SoftReference;
// SoftReference로 감싸기
Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
// Strong 참조 제거
obj = null;
// 메모리가 충분하면 여전히 접근 가능
Object retrieved = softRef.get(); // null이 아닐 수 있음
SoftReference의 핵심은 ** 메모리가 충분하면 살아있고, 부족하면 GC가 수거한다 **는 점입니다. JVM은 OOM을 던지기 전에 모든 Soft 참조 객체를 수거합니다.
캐시에서의 활용
import java.lang.ref.SoftReference;
import java.util.HashMap;
import java.util.Map;
public class SoftCache<K, V> {
private final Map<K, SoftReference<V>> cache = new HashMap<>();
public void put(K key, V value) {
cache.put(key, new SoftReference<>(value));
}
public V get(K key) {
SoftReference<V> ref = cache.get(key);
if (ref == null) return null;
V value = ref.get();
if (value == null) {
// GC에 의해 수거됨 — 엔트리 정리
cache.remove(key);
}
return value;
}
}
이 캐시는 메모리가 부족하면 자동으로 값이 정리됩니다. 다만 실무에서는 SoftReference 기반 캐시보다 Caffeine이나 Guava Cache처럼 명시적 만료 정책이 있는 라이브러리를 더 많이 씁니다. GC 타이밍에 의존하는 건 예측이 어렵기 때문입니다.
SoftReference 기반 캐시는 구현이 간단하지만, GC 타이밍에 의존하기 때문에 캐시 적중률 예측이 어렵습니다. 실무에서는 Caffeine이나 Guava Cache처럼 명시적 만료 정책이 있는 라이브러리가 더 많이 쓰입니다.
WeakReference — GC가 실행되면 바로 수거
import java.lang.ref.WeakReference;
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
// Strong 참조 제거
obj = null;
// 다음 GC에서 수거될 수 있음
System.gc(); // GC 유도 (보장은 아님)
System.out.println(weakRef.get()); // null일 가능성 높음
WeakReference는 Strong 참조가 없으면 다음 GC에서 바로 수거 대상 이 됩니다. Soft와 달리 메모리 여유와 상관없이 수거됩니다.
WeakHashMap의 동작 원리
WeakHashMap은 키를 WeakReference로 감싸는 Map입니다.
import java.util.WeakHashMap;
WeakHashMap<Object, String> map = new WeakHashMap<>();
Object key = new Object();
map.put(key, "데이터");
System.out.println(map.size()); // 1
// 키에 대한 Strong 참조 제거
key = null;
// GC 유도
System.gc();
// 약간의 지연 후 확인
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println(map.size()); // 0일 가능성 높음
내부 동작을 좀 더 자세히 보겠습니다.
put()할 때 키를WeakReference로 감싸서 저장합니다- 키에 대한 외부 Strong 참조가 사라지면, GC가 해당 키를 수거합니다
- 수거된 키의
WeakReference가 내부ReferenceQueue에 등록됩니다 - 다음번
get(),put(),size()등을 호출하면expungeStaleEntries()가 실행되어 해당 엔트리를 제거합니다
// WeakHashMap 내부 구조 (개념적 의사 코드)
class WeakHashMap<K, V> {
private ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 키를 WeakReference로 감싸서 저장
static class Entry<K, V> extends WeakReference<Object> {
V value;
Entry(Object key, V value, ReferenceQueue<Object> queue) {
super(key, queue); // 키를 약한 참조로, 큐에 등록
this.value = value;
}
}
// 맵 접근 시마다 호출되어 GC된 엔트리 정리
private void expungeStaleEntries() {
// queue에서 수거된 참조를 꺼내서 맵에서 제거
}
}
주의할 점: 문자열 리터럴이나 Integer 캐시(-128~127) 같은 값을 키로 쓰면 JVM 내부에서 Strong 참조를 유지하기 때문에 GC가 수거하지 않습니다.
WeakHashMap<String, String> map = new WeakHashMap<>();
// 문자열 리터럴은 String Pool에 Strong 참조가 있어서 수거 안 됨
map.put("hello", "world");
// new String()으로 만들어야 수거 가능
map.put(new String("hello2"), "world2");
PhantomReference — finalize 이후의 정리 작업
PhantomReference는 다른 참조 타입과 성격이 많이 다릅니다.
get()이 항상null을 반환합니다- 반드시
ReferenceQueue와 함께 사용해야 합니다 - 객체가 finalize된 후에 큐에 등록됩니다
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object obj = new Object();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
// get()은 항상 null
System.out.println(phantomRef.get()); // null
// Strong 참조 제거
obj = null;
System.gc();
리소스 정리 패턴
PhantomReference의 진짜 용도는 ** 객체 소멸 시점에 외부 리소스를 정리 **하는 것입니다. finalize()의 대안이라고 보면 됩니다.
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.Reference;
public class ResourceCleaner {
private static final ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 정리해야 할 리소스 정보를 별도로 보관
private static final Map<PhantomReference<Object>, Runnable> cleanupActions
= new ConcurrentHashMap<>();
// 리소스 등록
public static void register(Object obj, Runnable cleanupAction) {
PhantomReference<Object> ref = new PhantomReference<>(obj, queue);
cleanupActions.put(ref, cleanupAction);
}
// 정리 스레드 — 백그라운드에서 계속 실행
static {
Thread cleanerThread = new Thread(() -> {
while (true) {
try {
// 큐에서 수거된 참조를 꺼냄 (블로킹)
Reference<?> ref = queue.remove();
Runnable action = cleanupActions.remove(ref);
if (action != null) {
action.run(); // 리소스 정리 실행
}
ref.clear(); // 참조 해제
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
cleanerThread.setDaemon(true);
cleanerThread.start();
}
}
사실 Java 9부터는 java.lang.ref.Cleaner라는 표준 API가 추가되어서 이 패턴을 직접 구현할 필요가 줄었습니다.
import java.lang.ref.Cleaner;
public class NativeResource implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// 정리 로직은 static 내부 클래스로 분리 (외부 객체 참조 방지)
private static class CleanupAction implements Runnable {
private long nativePointer;
CleanupAction(long nativePointer) {
this.nativePointer = nativePointer;
}
@Override
public void run() {
// 네이티브 리소스 해제
System.out.println("네이티브 리소스 정리: " + nativePointer);
nativePointer = 0;
}
}
private final long nativePointer;
private final Cleaner.Cleanable cleanable;
public NativeResource() {
this.nativePointer = allocateNative(); // 네이티브 리소스 할당
this.cleanable = cleaner.register(this, new CleanupAction(nativePointer));
}
@Override
public void close() {
cleanable.clean(); // 명시적 정리
}
private static long allocateNative() {
return System.nanoTime(); // 예시용
}
}
finalize()는 Java 9에서 deprecated되었습니다. 대안은Cleaner(내부적으로 PhantomReference 사용) 또는try-with-resources+AutoCloseable입니다.
ReferenceQueue — 참조 수거 알림 메커니즘
ReferenceQueue는 참조 대상 객체가 GC될 때 ** 해당 Reference 객체 자체 **가 등록되는 큐입니다.
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.lang.ref.Reference;
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object obj1 = new Object();
Object obj2 = new Object();
// 큐와 함께 WeakReference 생성
WeakReference<Object> ref1 = new WeakReference<>(obj1, queue);
WeakReference<Object> ref2 = new WeakReference<>(obj2, queue);
// Strong 참조 하나만 제거
obj1 = null;
System.gc();
// 큐에서 수거된 참조 확인
Reference<?> polled = queue.poll(); // ref1이 들어있을 수 있음
if (polled == ref1) {
System.out.println("obj1이 GC되었습니다");
}
주요 메서드는 두 가지입니다.
poll()— 큐에서 즉시 꺼냄 (없으면null반환)remove()— 큐에 등록될 때까지 블로킹
| 참조 타입 | ReferenceQueue 등록 시점 | 필수 여부 |
|---|---|---|
| SoftReference | 메모리 부족으로 수거 시 | 선택 |
| WeakReference | GC 수거 시 | 선택 |
| PhantomReference | finalize 이후 | ** 필수** |
Strong Reference Leak과의 관계
메모리 누수의 대부분은 ** 의도치 않은 Strong 참조 유지 **에서 발생합니다. Soft/Weak 참조는 이런 상황을 완화하는 도구입니다.
대표적인 Strong Reference Leak 패턴들입니다.
// 1. static 컬렉션에 계속 추가
private static final List<Object> list = new ArrayList<>();
// 2. 리스너 등록 후 해제 안 함
button.addActionListener(listener);
// removeActionListener를 안 하면 listener가 계속 살아있음
// 3. 내부 클래스가 외부 클래스를 참조
class Outer {
byte[] data = new byte[1024 * 1024]; // 1MB
class Inner {
// Inner는 Outer에 대한 암묵적 Strong 참조를 가짐
}
}
이런 경우에 WeakReference를 활용하면 도움이 됩니다.
// 리스너를 WeakReference로 관리하는 패턴
public class EventBus {
private final List<WeakReference<EventListener>> listeners = new ArrayList<>();
public void register(EventListener listener) {
listeners.add(new WeakReference<>(listener));
}
public void fire(Event event) {
// 순회하면서 GC된 리스너는 자동 정리
Iterator<WeakReference<EventListener>> it = listeners.iterator();
while (it.hasNext()) {
EventListener listener = it.next().get();
if (listener == null) {
it.remove(); // GC된 참조 제거
} else {
listener.onEvent(event);
}
}
}
}
전체 비교 정리
| 항목 | Strong | Soft | Weak | Phantom |
|---|---|---|---|---|
| GC 수거 조건 | 참조 없을 때 | 메모리 부족 시 | 다음 GC | finalize 후 |
get() 반환 | 객체 | 객체 또는 null | 객체 또는 null | 항상 null |
| ReferenceQueue | 없음 | 선택 | 선택 | 필수 |
| 주 용도 | 일반 사용 | 캐시 | 메타데이터 매핑 | 리소스 정리 |
| 대표 활용 | 모든 변수 | 이미지 캐시 | WeakHashMap | Cleaner |
주의할 점
WeakHashMap의 키로 문자열 리터럴을 쓰면 절대 GC되지 않는다
String Pool에 있는 문자열 리터럴은 JVM이 Strong 참조를 유지합니다. map.put("key", value)로 넣으면 "key"가 String Pool에 있으므로 GC 대상이 되지 않습니다. new String("key")로 만든 키만 GC 대상이 됩니다. Integer 캐시(-128~127)도 같은 문제가 있습니다.
ThreadLocal과 WeakReference의 함정
ThreadLocalMap은 내부적으로 키를 WeakReference로 감싸지만, 값(value)은 Strong 참조 입니다. ThreadLocal 인스턴스가 GC되면 키는 null이 되지만 값은 그대로 남아서, 스레드 풀 환경에서 메모리 누수가 발생합니다. ThreadLocal.remove()를 반드시 호출해야 하는 이유입니다.
SoftReference가 전부 동시에 수거되면 캐시 미스 폭풍
JVM은 OOM 직전에 모든 SoftReference를 한꺼번에 수거합니다. 캐시가 SoftReference 기반이면 갑자기 모든 캐시가 비워져서, 뒤이어 오는 요청이 전부 캐시 미스를 겪습니다. 이로 인해 DB에 순간적으로 과부하가 걸리는 "캐시 스탬피드" 현상이 발생할 수 있습니다.
정리
| 항목 | Strong | Soft | Weak | Phantom |
|---|---|---|---|---|
| GC 수거 조건 | 참조 없을 때 | 메모리 부족 시 | 다음 GC | finalize 후 |
get() 반환 | 객체 | 객체 또는 null | 객체 또는 null | 항상 null |
| ReferenceQueue | 없음 | 선택 | 선택 | 필수 |
| 주 용도 | 일반 사용 | 캐시 | 메타데이터 매핑 | 리소스 정리 |
| 대표 활용 | 모든 변수 | 이미지 캐시 | WeakHashMap | Cleaner |