Map<Object, byte[]> cache = new HashMap<>();
void work() {
Object key = new Object();
cache.put(key, new byte[1024 * 1024]); // 1MB
// 작업 끝
}
work()를 수천 번 호출하면? key는 지역 변수라 스코프를 벗어나면 GC 대상이 되지만, cache가 키를 강하게 들고 있어서 키·값 둘 다 계속 살아 있음. 이걸 “눈에 안 띄는 메모리 누수(코드 상에서는 아무것도 안 남은 것처럼 보이는데, 컬렉션이 붙잡고 있는 경우)”로 설명.
Map<Object, byte[]> cache = new WeakHashMap<>();
void work() {
Object key = new Object();
cache.put(key, new byte[1024 * 1024]);
// 작업 끝
}
key에 대한 강한 참조는 사라진다.public static void main(String[] args) throws Exception {
Map<Object, String> map = new WeakHashMap<>();
Object key = new Object();
map.put(key, "value");
System.out.println("before: " + map.size()); // 1
key = null; // 강한 참조 제거
System.gc();
Thread.sleep(100);
System.out.println("after: " + map.size()); // 보통 0
}
여기서는 살짝 더 깊게:
WeakReference<K>를 상속한 Entry로 구현되어 있고,reference.get()이 null을 반환하면서, 내부에서 그 엔트리를 제거한다.그래서 이 포인트들을 강조:
import java.util.Map;
import java.util.WeakHashMap;
class User {
private final String id;
User(String id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User other = (User) o;
return id.equals(other.id);
}
// hashCode 오버라이드 안 함 (전형적인 버그)
}
public class WeakHashMapBug {
public static void main(String[] args) {
Map<User, String> map = new WeakHashMap<>();
User u1 = new User("gh");
User u2 = new User("gh"); // 논리적으로는 같은 사용자
map.put(u1, "first");
System.out.println(map.get(u1)); // "first"
System.out.println(map.get(u2)); // null (같은 사용자처럼 보여도 다른 키)
// 해시 기반 Map에서는 "논리적으로 같다"의 기준을
// equals와 hashCode 둘 다로 보기 때문에 생기는 전형적인 삑사리
}
}
u1 / u2가 같은 유저처럼 보여도, hashCode를 안 건드렸기 때문에 HashMap / WeakHashMap 입장에서는 “완전 다른 키”로 취급한다. User를 쓰면, 같은 의미의 키가 계속 다른 엔트리로 쌓이면서 메모리 낭비가 난다.WeakHashMap이라고 해서 이 규칙이 특별히 달라지는 건 없다.
어차피 내부적으로도 해시 기반으로 키를 찾기 때문에, “equals만 고쳐놓고 hashCode는 기본 Object 걸 쓰는 버그”는 WeakHashMap에서도 그대로 터진다.
WeakHashMap의 핵심은 간단하다.
“키에 대한 강한 참조가 모두 끊기면, 그 엔트리는 자연스럽게 사라진다.”
그래서 보통은 이런 그림을 기대한다.
Map<Object, byte[]> cache = new WeakHashMap<>();
void work() {
Object key = new Object();
cache.put(key, new byte[1024 * 1024]);
// ...
key = null; // 이제 키를 더 이상 안 쓰니까 날려버림
// -> 언젠가 GC가 key를 수거하면서 엔트리도 같이 정리됨
}
문제는 Value 쪽에서 다시 Key를 붙잡기 시작할 때 터진다.
class Wrapper {
Object key;
byte[] data;
}
Map<Object, Wrapper> map = new WeakHashMap<>();
void work() {
Object key = new Object();
Wrapper wrapper = new Wrapper();
wrapper.key = key; // Value가 Key를 강하게 참조
wrapper.data = new byte[1024 * 1024];
map.put(key, wrapper);
key = null; // 지역 변수는 끊겼지만...
// wrapper.key가 계속 잡고 있음
}
코드만 보면 “key를 null로 만들었으니까, 이제 GC가 수거하겠지?”라고 생각하기 쉬운데, 실제로는 wrapper.key 때문에 키 객체가 여전히 강하게 살아 있다.
결국 WeakHashMap 입장에선 “키에 대한 강한 참조가 아직 있음”으로 보이기 때문에, 엔트리가 전혀 지워지지 않는 구조가 된다.
이 패턴이 은근 자주 나온다.
같은 식으로 아무 생각 없이 넣어두면, WeakHashMap이 가진 장점이 통째로 사라진다.
그래서 라이브러리 레벨에서는 Value 쪽도 약한 참조로 감싸는 식으로 방지하기도 한다.
class Wrapper {
WeakReference<Object> keyRef;
byte[] data;
}
이렇게 만들어 두면, Value가 Key를 “기억”은 하되 강하게 붙잡지는 않아서, GC가 Key를 수거하는 데 방해가 되지 않는다.
WeakHashMap 내부 구현을 뜯어보면, 단순히 WeakReference만 쓰는 게 아니라 ReferenceQueue를 같이 물려서 “죽은 키”를 감지한다.
이 조합을 직접 한 번 써보면 동작 구조가 확실히 와 닿는다.
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
public class WeakRefWithQueue {
private static final ReferenceQueue<Object> queue = new ReferenceQueue<>();
public static void main(String[] args) throws Exception {
Object obj = new Object();
WeakReference<Object> ref = new WeakReference<>(obj, queue);
obj = null;
System.gc();
// 여기서 블로킹으로 대기하다가,
// ref가 가리키던 객체가 수거되면 큐로 들어온다.
Reference<?> polled = queue.remove();
if (polled == ref) {
System.out.println("Object collected. Do cleanup here.");
}
}
}
여기서 포인트 두 개만 잡으면 된다.
ref.get() == null인지 보면서 “아, 수거됐구나”라고 수동으로 확인해야 한다.