☕ Java

Reference Types (Weak, Soft, Phantom)

Java provides four reference strengths beyond the ordinary strong reference: strong (the default), soft, weak, and phantom. Each has a different policy for when the garbage collector is permitted to reclaim the referenced object. Soft references are collected when the JVM is low on memory — ideal for memory-sensitive caches. Weak references are collected at the next GC cycle — ideal for canonicalising maps and listeners. Phantom references are enqueued after the object is finalised but before its memory is reclaimed — ideal for post-collection cleanup actions. Understanding these reference types enables building caches that do not cause OutOfMemoryError, listeners that automatically deregister themselves, and deterministic cleanup of native resources. This entry covers all four strengths with their precise semantics, the ReferenceQueue mechanism, and the canonical use case for each type.

Reference Strength Hierarchy

The four reference strengths form a hierarchy from strongest to weakest: strong, soft, weak, phantom. The strength determines how eagerly the GC is permitted to collect the referenced object. An object's effective reference strength is determined by the strongest reference pointing to it — if any strong reference exists, the object is strongly reachable and cannot be collected regardless of soft or weak references. Strong references are ordinary Java references — every variable declaration in Java is a strong reference by default. An object is strongly reachable if it can be reached through a chain of strong references from any GC root. The GC never collects a strongly reachable object. This is the only reference type needed in most code. Soft references (SoftReference<T>) allow the GC to collect the referenced object when memory is low. The JVM policy is: softly reachable objects will be collected before throwing OutOfMemoryError, but may be kept alive longer when memory is plentiful. The exact policy varies by JVM implementation and can be tuned. SoftReferences are the appropriate mechanism for memory-sensitive caches: the cache automatically releases entries when the application is under memory pressure, preventing OutOfMemoryError. Weak references (WeakReference<T>) allow the GC to collect the referenced object at the next garbage collection — the GC does not need to be memory-constrained to collect a weakly reachable object. Weak references are collected more eagerly than soft references. They are appropriate for caches where stale entries should be removed promptly (canonicalising maps, interning pools) and for associations between objects where the association should not keep either object alive. Phantom references (PhantomReference<T>) do not provide access to the referenced object — get() always returns null. Their purpose is not to access the object but to receive notification after the object has been GC'd. They are enqueued in a ReferenceQueue after the object is collected, allowing cleanup actions to run. The Cleaner class is built on PhantomReference.
Java
// ── Reference strength hierarchy ─────────────────────────────────────
//
// Reachability levels (strongest to weakest):
//
// STRONG     Object referenced by any variable/field without Reference wrapper
//            → NEVER collected while any strong reference exists
//
// SOFT       Object referenced only through SoftReference instances
//            → Collected when JVM needs memory (before OOM)
//            → Kept alive when memory is plentiful
//            → Use for memory-sensitive caches
//
// WEAK       Object referenced only through WeakReference instances
//            → Collected at the next GC cycle (promptly)
//            → Use for canonicalising maps, associations
//
// PHANTOM    Object referenced only through PhantomReference instances
//            → Already considered collected by the GC (get() returns null)
//            → Reference enqueued AFTER object is freed
//            → Use for post-collection cleanup notification
//
// UNREACHABLE Object reachable from no reference at all
//              → Collected (or finalised then collected)

// ── Creating reference objects ────────────────────────────────────────
Object target = new Object();

// Strong (implicit):
Object strong = target;     // strong reference — target cannot be collected

// Soft:
SoftReference<Object> soft = new SoftReference<>(target);

// Weak:
WeakReference<Object> weak = new WeakReference<>(target);

// Phantom (requires ReferenceQueue):
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(target, queue);
// phantom.get() ALWAYS returns null — no access to object

// ── Object becomes eligible when all strong references are removed ─────
target = null;   // strong reference removed
strong = null;   // last strong reference removed
// Now 'target' is only softly/weakly/phantomly reachable
// GC can now collect it according to reference strength policies

WeakReference — Prompt Collection

WeakReference allows the referenced object to be collected at the next GC cycle without waiting for memory pressure. After collection, WeakReference.get() returns null. Code using WeakReference must always null-check the return value of get(), because the reference may have been cleared between the check and the use. The WeakHashMap is the most important application of weak references. It is a Map implementation where the keys are weakly referenced. When a key becomes unreachable (no strong references to it exist outside the map), the GC collects the key, and the WeakHashMap removes the corresponding entry. This allows WeakHashMap to function as a canonicalising map — a map from objects to associated metadata — where the entry lives exactly as long as the key object lives, without the map itself keeping the key alive. The pitfall with WeakHashMap is using boxed primitives or interned strings as keys. Integer.valueOf() returns cached instances for small values, and string literals are interned — both are permanently strongly reachable from the JVM's internal caches. A WeakHashMap<Integer, Data> where keys are small Integer values will never have entries collected, defeating the purpose entirely. WeakReference is also used in listener patterns where the listener should be automatically removed when the listening object is collected. A WeakReference wrapping the listener allows the observer to hold a weak reference to the listener; if the listener object has no other strong references, it is collected and the WeakReference is cleared. The observer can then remove the cleared weak reference from its listener list during the next notification cycle.
Java
// ── WeakReference basic usage ────────────────────────────────────────
Object expensiveObject = new ExpensiveResource();
WeakReference<ExpensiveResource> weakRef =
    new WeakReference<>((ExpensiveResource) expensiveObject);

// Use the object — ALWAYS null-check
ExpensiveResource resource = weakRef.get();
if (resource != null) {
    resource.doWork();   // safe — still alive
    // WARNING: resource could be GC'd after this check but before use
    // in multithreaded code — always hold a local strong reference
} else {
    // Object has been collected — handle accordingly
    recreateIfNeeded();
}

// ── Thread-safe usage — hold local reference ──────────────────────────
public void safeAccess(WeakReference<ExpensiveResource> ref) {
    ExpensiveResource local = ref.get();  // get() returns strong ref
    if (local == null) return;            // already collected
    local.doWork();                       // local holds it alive during this method
    // local goes out of scope here — no longer held
}

// ── WeakHashMap — entries live as long as keys are reachable ──────────
WeakHashMap<Object, String> metadata = new WeakHashMap<>();

Object key1 = new Object();
Object key2 = new Object();
metadata.put(key1, "data for key1");
metadata.put(key2, "data for key2");

System.out.println(metadata.size());  // 2

key1 = null;   // key1 object no longer strongly referenced
System.gc();   // encourage collection

// After GC: WeakHashMap entry for key1 is removed
System.out.println(metadata.size());  // 1 (key2 still alive)

// ── PITFALL: boxed integer keys are cached — never collected ───────────
WeakHashMap<Integer, String> badMap = new WeakHashMap<>();
badMap.put(42, "value");      // Integer.valueOf(42) is CACHED
System.gc();
System.out.println(badMap.size());  // Still 1Integer(42) never collected!
// Integer values -128 to 127 are permanently cached by the JVM
// They are always strongly reachable → WeakHashMap entries NEVER expire

// ── Weak listener pattern ─────────────────────────────────────────────
public class WeakListenerList<T> {

    private final List<WeakReference<T>> listeners = new CopyOnWriteArrayList<>();

    public void add(T listener) {
        listeners.add(new WeakReference<>(listener));
    }

    public void notifyAll(Consumer<T> action) {
        listeners.removeIf(ref -> {
            T listener = ref.get();
            if (listener == null) return true;   // collected — remove
            action.accept(listener);
            return false;
        });
    }
}

SoftReference — Memory-Sensitive Caches

SoftReference allows the referenced object to be collected when the JVM is low on memory. The HotSpot policy is: clear soft references that have not been used recently when the heap is full, targeting objects whose last access was longest ago. The -XX:SoftRefLRUPolicyMSPerMB flag controls the minimum time a soft reference survives per MB of free heap — default 1000ms per MB, meaning a soft reference is eligible for collection if it has not been accessed within (freeHeapMB × 1000) milliseconds. SoftReference is the natural mechanism for memory-sensitive caches. A cache based on SoftReferences automatically releases entries when the application is under memory pressure, preventing OutOfMemoryError. When memory is plentiful, entries are retained and cache hit rates are high. When memory is tight, entries are released to make room, increasing cache misses but preventing the JVM from running out of memory. The correct implementation of a SoftReference cache requires handling several edge cases: SoftReference.get() returns null after the object has been collected; the cache should detect this and either remove the entry or re-compute the value on the next access; the cache must be thread-safe in a concurrent environment. Many production code bases use Guava Cache or Caffeine with softValues(), which handle all these details correctly. The limitation of SoftReference caches: they are unpredictable. The JVM may retain objects for a long time when memory is plentiful, leading to large memory consumption, and then suddenly collect many entries when memory becomes tight, causing a surge of cache misses. For caches where predictable behaviour matters more than automatic memory management, explicitly bounded caches (LRU with fixed maximum size) are more appropriate.
Java
// ── SoftReference basic usage ────────────────────────────────────────
byte[] largeData = computeExpensiveData();
SoftReference<byte[]> softRef = new SoftReference<>(largeData);
largeData = null;   // release strong reference — only soft ref remains

// Use — check for collection
byte[] data = softRef.get();
if (data != null) {
    process(data);   // still in memory — cache hit
} else {
    // Collected by GC under memory pressure — cache miss
    data = computeExpensiveData();   // recompute
    softRef = new SoftReference<>(data); // re-cache
    data = null;   // release strong reference again
}

// ── SoftReference cache implementation ───────────────────────────────
public class SoftReferenceCache<K, V> {

    private final Map<K, SoftReference<V>> cache =
        new ConcurrentHashMap<>();
    private final Function<K, V> loader;

    public SoftReferenceCache(Function<K, V> loader) {
        this.loader = loader;
    }

    public V get(K key) {
        SoftReference<V> ref = cache.get(key);
        if (ref != null) {
            V value = ref.get();
            if (value != null) return value;   // cache hit
            cache.remove(key, ref);            // ref cleared — remove stale entry
        }
        // Cache miss: load and cache
        V value = loader.apply(key);
        cache.put(key, new SoftReference<>(value));
        return value;
    }

    public void invalidate(K key) {
        cache.remove(key);
    }
}

// Usage:
SoftReferenceCache<Long, ReportData> reportCache =
    new SoftReferenceCache<>(id -> generateReport(id));

ReportData report = reportCache.get(orderId);

// ── SoftReference with ReferenceQueue — detect evictions ─────────────
ReferenceQueue<byte[]> evictionQueue = new ReferenceQueue<>();
Map<SoftReference<byte[]>, String> keyMap = new HashMap<>();

SoftReference<byte[]> ref =
    new SoftReference<>(new byte[1024], evictionQueue);
keyMap.put(ref, "someKey");   // track which key this ref belongs to

// Poll the queue to detect evictions:
Reference<? extends byte[]> evicted;
while ((evicted = evictionQueue.poll()) != null) {
    String key = keyMap.remove(evicted);  // find the key
    System.out.println("Evicted entry for key: " + key);
    // Could reload or notify
}

// ── Better alternative: Caffeine with softValues ───────────────────────
Cache<Long, ReportData> cache = Caffeine.newBuilder()
    .softValues()             // values held by soft references
    .maximumSize(10_000)      // also bounded by count
    .expireAfterWrite(1, TimeUnit.HOURS)
    .recordStats()            // monitor hit rate
    .build(id -> generateReport(id));  // auto-loader

PhantomReference and ReferenceQueue

PhantomReference is the most specialised reference type. Unlike SoftReference and WeakReference, PhantomReference.get() always returns null — it is impossible to access the referenced object through a PhantomReference. Its sole purpose is post-collection notification: when the referenced object has been garbage collected, the PhantomReference is enqueued in a ReferenceQueue. Code that monitors the queue can then perform cleanup actions in response to the collection. This is strictly more correct than finalize() for post-collection cleanup. The PhantomReference is enqueued after the object has been fully collected and its memory is about to be reclaimed — there is no way for the cleanup code to resurrect the object (since get() returns null), and no way for the cleanup to be affected by the object's state (since the object is gone). The Java 9 Cleaner class provides a higher-level API built on PhantomReference that handles the queue monitoring, threading, and error handling correctly. The ReferenceQueue mechanism works with all three reference types (soft, weak, phantom). When any reference object is constructed with a ReferenceQueue argument, the JVM enqueues the reference object in the queue after the referent becomes appropriately reachable (softly unreachable for SoftReference, weakly unreachable for WeakReference, phantom reachable for PhantomReference). The application can poll or block on the queue to receive these notifications. A common pattern using ReferenceQueue with WeakReference is the stale entry cleaner: a background thread blocks on a ReferenceQueue, and when a WeakReference is enqueued (its referent has been collected), the background thread removes the now-stale entry from the associated cache or map. This avoids the need to scan the entire cache to remove stale entries.
Java
// ── PhantomReference for post-collection cleanup ─────────────────────
public class ResourceTracker {

    private static final ReferenceQueue<Object> QUEUE =
        new ReferenceQueue<>();

    // Keeps PhantomReferences alive (otherwise they'd be GC'd too)
    private static final Set<CleanupRef> LIVE_REFS =
        Collections.synchronizedSet(new HashSet<>());

    // Background thread processes cleanup notifications
    static {
        Thread cleanerThread = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    // Block until a PhantomReference is enqueued
                    CleanupRef ref = (CleanupRef) QUEUE.remove();
                    LIVE_REFS.remove(ref);
                    ref.cleanup();   // perform cleanup
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }, "phantom-cleaner");
        cleanerThread.setDaemon(true);
        cleanerThread.start();
    }

    static class CleanupRef extends PhantomReference<Object> {
        private final String resourceId;
        private final long   nativeHandle;

        CleanupRef(Object referent, String id, long handle) {
            super(referent, QUEUE);   // registered with the queue
            this.resourceId   = id;
            this.nativeHandle = handle;
            // CRITICAL: CleanupRef does NOT hold a reference to 'referent'
            // (PhantomReference itself holds the phantom ref — not CleanupRef fields)
        }

        void cleanup() {
            System.out.println("Cleaning up resource: " + resourceId);
            releaseNative(nativeHandle);
        }
    }

    public static void track(Object obj, String id, long handle) {
        CleanupRef ref = new CleanupRef(obj, id, handle);
        LIVE_REFS.add(ref);   // prevent CleanupRef itself from being collected
    }
}

// ── ReferenceQueue with WeakReference — stale entry removal ───────────
public class WeakValueCache<K, V> {

    private final Map<K, WeakEntry<K, V>> map = new ConcurrentHashMap<>();
    private final ReferenceQueue<V>       queue = new ReferenceQueue<>();

    static class WeakEntry<K, V> extends WeakReference<V> {
        final K key;
        WeakEntry(K key, V value, ReferenceQueue<V> queue) {
            super(value, queue);
            this.key = key;
        }
    }

    public void put(K key, V value) {
        expungeStaleEntries();   // clean up before adding
        map.put(key, new WeakEntry<>(key, value, queue));
    }

    public V get(K key) {
        expungeStaleEntries();
        WeakEntry<K, V> entry = map.get(key);
        return entry != null ? entry.get() : null;  // null if collected
    }

    @SuppressWarnings("unchecked")
    private void expungeStaleEntries() {
        WeakEntry<K, V> stale;
        while ((stale = (WeakEntry<K, V>) queue.poll()) != null) {
            map.remove(stale.key, stale);  // remove stale entry
        }
    }
}

Related Topics in Java Memory Management

Stack Memory
Stack memory is the region of memory where the JVM stores method invocation frames, local variables, and partial results. Every thread has its own private stack created at thread creation, and the stack grows and shrinks as methods are called and return. Stack memory operates on a last-in, first-out discipline — the frame for the most recently called method sits on top, and when that method returns its frame is immediately discarded. Understanding stack memory explains why local variables are thread-safe by default, why recursive algorithms can cause StackOverflowError, why primitive values behave differently from objects, and what the JVM does at every method call and return. This entry covers stack frame structure, the stack pointer, local variable storage, operand stacks, frame lifecycle, thread isolation, and the performance characteristics that make stack allocation extremely fast.
Heap Memory
The heap is the runtime data area from which all Java object instances and arrays are allocated. It is shared across all threads in a JVM process, grows dynamically up to a configured maximum, and is managed entirely by the garbage collector. Understanding heap memory means understanding how objects are allocated, how the generational hypothesis drives GC design, how the major garbage collectors (G1, ZGC, Shenandoah, Parallel) partition and manage the heap, what triggers garbage collection, how to tune heap size and GC behaviour, and how to diagnose heap-related problems including OutOfMemoryError and excessive GC pause times. This entry covers heap structure, generational design, object allocation, garbage collection triggers, common collectors, heap tuning, and memory leak detection.
Metaspace
Metaspace is the JVM memory region that stores class metadata — the internal representations of loaded classes, methods, fields, constant pools, and annotations. It replaced PermGen (Permanent Generation) in Java 8. Unlike PermGen which was a fixed-size heap region, Metaspace is allocated from native memory (outside the Java heap) and grows dynamically up to an optional maximum. Understanding Metaspace means understanding what class metadata contains, what causes Metaspace to grow, how class unloading reclaims Metaspace, what OutOfMemoryError from Metaspace looks like, how to monitor and limit it, and what the practical implications are for application servers, OSGi containers, and frameworks that generate classes dynamically.
JVM Memory Model
The JVM Memory Model encompasses two related but distinct concepts. The first is the runtime memory architecture — how the JVM partitions memory into regions (stack, heap, Metaspace, code cache, native memory) and what each region stores. The second, more precise meaning is the Java Memory Model (JMM) defined in the Java Language Specification — the formal set of rules that govern how multithreaded programs observe memory: when writes made by one thread become visible to other threads, what ordering guarantees exist, and how volatile, synchronized, and final provide those guarantees. This entry covers both: the complete runtime memory architecture with all regions explained, and the Java Memory Model's happens-before relationship, visibility rules, and the practical consequences for concurrent code.