Skip to main content

Command Palette

Search for a command to run...

Java Reference 내부 구조 — Soft/Weak/Phantom, ReferenceQueue, 그리고 ReferenceHandler가 GC와 협업하는 방법

Updated
14 min read

WeakHashMap은 어떻게 키가 사라지면 값까지 비울까요. DirectByteBuffer는 finalize 없이도 native 메모리를 정리합니다. ThreadLocalMap.Entry의 키는 WeakReference인데 왜 메모리 누수가 발생할까요. 이 글은 java.lang.ref 패키지의 네 가지 Reference 타입, ReferenceQueue, 그리고 GC와 협업하는 ReferenceHandler 스레드의 내부 구조를 풀어냅니다.


1. 왜 Reference인가

Strong Reference의 한계

자바에서 객체 참조는 기본적으로 강한 참조(strong reference) 입니다. 변수가 객체를 가리키는 한 GC는 그 객체를 회수하지 않습니다.

Object o = new Object();   // strong reference
o = null;                  // 이제 회수 가능

대부분은 이 규칙으로 충분합니다. 그러나 다음 두 가지 상황에서는 강한 참조만으로 풀 수 없는 문제가 생깁니다.

첫째, 메모리에 따라 동적으로 비워야 하는 캐시입니다. 자주 사용하는 객체는 메모리에 두고, 힙이 부족해지면 자동으로 비워졌으면 합니다. 강한 참조로 캐시를 만들면 GC는 캐시 안의 객체를 절대 회수할 수 없습니다.

둘째, 객체가 회수된 사실을 알려달라는 요구입니다. 예를 들어 native 메모리를 직접 할당한 DirectByteBuffer는 자바 객체가 회수될 때 그 native 메모리도 해제해야 합니다. 자바 객체의 회수를 감지하는 통로가 필요합니다.

java.lang.ref 패키지는 이 두 요구를 정확히 해결하기 위해 만들어졌습니다. 핵심 도구는 네 가지 Reference 타입과 ReferenceQueue입니다.

패키지가 제공하는 도구

flowchart TB
    Ref[java.lang.ref.Reference abstract]
    Soft[SoftReference: GC-aware cache]
    Weak[WeakReference: canonicalizing map]
    Phantom[PhantomReference: post-mortem cleanup]
    Cleaner[Cleaner: managed PhantomReference]
    Queue[ReferenceQueue: notification channel]

    Ref --> Soft
    Ref --> Weak
    Ref --> Phantom
    Phantom --> Cleaner
    Soft -.uses.-> Queue
    Weak -.uses.-> Queue
    Phantom -.uses.-> Queue

세 가지 Reference는 모두 추상 클래스 Reference의 서브클래스이며, GC가 각각 다른 시점에 처리합니다. 그 처리 결과를 사용자 코드가 받아보는 통로가 ReferenceQueue입니다.


2. 네 가지 Reachability

자바의 GC 모델에서 객체는 다섯 가지 도달 가능성(reachability) 중 하나입니다. java.lang.ref 패키지 문서가 정의한 강한 순서대로 살펴봅니다.

단계 정의 GC 동작
Strongly reachable 일반 변수에서 도달 가능 절대 회수 안 함
Softly reachable 약하지만 strong은 아님, SoftReference로만 도달 메모리 부족 시 회수
Weakly reachable WeakReference로만 도달 다음 GC 사이클에 회수
Phantom reachable PhantomReference로만 도달, 이미 finalize 끝 큐에 enqueue 후 회수
Unreachable 어디서도 도달 불가 즉시 회수

핵심은 순서입니다. GC는 strong → soft → weak → phantom 순서로 검사하며, 더 강한 참조가 살아 있는 한 더 약한 참조는 발동하지 않습니다.

예를 들어 어떤 객체에 strong reference와 WeakReference가 동시에 있다면, strong reference가 사라질 때까지 GC는 그 객체를 회수하지 않습니다. WeakReference는 strong이 사라진 뒤에야 의미를 갖습니다.

Object o = new Object();
WeakReference<Object> ref = new WeakReference<>(o);

System.gc();
System.out.println(ref.get());   // not null, 아직 strong이 살아 있음

o = null;
System.gc();
System.out.println(ref.get());   // null, weakly reachable이 되어 회수됨

3. Reference 클래스의 네 가지 필드

java.lang.ref.Reference<T>는 모든 Reference 타입의 추상 부모입니다. OpenJDK 소스를 보면 클래스에는 정확히 네 개의 필드가 있습니다.

public abstract class Reference<T> {
    private T referent;
    volatile ReferenceQueue<? super T> queue;
    volatile Reference next;
    private transient Reference<?> discovered;
    // ...
}

각 필드의 역할은 다음과 같습니다.

  • referent — 실제 참조 대상. GC가 특수하게 다루는 단 하나의 필드입니다. GC가 회수를 결정하면 이 필드를 null로 만듭니다.
  • queue — Reference 생성 시 등록한 ReferenceQueue. enqueue될 큐를 가리킵니다.
  • next — ReferenceQueue가 관리하는 연결 리스트의 다음 노드.
  • discovered — GC가 회수 대상으로 찾은 Reference들을 임시로 묶어두는 pending list의 다음 노드. 사용자 코드와는 무관한 GC-VM 협업 필드입니다.

nextdiscovered가 둘 다 있는 이유는, 같은 Reference가 두 단계의 리스트(VM의 pending list, 자바 쪽 ReferenceQueue)를 거치기 때문입니다. 두 리스트가 동시에 충돌하지 않도록 별도 포인터를 둡니다.

네 가지 상태

Reference.java의 주석은 Reference 인스턴스가 라이프사이클 동안 거치는 네 가지 상태를 명시합니다.

flowchart LR
    Active[Active<br/>referent != null<br/>discovered = null/in GC list]
    Pending[Pending<br/>referent = null<br/>on VM pending list]
    Enqueued[Enqueued<br/>on ReferenceQueue<br/>queue = ENQUEUED]
    Inactive[Inactive<br/>polled by user<br/>queue = NULL]

    Active -->|GC detects| Pending
    Pending -->|ReferenceHandler<br/>enqueueFromPending| Enqueued
    Enqueued -->|queue.poll/remove| Inactive
    Active -->|no queue registered| Inactive
  • Active — 막 생성된 상태. referent가 살아 있고 GC가 감시 중입니다.
  • Pending — GC가 referent를 회수하기로 결정해 referent를 null로 만든 직후. VM이 관리하는 pending list에 들어갑니다.
  • Enqueued — ReferenceHandler 스레드가 pending list에서 꺼내 ReferenceQueue에 넣은 상태.
  • Inactive — 사용자가 queue.poll() 등으로 꺼낸 뒤. 더 이상 어떤 리스트에도 속하지 않습니다.

Reference가 queue 없이 만들어지면 Pending/Enqueued 단계를 건너뛰고 Active에서 바로 Inactive로 갑니다.


4. ReferenceHandler — GC와 자바를 잇는 다리

여기서 가장 흥미로운 부분입니다. GC는 네이티브 코드(C++)로 동작하지만 ReferenceQueue는 자바 객체입니다. 둘을 어떻게 잇는가.

답은 ReferenceHandler 스레드입니다.

JVM 부팅 시 Reference 클래스가 초기화되면, Reference 안의 static 초기화 블록이 ReferenceHandler 스레드를 만들어 시작합니다. 이 스레드는 다음 두 가지 특성을 갖습니다.

  • 최고 우선순위 데몬 스레드Thread.MAX_PRIORITY로 설정되어 다른 사용자 스레드보다 먼저 스케줄됩니다.
  • 무한 루프processPendingReferences()를 끝없이 호출합니다.

처리 흐름은 이렇습니다.

// 의사 코드. 실제 OpenJDK 구조의 흐름만 발췌
static void processPendingReferences() {
    waitForReferencePendingList();           // (1) native가 깨워줄 때까지 대기
    Reference<?> list = getAndClearReferencePendingList();  // (2) atomic하게 인수
    while (list != null) {
        Reference<?> ref = list;
        list = ref.discovered;
        ref.discovered = null;
        if (ref instanceof Cleaner cleaner) { // Cleaner는 큐를 거치지 않음
            cleaner.clean();
        } else {
            ReferenceQueue<? super Object> q = ref.queue;
            if (q != ReferenceQueue.NULL) q.enqueue(ref);
        }
    }
}

(1) GC가 회수 결정을 내리고 pending list에 Reference들을 쌓아두면 VM이 이 스레드를 깨웁니다. (2) 한 번에 모든 pending Reference를 atomic하게 가져옵니다. 그 다음 하나씩 꺼내 각 Reference의 큐에 enqueue합니다.

flowchart TB
    subgraph GC[GC Thread native]
        Trace[mark/sweep]
        Detect[detect unreachable referents]
        Build[link via discovered field<br/>build pending list]
        Signal[notify ReferenceHandler]
    end

    subgraph RH[ReferenceHandler Thread Java]
        Wait[waitForReferencePendingList]
        Get[getAndClearReferencePendingList]
        Loop[for each ref:<br/>enqueueFromPending]
    end

    subgraph User[User Thread Java]
        Poll[queue.poll / queue.remove]
        Process[run cleanup logic]
    end

    Trace --> Detect --> Build --> Signal
    Signal -.wake.-> Wait
    Wait --> Get --> Loop
    Loop -.enqueue.-> Poll
    Poll --> Process

여기서 짚을 점은 GC 사이클 자체는 ReferenceQueue를 만지지 않는다는 것입니다. GC가 만지는 것은 referent를 null로 만들고 discovered 필드로 pending list를 엮는 일까지입니다. ReferenceQueue.enqueue 호출은 ReferenceHandler 스레드의 책임입니다.

이 분리 덕분에 GC는 멈추는 시간을 최소화할 수 있고, 자바 모니터 락(ReferenceQueue.lock)을 native에서 만질 필요가 없습니다.


5. ReferenceQueue 내부 구조

ReferenceQueue<T>는 일반적인 동시성 큐가 아닙니다. 연결 리스트의 헤드 포인터 하나로 모든 게 끝나는 매우 단순한 자료구조입니다.

public class ReferenceQueue<T> {
    private volatile Reference<? extends T> head;
    private long queueLength = 0;
    private final Lock lock = new Lock();
    static final ReferenceQueue<Object> NULL = new Null();
    static final ReferenceQueue<Object> ENQUEUED = new Null();
    // ...
}

head만 들고 있고, 각 노드의 next 필드를 따라 리스트가 이어집니다. 별도의 노드 객체를 만들지 않습니다. Reference 자신이 노드 역할까지 겸합니다.

Enqueue

ReferenceHandler가 호출하는 enqueue0은 거의 한 줄입니다.

// 의사 코드
boolean enqueue0(Reference<? extends T> r) {
    synchronized (lock) {
        if (r.queue == ENQUEUED) return false;   // 중복 방지
        r.queue = ENQUEUED;
        r.next = (head == null) ? r : head;       // 자기 자신을 가리키면 리스트의 끝
        head = r;
        queueLength++;
        lock.notifyAll();                          // remove()에서 wait 중인 스레드 깨우기
        return true;
    }
}

마지막 노드의 next가 자기 자신을 가리키는 것이 신호입니다. 이렇게 하면 null 체크 대신 r.next == r 비교로 끝을 알 수 있습니다.

Poll과 Remove

public Reference<? extends T> poll() {
    if (head == null) return null;                 // fast path: 락 없이 체크
    synchronized (lock) {
        // ...
        Reference<? extends T> r = head;
        head = (r.next == r) ? null : r.next;      // 마지막 노드면 head = null
        r.queue = NULL;
        r.next = r;                                 // dequeue 표시
        queueLength--;
        return r;
    }
}

poll()은 즉시 반환하는 비차단 메서드이고, remove()는 큐가 빌 때까지 lock.wait()로 블록됩니다. remove(timeout)은 지정 시간만큼만 기다립니다.

head가 volatile이라 락 없이 fast-path 체크가 가능합니다. 큐가 비어 있는 일반적인 경우 락 충돌을 피할 수 있습니다.


6. SoftReference — 메모리 압력 기반 캐시

SoftReference는 GC가 메모리가 충분하다고 판단하는 한 회수하지 않습니다. OutOfMemoryError를 던지기 직전에는 모든 softly reachable 객체를 회수합니다.

public class SoftReference<T> extends Reference<T> {
    private static long clock;     // GC가 업데이트하는 글로벌 클록
    private long timestamp;        // 마지막 접근 타임스탬프

    public T get() {
        T o = super.get();
        if (o != null && this.timestamp != clock)
            this.timestamp = clock;
        return o;
    }
}

clock은 static 필드로, GC가 회수 결정을 내릴 때 업데이트합니다. timestampget()이 호출될 때마다 현재 clock 값으로 갱신됩니다. 즉, 자주 접근되는 SoftReference는 timestamp가 최근입니다.

GC는 이 timestamp를 LRU에 가까운 신호로 활용합니다. HotSpot의 정책은 -XX:SoftRefLRUPolicyMSPerMB 플래그로 조절합니다(기본값 1000). 의미는 "남은 heap 1MB당 SoftReference를 몇 ms 살려둘지"입니다. 예를 들어 남은 heap이 100MB면 100,000ms(100초) 동안 접근되지 않은 SoftReference만 회수 대상입니다.

flowchart LR
    GC[GC Cycle] --> Check{free heap MB<br/>x policy ms<br/>vs<br/>now - timestamp}
    Check -->|now - ts > threshold| Clear[clear referent]
    Check -->|else| Keep[keep alive]

이 정책 때문에 SoftReference는 짧은 시간에 메모리 압박이 없으면 거의 회수되지 않습니다. 캐시 용도로 쓸 때는 이 보수성을 기대하고 설계해야 합니다.

흔한 오해

SoftReference는 "약간 약한 strong reference"가 아닙니다. 일반적인 캐시 용도라면 SoftReference보다 Caffeine이나 LinkedHashMap.removeEldestEntry 같은 명시적 정책 캐시가 더 예측 가능합니다. SoftReference는 GC의 휴리스틱에 정책을 위임하므로, 워크로드가 바뀌면 동작도 바뀝니다.


7. WeakReference — 다음 GC면 사라진다

WeakReference는 가장 흔히 쓰이는 Reference 타입입니다. referent가 weakly reachable이 되면 다음 GC 사이클에 회수됩니다. 메모리 압력과 무관합니다.

public class WeakReference<T> extends Reference<T> {
    public WeakReference(T referent) { super(referent); }
    public WeakReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

클래스 자체는 거의 비어 있습니다. 동작은 추상 부모 Reference와 GC가 모두 처리합니다.

WeakHashMap

java.util.WeakHashMap은 키를 WeakReference로 감쌉니다. 키가 외부 어디서도 strong reference되지 않으면 GC가 키를 회수하고, WeakHashMap은 ReferenceQueue를 통해 그 사실을 알아 엔트리를 비웁니다.

// WeakHashMap.Entry는 WeakReference<K>를 상속
private static class Entry<K, V> extends WeakReference<Object> implements Map.Entry<K, V> {
    V value;
    final int hash;
    Entry<K, V> next;

    Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K, V> next) {
        super(key, queue);
        // ...
    }
}

새 엔트리를 만들 때 해당 키를 WeakReference로 감싸고 공유 큐에 등록합니다. 매 연산마다 expungeStaleEntries()가 호출되어 큐에서 죽은 엔트리를 빼냅니다.

private void expungeStaleEntries() {
    for (Object x; (x = queue.poll()) != null; ) {
        synchronized (queue) {
            @SuppressWarnings("unchecked")
            Entry<K, V> e = (Entry<K, V>) x;
            // table에서 e를 찾아 제거
        }
    }
}

따라서 WeakHashMap은 "키가 사라지면 자동으로 엔트리도 비워지는 맵"이 됩니다.

ThreadLocalMap의 함정

비슷한 트릭을 ThreadLocal.ThreadLocalMap.Entry도 씁니다. Entry는 WeakReference<ThreadLocal<?>>를 상속합니다. ThreadLocal 객체가 사라지면 Entry의 key가 null이 됩니다.

문제는 값(value)은 strong reference라는 점입니다. key는 약하지만 value는 강합니다. ThreadLocal이 사라지면 키만 null이 되고, 값은 Entry가 청소되기 전까지 살아 있습니다.

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;   // strong reference

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocalMap은 별도의 ReferenceQueue를 쓰지 않습니다. 각 get/set/remove 호출 시점에 staleEntry를 청소하지만, 그 메서드가 한 번도 호출되지 않으면 value는 회수되지 않습니다. 특히 스레드 풀에서 스레드가 재사용될 때 ThreadLocal 누수의 주요 원인입니다. 해결은 명시적인 ThreadLocal.remove() 호출입니다.


8. PhantomReference — 사후 정리의 통로

PhantomReference는 가장 약한 참조입니다. referent가 phantom reachable이 되면 큐에 enqueue됩니다. 핵심 특징은 두 가지입니다.

첫째, get()이 항상 null을 반환합니다.

public class PhantomReference<T> extends Reference<T> {
    public T get() { return null; }
    // ...
}

PhantomReference로는 객체에 다시 접근할 수 없습니다. 이는 의도된 설계입니다. phantom reachable이 된 객체를 다시 살리는 길을 막아 GC 결정을 안정적으로 유지합니다.

둘째, 생성자가 ReferenceQueue를 반드시 요구합니다.

public PhantomReference(T referent, ReferenceQueue<? super T> q) {
    super(referent, q);
}

큐 없는 PhantomReference는 만들 수 없습니다. 이유는 단순합니다. PhantomReference의 유일한 의미는 "객체가 phantom reachable이 됐다는 통지"인데, 통지를 받을 큐가 없으면 만들 이유가 없습니다.

finalize와의 차이

Object.finalize()는 객체가 회수되기 직전에 호출되는 메서드입니다. JEP 421로 deprecate되었고, JDK 18부터는 --finalization=disabled 옵션으로 끌 수 있습니다. 이유는 다음과 같습니다.

  • finalize는 회수 직전에 호출되므로, 그 안에서 자기 객체를 strong reference에 다시 등록하면 객체가 부활합니다. GC 사이클이 일그러집니다.
  • 어떤 스레드에서, 어떤 순서로, 언제 호출될지 보장이 없습니다.
  • 예외가 발생해도 silently swallow됩니다.
  • finalize를 가진 객체는 GC가 두 번 거쳐야 회수됩니다(첫 번째: finalize 큐에 등록, 두 번째: 실제 회수). 성능 페널티가 큽니다.

PhantomReference는 이 문제를 모두 회피합니다. enqueue 시점에 referent는 이미 회수 대상이고, get()이 null이므로 부활이 불가능합니다. 사용자가 직접 ReferenceQueue를 poll하므로 호출 시점과 스레드도 통제 가능합니다.


9. Cleaner — PhantomReference의 매니지드 래퍼

Cleaner(JDK 9+)는 PhantomReference를 직접 다루는 보일러플레이트를 감춰주는 API입니다. JDK 내부의 DirectByteBuffer, JDK의 Files 등이 광범위하게 사용합니다.

public class Cleaner {
    public static Cleaner create() { /* ... */ }
    public static Cleaner create(ThreadFactory threadFactory) { /* ... */ }
    public Cleanable register(Object obj, Runnable action) { /* ... */ }

    public interface Cleanable {
        void clean();
    }
}

사용법은 단순합니다.

class Resource {
    private static final Cleaner cleaner = Cleaner.create();

    private final long nativeHandle;
    private final Cleaner.Cleanable cleanable;

    Resource() {
        this.nativeHandle = allocateNative();
        this.cleanable = cleaner.register(this, new CleanupAction(nativeHandle));
    }

    private static class CleanupAction implements Runnable {
        private final long handle;
        CleanupAction(long h) { this.handle = h; }
        @Override public void run() { freeNative(handle); }
    }

    public void close() { cleanable.clean(); }
}

핵심은 CleanupAction을 static nested class로 만든다는 것입니다. 인스턴스의 익명/이너 클래스로 만들면 outer Resource를 implicit하게 참조하므로 phantom reachable이 영영 되지 않습니다. Cleaner.register의 첫 인자가 phantom reachable이 되어야 하는데, cleanup action이 그 객체를 strong reference로 잡고 있으면 자기 회수를 자기가 막는 셈입니다. Javadoc도 이 부분을 명시적으로 경고합니다.

Cleaner의 내부

Cleaner.create()는 데몬 스레드를 시작합니다. 이 스레드는 내부 ReferenceQueue를 무한히 remove()하면서 dequeue된 PhantomCleanableclean()을 호출합니다.

flowchart TB
    User[User: register obj, action] --> Wrap[Wrap as PhantomCleanable]
    Wrap --> Track[register in Cleaner's internal list]
    Track --> Wait[Cleaner thread waits on ReferenceQueue]
    GC[obj becomes phantom reachable] --> EnqueueByGC[GC + ReferenceHandler<br/>enqueue PhantomCleanable]
    EnqueueByGC --> Wake[Cleaner thread wakes]
    Wake --> RunAction[invoke registered Runnable]

PhantomCleanablePhantomReference를 상속하면서 Cleanable 인터페이스도 구현합니다. 두 가지 호출 경로가 있습니다.

  1. 자동 — referent가 phantom reachable이 되면 GC가 enqueue, Cleaner 스레드가 깨어나 clean() 호출.
  2. 수동 — 사용자가 Cleanable.clean()을 직접 호출. 이미 큐에서 빠진 상태가 되어 자동 경로에서는 다시 실행되지 않습니다.

clean()은 내부 플래그로 정확히 한 번만 실행되도록 보장됩니다. 자동/수동 어느 경로로 들어와도 두 번 실행되지 않습니다. native 메모리 free처럼 두 번 호출하면 안 되는 작업에 안전합니다.

DirectByteBuffer의 정리

ByteBuffer.allocateDirect(int)로 만든 DirectByteBuffer는 native 메모리를 할당합니다. 자바 객체가 회수될 때 이 메모리도 해제되어야 합니다.

JDK 8까지의 sun.misc.Cleaner는 JDK 9에서 jdk.internal.ref.Cleaner로 이동되었습니다 (여전히 내부 비공개 API). JDK 9에서는 별도로 java.lang.ref.Cleaner (public API)가 새로 추가되었으며, 두 클래스는 같은 이름을 공유할 뿐 별개입니다. DirectByteBuffer는 내부적으로 jdk.internal.ref.Cleaner 경로를 사용하며, PhantomReference 패턴은 JDK 9 이후에도 동일하게 유지됩니다. 사용자는 그저 allocateDirect만 호출하지만, 내부에서는 PhantomReference 한 개와 Cleaner 스레드 한 개가 함께 일하고 있습니다.


10. 자주 부딪히는 함정

ReferenceQueue를 비우지 않을 때

ReferenceQueue를 등록만 하고 poll()하지 않으면 Reference 객체 자체가 누수됩니다. referent는 회수되지만 Reference는 살아남아 큐 안에 쌓입니다.

ReferenceQueue<Object> queue = new ReferenceQueue<>();
for (int i = 0; i < 1_000_000; i++) {
    new WeakReference<>(new Object(), queue);
}
// Object들은 회수되지만 WeakReference 100만 개는 queue에 쌓임

WeakHashMap은 이걸 매 연산마다 expungeStaleEntries()로 처리합니다. 직접 ReferenceQueue를 다루는 코드라면 별도의 청소 루프나 호출 진입점이 필요합니다.

Reference 자체에 대한 strong reference가 없을 때

다음 코드는 의도와 다르게 동작합니다.

WeakReference<Object> ref = new WeakReference<>(someObject);
someObject = null;
// ref는 잘 동작, queue에 enqueue도 됨

// 그러나 ref가 지역 변수면, 메서드 종료 후 ref 자체가 회수될 수 있음

Reference 자신을 어디선가 strong reference로 잡고 있어야 큐를 폴할 수 있습니다. Cleaner.register가 반환하는 Cleanable을 인스턴스 필드로 잡으라고 권장되는 이유도 이것입니다.

Phantom reachable과 finalize의 충돌

같은 객체에 finalize를 오버라이드한 채 PhantomReference를 등록하면, GC는 먼저 finalize를 호출하고 그 다음 사이클에 phantom reachable로 표시합니다. PhantomReference의 enqueue가 finalize보다 한 사이클 늦어집니다. JEP 421로 finalize가 사라지는 방향이므로, 두 메커니즘을 섞지 않는 것이 안전합니다.

Cleaner의 cleanup action이 inner class일 때

앞서 언급한 함정의 재강조입니다. 다음은 동작하지 않습니다.

class Resource {
    private final Cleaner.Cleanable cleanable;
    Resource() {
        this.cleanable = cleaner.register(this, () -> freeNative(handle));
        // 람다가 this를 캡처 → outer Resource 참조 → phantom reachable 불가
    }
}

cleanup action은 반드시 outer 인스턴스를 참조하지 않는 별도 클래스여야 합니다. 람다도 클로저를 만들기 때문에 같은 함정에 빠지기 쉽습니다.


11. 정리

java.lang.ref 패키지는 한 줄로 요약하면 "GC와 자바 코드를 잇는 비동기 통지 채널" 입니다.

  • 네 가지 Reference 타입은 객체의 reachability에 단계를 부여합니다.
  • Reference의 referent, queue, next, discovered 네 필드와 Active/Pending/Enqueued/Inactive 네 상태가 라이프사이클을 표현합니다.
  • ReferenceHandler 스레드가 GC의 pending list를 자바 ReferenceQueue로 옮깁니다. GC는 native 자료구조만 만지고, 자바 락은 ReferenceHandler가 책임집니다.
  • SoftReference는 메모리 압력 기반, WeakReference는 다음 GC 사이클 기반, PhantomReference는 사후 정리용입니다.
  • Cleaner는 PhantomReference의 매니지드 래퍼이며, JEP 421로 deprecate된 finalize의 권장 대체재입니다.

자료구조 한 줄, 스레드 한 개, 네이티브 코드 한 조각. 그 위에 자바의 GC-aware 캐시와 native 자원 정리가 모두 올라가 있습니다.


참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ